Skip to content

Commit d8708e9

Browse files
Merge pull request #6134 from Shopify/refactor-import-extensions
Refactor import-extensions
2 parents e102716 + 76ff289 commit d8708e9

File tree

5 files changed

+394
-125
lines changed

5 files changed

+394
-125
lines changed

packages/app/src/cli/commands/app/import-extensions.ts

Lines changed: 22 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,12 @@
1-
import {buildTomlObject as buildPaymentsTomlObject} from '../../services/payments/extension-to-toml.js'
2-
import {buildTomlObject as buildFlowTomlObject} from '../../services/flow/extension-to-toml.js'
3-
import {buildTomlObject as buildAdminLinkTomlObject} from '../../services/admin-link/extension-to-toml.js'
4-
import {buildTomlObject as buildMarketingActivityTomlObject} from '../../services/marketing_activity/extension-to-toml.js'
5-
import {buildTomlObject as buildSubscriptionLinkTomlObject} from '../../services/subscription_link/extension-to-toml.js'
6-
import {ExtensionRegistration} from '../../api/graphql/all_app_extension_registrations.js'
71
import {appFlags} from '../../flags.js'
8-
import {importExtensions} from '../../services/import-extensions.js'
2+
import {allExtensionTypes, importExtensions} from '../../services/import-extensions.js'
93
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
104
import {linkedAppContext} from '../../services/app-context.js'
11-
import {CurrentAppConfiguration} from '../../models/app/app.js'
12-
import {renderSelectPrompt, renderFatalError} from '@shopify/cli-kit/node/ui'
5+
import {getMigrationChoices, selectMigrationChoice} from '../../prompts/import-extensions.js'
6+
import {getExtensions} from '../../services/fetch-extensions.js'
137
import {Flags} from '@oclif/core'
148
import {globalFlags} from '@shopify/cli-kit/node/cli'
15-
import {AbortError} from '@shopify/cli-kit/node/error'
16-
17-
interface MigrationChoice {
18-
label: string
19-
value: string
20-
extensionTypes: string[]
21-
buildTomlObject: (
22-
ext: ExtensionRegistration,
23-
allExtensions: ExtensionRegistration[],
24-
appConfiguration: CurrentAppConfiguration,
25-
) => string
26-
}
27-
28-
const getMigrationChoices = (): MigrationChoice[] => [
29-
{
30-
label: 'Payments Extensions',
31-
value: 'payments',
32-
extensionTypes: [
33-
'payments_app',
34-
'payments_app_credit_card',
35-
'payments_app_custom_credit_card',
36-
'payments_app_custom_onsite',
37-
'payments_app_redeemable',
38-
'payments_extension',
39-
],
40-
buildTomlObject: buildPaymentsTomlObject,
41-
},
42-
{
43-
label: 'Flow Extensions',
44-
value: 'flow',
45-
extensionTypes: ['flow_action_definition', 'flow_trigger_definition', 'flow_trigger_discovery_webhook'],
46-
buildTomlObject: buildFlowTomlObject,
47-
},
48-
{
49-
label: 'Marketing Activity Extensions',
50-
value: 'marketing activity',
51-
extensionTypes: ['marketing_activity_extension'],
52-
buildTomlObject: buildMarketingActivityTomlObject,
53-
},
54-
{
55-
label: 'Subscription Link Extensions',
56-
value: 'subscription link',
57-
extensionTypes: ['subscription_link', 'subscription_link_extension'],
58-
buildTomlObject: buildSubscriptionLinkTomlObject,
59-
},
60-
{
61-
label: 'Admin Link extensions',
62-
value: 'link extension',
63-
extensionTypes: ['app_link', 'bulk_action'],
64-
buildTomlObject: buildAdminLinkTomlObject,
65-
},
66-
]
9+
import {renderSuccess} from '@shopify/cli-kit/node/ui'
6710

6811
export default class ImportExtensions extends AppLinkedCommand {
6912
static description = 'Import dashboard-managed extensions into your app.'
@@ -88,22 +31,26 @@ export default class ImportExtensions extends AppLinkedCommand {
8831
userProvidedConfigName: flags.config,
8932
})
9033

91-
const migrationChoices = getMigrationChoices()
92-
const choices = migrationChoices.map((choice) => {
93-
return {label: choice.label, value: choice.value}
34+
const extensions = await getExtensions({
35+
developerPlatformClient: appContext.developerPlatformClient,
36+
apiKey: appContext.remoteApp.apiKey,
37+
organizationId: appContext.remoteApp.organizationId,
38+
extensionTypes: allExtensionTypes,
9439
})
95-
const promptAnswer = await renderSelectPrompt({message: 'Extension type to migrate', choices})
96-
const migrationChoice = migrationChoices.find((choice) => choice.value === promptAnswer)
97-
if (migrationChoice === undefined) {
98-
renderFatalError(new AbortError('Invalid migration choice'))
99-
process.exit(1)
100-
}
10140

102-
await importExtensions({
103-
...appContext,
104-
extensionTypes: migrationChoice.extensionTypes,
105-
buildTomlObject: migrationChoice.buildTomlObject,
106-
})
41+
const migrationChoices = getMigrationChoices(extensions)
42+
43+
if (migrationChoices.length === 0) {
44+
renderSuccess({headline: ['No extensions to migrate.']})
45+
} else {
46+
const migrationChoice = await selectMigrationChoice(migrationChoices)
47+
await importExtensions({
48+
...appContext,
49+
extensions,
50+
extensionTypes: migrationChoice.extensionTypes,
51+
buildTomlObject: migrationChoice.buildTomlObject,
52+
})
53+
}
10754

10855
return {app: appContext.app}
10956
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import {getMigrationChoices, selectMigrationChoice, allMigrationChoices, MigrationChoice} from './import-extensions.js'
2+
import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js'
3+
import {describe, expect, test, vi} from 'vitest'
4+
import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'
5+
import {AbortError} from '@shopify/cli-kit/node/error'
6+
7+
vi.mock('@shopify/cli-kit/node/ui')
8+
9+
describe('allMigrationChoices', () => {
10+
test('contains all expected migration choices', () => {
11+
expect(allMigrationChoices).toHaveLength(5)
12+
13+
const values = allMigrationChoices.map((choice) => choice.value)
14+
expect(values).toContain('payments')
15+
expect(values).toContain('flow')
16+
expect(values).toContain('marketing activity')
17+
expect(values).toContain('subscription link')
18+
expect(values).toContain('link extension')
19+
})
20+
21+
test('each migration choice has required properties', () => {
22+
allMigrationChoices.forEach((choice) => {
23+
expect(choice).toHaveProperty('label')
24+
expect(choice).toHaveProperty('value')
25+
expect(choice).toHaveProperty('extensionTypes')
26+
expect(choice).toHaveProperty('buildTomlObject')
27+
expect(Array.isArray(choice.extensionTypes)).toBe(true)
28+
expect(choice.extensionTypes.length).toBeGreaterThan(0)
29+
expect(typeof choice.buildTomlObject).toBe('function')
30+
})
31+
})
32+
33+
test('payments migration choice has correct extension types', () => {
34+
const paymentsChoice = allMigrationChoices.find((choice) => choice.value === 'payments')
35+
expect(paymentsChoice?.extensionTypes).toEqual([
36+
'payments_app',
37+
'payments_app_credit_card',
38+
'payments_app_custom_credit_card',
39+
'payments_app_custom_onsite',
40+
'payments_app_redeemable',
41+
'payments_extension',
42+
])
43+
})
44+
45+
test('flow migration choice has correct extension types', () => {
46+
const flowChoice = allMigrationChoices.find((choice) => choice.value === 'flow')
47+
expect(flowChoice?.extensionTypes).toEqual([
48+
'flow_action_definition',
49+
'flow_trigger_definition',
50+
'flow_trigger_discovery_webhook',
51+
])
52+
})
53+
54+
test('marketing activity migration choice has correct extension types', () => {
55+
const marketingChoice = allMigrationChoices.find((choice) => choice.value === 'marketing activity')
56+
expect(marketingChoice?.extensionTypes).toEqual(['marketing_activity_extension'])
57+
})
58+
59+
test('subscription link migration choice has correct extension types', () => {
60+
const subscriptionChoice = allMigrationChoices.find((choice) => choice.value === 'subscription link')
61+
expect(subscriptionChoice?.extensionTypes).toEqual(['subscription_link', 'subscription_link_extension'])
62+
})
63+
64+
test('admin link migration choice has correct extension types', () => {
65+
const adminLinkChoice = allMigrationChoices.find((choice) => choice.value === 'link extension')
66+
expect(adminLinkChoice?.extensionTypes).toEqual(['app_link', 'bulk_action'])
67+
})
68+
})
69+
70+
describe('getMigrationChoices', () => {
71+
const mockExtension = (type: string): ExtensionRegistration => ({
72+
id: '1',
73+
uuid: 'uuid',
74+
type,
75+
title: 'Extension',
76+
})
77+
78+
test('returns empty array when no extensions match', () => {
79+
const extensions = [mockExtension('unknown_type')]
80+
const result = getMigrationChoices(extensions)
81+
expect(result).toEqual([])
82+
})
83+
84+
test('returns payment migration choice when payment extension is present', () => {
85+
const extensions = [mockExtension('payments_app')]
86+
const result = getMigrationChoices(extensions)
87+
expect(result).toHaveLength(1)
88+
expect(result[0]?.value).toBe('payments')
89+
})
90+
91+
test('returns flow migration choice when flow extension is present', () => {
92+
const extensions = [mockExtension('flow_action_definition')]
93+
const result = getMigrationChoices(extensions)
94+
expect(result).toHaveLength(1)
95+
expect(result[0]?.value).toBe('flow')
96+
})
97+
98+
test('returns multiple migration choices when different extension types are present', () => {
99+
const extensions = [
100+
mockExtension('payments_app'),
101+
mockExtension('flow_trigger_definition'),
102+
mockExtension('app_link'),
103+
]
104+
const result = getMigrationChoices(extensions)
105+
expect(result).toHaveLength(3)
106+
const values = result.map((choice) => choice.value)
107+
expect(values).toContain('payments')
108+
expect(values).toContain('flow')
109+
expect(values).toContain('link extension')
110+
})
111+
112+
test('handles case insensitive extension type matching', () => {
113+
const extensions = [mockExtension('PAYMENTS_APP')]
114+
const result = getMigrationChoices(extensions)
115+
expect(result).toHaveLength(1)
116+
expect(result[0]?.value).toBe('payments')
117+
})
118+
119+
test('returns unique migration choices even with multiple extensions of same type', () => {
120+
const extensions = [
121+
mockExtension('payments_app'),
122+
mockExtension('payments_app_credit_card'),
123+
mockExtension('payments_extension'),
124+
]
125+
const result = getMigrationChoices(extensions)
126+
expect(result).toHaveLength(1)
127+
expect(result[0]?.value).toBe('payments')
128+
})
129+
})
130+
131+
describe('selectMigrationChoice', () => {
132+
test('returns the only choice when there is exactly one migration choice', async () => {
133+
const singleChoice: MigrationChoice = {
134+
label: 'Test Extension',
135+
value: 'test',
136+
extensionTypes: ['test_type'],
137+
buildTomlObject: vi.fn(),
138+
}
139+
const result = await selectMigrationChoice([singleChoice])
140+
expect(result).toBe(singleChoice)
141+
expect(renderSelectPrompt).not.toHaveBeenCalled()
142+
})
143+
144+
test('prompts user when there are multiple migration choices', async () => {
145+
const choices: MigrationChoice[] = [
146+
{
147+
label: 'Choice 1',
148+
value: 'choice1',
149+
extensionTypes: ['type1'],
150+
buildTomlObject: vi.fn(),
151+
},
152+
{
153+
label: 'Choice 2',
154+
value: 'choice2',
155+
extensionTypes: ['type2'],
156+
buildTomlObject: vi.fn(),
157+
},
158+
]
159+
160+
vi.mocked(renderSelectPrompt).mockResolvedValue('choice1')
161+
162+
const result = await selectMigrationChoice(choices)
163+
164+
expect(renderSelectPrompt).toHaveBeenCalledWith({
165+
message: 'Extension type to migrate',
166+
choices: [
167+
{label: 'Choice 1', value: 'choice1'},
168+
{label: 'Choice 2', value: 'choice2'},
169+
],
170+
})
171+
expect(result).toBe(choices[0])
172+
})
173+
174+
test('throws AbortError when prompt returns invalid choice', async () => {
175+
const choices: MigrationChoice[] = [
176+
{
177+
label: 'Choice 1',
178+
value: 'choice1',
179+
extensionTypes: ['type1'],
180+
buildTomlObject: vi.fn(),
181+
},
182+
{
183+
label: 'Choice 2',
184+
value: 'choice2',
185+
extensionTypes: ['type2'],
186+
buildTomlObject: vi.fn(),
187+
},
188+
]
189+
190+
vi.mocked(renderSelectPrompt).mockResolvedValue('invalid_choice')
191+
192+
await expect(selectMigrationChoice(choices)).rejects.toThrow(AbortError)
193+
await expect(selectMigrationChoice(choices)).rejects.toThrow('Invalid migration choice')
194+
})
195+
196+
test('throws AbortError when passed empty array', async () => {
197+
await expect(selectMigrationChoice([])).rejects.toThrow(AbortError)
198+
await expect(selectMigrationChoice([])).rejects.toThrow('Invalid migration choice')
199+
})
200+
201+
test('correctly maps choices for prompt', async () => {
202+
const choices: MigrationChoice[] = [
203+
{
204+
label: 'Payments Extensions',
205+
value: 'payments',
206+
extensionTypes: ['payments_app'],
207+
buildTomlObject: vi.fn(),
208+
},
209+
{
210+
label: 'Flow Extensions',
211+
value: 'flow',
212+
extensionTypes: ['flow_action_definition'],
213+
buildTomlObject: vi.fn(),
214+
},
215+
{
216+
label: 'Marketing Activity Extensions',
217+
value: 'marketing activity',
218+
extensionTypes: ['marketing_activity_extension'],
219+
buildTomlObject: vi.fn(),
220+
},
221+
]
222+
223+
vi.mocked(renderSelectPrompt).mockResolvedValue('flow')
224+
225+
const result = await selectMigrationChoice(choices)
226+
227+
expect(renderSelectPrompt).toHaveBeenCalledWith({
228+
message: 'Extension type to migrate',
229+
choices: [
230+
{label: 'Payments Extensions', value: 'payments'},
231+
{label: 'Flow Extensions', value: 'flow'},
232+
{label: 'Marketing Activity Extensions', value: 'marketing activity'},
233+
],
234+
})
235+
expect(result).toBe(choices[1])
236+
})
237+
})

0 commit comments

Comments
 (0)