From 92ef39d7678df877d2388be4322baabc6fa2f010 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 14 Nov 2024 15:45:27 +0200 Subject: [PATCH 1/4] Update apps query to search using appsConnection --- .../graphql/app-management/generated/apps.ts | 106 +++++++++++++----- .../app-management/queries/apps.graphql | 27 +++-- .../app-management-client.ts | 8 +- 3 files changed, 104 insertions(+), 37 deletions(-) diff --git a/packages/app/src/cli/api/graphql/app-management/generated/apps.ts b/packages/app/src/cli/api/graphql/app-management/generated/apps.ts index a853e4f0ccf..c80651dda1f 100644 --- a/packages/app/src/cli/api/graphql/app-management/generated/apps.ts +++ b/packages/app/src/cli/api/graphql/app-management/generated/apps.ts @@ -4,25 +4,32 @@ import {JsonMapType} from '@shopify/cli-kit/node/toml' import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' -export type ListAppsQueryVariables = Types.Exact<{[key: string]: never}> +export type ListAppsQueryVariables = Types.Exact<{ + query?: Types.InputMaybe +}> export type ListAppsQuery = { - apps: { - id: string - key: string - activeRelease: { - id: string - version: { - name: string - appModules: { - uuid: string - handle: string - config: JsonMapType - specification: {identifier: string; externalIdentifier: string; name: string} - }[] + appsConnection?: { + edges: { + node: { + id: string + key: string + activeRelease: { + id: string + version: { + name: string + appModules: { + uuid: string + handle: string + config: JsonMapType + specification: {identifier: string; externalIdentifier: string; name: string} + }[] + } + } } - } - }[] + }[] + pageInfo: {hasNextPage: boolean} + } | null } export const ListApps = { @@ -32,38 +39,76 @@ export const ListApps = { kind: 'OperationDefinition', operation: 'query', name: {kind: 'Name', value: 'listApps'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'query'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], selectionSet: { kind: 'SelectionSet', selections: [ { kind: 'Field', - name: {kind: 'Name', value: 'apps'}, + name: {kind: 'Name', value: 'appsConnection'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'query'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'query'}}, + }, + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '50'}}, + ], selectionSet: { kind: 'SelectionSet', selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'id'}}, - {kind: 'Field', name: {kind: 'Name', value: 'key'}}, { kind: 'Field', - name: {kind: 'Name', value: 'activeRelease'}, + name: {kind: 'Name', value: 'edges'}, selectionSet: { kind: 'SelectionSet', selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'id'}}, { kind: 'Field', - name: {kind: 'Name', value: 'version'}, + name: {kind: 'Name', value: 'node'}, selectionSet: { kind: 'SelectionSet', selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, { kind: 'Field', - name: {kind: 'Name', value: 'appModules'}, + name: {kind: 'Name', value: 'activeRelease'}, selectionSet: { kind: 'SelectionSet', selections: [ - {kind: 'FragmentSpread', name: {kind: 'Name', value: 'ReleasedAppModule'}}, + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'version'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'appModules'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: {kind: 'Name', value: 'ReleasedAppModule'}, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, ], }, @@ -76,6 +121,17 @@ export const ListApps = { ], }, }, + { + kind: 'Field', + name: {kind: 'Name', value: 'pageInfo'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'hasNextPage'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, ], }, diff --git a/packages/app/src/cli/api/graphql/app-management/queries/apps.graphql b/packages/app/src/cli/api/graphql/app-management/queries/apps.graphql index ca0bd71361c..e726ca10343 100644 --- a/packages/app/src/cli/api/graphql/app-management/queries/apps.graphql +++ b/packages/app/src/cli/api/graphql/app-management/queries/apps.graphql @@ -1,15 +1,22 @@ -query listApps { - apps { - id - key - activeRelease { - id - version { - name - appModules { - ...ReleasedAppModule +query listApps($query: String) { + appsConnection(query: $query, first: 50) { + edges { + node { + id + key + activeRelease { + id + version { + name + appModules { + ...ReleasedAppModule + } + } } } } + pageInfo { + hasNextPage + } } } diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 06c805bcc34..9b32bf2de00 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -260,7 +260,11 @@ export class AppManagementClient implements DeveloperPlatformClient { async appsForOrg(organizationId: string, _term?: string): Promise> { const query = ListApps const result = await appManagementRequestDoc(organizationId, query, await this.token()) - const minimalOrganizationApps = result.apps.map((app) => { + if (!result.appsConnection) { + throw new AbortError('Server failed to retrieve apps') + } + const minimalOrganizationApps = result.appsConnection.edges.map((edge) => { + const app = edge.node return { id: app.id, apiKey: app.key, @@ -270,7 +274,7 @@ export class AppManagementClient implements DeveloperPlatformClient { }) return { apps: minimalOrganizationApps, - hasMorePages: false, + hasMorePages: result.appsConnection.pageInfo.hasNextPage, } } From b09181d4ef264d9e82862b08277d0b340a3b4eb2 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 14 Nov 2024 17:42:38 +0200 Subject: [PATCH 2/4] Use search term to query by app title --- .../app-management-client.test.ts | 62 +++++++++++++++++++ .../app-management-client.ts | 11 +++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 997e59373b2..d57e24816b2 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -9,13 +9,16 @@ import { import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' import {testUIExtension, testRemoteExtensionTemplates, testOrganizationApp} from '../../models/app/app.test-data.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {ListApps} from '../../api/graphql/app-management/generated/apps.js' import {describe, expect, test, vi} from 'vitest' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' +import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/api/app-management') const extensionA = await testUIExtension({uid: 'extension-a-uuid'}) const extensionB = await testUIExtension({uid: 'extension-b-uuid'}) @@ -187,3 +190,62 @@ describe('versionDeepLink', () => { expect(got).toEqual('https://dev.shopify.com/dashboard/1/apps/2/versions/3') }) }) + +describe('searching for apps', () => { + async function appSearchTest({query, queryVariable}: {query?: string; queryVariable: string}) { + // Given + const orgId = '1' + const appName = 'test-app' + const apps = [testOrganizationApp({title: appName})] + const mockedFetchAppsResponse = { + appsConnection: { + edges: apps.map((app, index) => ({ + node: { + ...app, + key: `key-${index}`, + activeRelease: { + id: 'gid://shopify/Release/1', + version: { + name: app.title, + appModules: [], + }, + }, + }, + })), + pageInfo: { + hasNextPage: false, + }, + }, + } + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedFetchAppsResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + const got = await client.appsForOrg(orgId, query) + + // Then + expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith(orgId, ListApps, 'token', {query: queryVariable}) + expect(got).toEqual({ + apps: apps.map((app, index) => ({ + apiKey: `key-${index}`, + id: app.id, + organizationId: app.organizationId, + title: app.title, + })), + hasMorePages: false, + }) + } + + test('passes in a blank search if none is provided', async () => { + await appSearchTest({query: undefined, queryVariable: ''}) + }) + + test('searches for apps by name with a single term passed in the query', async () => { + await appSearchTest({query: 'test-app', queryVariable: 'title:test-app'}) + }) + + test('searches for apps by name with multiple terms passes in the query', async () => { + await appSearchTest({query: 'test app', queryVariable: 'title:test title:app'}) + }) +}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 9b32bf2de00..e6f576d4d37 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -257,9 +257,16 @@ export class AppManagementClient implements DeveloperPlatformClient { return {organization: organization!, apps, hasMorePages} } - async appsForOrg(organizationId: string, _term?: string): Promise> { + async appsForOrg(organizationId: string, term = ''): Promise> { const query = ListApps - const result = await appManagementRequestDoc(organizationId, query, await this.token()) + const variables = { + query: term + .split(' ') + .filter((word) => word) + .map((word) => `title:${word}`) + .join(' '), + } + const result = await appManagementRequestDoc(organizationId, query, await this.token(), variables) if (!result.appsConnection) { throw new AbortError('Server failed to retrieve apps') } From db183b7d546c0d084064cc76acd06cb78d1cd23a Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Sun, 24 Nov 2024 14:41:10 +0200 Subject: [PATCH 3/4] Use test.each for more semantic vitest tests --- .../app-management-client.test.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index d57e24816b2..4653b518fe0 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -192,7 +192,12 @@ describe('versionDeepLink', () => { }) describe('searching for apps', () => { - async function appSearchTest({query, queryVariable}: {query?: string; queryVariable: string}) { + test.each([ + ['without a term if none is provided', undefined, ''], + ['without a term if a blank string is provided', '', ''], + ['with a single term passed in the query', 'test-app', 'title:test-app'], + ['with multiple terms passed in the query', 'test app', 'title:test title:app'], + ])('searches for apps by name %s', async (_: string, query: string | undefined, queryVariable: string) => { // Given const orgId = '1' const appName = 'test-app' @@ -235,17 +240,5 @@ describe('searching for apps', () => { })), hasMorePages: false, }) - } - - test('passes in a blank search if none is provided', async () => { - await appSearchTest({query: undefined, queryVariable: ''}) - }) - - test('searches for apps by name with a single term passed in the query', async () => { - await appSearchTest({query: 'test-app', queryVariable: 'title:test-app'}) - }) - - test('searches for apps by name with multiple terms passes in the query', async () => { - await appSearchTest({query: 'test app', queryVariable: 'title:test title:app'}) }) }) From c55df5d4a325274d54253cdd9014ca9cf61f715e Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Sun, 24 Nov 2024 14:46:51 +0200 Subject: [PATCH 4/4] Throw BugError if response is improperly formatted --- .../app-management-client.test.ts | 14 ++++++++++++++ .../app-management-client.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 4653b518fe0..eef4777b28d 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -15,6 +15,7 @@ import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {BugError} from '@shopify/cli-kit/node/error' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/business-platform') @@ -241,4 +242,17 @@ describe('searching for apps', () => { hasMorePages: false, }) }) + + test("Throws a BugError if the response doesn't contain the expected data", async () => { + // Given + const orgId = '1' + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({}) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + + // Then + await expect(client.appsForOrg(orgId)).rejects.toThrow(BugError) + }) }) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index e6f576d4d37..81197144e4f 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -268,7 +268,7 @@ export class AppManagementClient implements DeveloperPlatformClient { } const result = await appManagementRequestDoc(organizationId, query, await this.token(), variables) if (!result.appsConnection) { - throw new AbortError('Server failed to retrieve apps') + throw new BugError('Server failed to retrieve apps') } const minimalOrganizationApps = result.appsConnection.edges.map((edge) => { const app = edge.node