diff --git a/packages/app/src/cli/api/graphql/create_app.ts b/packages/app/src/cli/api/graphql/create_app.ts index de141eab5e..e74ec33173 100644 --- a/packages/app/src/cli/api/graphql/create_app.ts +++ b/packages/app/src/cli/api/graphql/create_app.ts @@ -29,7 +29,7 @@ export const CreateAppQuery = gql` } appType grantedScopes - disabledBetas + disabledFlags } userErrors { field @@ -60,7 +60,10 @@ export interface CreateAppQuerySchema { }[] appType: string grantedScopes: string[] - disabledBetas: string[] + applicationUrl: string + redirectUrlWhitelist: string[] + requestedAccessScopes?: string[] + disabledFlags: string[] } userErrors: { field: string[] diff --git a/packages/app/src/cli/api/graphql/find_app.ts b/packages/app/src/cli/api/graphql/find_app.ts index c5e38e4c8b..c03bb01290 100644 --- a/packages/app/src/cli/api/graphql/find_app.ts +++ b/packages/app/src/cli/api/graphql/find_app.ts @@ -13,7 +13,7 @@ export const FindAppQuery = gql` appType grantedScopes developmentStorePreviewEnabled - disabledBetas + disabledFlags } } ` @@ -30,6 +30,6 @@ export interface FindAppQuerySchema { appType: string grantedScopes: string[] developmentStorePreviewEnabled: boolean - disabledBetas: string[] + disabledFlags: string[] } } diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index c38c9b309b..81f8f1ec4c 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -155,8 +155,8 @@ export function testOrganizationApp(app: Partial = {}): Organiz apiSecretKeys: [{secret: 'api-secret'}], organizationId: '1', grantedScopes: [], - disabledBetas: [], - betas: [], + disabledFlags: ['5b25141b'], + flags: [], } return {...defaultApp, ...app} } diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index 58483aa503..66bb39035a 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -15,8 +15,6 @@ import {describe, expect, test} from 'vitest' import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -const DEFAULT_APP = testApp() - const CORRECT_CURRENT_APP_SCHEMA: CurrentAppConfiguration = { path: '', name: 'app 1', diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 9dc717ed8e..51ea1d5a54 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -5,7 +5,7 @@ import {isType} from '../../utilities/types.js' import {FunctionConfigType} from '../extensions/specifications/function.js' import {ExtensionSpecification} from '../extensions/specification.js' import {SpecsAppConfiguration} from '../extensions/specifications/types/app_config.js' -import {BetaFlag} from '../../services/dev/fetch.js' +import {Flag} from '../../services/dev/fetch.js' import {zod} from '@shopify/cli-kit/node/schema' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {getDependencies, PackageManager, readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager' @@ -168,7 +168,7 @@ export interface AppInterface extends AppConfigurationInterface { specifications?: ExtensionSpecification[] errors?: AppErrors includeConfigOnDeploy: boolean | undefined - remoteBetaFlags: BetaFlag[] + remoteFlags: Flag[] hasExtensions: () => boolean updateDependencies: () => Promise extensionsForType: (spec: {identifier: string; externalIdentifier: string}) => ExtensionInstance[] @@ -190,7 +190,7 @@ interface AppConstructor { errors?: AppErrors specifications?: ExtensionSpecification[] configSchema?: zod.ZodTypeAny - remoteBetaFlags?: BetaFlag[] + remoteFlags?: Flag[] } export class App implements AppInterface { @@ -206,7 +206,7 @@ export class App implements AppInterface { errors?: AppErrors specifications?: ExtensionSpecification[] configSchema: zod.ZodTypeAny - remoteBetaFlags: BetaFlag[] + remoteFlags: Flag[] private realExtensions: ExtensionInstance[] constructor({ @@ -223,7 +223,7 @@ export class App implements AppInterface { errors, specifications, configSchema, - remoteBetaFlags, + remoteFlags, }: AppConstructor) { this.name = name this.idEnvironmentVariableName = idEnvironmentVariableName @@ -238,7 +238,7 @@ export class App implements AppInterface { this.usesWorkspaces = usesWorkspaces this.specifications = specifications this.configSchema = configSchema ?? AppSchema - this.remoteBetaFlags = remoteBetaFlags ?? [] + this.remoteFlags = remoteFlags ?? [] } get allExtensions() { @@ -323,7 +323,7 @@ function findExtensionByHandle(allExtensions: ExtensionInstance[], handle: strin } export class EmptyApp extends App { - constructor(specifications?: ExtensionSpecification[], betas?: BetaFlag[], clientId?: string) { + constructor(specifications?: ExtensionSpecification[], flags?: Flag[], clientId?: string) { const configuration = clientId ? {client_id: clientId, access_scopes: {scopes: ''}, path: ''} : {scopes: '', path: ''} @@ -340,7 +340,7 @@ export class EmptyApp extends App { usesWorkspaces: false, specifications, configSchema, - remoteBetaFlags: betas ?? [], + remoteFlags: flags ?? [], }) } } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index c2cc906db0..80c1a1b1b1 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2745,107 +2745,71 @@ describe('WebhooksSchema', () => { expect(parsedConfiguration.webhooks).toMatchObject(webhookConfig) }) - test('does not allow identical compliance_topics and uri', async () => { + test('throws an error if we have privacy_compliance section and subscriptions with compliance_topics', async () => { const webhookConfig: WebhooksConfig = { api_version: '2021-07', + privacy_compliance: { + customer_data_request_url: 'https://example.com', + }, subscriptions: [ { - topics: ['metaobjects/create'], + compliance_topics: ['customers/data_request'], uri: 'https://example.com', - sub_topic: 'type:metaobject_one', - compliance_topics: ['shop/redact'], - }, - { - topics: ['metaobjects/create'], - uri: 'https://example.com', - sub_topic: 'type:metaobject_two', - compliance_topics: ['shop/redact'], }, ], } const errorObj = { code: zod.ZodIssueCode.custom, - message: 'You can’t have duplicate privacy compliance subscriptions with the exact same `uri`', - fatal: true, - path: ['webhooks', 'subscriptions', 1, 'compliance_topics', 0, 'shop/redact'], + message: `The privacy_compliance section can't be used if there are subscriptions including compliance_topics`, + path: ['webhooks'], } const {abortOrReport, expectedFormatted} = await setupParsing(errorObj, webhookConfig) expect(abortOrReport).toHaveBeenCalledWith(expectedFormatted, {}, 'tmp', [errorObj]) }) - test('does not allow identical compliance_topics in same subscription (will get by zod enum validation)', async () => { + test('throws an error if neither topics nor compliance_topics are added', async () => { const webhookConfig: WebhooksConfig = { api_version: '2021-07', subscriptions: [ { - topics: ['metaobjects/create'], uri: 'https://example.com', - sub_topic: 'type:metaobject_one', - compliance_topics: ['shop/redact', 'shop/redact'], }, ], } const errorObj = { code: zod.ZodIssueCode.custom, - message: 'You can’t have duplicate privacy compliance subscriptions with the exact same `uri`', - fatal: true, - path: ['webhooks', 'subscriptions', 0, 'compliance_topics', 1, 'shop/redact'], + message: 'Either topics or compliance_topics must be added to the webhook subscription', + path: ['webhooks', 'subscriptions', 0], } const {abortOrReport, expectedFormatted} = await setupParsing(errorObj, webhookConfig) expect(abortOrReport).toHaveBeenCalledWith(expectedFormatted, {}, 'tmp', [errorObj]) }) - test('allows same compliance_topics if uri is different', async () => { + test('throws an error when there are duplicated compliance topics', async () => { const webhookConfig: WebhooksConfig = { api_version: '2021-07', subscriptions: [ { - topics: ['metaobjects/create'], uri: 'https://example.com', - sub_topic: 'type:metaobject_one', - compliance_topics: ['shop/redact'], + compliance_topics: ['customers/data_request'], }, { - topics: ['products/create'], - uri: 'https://example-two.com', - sub_topic: 'type:metaobject_two', - compliance_topics: ['shop/redact'], + uri: 'https://example.com/other', + compliance_topics: ['customers/data_request'], }, ], } - - const {abortOrReport, parsedConfiguration} = await setupParsing({}, webhookConfig) - expect(abortOrReport).not.toHaveBeenCalled() - expect(parsedConfiguration.webhooks).toMatchObject(webhookConfig) - }) - - test('allows same compliance_topics across https, pub sub and arn with multiple topics', async () => { - const webhookConfig: WebhooksConfig = { - api_version: '2021-07', - subscriptions: [ - { - topics: ['products/create'], - uri: 'https://example.com/all_webhooks', - compliance_topics: ['shop/redact', 'customers/data_request', 'customers/redact'], - }, - { - topics: ['products/create'], - uri: 'pubsub://my-project-123:my-topic', - compliance_topics: ['customers/data_request', 'customers/redact'], - }, - { - topics: ['products/create'], - uri: 'arn:aws:events:us-west-2::event-source/aws.partner/shopify.com/123/compliance', - compliance_topics: ['shop/redact', 'customers/redact'], - }, - ], + const errorObj = { + code: zod.ZodIssueCode.custom, + message: 'You can’t have multiple subscriptions with the same compliance topic', + fatal: true, + path: ['webhooks', 'subscriptions'], } - const {abortOrReport, parsedConfiguration} = await setupParsing({}, webhookConfig) - expect(abortOrReport).not.toHaveBeenCalled() - expect(parsedConfiguration.webhooks).toMatchObject(webhookConfig) + const {abortOrReport, expectedFormatted} = await setupParsing(errorObj, webhookConfig) + expect(abortOrReport).toHaveBeenCalledWith(expectedFormatted, {}, 'tmp', [errorObj]) }) async function setupParsing(errorObj: zod.ZodIssue | {}, webhookConfigOverrides: WebhooksConfig) { diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index f2eea9ea05..dfb1530397 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -20,7 +20,7 @@ import {ExtensionSpecification} from '../extensions/specification.js' import {getCachedAppInfo} from '../../services/local-storage.js' import use from '../../services/app/config/use.js' import {loadLocalExtensionsSpecifications} from '../extensions/load-specifications.js' -import {BetaFlag} from '../../services/dev/fetch.js' +import {Flag} from '../../services/dev/fetch.js' import {deepStrict, zod} from '@shopify/cli-kit/node/schema' import {fileExists, readFile, glob, findPathUp, fileExistsSync} from '@shopify/cli-kit/node/fs' import {readAndParseDotEnv, DotEnvFile} from '@shopify/cli-kit/node/dot-env' @@ -170,7 +170,7 @@ interface AppLoaderConstructorArgs { mode?: AppLoaderMode configName?: string specifications?: ExtensionSpecification[] - remoteBetas?: BetaFlag[] + remoteFlags?: Flag[] } /** @@ -202,14 +202,14 @@ class AppLoader { private configName?: string private errors: AppErrors = new AppErrors() private specifications: ExtensionSpecification[] - private remoteBetas: BetaFlag[] + private remoteFlags: Flag[] - constructor({directory, configName, mode, specifications, remoteBetas}: AppLoaderConstructorArgs) { + constructor({directory, configName, mode, specifications, remoteFlags}: AppLoaderConstructorArgs) { this.mode = mode ?? 'strict' this.directory = directory this.specifications = specifications ?? [] this.configName = configName - this.remoteBetas = remoteBetas ?? [] + this.remoteFlags = remoteFlags ?? [] } findSpecificationForType(type: string) { @@ -262,7 +262,7 @@ class AppLoader { dotenv, specifications: this.specifications, configSchema, - remoteBetaFlags: this.remoteBetas, + remoteFlags: this.remoteFlags, }) if (!this.errors.isEmpty()) appClass.errors = this.errors @@ -545,7 +545,7 @@ interface AppConfigurationLoaderConstructorArgs { directory: string configName?: string specifications?: ExtensionSpecification[] - remoteBetas?: BetaFlag[] + remoteFlags?: Flag[] } type LinkedConfigurationSource = @@ -573,13 +573,13 @@ class AppConfigurationLoader { private directory: string private configName?: string private specifications?: ExtensionSpecification[] - private remoteBetas: BetaFlag[] + private remoteFlags: Flag[] - constructor({directory, configName, specifications, remoteBetas}: AppConfigurationLoaderConstructorArgs) { + constructor({directory, configName, specifications, remoteFlags}: AppConfigurationLoaderConstructorArgs) { this.directory = directory this.configName = configName this.specifications = specifications - this.remoteBetas = remoteBetas ?? [] + this.remoteFlags = remoteFlags ?? [] } async loaded() { diff --git a/packages/app/src/cli/models/extensions/load-specifications.ts b/packages/app/src/cli/models/extensions/load-specifications.ts index 427bc76003..c3b4f41ced 100644 --- a/packages/app/src/cli/models/extensions/load-specifications.ts +++ b/packages/app/src/cli/models/extensions/load-specifications.ts @@ -6,7 +6,7 @@ import appWebhooksSpec, {WebhooksSpecIdentifier} from './specifications/app_conf import appBrandingSpec, {BrandingSpecIdentifier} from './specifications/app_config_branding.js' import appAccessSpec, {AppAccessSpecIdentifier} from './specifications/app_config_app_access.js' import appPrivacyComplienceSpec, { - PrivacyComplianceWebbhooksSpecIdentifier, + PrivacyComplianceWebhooksSpecIdentifier, } from './specifications/app_config_privacy_compliance_webhooks.js' import checkoutPostPurchaseSpec from './specifications/checkout_post_purchase.js' import checkoutSpec from './specifications/checkout_ui_extension.js' @@ -27,7 +27,7 @@ const SORTED_CONFIGURATION_SPEC_IDENTIFIERS = [ BrandingSpecIdentifier, AppAccessSpecIdentifier, WebhooksSpecIdentifier, - PrivacyComplianceWebbhooksSpecIdentifier, + PrivacyComplianceWebhooksSpecIdentifier, AppProxySpecIdentifier, PosSpecIdentifier, AppHomeSpecIdentifier, diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 9b4eb4d976..55a71f0e9b 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,8 +1,9 @@ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' +import {SpecsAppConfiguration} from './specifications/types/app_config.js' import {blocks} from '../../constants.js' -import {BetaFlag} from '../../services/dev/fetch.js' +import {Flag} from '../../services/dev/fetch.js' import {Result} from '@shopify/cli-kit/node/result' import {capitalize} from '@shopify/cli-kit/common/string' import {zod} from '@shopify/cli-kit/node/schema' @@ -22,8 +23,12 @@ export interface TransformationConfig { } export interface CustomTransformationConfig { - forward?: (obj: object) => object - reverse?: (obj: object) => object + forward?: (obj: object, options?: {flags?: Flag[]}) => object + reverse?: (obj: object, options?: {flags?: Flag[]}) => object +} + +export interface SimplifyConfig { + simplify?: (obj: SpecsAppConfiguration) => SpecsAppConfiguration } export type ExtensionExperience = 'extension' | 'configuration' @@ -57,7 +62,8 @@ export interface ExtensionSpecification ExtensionFeature[] transform?: (content: object) => object - reverseTransform?: (content: object, options?: {betas?: BetaFlag[]}) => object + reverseTransform?: (content: object, options?: {flags?: Flag[]}) => object + simplify?: (remoteConfig: SpecsAppConfiguration) => SpecsAppConfiguration } /** @@ -116,6 +122,7 @@ export function createExtensionSpecification appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[] transformConfig?: TransformationConfig | CustomTransformationConfig + simplify?: SimplifyConfig }): ExtensionSpecification { const appModuleFeatures = spec.appModuleFeatures ?? (() => []) return createExtensionSpecification({ @@ -144,10 +152,16 @@ export function createConfigExtensionSpecification content) +} + function resolveAppConfigTransform(transformConfig?: TransformationConfig | CustomTransformationConfig) { if (!transformConfig) return (content: object) => defaultAppConfigTransform(content as {[key: string]: unknown}) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.test.ts index 7d4f950f5f..d2f5d86cd4 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.test.ts @@ -1,10 +1,11 @@ import spec from './app_config_privacy_compliance_webhooks.js' +import {Flag} from '../../../services/dev/fetch.js' import {isEmpty} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('privacy_compliance_webhooks', () => { describe('transform', () => { - test('should return the transformed object', () => { + test('should return the transformed object from the old format', () => { // Given const object = { webhooks: { @@ -27,7 +28,37 @@ describe('privacy_compliance_webhooks', () => { shop_redact_url: 'https://shop-deletion-url.dev', }) }) - test('should return undefined if all porperties are empty', () => { + + test('should return the transformed object from the new format', () => { + // Given + const object = { + webhooks: { + subscriptions: [ + { + compliance_topics: ['customers/redact', 'customers/data_request'], + uri: 'https://example.com/customers_webhooks', + }, + { + compliance_topics: ['shop/redact'], + uri: 'https://example.com/shop_webhooks', + }, + ], + }, + } + const privacyComplianceSpec = spec + + // When + const result = privacyComplianceSpec.transform!(object) + + // Then + expect(result).toMatchObject({ + customers_redact_url: 'https://example.com/customers_webhooks', + customers_data_request_url: 'https://example.com/customers_webhooks', + shop_redact_url: 'https://example.com/shop_webhooks', + }) + }) + + test('should return an empty object if all properties are empty', () => { // Given const object = { webhooks: { @@ -49,31 +80,66 @@ describe('privacy_compliance_webhooks', () => { }) }) - describe('reverseTransform', () => { + describe('reverseTransform with declarative_webhooks flag', () => { test('should return the reversed transformed object', () => { // Given const object = { - customers_redact_url: 'https://customer-deletion-url.dev', - customers_data_request_url: 'https://customer-data-request-url.dev', - shop_redact_url: 'https://shop-deletion-url.dev', + customers_redact_url: 'https://example.com/customer-deletion', + customers_data_request_url: 'https://example.com/customer-data-request', + shop_redact_url: 'https://example.com/shop-deletion', } const privacyComplianceSpec = spec // When - const result = privacyComplianceSpec.reverseTransform!(object) + const result = privacyComplianceSpec.reverseTransform!(object, {flags: [Flag.DeclarativeWebhooks]}) // Then expect(result).toMatchObject({ webhooks: { - privacy_compliance: { - customer_deletion_url: 'https://customer-deletion-url.dev', - customer_data_request_url: 'https://customer-data-request-url.dev', - shop_deletion_url: 'https://shop-deletion-url.dev', - }, + subscriptions: [ + { + compliance_topics: ['customers/redact'], + uri: 'https://example.com/customer-deletion', + }, + { + compliance_topics: ['customers/data_request'], + uri: 'https://example.com/customer-data-request', + }, + { + compliance_topics: ['shop/redact'], + uri: 'https://example.com/shop-deletion', + }, + ], }, }) }) - test('should return undefined if all properties are empty', () => { + + test('should return only the properties that are not empty', () => { + // Given + const object = { + customers_redact_url: 'https://example.com/customer-deletion', + customers_data_request_url: '', + shop_redact_url: undefined, + } + const privacyComplianceSpec = spec + + // When + const result = privacyComplianceSpec.reverseTransform!(object, {flags: [Flag.DeclarativeWebhooks]}) + + // Then + expect(result).toEqual({ + webhooks: { + subscriptions: [ + { + compliance_topics: ['customers/redact'], + uri: 'https://example.com/customer-deletion', + }, + ], + }, + }) + }) + + test('should return an empty object if all properties are empty', () => { // Given const object = { customers_redact_url: '', @@ -83,15 +149,42 @@ describe('privacy_compliance_webhooks', () => { const privacyComplianceSpec = spec // When - const result = privacyComplianceSpec.reverseTransform!(object) + const result = privacyComplianceSpec.reverseTransform!(object, {flags: [Flag.DeclarativeWebhooks]}) // Then expect(isEmpty(result)).toBeTruthy() }) + }) + + describe('reverseTransform without declarative_webhooks flag', () => { + test('should return the reversed transformed object', () => { + // Given + const object = { + customers_redact_url: 'https://example.com/customer-deletion', + customers_data_request_url: 'https://example.com/customer-data-request', + shop_redact_url: 'https://example.com/shop-deletion', + } + const privacyComplianceSpec = spec + + // When + const result = privacyComplianceSpec.reverseTransform!(object) + + // Then + expect(result).toMatchObject({ + webhooks: { + privacy_compliance: { + customer_data_request_url: 'https://example.com/customer-data-request', + customer_deletion_url: 'https://example.com/customer-deletion', + shop_deletion_url: 'https://example.com/shop-deletion', + }, + }, + }) + }) + test('should return only the properties that are not empty', () => { // Given const object = { - customers_redact_url: 'http://customer-deletion-url.dev', + customers_redact_url: 'https://example.com/customer-deletion', customers_data_request_url: '', shop_redact_url: undefined, } @@ -104,10 +197,26 @@ describe('privacy_compliance_webhooks', () => { expect(result).toEqual({ webhooks: { privacy_compliance: { - customer_deletion_url: 'http://customer-deletion-url.dev', + customer_deletion_url: 'https://example.com/customer-deletion', }, }, }) }) + + test('should return an empty object if all properties are empty', () => { + // Given + const object = { + customers_redact_url: '', + customers_data_request_url: '', + shop_redact_url: undefined, + } + const privacyComplianceSpec = spec + + // When + const result = privacyComplianceSpec.reverseTransform!(object) + + // Then + expect(isEmpty(result)).toBeTruthy() + }) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts index c057604d0c..ca8fcd0c0b 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts @@ -1,55 +1,84 @@ -import {WebhookSchema} from './app_config_webhook.js' -import {WebhooksConfig} from './types/app_config_webhook.js' +import {ComplianceTopic, WebhookSchema, WebhookSimplifyConfig} from './app_config_webhook.js' +import {WebhookSubscription, WebhooksConfig} from './types/app_config_webhook.js' import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' -import {getPathValue} from '@shopify/cli-kit/common/object' +import {Flag} from '../../../services/dev/fetch.js' +import {compact, getPathValue} from '@shopify/cli-kit/common/object' -const PrivacyComplianceWebbhooksTransformConfig: CustomTransformationConfig = { - forward: (content: object) => transformToPrivacyComplianceWebhooksModule(content), - reverse: (content: object) => transformFromPrivacyComplianceWebhooksModule(content), +const PrivacyComplianceWebhooksTransformConfig: CustomTransformationConfig = { + forward: (content: object, options?: {flags?: Flag[]}) => transformToPrivacyComplianceWebhooksModule(content), + reverse: (content: object, options?: {flags?: Flag[]}) => + transformFromPrivacyComplianceWebhooksModule(content, options), } -export const PrivacyComplianceWebbhooksSpecIdentifier = 'privacy_compliance_webhooks' +export const PrivacyComplianceWebhooksSpecIdentifier = 'privacy_compliance_webhooks' // Uses the same schema as the webhooks specs because its content is nested under the same webhooks section const appPrivacyComplienceSpec = createConfigExtensionSpecification({ - identifier: PrivacyComplianceWebbhooksSpecIdentifier, + identifier: PrivacyComplianceWebhooksSpecIdentifier, schema: WebhookSchema, - transformConfig: PrivacyComplianceWebbhooksTransformConfig, + transformConfig: PrivacyComplianceWebhooksTransformConfig, + simplify: WebhookSimplifyConfig, }) export default appPrivacyComplienceSpec function transformToPrivacyComplianceWebhooksModule(content: object) { const webhooks = getPathValue(content, 'webhooks') as WebhooksConfig - if ( - webhooks?.privacy_compliance?.customer_deletion_url || - webhooks?.privacy_compliance?.customer_data_request_url || - webhooks?.privacy_compliance?.shop_deletion_url - ) { - return { - customers_redact_url: webhooks?.privacy_compliance?.customer_deletion_url, - customers_data_request_url: webhooks?.privacy_compliance?.customer_data_request_url, - shop_redact_url: webhooks?.privacy_compliance?.shop_deletion_url, - } - } - return {} + + return compact({ + customers_redact_url: getCustomersDeletionUri(webhooks), + customers_data_request_url: getCustomersDataRequestUri(webhooks), + shop_redact_url: getShopDeletionUri(webhooks), + }) } -function transformFromPrivacyComplianceWebhooksModule(content: object) { +function transformFromPrivacyComplianceWebhooksModule(content: object, options?: {flags?: Flag[]}) { const customersRedactUrl = getPathValue(content, 'customers_redact_url') as string const customersDataRequestUrl = getPathValue(content, 'customers_data_request_url') as string const shopRedactUrl = getPathValue(content, 'shop_redact_url') as string - if (customersRedactUrl?.length > 0 || customersDataRequestUrl?.length > 0 || shopRedactUrl?.length > 0) { + if (options?.flags?.includes(Flag.DeclarativeWebhooks)) { + const webhooks: WebhookSubscription[] = [] + if (customersRedactUrl) { + webhooks.push({compliance_topics: [ComplianceTopic.CustomersRedact], uri: customersRedactUrl}) + } + if (customersDataRequestUrl) { + webhooks.push({compliance_topics: [ComplianceTopic.CustomersDataRequest], uri: customersDataRequestUrl}) + } + if (shopRedactUrl) { + webhooks.push({compliance_topics: [ComplianceTopic.ShopRedact], uri: shopRedactUrl}) + } + + if (webhooks.length === 0) return {} + return {webhooks: {subscriptions: webhooks, privacy_compliance: undefined}} + } + + if (customersRedactUrl || customersDataRequestUrl || shopRedactUrl) { return { webhooks: { privacy_compliance: { - ...(customersRedactUrl?.length > 0 ? {customer_deletion_url: customersRedactUrl} : {}), - ...(customersDataRequestUrl?.length > 0 ? {customer_data_request_url: customersDataRequestUrl} : {}), - ...(shopRedactUrl?.length > 0 ? {shop_deletion_url: shopRedactUrl} : {}), + ...(customersRedactUrl ? {customer_deletion_url: customersRedactUrl} : {}), + ...(customersDataRequestUrl ? {customer_data_request_url: customersDataRequestUrl} : {}), + ...(shopRedactUrl ? {shop_deletion_url: shopRedactUrl} : {}), }, }, } } return {} } + +function getComplianceUri(webhooks: WebhooksConfig, complianceTopic: string): string | undefined { + return webhooks.subscriptions?.find((subscription) => subscription.compliance_topics?.includes(complianceTopic))?.uri +} + +function getCustomersDeletionUri(webhooks: WebhooksConfig) { + return getComplianceUri(webhooks, 'customers/redact') || webhooks?.privacy_compliance?.customer_deletion_url +} + +function getCustomersDataRequestUri(webhooks: WebhooksConfig) { + return getComplianceUri(webhooks, 'customers/data_request') || webhooks?.privacy_compliance?.customer_data_request_url +} + +function getShopDeletionUri(webhooks: WebhooksConfig) { + return getComplianceUri(webhooks, 'shop/redact') || webhooks?.privacy_compliance?.shop_deletion_url +} diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts index 02b4d46f1a..bd365ffdef 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts @@ -1,4 +1,5 @@ import spec from './app_config_webhook.js' +import {SpecsAppConfiguration} from './types/app_config.js' import {describe, expect, test} from 'vitest' describe('webhooks', () => { @@ -133,21 +134,11 @@ describe('webhooks', () => { const object = { api_version: '2024-01', subscriptions: [ - { - metafield_namespaces: ['id', 'size'], - topic: 'orders/delete', - uri: 'https://example.com/webhooks/orders', - }, { metafield_namespaces: ['id', 'size'], topic: 'orders/create', uri: 'https://example.com/webhooks/orders', }, - { - metafield_namespaces: ['id', 'size'], - topic: 'orders/edited', - uri: 'https://example.com/webhooks/orders', - }, { topic: 'products/create', uri: 'https://example.com/webhooks/products', @@ -157,32 +148,12 @@ describe('webhooks', () => { topic: 'metaobjects/create', uri: 'pubsub://absolute-feat-test:pub-sub-topic2', }, - { - sub_topic: 'type:metaobject_two', - topic: 'metaobjects/create', - uri: 'pubsub://absolute-feat-test:pub-sub-topic2', - }, - { - sub_topic: 'type:metaobject_one', - topic: 'metaobjects/update', - uri: 'pubsub://absolute-feat-test:pub-sub-topic2', - }, { include_fields: ['variants', 'title'], metafield_namespaces: ['size'], topic: 'orders/create', uri: 'https://valid-url', }, - { - sub_topic: 'type:metaobject_one', - topic: 'metaobjects/create', - uri: 'arn:aws:events:us-west-2::event-source/aws.partner/shopify.com/1234567890/SOME_PATH', - }, - { - sub_topic: 'type:metaobject_one', - topic: 'metaobjects/delete', - uri: 'arn:aws:events:us-west-2::event-source/aws.partner/shopify.com/1234567890/SOME_PATH', - }, ], } const webhookSpec = spec @@ -197,7 +168,7 @@ describe('webhooks', () => { subscriptions: [ { metafield_namespaces: ['id', 'size'], - topics: ['orders/delete', 'orders/create', 'orders/edited'], + topics: ['orders/create'], uri: 'https://example.com/webhooks/orders', }, { @@ -206,11 +177,6 @@ describe('webhooks', () => { }, { sub_topic: 'type:metaobject_one', - topics: ['metaobjects/create', 'metaobjects/update'], - uri: 'pubsub://absolute-feat-test:pub-sub-topic2', - }, - { - sub_topic: 'type:metaobject_two', topics: ['metaobjects/create'], uri: 'pubsub://absolute-feat-test:pub-sub-topic2', }, @@ -220,11 +186,6 @@ describe('webhooks', () => { topics: ['orders/create'], uri: 'https://valid-url', }, - { - sub_topic: 'type:metaobject_one', - topics: ['metaobjects/create', 'metaobjects/delete'], - uri: 'arn:aws:events:us-west-2::event-source/aws.partner/shopify.com/1234567890/SOME_PATH', - }, ], }, }) @@ -247,4 +208,83 @@ describe('webhooks', () => { }) }) }) + + describe('simplify', () => { + test('simplifies all webhooks, including privacy compliance webhooks, under the same [[webhook.subscription]] if they have the same fields', () => { + // Given + const remoteApp = { + name: 'test-app', + handle: 'test-app', + access_scopes: {scopes: 'write_products'}, + auth: { + redirect_urls: [ + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/auth/callback', + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/auth/shopify/callback', + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/api/auth/callback', + ], + }, + webhooks: { + api_version: '2024-01', + subscriptions: [ + { + topics: ['products/create'], + uri: 'https://example.com/webhooks', + }, + { + compliance_topics: ['customers/redact'], + uri: 'https://example.com/webhooks', + }, + { + compliance_topics: ['customers/data_request'], + uri: 'https://example.com/webhooks', + }, + { + topics: ['metaobjects/create'], + sub_topic: 'subtopic', + uri: 'https://example.com/webhooks', + }, + ], + privacy_compliance: undefined, + }, + pos: {embedded: false}, + application_url: 'https://decided-tabs-chevrolet-stating.trycloudflare.com', + embedded: true, + } as unknown as SpecsAppConfiguration + const webhookSpec = spec + // When + const result = webhookSpec.simplify!(remoteApp) + // Then + expect(result).toMatchObject({ + name: 'test-app', + handle: 'test-app', + access_scopes: {scopes: 'write_products'}, + auth: { + redirect_urls: [ + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/auth/callback', + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/auth/shopify/callback', + 'https://decided-tabs-chevrolet-stating.trycloudflare.com/api/auth/callback', + ], + }, + webhooks: { + api_version: '2024-01', + subscriptions: [ + { + topics: ['products/create'], + compliance_topics: ['customers/redact', 'customers/data_request'], + uri: 'https://example.com/webhooks', + }, + { + topics: ['metaobjects/create'], + sub_topic: 'subtopic', + uri: 'https://example.com/webhooks', + }, + ], + privacy_compliance: undefined, + }, + pos: {embedded: false}, + application_url: 'https://decided-tabs-chevrolet-stating.trycloudflare.com', + embedded: true, + }) + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts index 256edb07c7..849932b481 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts @@ -1,24 +1,35 @@ -import {transformToWebhookConfig, transformWebhookConfig} from './transform/app_config_webhook.js' +import {transformToWebhookConfig, transformFromWebhookConfig} from './transform/app_config_webhook.js' import {UriValidation, removeTrailingSlash} from './validation/common.js' import {webhookValidator} from './validation/app_config_webhook.js' -import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import {WebhookSubscription} from './types/app_config_webhook.js' +import {SpecsAppConfiguration} from './types/app_config.js' +import {CustomTransformationConfig, SimplifyConfig, createConfigExtensionSpecification} from '../specification.js' import {zod} from '@shopify/cli-kit/node/schema' +export enum ComplianceTopic { + CustomersRedact = 'customers/redact', + CustomersDataRequest = 'customers/data_request', + ShopRedact = 'shop/redact', +} + const WebhookSubscriptionSchema = zod.object({ topics: zod .array(zod.string({invalid_type_error: 'Values within array must be a string'}), { invalid_type_error: 'Value must be string[]', }) - .nonempty({message: "Value can't be empty"}), + .optional(), uri: zod.preprocess(removeTrailingSlash, UriValidation, {required_error: 'Missing value at'}), sub_topic: zod.string({invalid_type_error: 'Value must be a string'}).optional(), include_fields: zod.array(zod.string({invalid_type_error: 'Value must be a string'})).optional(), metafield_namespaces: zod.array(zod.string({invalid_type_error: 'Value must be a string'})).optional(), compliance_topics: zod - .array(zod.enum(['customers/redact', 'customers/data_request', 'shop/redact']), { - invalid_type_error: - 'Value must be an array containing values: customers/redact, customers/data_request or shop/redact', - }) + .array( + zod.enum([ComplianceTopic.CustomersRedact, ComplianceTopic.CustomersDataRequest, ComplianceTopic.ShopRedact]), + { + invalid_type_error: + 'Value must be an array containing values: customers/redact, customers/data_request or shop/redact', + }, + ) .optional(), }) @@ -43,14 +54,51 @@ export const WebhookSchema = zod.object({ export const WebhooksSpecIdentifier = 'webhooks' const WebhookTransformConfig: CustomTransformationConfig = { - forward: (content: object) => transformWebhookConfig(content), + forward: (content: object) => transformFromWebhookConfig(content), reverse: (content: object) => transformToWebhookConfig(content), } +export const WebhookSimplifyConfig: SimplifyConfig = { + simplify: (remoteConfig: SpecsAppConfiguration) => simplifyWebhooks(remoteConfig), +} + const appWebhooksSpec = createConfigExtensionSpecification({ identifier: WebhooksSpecIdentifier, schema: WebhookSchema, transformConfig: WebhookTransformConfig, + simplify: WebhookSimplifyConfig, }) export default appWebhooksSpec + +function simplifyWebhooks(remoteConfig: SpecsAppConfiguration) { + if (!remoteConfig.webhooks?.subscriptions) return remoteConfig + + remoteConfig.webhooks.subscriptions = mergeWebhooks(remoteConfig.webhooks.subscriptions) + return remoteConfig +} + +function mergeWebhooks(subscriptions: WebhookSubscription[]): WebhookSubscription[] { + return subscriptions.reduce((accumulator, subscription) => { + const existingSubscription = accumulator.find( + (sub) => + sub.uri === subscription.uri && + sub.sub_topic === subscription.sub_topic && + sub.include_fields === subscription.include_fields && + sub.metafield_namespaces === subscription.metafield_namespaces, + ) + if (existingSubscription) { + if (subscription.compliance_topics) { + existingSubscription.compliance_topics ??= [] + existingSubscription.compliance_topics.push(...subscription.compliance_topics) + } + if (subscription.topics) { + existingSubscription.topics ??= [] + existingSubscription.topics.push(...subscription.topics) + } + } else { + accumulator.push(subscription) + } + return accumulator + }, [] as WebhookSubscription[]) +} diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts index 97c4a87977..0532d65f4f 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts @@ -1,7 +1,7 @@ import {WebhooksConfig, NormalizedWebhookSubscription} from '../types/app_config_webhook.js' import {deepMergeObjects, getPathValue} from '@shopify/cli-kit/common/object' -export function transformWebhookConfig(content: object) { +export function transformFromWebhookConfig(content: object) { const webhooks = getPathValue(content, 'webhooks') as WebhooksConfig if (!webhooks) return content @@ -9,10 +9,9 @@ export function transformWebhookConfig(content: object) { // eslint-disable-next-line @typescript-eslint/naming-convention const {api_version, subscriptions = []} = webhooks - // eslint-disable-next-line no-warning-comments - // TODO: pass along compliance_topics once we're ready to store them in its own module + // Compliance topics are handled from app_config_privacy_compliance_webhooks.ts for (const {uri, topics, compliance_topics: _, ...optionalFields} of subscriptions) { - webhookSubscriptions.push(topics.map((topic) => ({uri, topic, ...optionalFields}))) + if (topics) webhookSubscriptions.push(topics.map((topic) => ({uri, topic, ...optionalFields}))) } return webhookSubscriptions.length > 0 ? {subscriptions: webhookSubscriptions.flat(), api_version} : {api_version} @@ -27,19 +26,8 @@ export function transformToWebhookConfig(content: object) { const webhooksSubscriptions: WebhooksConfig['subscriptions'] = [] - // eslint-disable-next-line @typescript-eslint/naming-convention - for (const {uri, topic, sub_topic, ...optionalFields} of serverWebhooks) { - const currSubscription = webhooksSubscriptions.find((sub) => sub.uri === uri && sub.sub_topic === sub_topic) - if (currSubscription) { - currSubscription.topics.push(topic) - } else { - webhooksSubscriptions.push({ - topics: [topic], - uri, - ...(sub_topic ? {sub_topic} : {}), - ...optionalFields, - }) - } + for (const {topic, ...otherFields} of serverWebhooks) { + webhooksSubscriptions.push({topics: [topic], ...otherFields}) } const webhooksSubscriptionsObject = webhooksSubscriptions.length > 0 ? {subscriptions: webhooksSubscriptions} : {} diff --git a/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts index 33150ecf30..fcf7d04391 100644 --- a/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts @@ -1,10 +1,10 @@ export interface WebhookSubscription { - topics: string[] uri: string + topics?: string[] + compliance_topics?: string[] sub_topic?: string include_fields?: string[] metafield_namespaces?: string[] - compliance_topics?: string[] } export interface PrivacyComplianceConfig { diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts index c0aac975ab..e23ff4792b 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts @@ -1,4 +1,5 @@ import {zod} from '@shopify/cli-kit/node/schema' +import {uniq} from '@shopify/cli-kit/common/array' import type {WebhooksConfig} from '../types/app_config_webhook.js' export function webhookValidator(schema: object, ctx: zod.RefinementCtx) { @@ -13,14 +14,41 @@ export function webhookValidator(schema: object, ctx: zod.RefinementCtx) { function validateSubscriptions(webhookConfig: WebhooksConfig) { const {subscriptions = []} = webhookConfig const uniqueSubscriptionSet = new Set() - const uniqueComplianceSubscriptionSet = new Set() if (!subscriptions.length) return + if ( + webhookConfig.privacy_compliance && + webhookConfig.subscriptions?.some((subscription) => subscription.compliance_topics) + ) { + return { + code: zod.ZodIssueCode.custom, + message: `The privacy_compliance section can't be used if there are subscriptions including compliance_topics`, + } + } + + const complianceTopics = subscriptions.flatMap((subscription) => subscription.compliance_topics).filter(Boolean) + if (uniq(complianceTopics).length !== complianceTopics.length) { + return { + code: zod.ZodIssueCode.custom, + message: 'You can’t have multiple subscriptions with the same compliance topic', + fatal: true, + path: ['subscriptions'], + } + } + // eslint-disable-next-line @typescript-eslint/naming-convention - for (const [i, {uri, topics, compliance_topics = [], sub_topic = ''}] of subscriptions.entries()) { + for (const [i, {uri, topics = [], compliance_topics = [], sub_topic = ''}] of subscriptions.entries()) { const path = ['subscriptions', i] + if (!topics.length && !compliance_topics.length) { + return { + code: zod.ZodIssueCode.custom, + message: `Either topics or compliance_topics must be added to the webhook subscription`, + path, + } + } + for (const [j, topic] of topics.entries()) { const key = `${topic}::${sub_topic}::${uri}` @@ -35,20 +63,5 @@ function validateSubscriptions(webhookConfig: WebhooksConfig) { uniqueSubscriptionSet.add(key) } - - for (const [j, complianceTopic] of compliance_topics.entries()) { - const key = `${complianceTopic}::${uri}` - - if (uniqueComplianceSubscriptionSet.has(key)) { - return { - code: zod.ZodIssueCode.custom, - message: 'You can’t have duplicate privacy compliance subscriptions with the exact same `uri`', - fatal: true, - path: [...path, 'compliance_topics', j, complianceTopic], - } - } - - uniqueComplianceSubscriptionSet.add(key) - } } } diff --git a/packages/app/src/cli/models/organization.ts b/packages/app/src/cli/models/organization.ts index 6afa29bdcf..0259c578fe 100644 --- a/packages/app/src/cli/models/organization.ts +++ b/packages/app/src/cli/models/organization.ts @@ -1,5 +1,5 @@ import {SpecsAppConfiguration} from './extensions/specifications/types/app_config.js' -import {BetaFlag} from '../services/dev/fetch.js' +import {Flag} from '../services/dev/fetch.js' export interface Organization { id: string @@ -26,7 +26,7 @@ export type OrganizationApp = MinimalOrganizationApp & { grantedScopes: string[] developmentStorePreviewEnabled?: boolean configuration?: SpecsAppConfiguration - betas: BetaFlag[] + flags: Flag[] } export interface OrganizationStore { diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index f3d9275907..c4ec3348f2 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -566,6 +566,103 @@ embedded = false }) }) + test('fetches the privacy compliance webhooks from the configuration module', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const remoteExtensionRegistrations = { + app: { + extensionRegistrations: [], + configurationRegistrations: [ + { + type: 'PRIVACY_COMPLIANCE_WEBHOOKS', + id: '123', + uuid: '123', + title: 'Privacy compliance webhooks', + activeVersion: { + config: JSON.stringify({ + shop_redact_url: null, + customers_redact_url: 'https://example.com/customers', + customers_data_request_url: 'https://example.com/customers', + }), + }, + }, + ], + dashboardManagedExtensionRegistrations: [], + }, + } + const options: LinkOptions = { + directory: tmp, + developerPlatformClient: testDeveloperPlatformClient({ + appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), + }), + } + + vi.mocked(loadApp).mockRejectedValue('App not found') + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp()) + const remoteConfiguration = { + ...DEFAULT_REMOTE_CONFIGURATION, + webhooks: { + api_version: '2023-07', + subscriptions: [ + { + compliance_topics: ['customers/redact', 'customers/data_request'], + uri: 'https://example.com/customers', + }, + ], + }, + } + vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue(remoteConfiguration) + + // When + await link(options) + + // Then + const content = await readFile(joinPath(tmp, 'shopify.app.toml')) + const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "app1" +application_url = "https://example.com" +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "https://example.com/customers" + compliance_topics = [ "customers/redact", "customers/data_request" ] + +[pos] +embedded = false +` + expect(content).toEqual(expectedContent) + expect(saveCurrentConfig).toHaveBeenCalledWith({configFileName: 'shopify.app.toml', directory: tmp}) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'shopify.app.toml is now linked to "app1" on Shopify', + body: 'Using shopify.app.toml as your default config.', + nextSteps: [ + [`Make updates to shopify.app.toml in your local project`], + ['To upload your config, run', {command: 'npm run shopify app deploy'}], + ], + reference: [ + { + link: { + label: 'App configuration', + url: 'https://shopify.dev/docs/apps/tools/cli/configuration', + }, + }, + ], + }) + }) + }) + test('the api client configuration is deep merged with the remote app_config extension registrations', async () => { await inTemporaryDirectory(async (tmp) => { // Given @@ -799,7 +896,7 @@ embedded = false async function mockApp( directory: string, app?: Partial, - betas = [], + flags = [], schemaType: 'current' | 'legacy' = 'legacy', ) { const versionSchema = await buildVersionedAppSchema() @@ -808,7 +905,7 @@ async function mockApp( localApp.configSchema = versionSchema.schema localApp.specifications = versionSchema.configSpecifications localApp.directory = directory - setPathValue(localApp, 'remoteBetaFlags', betas) + setPathValue(localApp, 'remoteFlags', flags) return localApp } diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 42bf5248e6..56e499b9f1 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -15,7 +15,7 @@ import { logMetadataForLoadedContext, appFromId, } from '../../context.js' -import {BetaFlag} from '../../dev/fetch.js' +import {Flag} from '../../dev/fetch.js' import {configurationFileNames} from '../../../constants.js' import {writeAppConfigurationFile} from '../write-app-configuration-file.js' import {getCachedCommandInfo} from '../../local-storage.js' @@ -51,7 +51,7 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t remoteApp, developerPlatformClient, localApp.specifications ?? [], - localApp.remoteBetaFlags, + localApp.remoteFlags, ) const replaceLocalArrayStrategy = (_destinationArray: unknown[], sourceArray: unknown[]) => sourceArray configuration = deepMergeObjects( @@ -88,7 +88,7 @@ async function loadLocalApp(options: LinkOptions, remoteApp: OrganizationApp, di developerPlatformClient: options.developerPlatformClient!, apiKey: remoteApp.apiKey, }) - const localApp = await loadAppOrEmptyApp(options, specifications, remoteApp.betas, remoteApp) + const localApp = await loadAppOrEmptyApp(options, specifications, remoteApp.flags, remoteApp) const configFileName = await loadConfigurationFileName(remoteApp, options, localApp) const configFilePath = joinPath(directory, configFileName) return { @@ -101,7 +101,7 @@ async function loadLocalApp(options: LinkOptions, remoteApp: OrganizationApp, di async function loadAppOrEmptyApp( options: LinkOptions, specifications?: ExtensionSpecification[], - remoteBetas?: BetaFlag[], + remoteFlags?: Flag[], remoteApp?: OrganizationApp, ): Promise { try { @@ -110,14 +110,14 @@ async function loadAppOrEmptyApp( directory: options.directory, mode: 'report', configName: options.baseConfigName, - remoteBetas, + remoteFlags, }) const configuration = app.configuration if (!isCurrentAppSchema(configuration) || remoteApp?.apiKey === configuration.client_id) return app - return new EmptyApp(await loadLocalExtensionsSpecifications(), remoteBetas, remoteApp?.apiKey) + return new EmptyApp(await loadLocalExtensionsSpecifications(), remoteFlags, remoteApp?.apiKey) // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { - return new EmptyApp(await loadLocalExtensionsSpecifications(), remoteBetas) + return new EmptyApp(await loadLocalExtensionsSpecifications(), remoteFlags) } } diff --git a/packages/app/src/cli/services/app/env/show.test.ts b/packages/app/src/cli/services/app/env/show.test.ts index 70854421b3..a28eebec7d 100644 --- a/packages/app/src/cli/services/app/env/show.test.ts +++ b/packages/app/src/cli/services/app/env/show.test.ts @@ -22,7 +22,7 @@ describe('env show', () => { const app = mockApp() const organization = { id: '123', - betas: {}, + flags: {}, businessName: 'test', website: '', apps: {nodes: []}, diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 980f299296..f22d608c34 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -1,6 +1,6 @@ import {MinimalOrganizationApp, OrganizationApp} from '../../models/organization.js' import {selectOrganizationPrompt, selectAppPrompt} from '../../prompts/dev.js' -import {BetaFlag, fetchOrganizations} from '../dev/fetch.js' +import {Flag, fetchOrganizations} from '../dev/fetch.js' import {ExtensionSpecification} from '../../models/extensions/specification.js' import {SpecsAppConfiguration} from '../../models/extensions/specifications/types/app_config.js' import { @@ -25,22 +25,26 @@ export async function fetchAppRemoteConfiguration( remoteApp: MinimalOrganizationApp, developerPlatformClient: DeveloperPlatformClient, specifications: ExtensionSpecification[], - betas: BetaFlag[], + flags: Flag[], ) { const activeAppVersion = await developerPlatformClient.activeAppVersion(remoteApp) const appModuleVersionsConfig = activeAppVersion?.appModuleVersions.filter((module) => module.specification?.experience === 'configuration') || [] - return remoteAppConfigurationExtensionContent( + const remoteConfiguration = remoteAppConfigurationExtensionContent( appModuleVersionsConfig, specifications, - betas, + flags, ) as unknown as SpecsAppConfiguration + return specifications.reduce( + (simplifiedConfiguration, spec) => spec.simplify?.(simplifiedConfiguration) ?? simplifiedConfiguration, + remoteConfiguration, + ) } export function remoteAppConfigurationExtensionContent( configRegistrations: AppModuleVersion[], specifications: ExtensionSpecification[], - betas: BetaFlag[], + flags: Flag[], ) { let remoteAppConfig: {[key: string]: unknown} = {} const configSpecifications = specifications.filter((spec) => spec.experience === 'configuration') @@ -52,7 +56,8 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.reverseTransform?.(config, {betas}) ?? config) + remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.reverseTransform?.(config, {flags}) ?? config) }) + return {...remoteAppConfig} } diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 5590f16df8..49e222c81d 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -206,7 +206,7 @@ export async function ensureDevContext( selectedApp, developerPlatformClient, specifications, - selectedApp.betas, + selectedApp.flags, ), } @@ -214,7 +214,7 @@ export async function ensureDevContext( directory: options.directory, specifications, configName: getAppConfigurationShorthand(configuration.path), - remoteBetas: selectedApp.betas, + remoteFlags: selectedApp.flags, }) // We only update the cache or config if the current app is the right one @@ -418,7 +418,7 @@ export async function ensureDeployContext(options: DeployContextOptions): Promis specifications, directory: options.app.directory, configName: getAppConfigurationShorthand(options.app.configuration.path), - remoteBetas: remoteApp.betas, + remoteFlags: remoteApp.flags, }) const org = await fetchOrgFromId(remoteApp.organizationId, developerPlatformClient) @@ -451,7 +451,7 @@ export async function ensureDeployContext(options: DeployContextOptions): Promis appType: remoteApp.appType, organizationId: remoteApp.organizationId, grantedScopes: remoteApp.grantedScopes, - betas: remoteApp.betas, + flags: remoteApp.flags, }, identifiers, release: !noRelease, @@ -582,7 +582,6 @@ function includeConfigOnDeployPrompt(configPath: string): Promise { * * If there is an API key via flag, configuration or env file, we check if it is valid. Otherwise, throw an error. * If there is no API key (or is invalid), show prompts to select an org and app. - * If the app doesn't have the simplified deployments beta enabled, throw an error. * Finally, the info is updated in the env file. * * @param options - Current dev context options @@ -623,11 +622,9 @@ interface VersionsListContextOutput { /** * Make sure there is a valid context to execute `versions list` - * That means we have a valid session, organization and app with the simplified deployments beta enabled. * * If there is an API key via flag, configuration or env file, we check if it is valid. Otherwise, throw an error. * If there is no API key (or is invalid), show prompts to select an org and app. - * If the app doesn't have the simplified deployments beta enabled, throw an error. * * @param options - Current dev context options * @returns The Developer Platform client and the app diff --git a/packages/app/src/cli/services/context/breakdown-extensions.test.ts b/packages/app/src/cli/services/context/breakdown-extensions.test.ts index 8acb3d2dd1..22826d0da4 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.test.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.test.ts @@ -214,7 +214,7 @@ const APP_CONFIGURATION: CurrentAppConfiguration = { const LOCAL_APP = async ( uiExtensions: ExtensionInstance[], configuration: AppConfiguration = APP_CONFIGURATION, - betas = [], + flags = [], ): Promise => { const versionSchema = await buildVersionedAppSchema() @@ -227,7 +227,7 @@ const LOCAL_APP = async ( configSchema: versionSchema.schema, }) - setPathValue(localApp, 'remoteBetaFlags', betas) + setPathValue(localApp, 'remoteFlags', flags) return localApp } diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index ebef88f9ba..677df5db40 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -129,10 +129,10 @@ async function resolveRemoteConfigExtensionIdentifiersBreakdown( remoteApp, developerPlatformClient, app.specifications ?? [], - app.remoteBetaFlags, + app.remoteFlags, ) const baselineConfig = versionAppModules - ? remoteAppConfigurationExtensionContent(versionAppModules, app.specifications ?? [], app.remoteBetaFlags) + ? remoteAppConfigurationExtensionContent(versionAppModules, app.specifications ?? [], app.remoteFlags) : app.configuration const diffConfigContent = buildDiffConfigContent( baselineConfig as CurrentAppConfiguration, diff --git a/packages/app/src/cli/services/context/identifiers-extensions.test.ts b/packages/app/src/cli/services/context/identifiers-extensions.test.ts index d04c69dd51..c00f0cda2e 100644 --- a/packages/app/src/cli/services/context/identifiers-extensions.test.ts +++ b/packages/app/src/cli/services/context/identifiers-extensions.test.ts @@ -113,7 +113,7 @@ const options = ( release?: boolean includeDeployConfig?: boolean configExtensions?: ExtensionInstance[] - betas?: string[] + flags?: string[] developerPlatformClient?: DeveloperPlatformClient } = {}, ): EnsureDeploymentIdsPresenceOptions => { @@ -123,7 +123,7 @@ const options = ( release = true, includeDeployConfig = false, configExtensions = [], - betas = [], + flags = [], developerPlatformClient = testDeveloperPlatformClient(), } = options const localApp = { @@ -136,7 +136,7 @@ const options = ( remoteApp, release, } - setPathValue(localApp.app, 'remoteBetaFlags', betas) + setPathValue(localApp.app, 'remoteFlags', flags) return localApp } diff --git a/packages/app/src/cli/services/deploy.test.ts b/packages/app/src/cli/services/deploy.test.ts index 41d13f9b49..d80be422a8 100644 --- a/packages/app/src/cli/services/deploy.test.ts +++ b/packages/app/src/cli/services/deploy.test.ts @@ -65,7 +65,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { noRelease: false, @@ -96,7 +96,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { message: 'Deployed from CLI with flag', @@ -125,7 +125,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { version: '1.1.0', @@ -154,7 +154,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, developerPlatformClient, }) @@ -371,7 +371,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { noRelease: false, @@ -410,7 +410,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { noRelease: false, @@ -451,7 +451,7 @@ describe('deploy', () => { organizationId: 'org-id', title: 'app-title', grantedScopes: [], - betas: [], + flags: [], }, options: { noRelease: true, diff --git a/packages/app/src/cli/services/dev/fetch.ts b/packages/app/src/cli/services/dev/fetch.ts index 8b803f1f87..0d3d6b18cb 100644 --- a/packages/app/src/cli/services/dev/fetch.ts +++ b/packages/app/src/cli/services/dev/fetch.ts @@ -121,9 +121,13 @@ export async function fetchOrgAndApps( return {organization: parsedOrg, apps: {...org.apps, nodes: appsWithOrg}, stores: []} } -export enum BetaFlag {} +export enum Flag { + DeclarativeWebhooks, +} -const FlagMap: {[key: string]: BetaFlag} = {} +const FlagMap: {[key: string]: Flag} = { + '5b25141b': Flag.DeclarativeWebhooks, +} export async function fetchAppDetailsFromApiKey(apiKey: string, token: string): Promise { const res: FindAppQuerySchema = await partnersRequest(FindAppQuery, token, { @@ -131,15 +135,15 @@ export async function fetchAppDetailsFromApiKey(apiKey: string, token: string): }) const app = res.app if (app) { - const betas = filterDisabledBetas(app.disabledBetas) - return {...app, betas} + const flags = filterDisabledFlags(app.disabledFlags) + return {...app, flags} } } -export function filterDisabledBetas(disabledBetas: string[] = []): BetaFlag[] { - const defaultActiveBetas: BetaFlag[] = [] - const remoteDisabledFlags = disabledBetas.map((flag) => FlagMap[flag]) - return defaultActiveBetas.filter((beta) => !remoteDisabledFlags.includes(beta)) +export function filterDisabledFlags(disabledFlags: string[] = []): Flag[] { + const defaultActiveFlags: Flag[] = [Flag.DeclarativeWebhooks] + const remoteDisabledFlags = disabledFlags.map((flag) => FlagMap[flag]) + return defaultActiveFlags.filter((flag) => !remoteDisabledFlags.includes(flag)) } export async function fetchAppPreviewMode( diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index f4ba19173c..6c83a0f2f1 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -98,7 +98,7 @@ describe('setup-dev-processes', () => { title: 'App', organizationId: '5678', grantedScopes: [], - betas: [], + flags: [], } const graphiqlKey = 'somekey' diff --git a/packages/app/src/cli/services/draft-extensions/push.test.ts b/packages/app/src/cli/services/draft-extensions/push.test.ts index 62e0306e59..b7f3674fd7 100644 --- a/packages/app/src/cli/services/draft-extensions/push.test.ts +++ b/packages/app/src/cli/services/draft-extensions/push.test.ts @@ -56,7 +56,7 @@ const remoteApp = { applicationUrl: 'https://example.com', redirectUrlWhitelist: [], apiSecretKeys: [], - betas: [], + flags: [], } describe('draftExtensionsPush', () => { diff --git a/packages/app/src/cli/services/generate.ts b/packages/app/src/cli/services/generate.ts index 72d19d68b0..cf9e09ba15 100644 --- a/packages/app/src/cli/services/generate.ts +++ b/packages/app/src/cli/services/generate.ts @@ -195,7 +195,7 @@ async function handleTypeParameter( if (!extensionTemplate) { const isShopifolk = await isShopify() const allExternalTypes = extensionTemplates.map((spec) => spec.identifier) - const tryMsg = isShopifolk ? 'You might need to enable some beta flags on your Organization or App' : undefined + const tryMsg = isShopifolk ? 'You might need to enable some flags on your Organization or App' : undefined throw new AbortError( `Unknown extension type: ${typeFlag}.\nThe following extension types are supported: ${allExternalTypes.join( ', ', diff --git a/packages/app/src/cli/services/import-flow-legacy-extensions.test.ts b/packages/app/src/cli/services/import-flow-legacy-extensions.test.ts index 0928ac6f10..f1400a88c0 100644 --- a/packages/app/src/cli/services/import-flow-legacy-extensions.test.ts +++ b/packages/app/src/cli/services/import-flow-legacy-extensions.test.ts @@ -21,7 +21,7 @@ const organizationApp: OrganizationApp = { organizationId: 'organizationId', apiSecretKeys: [], grantedScopes: [], - betas: [], + flags: [], } const flowExtensionA: ExtensionRegistration = { diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 782ec102d4..76901db73a 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -29,7 +29,7 @@ const APP1 = testOrganizationApp({id: '123', title: 'my app', apiKey: '12345'}) const ORG1 = { id: '123', - betas: {}, + flags: {}, businessName: 'test', website: '', apps: {nodes: []}, diff --git a/packages/app/src/cli/services/release.test.ts b/packages/app/src/cli/services/release.test.ts index 031d328b6e..8f2e945994 100644 --- a/packages/app/src/cli/services/release.test.ts +++ b/packages/app/src/cli/services/release.test.ts @@ -27,7 +27,7 @@ const APP = { applicationUrl: 'https://example.com', redirectUrlWhitelist: [], apiSecretKeys: [], - betas: [], + flags: [], } beforeEach(() => { diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index ce697c2fa3..ab06ac3ffa 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -37,7 +37,7 @@ describe('versions-list', () => { organizationId: ORG1.id, apiSecretKeys: [], grantedScopes: [], - betas: [], + flags: [], }, }) // vi.mocked(fetchOrgFromId).mockResolvedValue(ORG1) diff --git a/packages/app/src/cli/services/webhook/find-app-info.test.ts b/packages/app/src/cli/services/webhook/find-app-info.test.ts index 4abc03452d..f970c6dc71 100644 --- a/packages/app/src/cli/services/webhook/find-app-info.test.ts +++ b/packages/app/src/cli/services/webhook/find-app-info.test.ts @@ -62,7 +62,7 @@ describe('findOrganizationApp', () => { const org = { id: '1', businessName: 'org1', - betas: {}, + flags: {}, website: 'http://example.org', } const anApp = {id: '1', title: anAppName, apiKey: anApiKey, organizationId: org.id} diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index a5fa21bf0a..417dec93b4 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -6,7 +6,7 @@ import { } from '../../api/graphql/all_dev_stores_by_org.js' import {ActiveAppVersion, DeveloperPlatformClient, Paginateable} from '../developer-platform-client.js' import {fetchCurrentAccountInformation, PartnersSession} from '../../../cli/services/context/partner-account-info.js' -import {fetchAppDetailsFromApiKey, fetchOrgAndApps, filterDisabledBetas} from '../../../cli/services/dev/fetch.js' +import {fetchAppDetailsFromApiKey, fetchOrgAndApps, filterDisabledFlags} from '../../../cli/services/dev/fetch.js' import { MinimalAppIdentifiers, MinimalOrganizationApp, @@ -271,8 +271,8 @@ export class PartnersClient implements DeveloperPlatformClient { throw new AbortError(errors) } - const betas = filterDisabledBetas(result.appCreate.app.disabledBetas) - return {...result.appCreate.app, organizationId: org.id, newApp: true, betas} + const flags = filterDisabledFlags(result.appCreate.app.disabledFlags) + return {...result.appCreate.app, organizationId: org.id, newApp: true, flags} } async devStoresForOrg(orgId: string): Promise { diff --git a/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts b/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts index cacb4f7038..8cd61d4a6f 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts @@ -22,7 +22,6 @@ import { import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import {DeveloperPlatformClient, Paginateable, ActiveAppVersion} from '../developer-platform-client.js' import {PartnersSession} from '../../../cli/services/context/partner-account-info.js' -import {filterDisabledBetas} from '../../../cli/services/dev/fetch.js' import { MinimalAppIdentifiers, MinimalOrganizationApp, @@ -30,6 +29,7 @@ import { OrganizationApp, OrganizationStore, } from '../../models/organization.js' +import {filterDisabledFlags} from '../../../cli/services/dev/fetch.js' import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' import { GenerateSignedUploadUrlSchema, @@ -129,7 +129,7 @@ export class ShopifyDevelopersClient implements DeveloperPlatformClient { organizationId: appIdentifiers.organizationId, apiSecretKeys: [], grantedScopes: appAccessModule.config.scopes as string[], - betas: [], + flags: [], } } @@ -210,7 +210,7 @@ export class ShopifyDevelopersClient implements DeveloperPlatformClient { } // Need to figure this out still - const betas = filterDisabledBetas([]) + const flags = filterDisabledFlags([]) const createdApp = result.appCreate.app return { ...createdApp, @@ -220,7 +220,7 @@ export class ShopifyDevelopersClient implements DeveloperPlatformClient { grantedScopes: options?.scopesArray ?? [], organizationId: org.id, newApp: true, - betas, + flags, } } diff --git a/packages/cli-kit/src/public/common/array.test.ts b/packages/cli-kit/src/public/common/array.test.ts index 2b2bf088f6..720becbac0 100644 --- a/packages/cli-kit/src/public/common/array.test.ts +++ b/packages/cli-kit/src/public/common/array.test.ts @@ -1,4 +1,4 @@ -import {difference, uniqBy} from './array.js' +import {difference, uniq, uniqBy} from './array.js' import {describe, test, expect} from 'vitest' describe('uniqBy', () => { @@ -36,6 +36,19 @@ describe('uniqBy', () => { }) }) +describe('uniq', () => { + test('removes duplicates', () => { + // Given + const array = [1, 2, 2, 3] + + // When + const got = uniq(array) + + // Then + expect(got).toEqual([1, 2, 3]) + }) +}) + describe('difference', () => { test('returns the different elements', () => { // Given diff --git a/packages/cli-kit/src/public/common/array.ts b/packages/cli-kit/src/public/common/array.ts index 0ccae26114..8a44dd71fc 100644 --- a/packages/cli-kit/src/public/common/array.ts +++ b/packages/cli-kit/src/public/common/array.ts @@ -32,6 +32,16 @@ export function getArrayContainsDuplicates(array: T[]): boolean { return array.length !== new Set(array).size } +/** + * Removes duplicated items from an array. + * + * @param array - The array to inspect. + * @returns Returns the new duplicate free array. + */ +export function uniq(array: T[]): T[] { + return Array.from(new Set(array)) +} + /** * This method is like `_.uniq` except that it accepts `iteratee` which is * invoked for each element in `array` to generate the criterion by which diff --git a/packages/cli-kit/src/public/common/object.test.ts b/packages/cli-kit/src/public/common/object.test.ts index aa363f9c1d..5ce97201ff 100644 --- a/packages/cli-kit/src/public/common/object.test.ts +++ b/packages/cli-kit/src/public/common/object.test.ts @@ -1,4 +1,13 @@ -import {deepCompare, deepDifference, deepMergeObjects, getPathValue, mapValues, pickBy, setPathValue} from './object.js' +import { + compact, + deepCompare, + deepDifference, + deepMergeObjects, + getPathValue, + mapValues, + pickBy, + setPathValue, +} from './object.js' import {describe, expect, test} from 'vitest' describe('deepMergeObjects', () => { @@ -233,3 +242,36 @@ describe('setPathValue', () => { expect(getPathValue(result, 'key1')).toEqual({key11: 2}) }) }) + +describe('compact', () => { + test('removes the undefined elements from the object', () => { + // Given + const obj: object = { + key1: '1', + key2: undefined, + key3: false, + key4: null, + key5: 0, + } + + // When + const result = compact(obj) + + // Then + expect(Object.keys(result)).toEqual(['key1', 'key3', 'key5']) + }) + + test('returns an empty object when all the values are undefined', () => { + // Given + const obj: object = { + key1: undefined, + key2: null, + } + + // When + const result = compact(obj) + + // Then + expect(result).toEqual({}) + }) +}) diff --git a/packages/cli-kit/src/public/common/object.ts b/packages/cli-kit/src/public/common/object.ts index 0697cfea82..a44e66e420 100644 --- a/packages/cli-kit/src/public/common/object.ts +++ b/packages/cli-kit/src/public/common/object.ts @@ -112,3 +112,13 @@ export function setPathValue(object: object, path: string, value?: unknown): obj export function isEmpty(object: object): boolean { return lodashIsEmpty(object) } + +/** + * Removes the undefined elements. + * + * @param object - The object whose undefined will be deleted. + * @returns A copy of the object with the undefined elements deleted. + */ +export function compact(object: object): object { + return Object.fromEntries(Object.entries(object).filter(([_, value]) => value != null)) +}