From 27dea0ed0b09dc4493f6b6729cc72a5cc76ba699 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 1 Jul 2026 16:38:51 +0200 Subject: [PATCH] Add installed workspaces view to application registration (#22359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## After image image image ## Summary Add a new "Installed workspaces" section to the application registration settings page that displays all workspaces that have installed a given application, with pagination support. ## Key Changes - **Backend Service**: Added `getInstalledWorkspaces()` method to `ApplicationRegistrationService` that queries installed applications across workspaces with pagination support - **Backend DTO**: Created `ApplicationRegistrationInstalledWorkspacesDTO` and `InstalledWorkspaceDTO` to structure the response with workspace details (id, displayName, logo, version), total count, and hasMore flag - **GraphQL Resolver**: Added `findApplicationRegistrationInstalledWorkspaces` query resolver with pagination (page parameter, default page size of 10) and proper authorization guards - **Frontend Component**: Created `SettingsApplicationRegistrationInstalledWorkspaces` component that: - Displays installed workspaces in a table with workspace logo, name, and version - Shows initial 3 workspaces with "Show all" button to expand - Implements pagination with "Show more" button to load additional pages - Handles empty state (returns null if no workspaces installed) - **GraphQL Query**: Added `FindApplicationRegistrationInstalledWorkspaces` query document for frontend data fetching - **Integration**: Integrated the new component into `SettingsApplicationRegistrationGeneralTab` ## Implementation Details - Pagination uses offset-based approach with configurable page size (10 workspaces per page) - Query results are ordered by workspace displayName and id for consistent ordering - Soft-deleted applications and workspaces are excluded from the list and counts - Apollo Client's `fetchMore` with `updateQuery` merges paginated results into the cache - Component respects existing authorization (API_KEYS_AND_WEBHOOKS permission required) - Uses existing UI components (Table, Card, Avatar, Button) from twenty-ui library - Supports internationalization with Lingui ## Screenshots The new "Installed workspaces" section on the app registration General tab (admin app detail page), captured against a local instance with a demo app installed in 14 workspaces. The three PNGs are committed under `.github/assets/screenshots/installed-workspaces/` and render inline in the **Files changed** tab of this PR: - `1-first-3-show-all.png` — Collapsed: the first 3 installed workspaces (avatar + name + installed version) with a "Show all" button. - `2-expanded-show-more.png` — "Show all": the first page of 10 workspaces, with a "Show more" button (more remain). - `3-all-paginated.png` — "Show more": all 14 workspaces loaded, button gone. Review in cubic: https://cubic.dev/pr/twentyhq/twenty/pull/22359?utm_source=github https://claude.ai/code/session_012nWtviSBdfFeHEASTtwvJ7 --- .../src/metadata/generated/schema.graphql | 22 +- .../src/metadata/generated/schema.ts | 88 +++--- .../src/metadata/generated/types.ts | 130 ++++---- .../src/generated-admin/graphql.ts | 62 ++++ ...licationRegistrationInstalledWorkspaces.ts | 18 ++ .../findAdminApplicationRegistrationStats.ts | 14 + ...ngsApplicationRegistrationGeneralStats.tsx | 67 +---- ...ngsApplicationRegistrationInstallStats.tsx | 60 ++++ ...icationRegistrationInstalledWorkspaces.tsx | 216 ++++++++++++++ ...tingsApplicationRegistrationGeneralTab.tsx | 8 +- .../admin-panel/admin-panel.resolver.ts | 31 ++ .../application-registration.service.ts | 85 +++++- ...n-registration-installed-workspaces.dto.ts | 30 ++ ...registration-installed-workspaces.input.ts | 22 ++ ...ion-registration-stats.integration-spec.ts | 278 ++++++++++++++++++ ...-panel-api-request-with-guest-role.util.ts | 26 ++ 16 files changed, 974 insertions(+), 183 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationInstalledWorkspaces.ts create mode 100644 packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationStats.ts create mode 100644 packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats.tsx create mode 100644 packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces.tsx create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/admin-panel/admin-panel-application-registration-stats.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index b6b9ff17de..7c4ee7bacb 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1800,6 +1800,17 @@ type ApplicationRegistrationVariableDTO { updatedAt: DateTime! } +type VersionDistributionEntry { + version: String! + count: Int! +} + +type ApplicationRegistrationStats { + activeInstalls: Int! + mostInstalledVersion: String + versionDistribution: [VersionDistributionEntry!]! +} + type BillingTrialPeriod { duration: Float! isCreditCardRequired: Boolean! @@ -1978,17 +1989,6 @@ type UsageBreakdownItem { creditsUsed: Float! } -type VersionDistributionEntry { - version: String! - count: Int! -} - -type ApplicationRegistrationStats { - activeInstalls: Int! - mostInstalledVersion: String - versionDistribution: [VersionDistributionEntry!]! -} - type CreateApplicationRegistration { applicationRegistration: ApplicationRegistration! clientSecret: String! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 7463f3a6d5..3595ae36f9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1431,6 +1431,19 @@ export interface ApplicationRegistrationVariableDTO { __typename: 'ApplicationRegistrationVariableDTO' } +export interface VersionDistributionEntry { + version: Scalars['String'] + count: Scalars['Int'] + __typename: 'VersionDistributionEntry' +} + +export interface ApplicationRegistrationStats { + activeInstalls: Scalars['Int'] + mostInstalledVersion?: Scalars['String'] + versionDistribution: VersionDistributionEntry[] + __typename: 'ApplicationRegistrationStats' +} + export interface BillingTrialPeriod { duration: Scalars['Float'] isCreditCardRequired: Scalars['Boolean'] @@ -1608,19 +1621,6 @@ export interface UsageBreakdownItem { __typename: 'UsageBreakdownItem' } -export interface VersionDistributionEntry { - version: Scalars['String'] - count: Scalars['Int'] - __typename: 'VersionDistributionEntry' -} - -export interface ApplicationRegistrationStats { - activeInstalls: Scalars['Int'] - mostInstalledVersion?: Scalars['String'] - versionDistribution: VersionDistributionEntry[] - __typename: 'ApplicationRegistrationStats' -} - export interface CreateApplicationRegistration { applicationRegistration: ApplicationRegistration clientSecret: Scalars['String'] @@ -4509,6 +4509,21 @@ export interface ApplicationRegistrationVariableDTOGenqlSelection{ __scalar?: boolean | number } +export interface VersionDistributionEntryGenqlSelection{ + version?: boolean | number + count?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + +export interface ApplicationRegistrationStatsGenqlSelection{ + activeInstalls?: boolean | number + mostInstalledVersion?: boolean | number + versionDistribution?: VersionDistributionEntryGenqlSelection + __typename?: boolean | number + __scalar?: boolean | number +} + export interface BillingTrialPeriodGenqlSelection{ duration?: boolean | number isCreditCardRequired?: boolean | number @@ -4694,21 +4709,6 @@ export interface UsageBreakdownItemGenqlSelection{ __scalar?: boolean | number } -export interface VersionDistributionEntryGenqlSelection{ - version?: boolean | number - count?: boolean | number - __typename?: boolean | number - __scalar?: boolean | number -} - -export interface ApplicationRegistrationStatsGenqlSelection{ - activeInstalls?: boolean | number - mostInstalledVersion?: boolean | number - versionDistribution?: VersionDistributionEntryGenqlSelection - __typename?: boolean | number - __scalar?: boolean | number -} - export interface CreateApplicationRegistrationGenqlSelection{ applicationRegistration?: ApplicationRegistrationGenqlSelection clientSecret?: boolean | number @@ -7542,6 +7542,22 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const VersionDistributionEntry_possibleTypes: string[] = ['VersionDistributionEntry'] + export const isVersionDistributionEntry = (obj?: { __typename?: any } | null): obj is VersionDistributionEntry => { + if (!obj?.__typename) throw new Error('__typename is missing in "isVersionDistributionEntry"') + return VersionDistributionEntry_possibleTypes.includes(obj.__typename) + } + + + + const ApplicationRegistrationStats_possibleTypes: string[] = ['ApplicationRegistrationStats'] + export const isApplicationRegistrationStats = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationStats => { + if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationStats"') + return ApplicationRegistrationStats_possibleTypes.includes(obj.__typename) + } + + + const BillingTrialPeriod_possibleTypes: string[] = ['BillingTrialPeriod'] export const isBillingTrialPeriod = (obj?: { __typename?: any } | null): obj is BillingTrialPeriod => { if (!obj?.__typename) throw new Error('__typename is missing in "isBillingTrialPeriod"') @@ -7686,22 +7702,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null - const VersionDistributionEntry_possibleTypes: string[] = ['VersionDistributionEntry'] - export const isVersionDistributionEntry = (obj?: { __typename?: any } | null): obj is VersionDistributionEntry => { - if (!obj?.__typename) throw new Error('__typename is missing in "isVersionDistributionEntry"') - return VersionDistributionEntry_possibleTypes.includes(obj.__typename) - } - - - - const ApplicationRegistrationStats_possibleTypes: string[] = ['ApplicationRegistrationStats'] - export const isApplicationRegistrationStats = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationStats => { - if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationStats"') - return ApplicationRegistrationStats_possibleTypes.includes(obj.__typename) - } - - - const CreateApplicationRegistration_possibleTypes: string[] = ['CreateApplicationRegistration'] export const isCreateApplicationRegistration = (obj?: { __typename?: any } | null): obj is CreateApplicationRegistration => { if (!obj?.__typename) throw new Error('__typename is missing in "isCreateApplicationRegistration"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2fc2d07041..dc0151e2f9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -53,11 +53,11 @@ export default { 166, 170, 172, - 177, - 178, - 185, - 188, - 191, + 179, + 180, + 187, + 190, + 193, 204, 220, 232, @@ -3491,6 +3491,31 @@ export default { 1 ] }, + "VersionDistributionEntry": { + "version": [ + 1 + ], + "count": [ + 21 + ], + "__typename": [ + 1 + ] + }, + "ApplicationRegistrationStats": { + "activeInstalls": [ + 21 + ], + "mostInstalledVersion": [ + 1 + ], + "versionDistribution": [ + 175 + ], + "__typename": [ + 1 + ] + }, "BillingTrialPeriod": { "duration": [ 11 @@ -3510,10 +3535,10 @@ export default { 1 ], "type": [ - 177 + 179 ], "status": [ - 178 + 180 ], "issuer": [ 1 @@ -3526,7 +3551,7 @@ export default { "SSOIdentityProviderStatus": {}, "AuthProviders": { "sso": [ - 176 + 178 ], "google": [ 6 @@ -3563,10 +3588,10 @@ export default { 3 ], "authProviders": [ - 179 + 181 ], "authBypassProviders": [ - 180 + 182 ], "logo": [ 1 @@ -3614,7 +3639,7 @@ export default { 1 ], "modelFamily": [ - 185 + 187 ], "modelFamilyLabel": [ 1 @@ -3629,7 +3654,7 @@ export default { 11 ], "nativeCapabilities": [ - 183 + 185 ], "isDeprecated": [ 6 @@ -3668,7 +3693,7 @@ export default { 1 ], "trialPeriods": [ - 175 + 177 ], "__typename": [ 1 @@ -3676,7 +3701,7 @@ export default { }, "Support": { "supportDriver": [ - 188 + 190 ], "supportFrontChatId": [ 1 @@ -3702,7 +3727,7 @@ export default { }, "Captcha": { "provider": [ - 191 + 193 ], "siteKey": [ 1 @@ -3739,7 +3764,7 @@ export default { 172 ], "metadata": [ - 193 + 195 ], "__typename": [ 1 @@ -3764,13 +3789,13 @@ export default { 1 ], "authProviders": [ - 179 + 181 ], "billing": [ - 186 + 188 ], "aiModels": [ - 184 + 186 ], "signInPrefilled": [ 6 @@ -3794,25 +3819,25 @@ export default { 6 ], "support": [ - 187 + 189 ], "isAttachmentPreviewEnabled": [ 6 ], "sentry": [ - 189 + 191 ], "captcha": [ - 190 + 192 ], "api": [ - 192 + 194 ], "canManageFeatureFlags": [ 6 ], "publicFeatureFlags": [ - 194 + 196 ], "isMicrosoftMessagingEnabled": [ 6 @@ -3851,7 +3876,7 @@ export default { 6 ], "maintenance": [ - 195 + 197 ], "__typename": [ 1 @@ -3871,31 +3896,6 @@ export default { 1 ] }, - "VersionDistributionEntry": { - "version": [ - 1 - ], - "count": [ - 21 - ], - "__typename": [ - 1 - ] - }, - "ApplicationRegistrationStats": { - "activeInstalls": [ - 21 - ], - "mostInstalledVersion": [ - 1 - ], - "versionDistribution": [ - 198 - ], - "__typename": [ - 1 - ] - }, "CreateApplicationRegistration": { "applicationRegistration": [ 7 @@ -4182,7 +4182,7 @@ export default { 3 ], "type": [ - 177 + 179 ], "issuer": [ 1 @@ -4191,7 +4191,7 @@ export default { 1 ], "status": [ - 178 + 180 ], "__typename": [ 1 @@ -4210,7 +4210,7 @@ export default { }, "FindAvailableSSOIDP": { "type": [ - 177 + 179 ], "id": [ 3 @@ -4222,7 +4222,7 @@ export default { 1 ], "status": [ - 178 + 180 ], "workspace": [ 224 @@ -4236,7 +4236,7 @@ export default { 3 ], "type": [ - 177 + 179 ], "issuer": [ 1 @@ -4245,7 +4245,7 @@ export default { 1 ], "status": [ - 178 + 180 ], "__typename": [ 1 @@ -4253,7 +4253,7 @@ export default { }, "SSOConnection": { "type": [ - 177 + 179 ], "id": [ 3 @@ -4265,7 +4265,7 @@ export default { 1 ], "status": [ - 178 + 180 ], "__typename": [ 1 @@ -4735,13 +4735,13 @@ export default { }, "UsageAnalytics": { "usageByUser": [ - 197 + 199 ], "usageByOperationType": [ - 197 + 199 ], "usageByModel": [ - 197 + 199 ], "timeSeries": [ 263 @@ -6369,7 +6369,7 @@ export default { 76 ], "getPublicWorkspaceDataByDomain": [ - 181, + 183, { "origin": [ 1 @@ -6377,7 +6377,7 @@ export default { } ], "getPublicWorkspaceDataById": [ - 182, + 184, { "id": [ 3, @@ -6472,7 +6472,7 @@ export default { } ], "findApplicationRegistrationStats": [ - 199, + 176, { "id": [ 1, @@ -11647,7 +11647,7 @@ export default { 3 ], "status": [ - 178 + 180 ], "__typename": [ 1 diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index bd4ae1a954..bb1a1bdfa5 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -202,6 +202,13 @@ export type ApplicationRegistration = { updatedAt: Scalars['DateTime']['output']; }; +export type ApplicationRegistrationInstalledWorkspaces = { + __typename?: 'ApplicationRegistrationInstalledWorkspaces'; + hasMore: Scalars['Boolean']['output']; + totalCount: Scalars['Int']['output']; + workspaces: Array; +}; + export enum ApplicationRegistrationSourceType { LOCAL = 'LOCAL', NPM = 'NPM', @@ -209,6 +216,13 @@ export enum ApplicationRegistrationSourceType { TARBALL = 'TARBALL' } +export type ApplicationRegistrationStats = { + __typename?: 'ApplicationRegistrationStats'; + activeInstalls: Scalars['Int']['output']; + mostInstalledVersion?: Maybe; + versionDistribution: Array; +}; + export type ApplicationRegistrationVariableDto = { __typename?: 'ApplicationRegistrationVariableDTO'; createdAt: Scalars['DateTime']['output']; @@ -308,6 +322,12 @@ export enum FeatureFlagKey { IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED' } +export type FindApplicationRegistrationInstalledWorkspacesInput = { + id: Scalars['String']['input']; + page?: Scalars['Int']['input']; + searchTerm?: InputMaybe; +}; + export enum HealthIndicatorId { app = 'app', connectedAccount = 'connectedAccount', @@ -316,6 +336,14 @@ export enum HealthIndicatorId { worker = 'worker' } +export type InstalledWorkspace = { + __typename?: 'InstalledWorkspace'; + displayName?: Maybe; + id: Scalars['UUID']['output']; + logo?: Maybe; + version?: Maybe; +}; + export type InstanceAndAllWorkspacesUpgradeStatus = { __typename?: 'InstanceAndAllWorkspacesUpgradeStatus'; computedAt: Scalars['DateTime']['output']; @@ -551,6 +579,8 @@ export type Query = { __typename?: 'Query'; adminPanelRecentUsers: Array; adminPanelTopWorkspaces: Array; + findAdminApplicationRegistrationInstalledWorkspaces: ApplicationRegistrationInstalledWorkspaces; + findAdminApplicationRegistrationStats: ApplicationRegistrationStats; findAdminApplicationRegistrationVariables: Array; findAllApplicationRegistrations: Array; findOneAdminApplicationRegistration: ApplicationRegistration; @@ -589,6 +619,16 @@ export type QueryAdminPanelTopWorkspacesArgs = { }; +export type QueryFindAdminApplicationRegistrationInstalledWorkspacesArgs = { + input: FindApplicationRegistrationInstalledWorkspacesInput; +}; + + +export type QueryFindAdminApplicationRegistrationStatsArgs = { + id: Scalars['String']['input']; +}; + + export type QueryFindAdminApplicationRegistrationVariablesArgs = { applicationRegistrationId: Scalars['String']['input']; }; @@ -839,6 +879,12 @@ export type UserLookup = { workspaces: Array; }; +export type VersionDistributionEntry = { + __typename?: 'VersionDistributionEntry'; + count: Scalars['Int']['output']; + version: Scalars['String']['output']; +}; + export type VersionInfo = { __typename?: 'VersionInfo'; currentVersion?: Maybe; @@ -1027,6 +1073,20 @@ export type UpdateAdminApplicationRegistrationVariableMutationVariables = Exact< export type UpdateAdminApplicationRegistrationVariableMutation = { __typename?: 'Mutation', updateAdminApplicationRegistrationVariable: { __typename?: 'ApplicationRegistrationVariableDTO', id: string, key: string, description: string, isSecret: boolean, isRequired: boolean, isFilled: boolean, createdAt: string, updatedAt: string } }; +export type FindAdminApplicationRegistrationInstalledWorkspacesQueryVariables = Exact<{ + input: FindApplicationRegistrationInstalledWorkspacesInput; +}>; + + +export type FindAdminApplicationRegistrationInstalledWorkspacesQuery = { __typename?: 'Query', findAdminApplicationRegistrationInstalledWorkspaces: { __typename?: 'ApplicationRegistrationInstalledWorkspaces', totalCount: number, hasMore: boolean, workspaces: Array<{ __typename?: 'InstalledWorkspace', id: string, displayName?: string | null, logo?: string | null, version?: string | null }> } }; + +export type FindAdminApplicationRegistrationStatsQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type FindAdminApplicationRegistrationStatsQuery = { __typename?: 'Query', findAdminApplicationRegistrationStats: { __typename?: 'ApplicationRegistrationStats', activeInstalls: number, mostInstalledVersion?: string | null, versionDistribution: Array<{ __typename?: 'VersionDistributionEntry', version: string, count: number }> } }; + export type FindAdminApplicationRegistrationVariablesQueryVariables = Exact<{ applicationRegistrationId: Scalars['String']['input']; }>; @@ -1277,6 +1337,8 @@ export const BackfillApplicationInstallationDocument = {"kind":"Document","defin export const SyncMarketplaceCatalogDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SyncMarketplaceCatalog"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncMarketplaceCatalog"}}]}}]} as unknown as DocumentNode; export const UpdateAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const UpdateAdminApplicationRegistrationVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAdminApplicationRegistrationVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApplicationRegistrationVariableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAdminApplicationRegistrationVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; +export const FindAdminApplicationRegistrationInstalledWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationInstalledWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FindApplicationRegistrationInstalledWorkspacesInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationInstalledWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]}}]} as unknown as DocumentNode; +export const FindAdminApplicationRegistrationStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationStats"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationStats"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeInstalls"}},{"kind":"Field","name":{"kind":"Name","value":"mostInstalledVersion"}},{"kind":"Field","name":{"kind":"Name","value":"versionDistribution"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]}}]} as unknown as DocumentNode; export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationInstalledWorkspaces.ts b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationInstalledWorkspaces.ts new file mode 100644 index 0000000000..1df5f23989 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationInstalledWorkspaces.ts @@ -0,0 +1,18 @@ +import { gql } from '@apollo/client'; + +export const FIND_ADMIN_APPLICATION_REGISTRATION_INSTALLED_WORKSPACES = gql` + query FindAdminApplicationRegistrationInstalledWorkspaces( + $input: FindApplicationRegistrationInstalledWorkspacesInput! + ) { + findAdminApplicationRegistrationInstalledWorkspaces(input: $input) { + totalCount + hasMore + workspaces { + id + displayName + logo + version + } + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationStats.ts b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationStats.ts new file mode 100644 index 0000000000..3f0270ee0a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/apps/graphql/queries/findAdminApplicationRegistrationStats.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const FIND_ADMIN_APPLICATION_REGISTRATION_STATS = gql` + query FindAdminApplicationRegistrationStats($id: String!) { + findAdminApplicationRegistrationStats(id: $id) { + activeInstalls + mostInstalledVersion + versionDistribution { + version + count + } + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationGeneralStats.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationGeneralStats.tsx index 2c7161282a..2b2f1606f5 100644 --- a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationGeneralStats.tsx +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationGeneralStats.tsx @@ -1,16 +1,8 @@ -import { - IconBrandDocker, - IconChartBar, - IconStatusChange, -} from 'twenty-ui/icon'; +import { type ApplicationRegistration } from '~/generated-metadata/graphql'; +import { SettingsApplicationRegistrationInstalledWorkspaces } from '~/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces'; +import { SettingsApplicationRegistrationInstallStats } from '~/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats'; import { H2Title } from 'twenty-ui/typography'; import { useLingui } from '@lingui/react/macro'; -import { SettingsTableCard } from '@/settings/components/SettingsTableCard'; -import { - type ApplicationRegistration, - FindApplicationRegistrationStatsDocument, -} from '~/generated-metadata/graphql'; -import { useQuery } from '@apollo/client/react'; import { Section } from 'twenty-ui/layout'; export const SettingsApplicationRegistrationGeneralStats = ({ @@ -20,60 +12,17 @@ export const SettingsApplicationRegistrationGeneralStats = ({ }) => { const { t } = useLingui(); - const applicationRegistrationId = registration.id; - - const { data: statsData } = useQuery( - FindApplicationRegistrationStatsDocument, - { - variables: { id: applicationRegistrationId }, - skip: !applicationRegistrationId, - }, - ); - - const stats = statsData?.findApplicationRegistrationStats; - - const hasStats = (stats?.activeInstalls ?? 0) > 0; - - if (!hasStats) { - return null; - } - - const versionDistributionLabel = - stats?.versionDistribution - ?.map( - (entry: { version: string; count: number }) => - `${entry.version} (${entry.count})`, - ) - .join(', ') || '—'; - - const statsItems = [ - { - Icon: IconBrandDocker, - label: t`Active installs`, - value: stats?.activeInstalls ?? '—', - }, - { - Icon: IconStatusChange, - label: t`Most installed version`, - value: stats?.mostInstalledVersion ?? '—', - }, - { - Icon: IconChartBar, - label: t`Distribution`, - value: versionDistributionLabel, - }, - ]; - return (
- +
); diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats.tsx new file mode 100644 index 0000000000..addccab465 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstallStats.tsx @@ -0,0 +1,60 @@ +import { + IconBrandDocker, + IconChartBar, + IconStatusChange, +} from 'twenty-ui/icon'; +import { useLingui } from '@lingui/react/macro'; +import { SettingsTableCard } from '@/settings/components/SettingsTableCard'; +import { type ApplicationRegistration } from '~/generated-metadata/graphql'; +import { FindAdminApplicationRegistrationStatsDocument } from '~/generated-admin/graphql'; +import { useQuery } from '@apollo/client/react'; +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; + +export const SettingsApplicationRegistrationInstallStats = ({ + registration, +}: { + registration: ApplicationRegistration; +}) => { + const { t } = useLingui(); + const apolloAdminClient = useApolloAdminClient(); + + const applicationRegistrationId = registration.id; + + const { data } = useQuery(FindAdminApplicationRegistrationStatsDocument, { + client: apolloAdminClient, + variables: { id: applicationRegistrationId }, + skip: !applicationRegistrationId, + }); + + const stats = data?.findAdminApplicationRegistrationStats; + + const versionDistributionLabel = + stats?.versionDistribution + ?.map( + (entry: { version: string; count: number }) => + `${entry.version} (${entry.count})`, + ) + .join(', ') || '—'; + + const statsItems = [ + { + Icon: IconBrandDocker, + label: t`Active installs`, + value: stats?.activeInstalls ?? '—', + }, + { + Icon: IconStatusChange, + label: t`Most installed version`, + value: stats?.mostInstalledVersion ?? '—', + }, + { + Icon: IconChartBar, + label: t`Distribution`, + value: versionDistributionLabel, + }, + ]; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces.tsx b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces.tsx new file mode 100644 index 0000000000..18d26ddd86 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationRegistrationInstalledWorkspaces.tsx @@ -0,0 +1,216 @@ +import styled from '@emotion/styled'; +import { useQuery } from '@apollo/client/react'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useDebounce } from 'use-debounce'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { Avatar } from 'twenty-ui/data-display'; +import { IconChevronDown, IconDotsVertical } from 'twenty-ui/icon'; +import { Button } from 'twenty-ui/input'; +import { Section } from 'twenty-ui/layout'; +import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader'; +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { Table } from '@/ui/layout/table/components/Table'; +import { TableBody } from '@/ui/layout/table/components/TableBody'; +import { TableCell } from '@/ui/layout/table/components/TableCell'; +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { TableRow } from '@/ui/layout/table/components/TableRow'; +import { type ApplicationRegistration } from '~/generated-metadata/graphql'; +import { FindAdminApplicationRegistrationInstalledWorkspacesDocument } from '~/generated-admin/graphql'; +import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; +import { SettingsPath } from 'twenty-shared/types'; + +const INITIAL_VISIBLE_WORKSPACES = 3; +const INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS = '1fr 120px'; + +const StyledSection = styled(Section)` + margin-top: ${themeCssVariables.spacing[5]}; +`; + +const StyledSearchInputContainer = styled.div` + padding-bottom: ${themeCssVariables.spacing[2]}; +`; + +const StyledButtonContainer = styled.div` + display: flex; + justify-content: center; + margin-top: ${themeCssVariables.spacing[2]}; +`; + +export const SettingsApplicationRegistrationInstalledWorkspaces = ({ + registration, +}: { + registration: ApplicationRegistration; +}) => { + const { t } = useLingui(); + const apolloAdminClient = useApolloAdminClient(); + + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm] = useDebounce(searchTerm, 300); + const [isExpanded, setIsExpanded] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + + const applicationRegistrationId = registration.id; + + const { data, loading, error, fetchMore } = useQuery( + FindAdminApplicationRegistrationInstalledWorkspacesDocument, + { + client: apolloAdminClient, + variables: { + input: { + id: applicationRegistrationId, + page: 1, + searchTerm: debouncedSearchTerm, + }, + }, + skip: !applicationRegistrationId, + }, + ); + + const result = data?.findAdminApplicationRegistrationInstalledWorkspaces; + const workspaces = result?.workspaces ?? []; + const totalCount = result?.totalCount ?? 0; + const hasMore = result?.hasMore ?? false; + + const isSearching = debouncedSearchTerm.trim() !== ''; + + // Don't render a misleading "no installs" state when the query actually failed + if (isDefined(error)) { + return null; + } + + // The app is installed nowhere (as opposed to a search yielding no matches) + const hasNoInstalls = totalCount === 0 && !isSearching && !loading; + + if (hasNoInstalls) { + return null; + } + + const visibleWorkspaces = isExpanded + ? workspaces + : workspaces.slice(0, INITIAL_VISIBLE_WORKSPACES); + + const handleSearchChange = (nextSearchTerm: string) => { + setSearchTerm(nextSearchTerm); + setCurrentPage(1); + setIsExpanded(false); + }; + + const handleShowMore = () => { + const nextPage = currentPage + 1; + + fetchMore({ + variables: { + input: { + id: applicationRegistrationId, + page: nextPage, + searchTerm: debouncedSearchTerm, + }, + }, + updateQuery: (previousData, { fetchMoreResult }) => { + if (!isDefined(fetchMoreResult)) { + return previousData; + } + + return { + findAdminApplicationRegistrationInstalledWorkspaces: { + ...fetchMoreResult.findAdminApplicationRegistrationInstalledWorkspaces, + workspaces: [ + ...previousData + .findAdminApplicationRegistrationInstalledWorkspaces.workspaces, + ...fetchMoreResult + .findAdminApplicationRegistrationInstalledWorkspaces.workspaces, + ], + }, + }; + }, + }); + + setCurrentPage(nextPage); + }; + + return ( + + + + + {loading ? ( + + ) : ( + + + {t`Workspace`} + {t`Version installed`} + + + {visibleWorkspaces.map((workspace) => ( + + + + + + + {workspace.version ?? '—'} + + + ))} + +
+ )} + {!isExpanded && totalCount > INITIAL_VISIBLE_WORKSPACES && ( + +