diff --git a/packages/app/src/cli/commands/app/generate/extension.ts b/packages/app/src/cli/commands/app/generate/extension.ts index d932fe6cb3f..630bc9c39d9 100644 --- a/packages/app/src/cli/commands/app/generate/extension.ts +++ b/packages/app/src/cli/commands/app/generate/extension.ts @@ -8,6 +8,7 @@ import {linkedAppContext} from '../../../services/app-context.js' import {Args, Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' import {renderWarning} from '@shopify/cli-kit/node/ui' +import { workflowFlags} from '../../../services/generate/workflows/registry.js' export default class AppGenerateExtension extends AppCommand { static summary = 'Generate a new app Extension.' @@ -23,6 +24,7 @@ export default class AppGenerateExtension extends AppCommand { static flags = { ...globalFlags, ...appFlags, + ...workflowFlags(), type: Flags.string({ char: 't', hidden: false, 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 4cac2ecdbd9..0410129eb21 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1084,6 +1084,13 @@ export const testRemoteExtensionTemplates: ExtensionTemplate[] = [ path: 'discounts/rust/product-discounts/default', }, ], + relatedExtensions: [ + { + type: 'ui_extension', + name: 'discount_function_settings', + directory: 'discount_ui/discount_ui', + }, + ], }, { identifier: 'order_discounts', @@ -1112,6 +1119,23 @@ export const testRemoteExtensionTemplates: ExtensionTemplate[] = [ }, ], }, + { + identifier: 'discount_function_settings', + name: 'Function - Discount settings', + defaultName: 'discount-function-settings', + group: 'Discounts and checkout', + supportLinks: [], + type: 'function', + url: 'https://github.com/Shopify/function-examples', + extensionPoints: [], + supportedFlavors: [ + { + name: 'JavaScript', + value: 'vanilla-js', + path: 'discounts/javascript/discount-function-settings/default', + }, + ], + }, productSubscriptionUIExtensionTemplate, themeAppExtensionTemplate, ] diff --git a/packages/app/src/cli/models/app/template.ts b/packages/app/src/cli/models/app/template.ts index 43d3acf229d..c483737983a 100644 --- a/packages/app/src/cli/models/app/template.ts +++ b/packages/app/src/cli/models/app/template.ts @@ -17,4 +17,9 @@ export interface ExtensionTemplate { extensionPoints: string[] supportedFlavors: ExtensionFlavor[] url: string + relatedExtensions?: { + type: string + name: string + directory: string + }[] } diff --git a/packages/app/src/cli/prompts/generate/extension.ts b/packages/app/src/cli/prompts/generate/extension.ts index fdb61284f6b..f202541ac78 100644 --- a/packages/app/src/cli/prompts/generate/extension.ts +++ b/packages/app/src/cli/prompts/generate/extension.ts @@ -2,7 +2,12 @@ import {AppInterface} from '../../models/app/app.js' import {ExtensionFlavorValue} from '../../services/generate/extension.js' import {ExtensionTemplate} from '../../models/app/template.js' import {fileExistsSync} from '@shopify/cli-kit/node/fs' -import {renderAutocompletePrompt, renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' +import { + renderAutocompletePrompt, + renderConfirmationPrompt, + renderSelectPrompt, + renderTextPrompt, +} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' import {joinPath} from '@shopify/cli-kit/node/path' import {slugify} from '@shopify/cli-kit/common/string' @@ -26,6 +31,11 @@ export interface GenerateExtensionPromptOutput { export interface GenerateExtensionContentOutput { name: string flavor?: ExtensionFlavorValue + relatedExtensions?: { + type: string + name: string + directory: string + }[] } export function buildChoices(extensionTemplates: ExtensionTemplate[], unavailableExtensions: ExtensionTemplate[] = []) { @@ -63,13 +73,12 @@ export function buildChoices(extensionTemplates: ExtensionTemplate[], unavailabl return templateSpecChoices.sort(compareChoices) } -const generateExtensionPrompts = async ( +export const generateExtensionPrompts = async ( options: GenerateExtensionPromptOptions, ): Promise => { let extensionTemplates = options.extensionTemplates let templateType = options.templateType const extensionFlavor = options.extensionFlavor - if (!templateType) { if (extensionFlavor) { extensionTemplates = extensionTemplates.filter((template) => @@ -81,11 +90,15 @@ const generateExtensionPrompts = async ( throw new AbortError('You have reached the limit for the number of extensions you can create.') } - // eslint-disable-next-line require-atomic-updates - templateType = await renderAutocompletePrompt({ - message: 'Type of extension?', - choices: buildChoices(extensionTemplates, options.unavailableExtensions), - }) + if (extensionTemplates.length === 1) { + templateType = extensionTemplates[0]?.identifier + } else { + // eslint-disable-next-line require-atomic-updates + templateType = await renderAutocompletePrompt({ + message: 'Type of extension?', + choices: buildChoices(extensionTemplates, options.unavailableExtensions), + }) + } } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -93,11 +106,21 @@ const generateExtensionPrompts = async ( const name = options.name || (await promptName(options.directory, extensionTemplate.defaultName)) const flavor = options.extensionFlavor ?? (await promptFlavor(extensionTemplate)) - const extensionContent = {name, flavor} + const extensionContent = { + name, + flavor, + } return {extensionTemplate, extensionContent} } +const promptAddExtensionConfirmation = (): Promise => { + return renderConfirmationPrompt({ + message: 'Would you like to create the function extension?', + defaultValue: true, + }) +} + async function promptName(directory: string, defaultName: string, number = 1): Promise { const separator = defaultName.includes(' ') ? ' ' : '-' const name = number <= 1 ? defaultName : `${defaultName}${separator}${number}` @@ -134,3 +157,4 @@ async function promptFlavor(extensionTemplate: ExtensionTemplate): Promise { │ • To preview this extension along with the rest of the project, run │ │ \`yarn shopify app dev\` │ │ │ - ╰──────────────────────────────────────────────────────────────────────────────╯ + ╰─────────────────────────────────────���────────────────────────────────────────╯ " `) }) @@ -207,6 +207,71 @@ describe('generate', () => { // Then await expect(got).rejects.toThrow(/Invalid template for extension type/) }) + + test.only('creates related extensions in a shared directory with common configuration', async () => { + const outputInfo = await mockSuccessfulCommandExecution('product_discounts') + + const productDiscountsTemplate = testRemoteExtensionTemplates.find( + (spec) => spec.identifier === 'product_discounts', + ) + if (!productDiscountsTemplate) throw new Error('Product discounts template not found') + + vi.mocked(generateExtensionPrompts).mockResolvedValue({ + extensionTemplate: productDiscountsTemplate, + extensionContent: { + name: 'product_discounts', + flavor: 'vanilla-js', + relatedExtensions: [ + { + type: 'function', + name: 'product_discounts', + directory: 'product_discounts_function/product_discounts_function', + }, + { + type: 'ui_extension', + name: 'discount_function_settings', + directory: 'discount_ui/discount_ui', + }, + ], + }, + }) + + // When + await generate({ + directory: '/', + reset: false, + app, + remoteApp, + specifications, + developerPlatformClient, + }) + + // Then + expect(generateExtensionTemplate).toHaveBeenCalledTimes(2) + + // Verify first extension (UI) + expect(generateExtensionTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + extensionContent: expect.objectContaining({ + name: 'discount_function_settings', + type: 'ui_extension', + }), + }), + ) + + // Verify second extension (function) + expect(generateExtensionTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + extensionContent: expect.objectContaining({ + name: 'product_discounts', + type: 'function', + }), + }), + ) + + // Verify shared configuration file was created + expect(outputInfo.info()).toContain('extensions/google-maps-validation') + }) }) async function mockSuccessfulCommandExecution(identifier: string, existingExtensions: ExtensionInstance[] = []) { diff --git a/packages/app/src/cli/services/generate.ts b/packages/app/src/cli/services/generate.ts index 360765ee94c..04b4f4a99c0 100644 --- a/packages/app/src/cli/services/generate.ts +++ b/packages/app/src/cli/services/generate.ts @@ -1,4 +1,5 @@ import {fetchExtensionTemplates} from './generate/fetch-template-specifications.js' +import {workflowRegistry, WorkflowResult} from './generate/workflows/registry.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' import generateExtensionPrompts, { @@ -23,7 +24,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output' import {groupBy} from '@shopify/cli-kit/common/collection' -interface GenerateOptions { +export interface GenerateOptions { app: AppLinkedInterface specifications: RemoteAwareExtensionSpecification[] remoteApp: OrganizationApp @@ -50,10 +51,27 @@ async function generate(options: GenerateOptions) { const generateExtensionOptions = buildGenerateOptions(promptAnswers, app, options, developerPlatformClient) const generatedExtension = await generateExtensionTemplate(generateExtensionOptions) - renderSuccessMessage(generatedExtension, app.packageManager) + const workflow = workflowRegistry[generatedExtension.extensionTemplate.identifier] + if (!workflow) { + renderSuccessMessage(generatedExtension, app.packageManager) + return + } + + const workflowResult = await workflow?.afterGenerate({ + generateOptions: options, + generatedExtension, + extensionTemplateOptions: generateExtensionOptions, + extensionTemplates, + }) + + if (!workflowResult?.success) { + // TODO: Cleanup extension? + } + + renderSuccessMessage(generatedExtension, app.packageManager, workflowResult) } -async function buildPromptOptions( +export async function buildPromptOptions( extensionTemplates: ExtensionTemplate[], specifications: ExtensionSpecification[], app: AppInterface, @@ -85,6 +103,7 @@ function checkLimits( const allValid = !limitReached(app, specifications, template) return allValid ? 'validTemplates' : 'templatesOverlimit' } + return groupBy(extensionTemplates, iterateeFunction) } @@ -104,7 +123,7 @@ async function saveAnalyticsMetadata(promptAnswers: GenerateExtensionPromptOutpu })) } -function buildGenerateOptions( +export function buildGenerateOptions( promptAnswers: GenerateExtensionPromptOutput, app: AppInterface, options: GenerateOptions, @@ -119,11 +138,16 @@ function buildGenerateOptions( } } -function renderSuccessMessage(extension: GeneratedExtension, packageManager: AppInterface['packageManager']) { +function renderSuccessMessage( + extension: GeneratedExtension, + packageManager: AppInterface['packageManager'], + workflowResult?: WorkflowResult, +) { const formattedSuccessfulMessage = formatSuccessfulRunMessage( extension.extensionTemplate, extension.directory, packageManager, + workflowResult, ) renderSuccess(formattedSuccessfulMessage) } @@ -145,11 +169,17 @@ function formatSuccessfulRunMessage( extensionTemplate: ExtensionTemplate, extensionDirectory: string, depndencyManager: PackageManager, + workflowResult?: WorkflowResult, ): RenderAlertOptions { + const workflowMessage = workflowResult?.message const options: RenderAlertOptions = { - headline: ['Your extension was created in', {filePath: extensionDirectory}, {char: '.'}], - nextSteps: [], - reference: [], + headline: workflowMessage?.headline || [ + 'Your extension was created in', + {filePath: extensionDirectory}, + {char: '.'}, + ], + nextSteps: workflowMessage?.nextSteps || [], + reference: workflowMessage?.reference || [], } if (extensionTemplate.type !== 'function') { diff --git a/packages/app/src/cli/services/generate/extension.ts b/packages/app/src/cli/services/generate/extension.ts index 5afacabf85f..23a97653570 100644 --- a/packages/app/src/cli/services/generate/extension.ts +++ b/packages/app/src/cli/services/generate/extension.ts @@ -60,6 +60,7 @@ function getTemplateLanguage(flavor: ExtensionFlavorValue | undefined): Template export interface GeneratedExtension { directory: string extensionTemplate: ExtensionTemplate + handle: string } interface ExtensionInitOptions { @@ -72,7 +73,7 @@ interface ExtensionInitOptions { uid: string | undefined onGetTemplateRepository: (url: string, destination: string) => Promise } - +// This might become plural? export async function generateExtensionTemplate( options: GenerateExtensionTemplateOptions, ): Promise { @@ -99,7 +100,11 @@ export async function generateExtensionTemplate( }), } await extensionInit(initOptions) - return {directory: relativizePath(directory), extensionTemplate: options.extensionTemplate} + return { + directory: relativizePath(directory), + extensionTemplate: options.extensionTemplate, + handle: slugify(extensionName), + } } async function extensionInit(options: ExtensionInitOptions) { @@ -152,6 +157,7 @@ async function functionExtensionInit({ uid, onGetTemplateRepository, }: ExtensionInitOptions) { + // Here we'd want to write the ui extension handle in the function toml const templateLanguage = getTemplateLanguage(extensionFlavor?.value) const taskList = [] @@ -168,6 +174,8 @@ async function functionExtensionInit({ await recursiveLiquidTemplateCopy(templateDirectory, directory, { name, handle: slugify(name), + // TODO: This is where we'd want to write the ui extension handle in the function toml + uiExtensionHandle: slugify(name), flavor: extensionFlavor?.value, uid, }) @@ -221,7 +229,6 @@ async function uiExtensionInit({ onGetTemplateRepository, }: ExtensionInitOptions) { const templateLanguage = getTemplateLanguage(extensionFlavor?.value) - const tasks = [ { title: `Generating extension`, diff --git a/packages/app/src/cli/services/generate/fetch-template-specifications.ts b/packages/app/src/cli/services/generate/fetch-template-specifications.ts index 28c6cbc9ce2..69f06b5dd07 100644 --- a/packages/app/src/cli/services/generate/fetch-template-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-template-specifications.ts @@ -8,8 +8,7 @@ export async function fetchExtensionTemplates( availableSpecifications: string[], ): Promise { const remoteTemplates: ExtensionTemplate[] = await developerPlatformClient.templateSpecifications(app) - return remoteTemplates.filter( - (template) => - availableSpecifications.includes(template.identifier) || availableSpecifications.includes(template.type), - ) + return remoteTemplates.filter((template) => { + return availableSpecifications.includes(template.identifier) || availableSpecifications.includes(template.type) + }) } diff --git a/packages/app/src/cli/services/generate/workflows/discount-details-function-settings-collection.ts b/packages/app/src/cli/services/generate/workflows/discount-details-function-settings-collection.ts new file mode 100644 index 00000000000..e3e52fee4e4 --- /dev/null +++ b/packages/app/src/cli/services/generate/workflows/discount-details-function-settings-collection.ts @@ -0,0 +1,42 @@ +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {Workflow} from './registry.js' +import {generateExtensionTemplate} from '../extension.js' +import {generateExtensionPrompts} from '../../../prompts/generate/extension.js' +import {buildGenerateOptions, buildPromptOptions} from '../../generate.js' + +export const discountDetailsFunctionSettingsCollection: Workflow = { + afterGenerate: async (options) => { + const {app, developerPlatformClient, specifications} = options.generateOptions + + const shouldCreateFunction = await renderConfirmationPrompt({ + message: 'Would you like to create a function for this extension?', + defaultValue: true, + }) + + if (shouldCreateFunction) { + // create a function extension + const extensionTemplates = options.extensionTemplates.filter( + (template) => + template.identifier === 'shipping_discounts' || + template.identifier === 'product_discounts' || + template.identifier === 'order_discounts' || + template.identifier == 'discounts_allocator', + ) + + const promptOptions = await buildPromptOptions(extensionTemplates, specifications, app, options.generateOptions) + const promptAnswers = await generateExtensionPrompts(promptOptions) + + const generateExtensionOptions = buildGenerateOptions( + promptAnswers, + app, + options.generateOptions, + developerPlatformClient, + ) + const generatedExtension = await generateExtensionTemplate(generateExtensionOptions) + } + + return { + success: true, + } + }, +} diff --git a/packages/app/src/cli/services/generate/workflows/editor-extension-collection.ts b/packages/app/src/cli/services/generate/workflows/editor-extension-collection.ts new file mode 100644 index 00000000000..6c01bb0878c --- /dev/null +++ b/packages/app/src/cli/services/generate/workflows/editor-extension-collection.ts @@ -0,0 +1,36 @@ +import {Workflow} from './registry.js' +import {patchConfigurationFile} from './patch-configuration-file.js' +import {joinPath} from '@shopify/cli-kit/node/path' +import {renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {Flags} from '@oclif/core' + +export const editorExtensionCollection: Workflow = { + afterGenerate: async (options) => { + const existingExtensions = options.generateOptions.app.extensionsForType({ + identifier: 'ui_extension', + externalIdentifier: 'ui_extension', + }) + const availableExtensions = existingExtensions.map((extension) => extension.handle).join(' ') + const extensions = await renderTextPrompt({ + message: `The extension handles to include in the collection, comma separated. Options: ${availableExtensions}`, + }) + await patchConfigurationFile({ + path: joinPath(options.generatedExtension.directory, 'shopify.extension.toml'), + patch: { + extensions: [ + { + includes: extensions.split(',').map((handle) => handle.trim()), + }, + ], + }, + }) + return { + success: true, + } + }, + flags: { + 'editor-collection-include': Flags.string({ + description: 'The extension handles to include in the collection, comma separated.', + }), + }, +} diff --git a/packages/app/src/cli/services/generate/workflows/function-with-admin-ui.ts b/packages/app/src/cli/services/generate/workflows/function-with-admin-ui.ts new file mode 100644 index 00000000000..f18b653a665 --- /dev/null +++ b/packages/app/src/cli/services/generate/workflows/function-with-admin-ui.ts @@ -0,0 +1,72 @@ +import {Workflow} from './registry.js' +import {patchConfigurationFile} from './patch-configuration-file.js' +import {generateExtensionTemplate} from '../extension.js' +import {generateExtensionPrompts} from '../../../prompts/generate/extension.js' +import {buildGenerateOptions, buildPromptOptions} from '../../generate.js' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' + +export function functionWithAdminUi(uiTemplateIdentifier: string): Workflow { + return { + afterGenerate: async (options) => { + const {app, developerPlatformClient, specifications} = options.generateOptions + const functionTomlFilePath = `${options.generatedExtension.directory}/shopify.extension.toml` + + const shouldLinkExtension = await renderConfirmationPrompt({ + message: 'Would you like to create an Admin UI for configuring your function?', + confirmationMessage: 'Yes (recommended)', + cancellationMessage: 'No', + defaultValue: true, + }) + + if (!shouldLinkExtension) { + return { + success: true, + } + } + + // create a UI extension + const extensionTemplates = options.extensionTemplates.filter( + (template) => template.identifier === uiTemplateIdentifier, + ) + + const promptOptions = await buildPromptOptions(extensionTemplates, specifications, app, options.generateOptions) + // TODO: What if it's larger than the limit? + promptOptions.name = `${options.generatedExtension.handle}-ui` + const promptAnswers = await generateExtensionPrompts(promptOptions) + const generateExtensionOptions = buildGenerateOptions( + promptAnswers, + app, + options.generateOptions, + developerPlatformClient, + ) + const generatedExtension = await generateExtensionTemplate(generateExtensionOptions) + + const patch = { + extensions: [ + { + ui: { + handle: generatedExtension.handle, + }, + }, + ], + } + await patchConfigurationFile({ + path: functionTomlFilePath, + patch, + }) + + return { + success: true, + message: { + headline: [ + 'Your extensions were created in', + {filePath: options.generatedExtension.directory}, + 'and', + {filePath: generatedExtension.directory}, + {char: '.'}, + ], + }, + } + }, + } +} diff --git a/packages/app/src/cli/services/generate/workflows/patch-configuration-file.ts b/packages/app/src/cli/services/generate/workflows/patch-configuration-file.ts new file mode 100644 index 00000000000..c2cfc375b54 --- /dev/null +++ b/packages/app/src/cli/services/generate/workflows/patch-configuration-file.ts @@ -0,0 +1,34 @@ +import {deepMergeObjects} from '@shopify/cli-kit/common/object' +import {readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {zod} from '@shopify/cli-kit/node/schema' +import {decodeToml, encodeToml} from '@shopify/cli-kit/node/toml' + +export interface PatchTomlOptions { + path: string + patch: {[key: string]: unknown} + schema?: zod.AnyZodObject +} + +function mergeArrayStrategy(existingArray: unknown[], newArray: unknown[]): unknown[] { + if ( + existingArray.length > 0 && + existingArray[0] && + typeof existingArray[0] === 'object' && + newArray[0] && + typeof newArray[0] === 'object' + ) { + return [{...(existingArray[0] as object), ...(newArray[0] as object)}] + } + return newArray +} + +export async function patchConfigurationFile({path, patch}: PatchTomlOptions) { + const tomlContents = await readFile(path) + const configuration = decodeToml(tomlContents) + + // Deep merge with custom array strategy + const updatedConfig = deepMergeObjects(configuration, patch, mergeArrayStrategy) + + const encodedString = encodeToml(updatedConfig) + await writeFile(path, encodedString) +} diff --git a/packages/app/src/cli/services/generate/workflows/registry.ts b/packages/app/src/cli/services/generate/workflows/registry.ts new file mode 100644 index 00000000000..facbf9514fb --- /dev/null +++ b/packages/app/src/cli/services/generate/workflows/registry.ts @@ -0,0 +1,74 @@ +import {editorExtensionCollection} from './editor-extension-collection.js' +import {discountDetailsFunctionSettingsCollection} from './discount-details-function-settings-collection.js' +import {functionWithAdminUi} from './function-with-admin-ui.js' +import {GeneratedExtension, GenerateExtensionTemplateOptions} from '../../generate/extension.js' +import {GenerateOptions} from '../../generate.js' +import {ExtensionTemplate} from '../../../models/app/template.js' +import {RenderAlertOptions} from '@shopify/cli-kit/node/ui' + +interface AfterGenerateOptions { + generateOptions: GenerateOptions + extensionTemplateOptions: GenerateExtensionTemplateOptions + extensionTemplates: ExtensionTemplate[] + generatedExtension: GeneratedExtension +} + +export interface WorkflowResult { + success: boolean + message?: RenderAlertOptions +} + +export interface Workflow { + afterGenerate: (options: AfterGenerateOptions) => Promise + flags?: { + [key: string]: unknown + } +} + +interface WorkflowRegistry { + [key: string]: Workflow +} + +export const workflowRegistry: WorkflowRegistry = { + editor_extension_collection: editorExtensionCollection, + discount_details_function_settings: discountDetailsFunctionSettingsCollection, + product_discounts: functionWithAdminUi('discount_details_function_settings'), + order_discounts: functionWithAdminUi('discount_details_function_settings'), + shipping_discounts: functionWithAdminUi('discount_details_function_settings'), + cart_checkout_validation: functionWithAdminUi('validation_settings_ui'), +} + +/** + * EXPERIMENT: Pass through additional flags for each workflow, and ensure they are dependent on the template flag. + * This works at the OCLIF layer but we would need to determine how to pass the flags to the `generate` service and the workflows. + * @returns Additional flags for the `generate extension` command. + */ +export function workflowFlags() { + return Object.keys(workflowRegistry).reduce<{[key: string]: unknown}>((flags, templateIdentifier) => { + const workflow = workflowRegistry[templateIdentifier] + if (workflow?.flags === undefined) { + return flags + } + Object.keys(workflow.flags).forEach((flagName) => { + if (workflow.flags === undefined) { + return + } + const flag = workflow.flags[flagName] as {relationships?: {type: string; flags: unknown[]}[]} + if (!flag.relationships) { + flag.relationships = [ + { + type: 'none', + flags: [ + { + name: 'template', + when: (flags: {template?: string}) => flags.template !== templateIdentifier, + }, + ], + }, + ] + } + flags[flagName] = flag + }) + return flags + }, {}) +}