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
67 changes: 20 additions & 47 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@ import {
devStoreNamePrompt as sharedDevStoreNamePrompt,
devStorePlanPrompt as sharedDevStorePlanPrompt,
devStoreDemoDataPrompt as sharedDevStoreDemoDataPrompt,
storeChoiceList,
} from '@shopify/organizations'
import {
RenderAutocompleteOptions,
renderAutocompletePrompt,
renderConfirmationPrompt,
renderTextPrompt,
} from '@shopify/cli-kit/node/ui'
import {renderAutocompletePrompt, renderConfirmationPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui'
import {outputCompleted} from '@shopify/cli-kit/node/output'
import type {DevStorePlan} from '@shopify/organizations'

Expand Down Expand Up @@ -78,9 +74,12 @@ interface SelectStorePromptOptions {
onCreateStore?: () => Promise<OrganizationStore | undefined>
}

interface ExtraAutoCompletePropsForStoreSelect {
search?: RenderAutocompleteOptions<string>['search']
}
/**
* Picks the dev store to preview the project on. The picking mechanics are shared with the `store`
* commands; this adds what is specific to `app dev` — the wording, and the organization store shape.
*/
// The value the "create a new store" choice submits. Namespaced so it can't collide with a store id.
const CREATE_STORE_CHOICE = '__create_new_dev_store__'

export async function selectStorePrompt({
stores,
Expand All @@ -96,48 +95,22 @@ export async function selectStorePrompt({
return stores[0]
}

const storeToChoice = (store: OrganizationStore): RenderAutocompleteOptions<string>['choices'][number] => {
let label = store.shopName
if (showDomainOnPrompt && store.shopDomain) {
label = `${store.shopName} (${store.shopDomain})`
}
return {label, value: store.shopId}
}

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) {
extraAutocompletePromptProps.search = async (term) => {
const result = await onSearchForStoresByName(term)
currentStores = result.stores
if (currentStores.length > 0) {
currentStores.forEach((store) => storesById.set(store.shopId, store))
}

return {
data: choices(),
meta: {
hasNextPage: result.hasMorePages,
},
}
}
}
const {promptProps, storeFor} = storeChoiceList({
stores,
toChoice: (store) => ({id: store.shopId, domain: store.shopDomain, name: store.shopName}),
showDomain: showDomainOnPrompt,
...(onSearchForStoresByName ? {onSearch: onSearchForStoresByName} : {}),
...(onCreateStore ? {extraChoices: [{label: 'Create a new dev store', value: CREATE_STORE_CHOICE}]} : {}),
})

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

if (selectedValue === CREATE_STORE_CHOICE) return onCreateStore?.()
return storeFor(selectedValue)
}

export async function appNamePrompt(currentName: string): Promise<string> {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7360,10 +7360,11 @@
"args": {
},
"customPluginName": "@shopify/store",
"description": "Deletes a dev store from your organization.",
"descriptionWithMarkdown": "Deletes a dev store from your organization.",
"description": "Deletes a dev store from your organization.\n\nWhen `--store` is omitted, the command prompts you to pick one of your organization's dev stores, so the flag is only required in non-interactive environments.",
"descriptionWithMarkdown": "Deletes a dev store from your organization.\n\nWhen `--store` is omitted, the command prompts you to pick one of your organization's dev stores, so the flag is only required in non-interactive environments.",
"enableJsonFlag": false,
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --json",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --force"
Expand Down Expand Up @@ -7404,12 +7405,11 @@
},
"store": {
"char": "s",
"description": "The myshopify.com domain of the store.",
"description": "The myshopify.com domain of the store. Required if non interactive.",
"env": "SHOPIFY_FLAG_STORE",
"hasDynamicHelp": false,
"multiple": false,
"name": "store",
"required": true,
"type": "option"
},
"verbose": {
Expand Down
101 changes: 101 additions & 0 deletions packages/organizations/src/cli/prompts/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {storeChoiceList} from './store.js'
import {describe, expect, test, vi} from 'vitest'

interface TestStore {
shopId: string
shopDomain: string
shopName?: string
extra?: string
}

const first: TestStore = {shopId: '1', shopDomain: 'first.myshopify.com', shopName: 'First', extra: 'kept'}
const second: TestStore = {shopId: '2', shopDomain: 'second.myshopify.com', shopName: 'Second'}
const third: TestStore = {shopId: '3', shopDomain: 'third.myshopify.com', shopName: 'Third'}

function toChoice(store: TestStore) {
return {id: store.shopId, domain: store.shopDomain, ...(store.shopName ? {name: store.shopName} : {})}
}

function listFor(stores: TestStore[], overrides = {}) {
return storeChoiceList({stores, toChoice, ...overrides})
}

describe('storeChoiceList', () => {
test('names each store by its name and domain', () => {
expect(listFor([first, second]).promptProps.choices).toEqual([
{label: 'First (first.myshopify.com)', value: '1'},
{label: 'Second (second.myshopify.com)', value: '2'},
])
})

test('names stores without the domain when showDomain is false', () => {
expect(listFor([first], {showDomain: false}).promptProps.choices).toEqual([{label: 'First', value: '1'}])
})

test('falls back to the domain for a store with no name', () => {
const stores = [{shopId: '1', shopDomain: 'first.myshopify.com'}]

expect(listFor(stores).promptProps.choices).toEqual([{label: 'first.myshopify.com', value: '1'}])
})

test('falls back to the name for a store with no domain', () => {
const stores = [{shopId: '1', shopDomain: '', shopName: 'First'}]

expect(listFor(stores).promptProps.choices).toEqual([{label: 'First', value: '1'}])
})

test('offers the extra choices below the stores', () => {
const extraChoices = [{label: 'Create a new dev store', value: 'create'}]

expect(listFor([first], {extraChoices}).promptProps.choices).toEqual([
{label: 'First (first.myshopify.com)', value: '1'},
{label: 'Create a new dev store', value: 'create'},
])
})

test('resolves a submitted value back to the caller store, shape intact', () => {
expect(listFor([first, second]).storeFor('1')).toEqual(first)
})

test('leaves search unset when the caller cannot search remotely, so the prompt filters in memory', () => {
expect(listFor([first]).promptProps).not.toHaveProperty('search')
})

test('labels remote search results and reports whether more pages remain', async () => {
const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: true})
const extraChoices = [{label: 'Create a new dev store', value: 'create'}]

const results = await listFor([first], {onSearch, extraChoices}).promptProps.search!('thi')

expect(onSearch).toHaveBeenCalledWith('thi')
expect(results.data).toEqual([
{label: 'Third (third.myshopify.com)', value: '3'},
{label: 'Create a new dev store', value: 'create'},
])
expect(results.meta).toEqual({hasNextPage: true})
})

test('resolves a store that only a search offered', async () => {
const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: false})
const list = listFor([first], {onSearch})

await list.promptProps.search!('thi')

expect(list.storeFor('3')).toEqual(third)
})

test('still resolves a store from the initial list after a search', async () => {
const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: false})
const list = listFor([first], {onSearch})

await list.promptProps.search!('thi')

expect(list.storeFor('1')).toEqual(first)
})

test('resolves nothing for a value that is not a store', () => {
const extraChoices = [{label: 'Create a new dev store', value: 'create'}]

expect(listFor([first], {extraChoices}).storeFor('create')).toBeUndefined()
})
})
79 changes: 79 additions & 0 deletions packages/organizations/src/cli/prompts/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {type RenderAutocompleteOptions} from '@shopify/cli-kit/node/ui'

/** The identity and labelling data a store picker needs, whatever shape the caller's store has. */
export interface StoreChoice {
// Stable identifier for the store, used as the value the prompt submits.
id: string
// The store's myshopify.com domain.
domain: string
// Human-readable store name, when the caller knows one.
name?: string
}

interface PromptChoice {
label: string
value: string
}

interface StoreChoiceListOptions<T> {
stores: T[]
// Projects a store onto the data the picker needs, so callers keep their own store shape.
toChoice: (store: T) => StoreChoice
// Whether labels name the domain alongside the store name. Defaults to true.
showDomain?: boolean
// Searches the caller's source by name, replacing the offered stores with the results.
onSearch?: (term: string) => Promise<{stores: T[]; hasMorePages: boolean}>
// Choices offered below the stores, such as creating one. Kept through searches.
extraChoices?: PromptChoice[]
}

interface StoreChoiceList<T> {
// Spread into `renderAutocompletePrompt`. `search` is absent, rather than set to undefined, when
// the caller can't search remotely: an explicit `search: undefined` overrides the prompt's own
// in-memory filtering instead of leaving it in place.
promptProps: {choices: PromptChoice[]; search?: RenderAutocompleteOptions<string>['search']}
// The store a submitted value stands for, or undefined for one of the extra choices.
storeFor: (value: string) => T | undefined
}

/**
* Labels a set of stores for an autocomplete prompt and resolves what the developer submits back to
* the store it stands for, including stores that only a remote search brought in.
*
* Only this bookkeeping is shared. The wording, the surrounding flow, and any extra choices stay
* with the caller, so `app dev` and the `store` commands can share it without sharing their flows.
*/
export function storeChoiceList<T>(options: StoreChoiceListOptions<T>): StoreChoiceList<T> {
const {stores, toChoice, showDomain = true, extraChoices = [], onSearch} = options

// Filled in as choices are built, so whatever a search offered stays resolvable afterwards.
const storesByValue = new Map<string, T>()
const choicesFor = (offered: T[]): PromptChoice[] => {
const storeChoices = offered.map((store) => {
const choice = toChoice(store)
storesByValue.set(choice.id, store)

return toPromptChoice(choice, showDomain)
})

return [...storeChoices, ...extraChoices]
}

const promptProps: StoreChoiceList<T>['promptProps'] = {choices: choicesFor(stores)}
if (onSearch) {
promptProps.search = async (term) => {
const {stores: found, hasMorePages} = await onSearch(term)

return {data: choicesFor(found), meta: {hasNextPage: hasMorePages}}
}
}

return {promptProps, storeFor: (value) => storesByValue.get(value)}
}

// Names a store by name and domain together, falling back to whichever of the two the caller knows.
function toPromptChoice({id, domain, name}: StoreChoice, showDomain: boolean): PromptChoice {
const label = showDomain && name && domain ? `${name} (${domain})` : (name ?? domain)

return {label, value: id}
}
2 changes: 2 additions & 0 deletions packages/organizations/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js'
export {selectOrg} from './cli/services/select.js'
export {selectOrganizationPrompt} from './cli/prompts/organization.js'
export {storeChoiceList} from './cli/prompts/store.js'
export type {StoreChoice} from './cli/prompts/store.js'
export type {Organization} from './cli/models/organization.js'
export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js'
export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co

export type ListAccessibleShopsQueryVariables = Types.Exact<{
first: Types.Scalars['Int']['input']
filters?: Types.InputMaybe<Types.ShopFilterInput[] | Types.ShopFilterInput>
search?: Types.InputMaybe<Types.Scalars['String']['input']>
}>

export type ListAccessibleShopsQuery = {
Expand Down Expand Up @@ -41,6 +43,19 @@ export const ListAccessibleShops = {
variable: {kind: 'Variable', name: {kind: 'Name', value: 'first'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}},
type: {
kind: 'ListType',
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ShopFilterInput'}}},
},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'search'}},
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
},
],
selectionSet: {
kind: 'SelectionSet',
Expand Down Expand Up @@ -70,31 +85,12 @@ export const ListAccessibleShops = {
{
kind: 'Argument',
name: {kind: 'Name', value: 'filters'},
value: {
kind: 'ListValue',
values: [
{
kind: 'ObjectValue',
fields: [
{
kind: 'ObjectField',
name: {kind: 'Name', value: 'field'},
value: {kind: 'EnumValue', value: 'STORE_STATUS'},
},
{
kind: 'ObjectField',
name: {kind: 'Name', value: 'operator'},
value: {kind: 'EnumValue', value: 'EQUALS'},
},
{
kind: 'ObjectField',
name: {kind: 'Name', value: 'value'},
value: {kind: 'StringValue', value: 'active', block: false},
},
],
},
],
},
value: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}},
},
{
kind: 'Argument',
name: {kind: 'Name', value: 'search'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'search'}},
},
],
selectionSet: {
Expand Down
Loading
Loading