Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/app-dev-store-picker-create-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Add a "Create a new dev store" option to the store picker in `app dev`.
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('app dev command', () => {
tunnelUrl: undefined,
localhostPort: undefined,
})
expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'when-empty'}))
expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'selection-option'}))
expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}}))
})
})
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default class Dev extends AppLinkedCommand {
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
storeCreationMode: 'when-empty',
storeCreationMode: 'selection-option',
})

const devOptions: DevOptions = {
Expand Down
107 changes: 105 additions & 2 deletions packages/app/src/cli/prompts/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,25 @@ describe('selectStore', () => {
expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project')
})

test('creates directly when the list is empty and a creation handler is provided', async () => {
test('prompts when only one store is loaded but more pages are available', async () => {
const stores: OrganizationStore[] = [STORE1]
vi.mocked(renderAutocompletePrompt).mockResolvedValue(STORE1.shopId)

const got = await selectStorePrompt({
stores,
hasMorePages: true,
showDomainOnPrompt: defaultShowDomainOnPrompt,
})

expect(got).toEqual(STORE1)
expect(renderAutocompletePrompt).toHaveBeenCalledWith({
message: 'Which store would you like to use to view your project?',
choices: [{label: 'store1', value: '1'}],
hasMorePages: true,
})
})

test('creates directly when the list is empty and a zero-store creation handler is provided', async () => {
const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE1)

const got = await selectStorePrompt({
Expand All @@ -157,7 +175,7 @@ describe('selectStore', () => {
expect(renderAutocompletePrompt).not.toHaveBeenCalled()
})

test('returns the only store without creating when a creation handler is provided', async () => {
test('returns the only store without creating when a zero-store creation handler is provided', async () => {
const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE2)
const outputMock = mockAndCaptureOutput()

Expand All @@ -173,6 +191,91 @@ describe('selectStore', () => {
expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project')
})

test('offers a create choice instead of auto-selecting the only store when a picker creation handler is provided', async () => {
const createdStore = {...STORE2, shopId: 'created'}
const onCreateStore = vi.fn().mockResolvedValue(createdStore)
vi.mocked(renderAutocompletePrompt).mockResolvedValue('__create_new_dev_store__')

const got = await selectStorePrompt({
stores: [STORE1],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStore,
})

expect(got).toEqual(createdStore)
expect(onCreateStore).toHaveBeenCalledOnce()
expect(renderAutocompletePrompt).toHaveBeenCalledWith({
message: 'Which store would you like to use to view your project?',
choices: [
{label: 'store1', value: '1'},
{label: 'Create a new dev store', value: '__create_new_dev_store__'},
],
hasMorePages: false,
})
})

test('returns the selected store when the create choice is offered with multiple stores', async () => {
const onCreateStore = vi.fn().mockResolvedValue(STORE3)
vi.mocked(renderAutocompletePrompt).mockResolvedValue('2')

const got = await selectStorePrompt({
stores: [STORE1, STORE2],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStore,
})

expect(got).toEqual(STORE2)
expect(onCreateStore).not.toHaveBeenCalled()
expect(renderAutocompletePrompt).toHaveBeenCalledWith({
message: 'Which store would you like to use to view your project?',
choices: [
{label: 'store1', value: '1'},
{label: 'store2', value: '2'},
{label: 'Create a new dev store', value: '__create_new_dev_store__'},
],
hasMorePages: false,
})
})

test('keeps the create choice when the store list is searched', async () => {
const onCreateStore = vi.fn().mockResolvedValue(STORE3)
vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => {
const searchResults = await search!('new')
expect(searchResults.data).toContainEqual({label: 'Create a new dev store', value: '__create_new_dev_store__'})
return '__create_new_dev_store__'
})

const got = await selectStorePrompt({
stores: [STORE1],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStore,
onSearchForStoresByName: (_term: string) => Promise.resolve({stores: [STORE3], hasMorePages: false}),
})

expect(got).toEqual(STORE3)
expect(onCreateStore).toHaveBeenCalledOnce()
})

test('returns a searched store without creating when the create choice is offered', async () => {
const onCreateStore = vi.fn().mockResolvedValue(STORE2)
vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => {
const searchResults = await search!('new')
expect(searchResults.data).toContainEqual({label: 'store3', value: '3'})
expect(searchResults.data).toContainEqual({label: 'Create a new dev store', value: '__create_new_dev_store__'})
return '3'
})

const got = await selectStorePrompt({
stores: [STORE1],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStore,
onSearchForStoresByName: (_term: string) => Promise.resolve({stores: [STORE3], hasMorePages: false}),
})

expect(got).toEqual(STORE3)
expect(onCreateStore).not.toHaveBeenCalled()
})

test('returns store if user selects one', async () => {
// Given
const stores: OrganizationStore[] = [STORE1, STORE2]
Expand Down
14 changes: 11 additions & 3 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ interface SelectStorePromptOptions {
hasMorePages?: boolean
showDomainOnPrompt: boolean
onCreateStoreWhenEmpty?: () => Promise<OrganizationStore | undefined>
onCreateStore?: () => Promise<OrganizationStore | undefined>
}

interface ExtraAutoCompletePropsForStoreSelect {
Expand All @@ -82,9 +83,10 @@ export async function selectStorePrompt({
onSearchForStoresByName,
showDomainOnPrompt = true,
onCreateStoreWhenEmpty,
onCreateStore,
}: SelectStorePromptOptions): Promise<OrganizationStore | undefined> {
if (stores.length === 0) return onCreateStoreWhenEmpty?.()
if (stores.length === 1) {
if (stores.length === 1 && !hasMorePages && !onCreateStore) {
outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`)
return stores[0]
}
Expand All @@ -99,6 +101,11 @@ export async function selectStorePrompt({

let currentStores = stores
const storesById = new Map(stores.map((store) => [store.shopId, store]))
const createStoreChoice = '__create_new_dev_store__'
const choices = () => [
...currentStores.map(storeToChoice),
...(onCreateStore ? [{label: 'Create a new dev store', value: createStoreChoice}] : []),
]

const extraAutocompletePromptProps: ExtraAutoCompletePropsForStoreSelect = {}
if (onSearchForStoresByName) {
Expand All @@ -110,7 +117,7 @@ export async function selectStorePrompt({
}

return {
data: currentStores.map(storeToChoice),
data: choices(),
meta: {
hasNextPage: result.hasMorePages,
},
Expand All @@ -120,10 +127,11 @@ export async function selectStorePrompt({

const id = await renderAutocompletePrompt({
message: 'Which store would you like to use to view your project?',
choices: currentStores.map(storeToChoice),
choices: choices(),
hasMorePages,
...extraAutocompletePromptProps,
})
if (id === createStoreChoice) return onCreateStore?.()
return storesById.get(id)
}

Expand Down
163 changes: 159 additions & 4 deletions packages/app/src/cli/services/dev/select-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('selectStore', async () => {
).resolves.toEqual(STORE1)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(createDevStore).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty')
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('auto-selects the only Partners store in a non-interactive environment when store creation is enabled', async () => {
Expand All @@ -114,7 +114,7 @@ describe('selectStore', async () => {
),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty')
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('keeps prompting in a non-interactive environment when store creation is disabled', async () => {
Expand Down Expand Up @@ -207,7 +207,7 @@ describe('selectStore', async () => {
selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'when-empty'),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty')
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('does not offer creation when store creation is disabled', async () => {
Expand All @@ -218,7 +218,162 @@ describe('selectStore', async () => {
STORE1,
)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStoreWhenEmpty')
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('offers creation with existing stores when the selection-option app-management organization is not capped', async () => {
const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement})
vi.mocked(devStoreCapReached).mockResolvedValue(false)
vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1)

await expect(
selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient)
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).toHaveProperty('onCreateStore')
})

test('hides creation but keeps paginated store selection when the selection-option organization is capped', async () => {
const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement})
vi.mocked(devStoreCapReached).mockResolvedValue(true)
vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1)

await expect(
selectStore({stores: [STORE1], hasMorePages: true}, ORG1, developerPlatformClient, 'selection-option'),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).toHaveBeenCalledWith(ORG1.id, developerPlatformClient)
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
expect(createDevStore).not.toHaveBeenCalled()
})

test('keeps the dashboard fallback when the capped selection-option app-management store prompt is cancelled', async () => {
const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement})
vi.mocked(devStoreCapReached).mockResolvedValue(true)
vi.mocked(selectStorePrompt).mockResolvedValueOnce(undefined)
vi.mocked(reloadStoreListPrompt).mockResolvedValue(false)

await expect(
selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'),
).rejects.toBeInstanceOf(CancelExecution)
expect(developerPlatformClient.getCreateDevStoreLink).toHaveBeenCalledWith(ORG1)
expect(sleep).toHaveBeenCalledWith(5)
expect(reloadStoreListPrompt).toHaveBeenCalledWith(ORG1)
expect(createDevStore).not.toHaveBeenCalled()
})

test('does not query the cap or offer creation for Partners with the selection-option mode', async () => {
const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners})
vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1)

await expect(
selectStore({stores: [STORE1, STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('fails before the prompt in a non-interactive environment when the selection-option app-management organization has no stores', async () => {
vi.mocked(isTTY).mockReturnValue(false)

await expect(
selectStore(
{stores: [], hasMorePages: false},
ORG1,
testDeveloperPlatformClient({clientName: ClientName.AppManagement}),
'selection-option',
),
).rejects.toMatchObject({
message: 'No development store was specified.',
tryMessage: 'Create a development store in Dev Dashboard, then run `app dev` again.',
})
expect(selectStorePrompt).not.toHaveBeenCalled()
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(createDevStore).not.toHaveBeenCalled()
})

test('fails before the prompt in a non-interactive environment when selection-option requires choosing between stores', async () => {
vi.mocked(isTTY).mockReturnValue(false)

await expect(
selectStore(
{stores: [STORE1, STORE2], hasMorePages: false},
ORG1,
testDeveloperPlatformClient({clientName: ClientName.AppManagement}),
'selection-option',
),
).rejects.toMatchObject({
message: 'No development store was specified.',
tryMessage: 'Run `app dev --store <store-domain>` to select a development store.',
})
expect(selectStorePrompt).not.toHaveBeenCalled()
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(createDevStore).not.toHaveBeenCalled()
})

test('fails before the prompt in a non-interactive environment when selection-option has more stores to load', async () => {
vi.mocked(isTTY).mockReturnValue(false)

await expect(
selectStore(
{stores: [STORE1], hasMorePages: true},
ORG1,
testDeveloperPlatformClient({clientName: ClientName.AppManagement}),
'selection-option',
),
).rejects.toMatchObject({
message: 'No development store was specified.',
tryMessage: 'Run `app dev --store <store-domain>` to select a development store.',
})
expect(selectStorePrompt).not.toHaveBeenCalled()
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(createDevStore).not.toHaveBeenCalled()
})

test('auto-selects the only app-management store in a non-interactive environment with the selection-option mode', async () => {
vi.mocked(isTTY).mockReturnValue(false)
vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1)

await expect(
selectStore(
{stores: [STORE1], hasMorePages: false},
ORG1,
testDeveloperPlatformClient({clientName: ClientName.AppManagement}),
'selection-option',
),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).not.toHaveBeenCalled()
expect(createDevStore).not.toHaveBeenCalled()
expect(vi.mocked(selectStorePrompt).mock.calls[0]?.[0]).not.toHaveProperty('onCreateStore')
})

test('creates and refetches an app-management store selected from a non-empty list', async () => {
const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement})
vi.mocked(devStoreCapReached).mockResolvedValue(false)
vi.mocked(devStoreNamePrompt).mockResolvedValue('created-store')
vi.mocked(devStorePlanPrompt).mockResolvedValue('grow')
vi.mocked(createDevStore).mockResolvedValue('created-store.myshopify.com')
vi.mocked(fetchStore).mockResolvedValueOnce(STORE1)
vi.mocked(renderTasks).mockImplementation(async (tasks: Task[]) => {
for (const task of tasks) {
// eslint-disable-next-line no-await-in-loop
await task.task({}, task)
}
return {}
})
vi.mocked(selectStorePrompt).mockImplementation(async ({onCreateStore}) => onCreateStore!())

await expect(
selectStore({stores: [STORE2], hasMorePages: false}, ORG1, developerPlatformClient, 'selection-option'),
).resolves.toEqual(STORE1)
expect(devStoreCapReached).toHaveBeenCalledTimes(2)
expect(createDevStore).toHaveBeenCalledWith({
name: 'created-store',
plan: 'grow',
organization: ORG1,
json: false,
summary: false,
})
expect(renderSuccess).toHaveBeenCalledWith({headline: 'Development store "store1" created successfully.'})
})

test('keeps the dashboard fallback when the app-management store prompt is cancelled', async () => {
Expand Down
Loading
Loading