Skip to content

Commit f594242

Browse files
authored
Merge pull request #6706 from Shopify/add-shopify-app-bulk-cancel-command
`shopify app bulk cancel` command to cancel bulkops through CLI
2 parents 2c3d73f + 839f44a commit f594242

File tree

10 files changed

+728
-7
lines changed

10 files changed

+728
-7
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-redundant-type-constituents */
2+
import * as Types from './types.js'
3+
4+
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'
5+
6+
export type BulkOperationCancelMutationVariables = Types.Exact<{
7+
id: Types.Scalars['ID']['input']
8+
}>
9+
10+
export type BulkOperationCancelMutation = {
11+
bulkOperationCancel?: {
12+
bulkOperation?: {
13+
completedAt?: unknown | null
14+
createdAt: unknown
15+
errorCode?: Types.BulkOperationErrorCode | null
16+
fileSize?: unknown | null
17+
id: string
18+
objectCount: unknown
19+
partialDataUrl?: string | null
20+
query: string
21+
rootObjectCount: unknown
22+
status: Types.BulkOperationStatus
23+
type: Types.BulkOperationType
24+
url?: string | null
25+
} | null
26+
userErrors: {field?: string[] | null; message: string}[]
27+
} | null
28+
}
29+
30+
export const BulkOperationCancel = {
31+
kind: 'Document',
32+
definitions: [
33+
{
34+
kind: 'OperationDefinition',
35+
operation: 'mutation',
36+
name: {kind: 'Name', value: 'BulkOperationCancel'},
37+
variableDefinitions: [
38+
{
39+
kind: 'VariableDefinition',
40+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
41+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
42+
},
43+
],
44+
selectionSet: {
45+
kind: 'SelectionSet',
46+
selections: [
47+
{
48+
kind: 'Field',
49+
name: {kind: 'Name', value: 'bulkOperationCancel'},
50+
arguments: [
51+
{
52+
kind: 'Argument',
53+
name: {kind: 'Name', value: 'id'},
54+
value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
55+
},
56+
],
57+
selectionSet: {
58+
kind: 'SelectionSet',
59+
selections: [
60+
{
61+
kind: 'Field',
62+
name: {kind: 'Name', value: 'bulkOperation'},
63+
selectionSet: {
64+
kind: 'SelectionSet',
65+
selections: [
66+
{kind: 'Field', name: {kind: 'Name', value: 'completedAt'}},
67+
{kind: 'Field', name: {kind: 'Name', value: 'createdAt'}},
68+
{kind: 'Field', name: {kind: 'Name', value: 'errorCode'}},
69+
{kind: 'Field', name: {kind: 'Name', value: 'fileSize'}},
70+
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
71+
{kind: 'Field', name: {kind: 'Name', value: 'objectCount'}},
72+
{kind: 'Field', name: {kind: 'Name', value: 'partialDataUrl'}},
73+
{kind: 'Field', name: {kind: 'Name', value: 'query'}},
74+
{kind: 'Field', name: {kind: 'Name', value: 'rootObjectCount'}},
75+
{kind: 'Field', name: {kind: 'Name', value: 'status'}},
76+
{kind: 'Field', name: {kind: 'Name', value: 'type'}},
77+
{kind: 'Field', name: {kind: 'Name', value: 'url'}},
78+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
79+
],
80+
},
81+
},
82+
{
83+
kind: 'Field',
84+
name: {kind: 'Name', value: 'userErrors'},
85+
selectionSet: {
86+
kind: 'SelectionSet',
87+
selections: [
88+
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
89+
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
90+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
91+
],
92+
},
93+
},
94+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
95+
],
96+
},
97+
},
98+
],
99+
},
100+
},
101+
],
102+
} as unknown as DocumentNode<BulkOperationCancelMutation, BulkOperationCancelMutationVariables>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
mutation BulkOperationCancel($id: ID!) {
2+
bulkOperationCancel(id: $id) {
3+
bulkOperation {
4+
completedAt
5+
createdAt
6+
errorCode
7+
fileSize
8+
id
9+
objectCount
10+
partialDataUrl
11+
query
12+
rootObjectCount
13+
status
14+
type
15+
url
16+
}
17+
userErrors {
18+
field
19+
message
20+
}
21+
}
22+
}
23+
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {appFlags} from '../../../flags.js'
2+
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
3+
import {prepareAppStoreContext} from '../../../utilities/execute-command-helpers.js'
4+
import {cancelBulkOperation} from '../../../services/bulk-operations/cancel-bulk-operation.js'
5+
import {normalizeBulkOperationId} from '../../../services/bulk-operations/bulk-operation-status.js'
6+
import {Flags} from '@oclif/core'
7+
import {globalFlags} from '@shopify/cli-kit/node/cli'
8+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
9+
10+
export default class BulkCancel extends AppLinkedCommand {
11+
static summary = 'Cancel a bulk operation.'
12+
13+
static description = 'Cancels a running bulk operation by ID.'
14+
15+
static hidden = true
16+
17+
static flags = {
18+
...globalFlags,
19+
...appFlags,
20+
id: Flags.string({
21+
description: 'The bulk operation ID to cancel (numeric ID or full GID).',
22+
env: 'SHOPIFY_FLAG_ID',
23+
required: true,
24+
}),
25+
store: Flags.string({
26+
char: 's',
27+
description: 'The store domain. Must be an existing dev store.',
28+
env: 'SHOPIFY_FLAG_STORE',
29+
parse: async (input) => normalizeStoreFqdn(input),
30+
}),
31+
}
32+
33+
async run(): Promise<AppLinkedCommandOutput> {
34+
const {flags} = await this.parse(BulkCancel)
35+
36+
const {appContextResult, store} = await prepareAppStoreContext(flags)
37+
38+
await cancelBulkOperation({
39+
organization: appContextResult.organization,
40+
storeFqdn: store.shopDomain,
41+
operationId: normalizeBulkOperationId(flags.id),
42+
remoteApp: appContextResult.remoteApp,
43+
})
44+
45+
return {app: appContextResult.app}
46+
}
47+
}

packages/app/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Build from './commands/app/build.js'
2+
import BulkCancel from './commands/app/bulk/cancel.js'
23
import BulkStatus from './commands/app/bulk/status.js'
34
import ConfigLink from './commands/app/config/link.js'
45
import ConfigUse from './commands/app/config/use.js'
@@ -40,6 +41,7 @@ import ImportCustomDataDefinitions from './commands/app/import-custom-data-defin
4041
*/
4142
export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlinkedCommand} = {
4243
'app:build': Build,
44+
'app:bulk:cancel': BulkCancel,
4345
'app:bulk:status': BulkStatus,
4446
'app:deploy': Deploy,
4547
'app:dev': Dev,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import {cancelBulkOperation} from './cancel-bulk-operation.js'
2+
import {createAdminSessionAsApp, formatOperationInfo} from '../graphql/common.js'
3+
import {OrganizationApp, Organization, OrganizationSource} from '../../models/organization.js'
4+
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'
5+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
6+
import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin'
7+
import {renderInfo, renderError, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
8+
9+
vi.mock('../graphql/common.js')
10+
vi.mock('@shopify/cli-kit/node/api/admin')
11+
vi.mock('@shopify/cli-kit/node/ui')
12+
13+
describe('cancelBulkOperation', () => {
14+
const mockOrganization: Organization = {
15+
id: 'test-org-id',
16+
businessName: 'Test Organization',
17+
source: OrganizationSource.BusinessPlatform,
18+
}
19+
20+
const mockRemoteApp = {
21+
apiKey: 'test-app-client-id',
22+
apiSecretKeys: [{secret: 'test-api-secret'}],
23+
title: 'Test App',
24+
} as OrganizationApp
25+
26+
const storeFqdn = 'test-store.myshopify.com'
27+
const operationId = 'gid://shopify/BulkOperation/123'
28+
const mockAdminSession = {token: 'test-token', storeFqdn}
29+
30+
beforeEach(() => {
31+
vi.mocked(createAdminSessionAsApp).mockResolvedValue(mockAdminSession)
32+
vi.mocked(formatOperationInfo).mockReturnValue([
33+
`Organization: ${mockOrganization.businessName}`,
34+
`App: ${mockRemoteApp.title}`,
35+
`Store: ${storeFqdn}`,
36+
])
37+
})
38+
39+
afterEach(() => {
40+
mockAndCaptureOutput().clear()
41+
})
42+
43+
test('renders initial info message with operation details', async () => {
44+
vi.mocked(adminRequestDoc).mockResolvedValue({
45+
bulkOperationCancel: {
46+
bulkOperation: {
47+
id: operationId,
48+
status: 'CANCELING',
49+
createdAt: '2024-01-01T00:00:00Z',
50+
completedAt: null,
51+
},
52+
userErrors: [],
53+
},
54+
})
55+
56+
await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
57+
58+
expect(renderInfo).toHaveBeenCalledWith(
59+
expect.objectContaining({
60+
headline: 'Canceling bulk operation.',
61+
}),
62+
)
63+
})
64+
65+
test('calls adminRequestDoc with correct parameters', async () => {
66+
vi.mocked(adminRequestDoc).mockResolvedValue({
67+
bulkOperationCancel: {
68+
bulkOperation: {
69+
id: operationId,
70+
status: 'CANCELING',
71+
createdAt: '2024-01-01T00:00:00Z',
72+
completedAt: null,
73+
},
74+
userErrors: [],
75+
},
76+
})
77+
78+
await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
79+
80+
expect(adminRequestDoc).toHaveBeenCalledWith({
81+
query: expect.any(Object),
82+
session: mockAdminSession,
83+
variables: {id: operationId},
84+
version: '2026-01',
85+
})
86+
})
87+
88+
test.each([
89+
{
90+
status: 'CANCELING' as const,
91+
renderer: 'renderSuccess',
92+
headline: 'Bulk operation is being cancelled.',
93+
},
94+
{
95+
status: 'CANCELED' as const,
96+
renderer: 'renderWarning',
97+
headline: 'Bulk operation is already canceled.',
98+
},
99+
{
100+
status: 'COMPLETED' as const,
101+
renderer: 'renderWarning',
102+
headline: 'Bulk operation is already completed.',
103+
},
104+
{
105+
status: 'RUNNING' as const,
106+
renderer: 'renderInfo',
107+
headline: 'Bulk operation in progress',
108+
},
109+
])('renders $renderer for $status status', async ({status, renderer, headline}) => {
110+
vi.mocked(adminRequestDoc).mockResolvedValue({
111+
bulkOperationCancel: {
112+
bulkOperation: {
113+
id: operationId,
114+
status,
115+
createdAt: '2024-01-01T00:00:00Z',
116+
completedAt: status === 'CANCELING' || status === 'RUNNING' ? null : '2024-01-01T01:00:00Z',
117+
},
118+
userErrors: [],
119+
},
120+
})
121+
122+
await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
123+
124+
const rendererFn = {renderSuccess, renderWarning, renderInfo}[renderer]
125+
expect(rendererFn).toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
headline: expect.stringContaining(headline),
128+
}),
129+
)
130+
})
131+
132+
test('renders user errors when present', async () => {
133+
vi.mocked(adminRequestDoc).mockResolvedValue({
134+
bulkOperationCancel: {
135+
bulkOperation: null,
136+
userErrors: [{field: ['id'], message: 'Operation not found'}],
137+
},
138+
})
139+
140+
await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
141+
142+
expect(renderError).toHaveBeenCalledWith({
143+
headline: 'Bulk operation cancellation errors.',
144+
body: 'id: Operation not found',
145+
})
146+
})
147+
148+
test('renders error when no operation is returned and no user errors', async () => {
149+
vi.mocked(adminRequestDoc).mockResolvedValue({
150+
bulkOperationCancel: {
151+
bulkOperation: null,
152+
userErrors: [],
153+
},
154+
})
155+
156+
await cancelBulkOperation({organization: mockOrganization, storeFqdn, operationId, remoteApp: mockRemoteApp})
157+
158+
expect(renderError).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
headline: 'Bulk operation not found or could not be canceled.',
161+
}),
162+
)
163+
})
164+
})

0 commit comments

Comments
 (0)