Skip to content
Draft
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/create-dev-store-from-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Allow `app dev` to create a development store inline from store selection.
1 change: 1 addition & 0 deletions bin/get-graphql-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const schemas = [
pathToFile: 'areas/platforms/organizations/db/graphql/organizations_schema.graphql',
localPaths: [
'./packages/app/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
'./packages/organizations/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
'./packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
],
},
Expand Down
5 changes: 5 additions & 0 deletions graphql.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export default {
functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'),
adminAsApp: projectFactory('admin', 'admin_schema.graphql'),
organizationsDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'organizations'),
organizationsBusinessPlatformOrganizations: projectFactory(
'business-platform-organizations',
'organizations_schema.graphql',
'organizations',
),
storeBusinessPlatformDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'store'),
storeBusinessPlatformOrganizations: projectFactory('business-platform-organizations', 'organizations_schema.graphql', 'store'),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type DevStoreCapReachedQueryVariables = Types.Exact<{[key: string]: never}>

export type DevStoreCapReachedQuery = {organization?: {devStoreCapReached: boolean} | null}

export const DevStoreCapReached = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'DevStoreCapReached'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'organization'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'devStoreCapReached'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<DevStoreCapReachedQuery, DevStoreCapReachedQueryVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
query DevStoreCapReached {
organization {
devStoreCapReached
}
}
49 changes: 49 additions & 0 deletions packages/app/src/cli/prompts/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,36 @@ describe('selectStore', () => {
expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project')
})

test('offers creating a store when a creation handler is provided', async () => {
const stores: OrganizationStore[] = [STORE1]
const createdStore = {...STORE2, shopId: 'created'}
const onCreateStore = vi.fn().mockResolvedValue(createdStore)
vi.mocked(renderAutocompletePrompt).mockResolvedValue('__create_new_dev_store__')

const got = await selectStorePrompt({stores, 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('creates directly when the list is empty and a creation handler is provided', async () => {
const onCreateStore = vi.fn().mockResolvedValue(STORE1)

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

expect(got).toEqual(STORE1)
expect(onCreateStore).toHaveBeenCalledOnce()
expect(renderAutocompletePrompt).not.toHaveBeenCalled()
})

test('returns store if user selects one', async () => {
// Given
const stores: OrganizationStore[] = [STORE1, STORE2]
Expand Down Expand Up @@ -189,6 +219,25 @@ describe('selectStore', () => {
expect(lastCall[0]).not.toHaveProperty('search')
})

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 correct store if user selects one after searching', async () => {
// Given
const stores: OrganizationStore[] = [STORE1, STORE2]
Expand Down
31 changes: 27 additions & 4 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {Organization, MinimalOrganizationApp, OrganizationStore, MinimalAppIdentifiers} from '../models/organization.js'
import {
devStoreNamePrompt as sharedDevStoreNamePrompt,
devStorePlanPrompt as sharedDevStorePlanPrompt,
} from '@shopify/organizations'
import type {DevStorePlan} from '@shopify/organizations'
import {getTomls} from '../utilities/app/config/getTomls.js'
import {Paginateable} from '../utilities/developer-platform-client.js'
import {APP_NAME_MAX_LENGTH} from '../models/app/validation/common.js'
Expand All @@ -11,6 +16,16 @@ import {
} from '@shopify/cli-kit/node/ui'
import {outputCompleted} from '@shopify/cli-kit/node/output'

export type {DevStorePlan}

export function devStoreNamePrompt(): Promise<string> {
return sharedDevStoreNamePrompt()
}

export function devStorePlanPrompt(): Promise<DevStorePlan> {
return sharedDevStorePlanPrompt()
}

export async function selectAppPrompt(
onSearchForAppsByName: (term: string) => Promise<{apps: MinimalOrganizationApp[]; hasMorePages: boolean}>,
apps: MinimalOrganizationApp[],
Expand Down Expand Up @@ -56,6 +71,7 @@ interface SelectStorePromptOptions {
stores: OrganizationStore[]
hasMorePages?: boolean
showDomainOnPrompt: boolean
onCreateStore?: () => Promise<OrganizationStore | undefined>
}

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

let currentStores = stores
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 @@ -91,7 +113,7 @@ export async function selectStorePrompt({
currentStores = result.stores

return {
data: currentStores.map(storeToChoice),
data: choices(),
meta: {
hasNextPage: result.hasMorePages,
},
Expand All @@ -101,10 +123,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 currentStores.find((store) => store.shopId === id)
}

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/services/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ beforeEach(async () => {
vi.mocked(getAppIdentifiers).mockReturnValue({})
vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1)
vi.mocked(selectOrCreateApp).mockResolvedValue(APP1)
vi.mocked(selectStore).mockResolvedValue(STORE1)
vi.mocked(selectStore).mockResolvedValue({store: STORE1, created: false})
vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2])
vi.mocked(fetchOrgFromId).mockResolvedValue(ORG1)
vi.mocked(getPackageManager).mockResolvedValue('npm')
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/cli/services/dev/cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {devStoreCapReached} from './cap.js'
import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js'
import {ClientName} from '../../utilities/developer-platform-client.js'
import {describe, expect, test, vi} from 'vitest'

describe('devStoreCapReached', () => {
test('returns the cap value for an app-management client', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.AppManagement,
devStoreCapReached: vi.fn().mockResolvedValue(true),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(true)
expect(client.devStoreCapReached).toHaveBeenCalledWith('1')
})

test('fails open when the cap request fails', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.AppManagement,
devStoreCapReached: vi.fn().mockRejectedValue(new Error('field is unavailable')),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(false)
})

test('does not query Partners clients', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.Partners,
devStoreCapReached: vi.fn().mockResolvedValue(true),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(false)
expect(client.devStoreCapReached).not.toHaveBeenCalled()
})
})
21 changes: 21 additions & 0 deletions packages/app/src/cli/services/dev/cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'

/**
* A cap check is advisory. Older Business Platform deployments may not expose the field yet,
* so request and schema errors must not block store creation.
*/
export async function devStoreCapReached(
organizationId: string,
developerPlatformClient: DeveloperPlatformClient,
): Promise<boolean> {
const capChecker = developerPlatformClient.devStoreCapReached
if (developerPlatformClient.clientName !== ClientName.AppManagement || !capChecker) {
return false
}

try {
return await capChecker(organizationId)
} catch {
return false
}
}
Loading
Loading