diff --git a/.changeset/store-info-data-table.md b/.changeset/store-info-data-table.md new file mode 100644 index 00000000000..9670ee18c89 --- /dev/null +++ b/.changeset/store-info-data-table.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': patch +--- + +Render `store info` details as a two-column data table and surface store URLs as clickable next-step actions (e.g. "View the storefront."). diff --git a/packages/store/src/cli/services/store/info/result.test.ts b/packages/store/src/cli/services/store/info/result.test.ts index 06c817eed0f..d5679c50a30 100644 --- a/packages/store/src/cli/services/store/info/result.test.ts +++ b/packages/store/src/cli/services/store/info/result.test.ts @@ -15,12 +15,25 @@ function baseResult(overrides: Partial = {}): StoreInfoResult { } } -function storeDetailItems(): string[] { +function storeDetailRows(): unknown[][] { const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as { - customSections: {title: string; body: {list: {items: string[]}}}[] + customSections: {title: string; body: {tabularData: unknown[][]}}[] } const section = opts.customSections.find((sec) => sec.title === 'Store details') - return section?.body.list.items ?? [] + return section?.body.tabularData ?? [] +} + +function storeActions(): unknown[] { + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as { + customSections: {title?: string; body: {list?: {items: unknown[]}}}[] + } + const section = opts.customSections.find((sec) => sec.body?.list) + return section?.body.list?.items ?? [] +} + +// The labels of the detail rows (first cell of each row). +function rowLabels(): string[] { + return storeDetailRows().map((row) => row[0] as string) } describe('renderStoreInfoResult', () => { @@ -76,6 +89,23 @@ describe('renderStoreInfoResult', () => { type: 'dev', plan: 'grow', featurePreview: 'extended_variants', + }), + 'text', + ) + + const rows = storeDetailRows() + expect(rows).toContainEqual(['ID', 'gid://shopify/Shop/1']) + expect(rows).toContainEqual(['Display Name', 'My Shop']) + expect(rows).toContainEqual(['Subdomain', 'shop.myshopify.com']) + expect(rows).toContainEqual(['Organization', 'Acme Holdings']) + expect(rows).toContainEqual(['Type', 'Dev']) + expect(rows).toContainEqual(['Plan', 'Grow']) + expect(rows).toContainEqual(['Feature Preview', 'extended_variants']) + }) + + test('renders store URLs as next-step action links', () => { + renderStoreInfoResult( + baseResult({ adminUrl: 'https://admin.shopify.com/store/acme-widgets', accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token', saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token', @@ -83,42 +113,57 @@ describe('renderStoreInfoResult', () => { 'text', ) - const items = storeDetailItems() - expect(items).toContain('ID: gid://shopify/Shop/1') - expect(items).toContain('Display Name: My Shop') - expect(items).toContain('Subdomain: shop.myshopify.com') - expect(items).toContain('Organization: Acme Holdings') - expect(items).toContain('Type: Dev') - expect(items).toContain('Plan: Grow') - expect(items).toContain('Feature Preview: extended_variants') - expect(items).toContain('Admin URL: https://admin.shopify.com/store/acme-widgets') - expect(items).toContain('Access URL: https://app.shopify.com/auth/preview-store?token=access-token') - expect(items).toContain('Save URL: https://admin.shopify.com/store-transfer/accept/claim-token') + const actions = storeActions() + expect(actions).toEqual([ + {link: {label: 'Manage this store in the Shopify admin', url: 'https://admin.shopify.com/store/acme-widgets'}}, + {link: {label: 'View the storefront', url: 'https://app.shopify.com/auth/preview-store?token=access-token'}}, + { + link: { + label: 'Save your progress on this store', + url: 'https://admin.shopify.com/store-transfer/accept/claim-token', + }, + }, + ]) + // URLs are no longer rendered as detail rows. + expect(rowLabels()).not.toContain('Admin URL') + expect(rowLabels()).not.toContain('Access URL') + expect(rowLabels()).not.toContain('Save URL') + }) + + test('omits action links for URLs that are not present', () => { + renderStoreInfoResult(baseResult({adminUrl: 'https://admin.shopify.com/store/acme-widgets'}), 'text') + expect(storeActions()).toEqual([ + {link: {label: 'Manage this store in the Shopify admin', url: 'https://admin.shopify.com/store/acme-widgets'}}, + ]) + }) + + test('omits next steps entirely when no URLs are present', () => { + renderStoreInfoResult(baseResult(), 'text') + expect(storeActions()).toEqual([]) }) test('formats the store owner as "name (email)" when both are present', () => { renderStoreInfoResult(baseResult({storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'}}), 'text') - expect(storeDetailItems()).toContain('Store owner: Jane Doe (jane@acme.com)') + expect(storeDetailRows()).toContainEqual(['Store owner', 'Jane Doe (jane@acme.com)']) }) test('falls back to the available store owner field when only one is present', () => { renderStoreInfoResult(baseResult({storeOwner: {name: 'Jane Doe'}}), 'text') - expect(storeDetailItems()).toContain('Store owner: Jane Doe') + expect(storeDetailRows()).toContainEqual(['Store owner', 'Jane Doe']) }) test('falls back to the email when the store owner has no name', () => { renderStoreInfoResult(baseResult({storeOwner: {email: 'jane@acme.com'}}), 'text') - expect(storeDetailItems()).toContain('Store owner: jane@acme.com') + expect(storeDetailRows()).toContainEqual(['Store owner', 'jane@acme.com']) }) test('omits fields that are not present', () => { renderStoreInfoResult(baseResult(), 'text') - const items = storeDetailItems() - expect(items.some((item) => item.startsWith('Feature Preview'))).toBe(false) - expect(items.some((item) => item.startsWith('Store owner'))).toBe(false) - expect(items.some((item) => item.startsWith('Type'))).toBe(false) - expect(items.some((item) => item.startsWith('Plan'))).toBe(false) - expect(items.some((item) => item.startsWith('Access URL'))).toBe(false) - expect(items.some((item) => item.startsWith('Save URL'))).toBe(false) + const labels = rowLabels() + expect(labels).not.toContain('Feature Preview') + expect(labels).not.toContain('Store owner') + expect(labels).not.toContain('Type') + expect(labels).not.toContain('Plan') + expect(storeDetailRows()).toHaveLength(2) }) }) diff --git a/packages/store/src/cli/services/store/info/result.ts b/packages/store/src/cli/services/store/info/result.ts index ebd9b94aefa..b6f81e24534 100644 --- a/packages/store/src/cli/services/store/info/result.ts +++ b/packages/store/src/cli/services/store/info/result.ts @@ -1,5 +1,5 @@ import {outputResult} from '@shopify/cli-kit/node/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {renderInfo, type InlineToken, type LinkToken} from '@shopify/cli-kit/node/ui' import {capitalizeWords} from '@shopify/cli-kit/common/string' import type {StoreInfoResult, StoreInfoStoreOwner} from './types.js' @@ -10,25 +10,34 @@ export function renderStoreInfoResult(result: StoreInfoResult, format: StoreInfo outputResult(JSON.stringify(result, null, 2)) return } + const actions = storeActions(result) renderInfo({ - customSections: [{title: 'Store details', body: {list: {items: storeDetailItems(result)}}}], + customSections: [ + {title: 'Store details', body: {tabularData: storeDetailRows(result), firstColumnSubdued: true}}, + ...(actions.length > 0 ? [{body: {list: {title: {bold: 'Next steps'}, items: actions}}}] : []), + ], }) } -function storeDetailItems(result: StoreInfoResult): string[] { - const items: string[] = [] - push(items, 'ID', result.id) - push(items, 'Display Name', result.displayName) - push(items, 'Subdomain', result.subdomain) - push(items, 'Organization', result.organizationName) - push(items, 'Store owner', formatOwner(result.storeOwner)) - push(items, 'Type', result.type ? capitalizeWords(result.type) : undefined) - push(items, 'Plan', result.plan ? capitalizeWords(result.plan) : undefined) - push(items, 'Feature Preview', result.featurePreview) - push(items, 'Admin URL', result.adminUrl) - push(items, 'Access URL', result.accessUrl) - push(items, 'Save URL', result.saveUrl) - return items +function storeDetailRows(result: StoreInfoResult): InlineToken[][] { + const rows: InlineToken[][] = [] + push(rows, 'ID', result.id) + push(rows, 'Display Name', result.displayName) + push(rows, 'Subdomain', result.subdomain) + push(rows, 'Organization', result.organizationName) + push(rows, 'Store owner', formatOwner(result.storeOwner)) + push(rows, 'Type', result.type ? capitalizeWords(result.type) : undefined) + push(rows, 'Plan', result.plan ? capitalizeWords(result.plan) : undefined) + push(rows, 'Feature Preview', result.featurePreview) + return rows +} + +function storeActions(result: StoreInfoResult): LinkToken[] { + const actions: LinkToken[] = [] + pushAction(actions, result.adminUrl, 'Manage this store in the Shopify admin') + pushAction(actions, result.accessUrl, 'View the storefront') + pushAction(actions, result.saveUrl, 'Save your progress on this store') + return actions } function formatOwner(owner: StoreInfoStoreOwner | undefined): string | undefined { @@ -37,6 +46,10 @@ function formatOwner(owner: StoreInfoStoreOwner | undefined): string | undefined return owner.name ?? owner.email } -function push(items: string[], label: string, value: string | undefined): void { - if (value) items.push(`${label}: ${value}`) +function push(rows: InlineToken[][], label: string, value: string | undefined): void { + if (value) rows.push([label, value]) +} + +function pushAction(actions: LinkToken[], url: string | undefined, label: string): void { + if (url) actions.push({link: {label, url}}) }