Skip to content
Merged
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/theme-commands-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add structured JSON output to finite theme operations, including metafield downloads.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -9588,6 +9588,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_ENVIRONMENT"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -9598,7 +9607,7 @@
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface thememetafieldspull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface thememetafieldspull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password <value>'?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"themeopen": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co

export type MetafieldDefinitionsByOwnerTypeQueryVariables = Types.Exact<{
ownerType: Types.MetafieldOwnerType
after?: Types.InputMaybe<Types.Scalars['String']['input']>
}>

export type MetafieldDefinitionsByOwnerTypeQuery = {
metafieldDefinitions: {
pageInfo: {hasNextPage: boolean; endCursor?: string | null}
nodes: {
key: string
name: string
Expand All @@ -32,6 +34,11 @@ export const MetafieldDefinitionsByOwnerType = {
variable: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'MetafieldOwnerType'}}},
},
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'after'}},
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
},
],
selectionSet: {
kind: 'SelectionSet',
Expand All @@ -46,10 +53,27 @@ export const MetafieldDefinitionsByOwnerType = {
value: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}},
},
{kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '250'}},
{
kind: 'Argument',
name: {kind: 'Name', value: 'after'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'after'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'pageInfo'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'hasNextPage'}},
{kind: 'Field', name: {kind: 'Name', value: 'endCursor'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
{
kind: 'Field',
name: {kind: 'Name', value: 'nodes'},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
query metafieldDefinitionsByOwnerType($ownerType: MetafieldOwnerType!) {
metafieldDefinitions(ownerType: $ownerType, first: 250) {
query metafieldDefinitionsByOwnerType($ownerType: MetafieldOwnerType!, $after: String) {
metafieldDefinitions(ownerType: $ownerType, first: 250, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
key
name
Expand Down
58 changes: 58 additions & 0 deletions packages/cli-kit/src/public/node/themes/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
AssetParams,
deleteThemeAssets,
parseThemeFileContent,
metafieldDefinitionsByOwnerType,
} from './api.js'
import {Operation} from './types.js'
import {ThemeDelete} from '../../../cli/api/graphql/admin/generated/theme_delete.js'
Expand All @@ -26,6 +27,7 @@ import {ThemeFilesDelete} from '../../../cli/api/graphql/admin/generated/theme_f
import {GetThemes} from '../../../cli/api/graphql/admin/generated/get_themes.js'
import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js'
import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js'
import {MetafieldDefinitionsByOwnerType} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.js'
import {adminRequestDoc, supportedApiVersions} from '../api/admin.js'
import {AbortError} from '../error.js'

Expand All @@ -45,6 +47,62 @@ const expectedApiOptions = expect.objectContaining({
useNetworkLevelRetry: true,
})

describe('metafieldDefinitionsByOwnerType', () => {
test('fetches every page for the requested owner type', async () => {
const first = {
key: 'first',
namespace: 'custom',
name: 'First',
description: null,
type: {name: 'number_integer', category: 'NUMBER'},
}
const second = {...first, key: 'second', name: 'Second'}
vi.mocked(adminRequestDoc)
.mockResolvedValueOnce({metafieldDefinitions: {nodes: [first], pageInfo: {hasNextPage: true, endCursor: 'next'}}})
.mockResolvedValueOnce({
metafieldDefinitions: {nodes: [second], pageInfo: {hasNextPage: false, endCursor: 'last'}},
})

await expect(metafieldDefinitionsByOwnerType('PRODUCT', session)).resolves.toEqual([first, second])
expect(adminRequestDoc).toHaveBeenNthCalledWith(1, {
query: MetafieldDefinitionsByOwnerType,
session,
variables: {ownerType: 'PRODUCT', after: null},
})
expect(adminRequestDoc).toHaveBeenNthCalledWith(2, {
query: MetafieldDefinitionsByOwnerType,
session,
variables: {ownerType: 'PRODUCT', after: 'next'},
})
expect(adminRequestDoc).toHaveBeenCalledTimes(2)
})

test('returns an empty collection when there are no definitions', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
metafieldDefinitions: {nodes: [], pageInfo: {hasNextPage: false, endCursor: null}},
})
await expect(metafieldDefinitionsByOwnerType('PRODUCT', session)).resolves.toEqual([])
expect(adminRequestDoc).toHaveBeenCalledTimes(1)
})

test('rejects when a later page fails instead of returning an incomplete collection', async () => {
vi.mocked(adminRequestDoc)
.mockResolvedValueOnce({metafieldDefinitions: {nodes: [], pageInfo: {hasNextPage: true, endCursor: 'next'}}})
.mockRejectedValueOnce(new Error('Request failed'))
await expect(metafieldDefinitionsByOwnerType('PRODUCT', session)).rejects.toThrow('Request failed')
})

test.each([null, 'next'])('rejects an invalid continuation cursor: %s', async (endCursor) => {
vi.mocked(adminRequestDoc)
.mockResolvedValueOnce({metafieldDefinitions: {nodes: [], pageInfo: {hasNextPage: true, endCursor: 'next'}}})
.mockResolvedValueOnce({metafieldDefinitions: {nodes: [], pageInfo: {hasNextPage: true, endCursor}}})
await expect(metafieldDefinitionsByOwnerType('PRODUCT', session)).rejects.toThrow(
'Failed to paginate metafield definitions',
)
expect(adminRequestDoc).toHaveBeenCalledTimes(2)
})
})

describe('fetchTheme', () => {
test('returns a store theme', async () => {
vi.mocked(adminRequestDoc).mockResolvedValue({
Expand Down
27 changes: 20 additions & 7 deletions packages/cli-kit/src/public/node/themes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
MetafieldOwnerType,
ThemeRole,
} from '../../../cli/api/graphql/admin/generated/types.js'
import {MetafieldDefinitionsByOwnerType} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.js'
import {
MetafieldDefinitionsByOwnerType,
type MetafieldDefinitionsByOwnerTypeQuery,
} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.js'
import {GetThemes} from '../../../cli/api/graphql/admin/generated/get_themes.js'
import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js'
import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js'
Expand Down Expand Up @@ -574,13 +577,23 @@ export async function themeDuplicate(

export async function metafieldDefinitionsByOwnerType(type: MetafieldOwnerType, session: AdminSession) {
recordEvent('theme-api:fetch-metafield-definitions')
const {metafieldDefinitions} = await adminRequestDoc({
query: MetafieldDefinitionsByOwnerType,
session,
variables: {ownerType: type},
})
const definitions: MetafieldDefinitionsByOwnerTypeQuery['metafieldDefinitions']['nodes'] = []
let after: string | null = null
while (true) {
// eslint-disable-next-line no-await-in-loop
const {metafieldDefinitions}: MetafieldDefinitionsByOwnerTypeQuery = await adminRequestDoc({
query: MetafieldDefinitionsByOwnerType,
session,
variables: {ownerType: type, after},
})
definitions.push(...metafieldDefinitions.nodes)
if (!metafieldDefinitions.pageInfo.hasNextPage) break
const endCursor: string | null | undefined = metafieldDefinitions.pageInfo.endCursor
if (!endCursor || endCursor === after) unexpectedGraphQLError('Failed to paginate metafield definitions')
after = endCursor
}

return metafieldDefinitions.nodes.map((definition) => ({
return definitions.map((definition) => ({
key: definition.key,
namespace: definition.namespace,
name: definition.name,
Expand Down
Loading
Loading