Add installed workspaces view to application registration (#22359)
## After <img width="895" height="344" alt="image" src="https://github.com/user-attachments/assets/33591753-f248-45ce-b32d-cc1112f50579" /> <img width="889" height="425" alt="image" src="https://github.com/user-attachments/assets/469ee228-9abb-486f-b2ec-9efb490bb2c8" /> <img width="766" height="343" alt="image" src="https://github.com/user-attachments/assets/2d88444a-6d98-4f97-8e5d-109197cfad27" /> ## 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
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<InstalledWorkspace>;
|
||||
};
|
||||
|
||||
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<Scalars['String']['output']>;
|
||||
versionDistribution: Array<VersionDistributionEntry>;
|
||||
};
|
||||
|
||||
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<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export enum HealthIndicatorId {
|
||||
app = 'app',
|
||||
connectedAccount = 'connectedAccount',
|
||||
@@ -316,6 +336,14 @@ export enum HealthIndicatorId {
|
||||
worker = 'worker'
|
||||
}
|
||||
|
||||
export type InstalledWorkspace = {
|
||||
__typename?: 'InstalledWorkspace';
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['UUID']['output'];
|
||||
logo?: Maybe<Scalars['String']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type InstanceAndAllWorkspacesUpgradeStatus = {
|
||||
__typename?: 'InstanceAndAllWorkspacesUpgradeStatus';
|
||||
computedAt: Scalars['DateTime']['output'];
|
||||
@@ -551,6 +579,8 @@ export type Query = {
|
||||
__typename?: 'Query';
|
||||
adminPanelRecentUsers: Array<AdminPanelRecentUser>;
|
||||
adminPanelTopWorkspaces: Array<AdminPanelTopWorkspace>;
|
||||
findAdminApplicationRegistrationInstalledWorkspaces: ApplicationRegistrationInstalledWorkspaces;
|
||||
findAdminApplicationRegistrationStats: ApplicationRegistrationStats;
|
||||
findAdminApplicationRegistrationVariables: Array<ApplicationRegistrationVariableDto>;
|
||||
findAllApplicationRegistrations: Array<ApplicationRegistration>;
|
||||
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<WorkspaceInfo>;
|
||||
};
|
||||
|
||||
export type VersionDistributionEntry = {
|
||||
__typename?: 'VersionDistributionEntry';
|
||||
count: Scalars['Int']['output'];
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type VersionInfo = {
|
||||
__typename?: 'VersionInfo';
|
||||
currentVersion?: Maybe<Scalars['String']['output']>;
|
||||
@@ -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<SyncMarketplaceCatalogMutation, SyncMarketplaceCatalogMutationVariables>;
|
||||
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<UpdateAdminApplicationRegistrationMutation, UpdateAdminApplicationRegistrationMutationVariables>;
|
||||
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<UpdateAdminApplicationRegistrationVariableMutation, UpdateAdminApplicationRegistrationVariableMutationVariables>;
|
||||
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<FindAdminApplicationRegistrationInstalledWorkspacesQuery, FindAdminApplicationRegistrationInstalledWorkspacesQueryVariables>;
|
||||
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<FindAdminApplicationRegistrationStatsQuery, FindAdminApplicationRegistrationStatsQueryVariables>;
|
||||
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<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
|
||||
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<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
|
||||
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<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+8
-59
@@ -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 (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Install Stats`}
|
||||
description={t`Usage across all workspaces on this server`}
|
||||
/>
|
||||
<SettingsTableCard
|
||||
rounded
|
||||
items={statsItems}
|
||||
gridAutoColumns="200px 1fr"
|
||||
<SettingsApplicationRegistrationInstallStats
|
||||
registration={registration}
|
||||
/>
|
||||
<SettingsApplicationRegistrationInstalledWorkspaces
|
||||
registration={registration}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
+60
@@ -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 (
|
||||
<SettingsTableCard rounded items={statsItems} gridAutoColumns="200px 1fr" />
|
||||
);
|
||||
};
|
||||
+216
@@ -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 (
|
||||
<StyledSection>
|
||||
<StyledSearchInputContainer>
|
||||
<SettingsTextInput
|
||||
instanceId="installed-workspaces-search"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t`Search by workspace name or version...`}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
{loading ? (
|
||||
<SettingsSectionSkeletonLoader />
|
||||
) : (
|
||||
<Table>
|
||||
<TableRow
|
||||
gridTemplateColumns={INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableHeader>{t`Workspace`}</TableHeader>
|
||||
<TableHeader align="right">{t`Version installed`}</TableHeader>
|
||||
</TableRow>
|
||||
<TableBody>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<TableRow
|
||||
key={workspace.id}
|
||||
gridTemplateColumns={INSTALLED_WORKSPACES_GRID_TEMPLATE_COLUMNS}
|
||||
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceDetail, {
|
||||
workspaceId: workspace.id,
|
||||
})}
|
||||
>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={getAbsoluteImageUrl(workspace.logo ?? undefined)}
|
||||
placeholder={workspace.displayName ?? '—'}
|
||||
placeholderColorSeed={workspace.id}
|
||||
size="md"
|
||||
/>
|
||||
<OverflowingTextWithTooltip
|
||||
text={workspace.displayName ?? '—'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
color={themeCssVariables.font.color.tertiary}
|
||||
>
|
||||
{workspace.version ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{!isExpanded && totalCount > INITIAL_VISIBLE_WORKSPACES && (
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
title={t`Show all`}
|
||||
Icon={IconChevronDown}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
)}
|
||||
{isExpanded && hasMore && (
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
title={t`Show more`}
|
||||
Icon={IconDotsVertical}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleShowMore}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
)}
|
||||
</StyledSection>
|
||||
);
|
||||
};
|
||||
+5
-3
@@ -37,9 +37,11 @@ export const SettingsApplicationRegistrationGeneralTab = ({
|
||||
registration={registration}
|
||||
/>
|
||||
)}
|
||||
<SettingsApplicationRegistrationGeneralStats
|
||||
registration={registration}
|
||||
/>
|
||||
{fromAdmin && (
|
||||
<SettingsApplicationRegistrationGeneralStats
|
||||
registration={registration}
|
||||
/>
|
||||
)}
|
||||
<SettingsAdminApplicationRegistrationDangerZone
|
||||
registration={registration}
|
||||
fromAdmin={fromAdmin}
|
||||
|
||||
@@ -52,6 +52,9 @@ import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { FindApplicationRegistrationInstalledWorkspacesInput } from 'src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import {
|
||||
BACKFILL_APPLICATION_INSTALLATION_JOB_NAME,
|
||||
@@ -102,6 +105,8 @@ import { ModelsDevProviderSuggestionDTO } from './dtos/models-dev-provider-sugge
|
||||
import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
|
||||
import { SetMaintenanceModeInput } from './dtos/set-maintenance-mode.input';
|
||||
|
||||
const INSTALLED_WORKSPACES_PAGE_SIZE = 10;
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@AdminResolver()
|
||||
@UseFilters(
|
||||
@@ -819,6 +824,32 @@ export class AdminPanelResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => ApplicationRegistrationStatsDTO)
|
||||
async findAdminApplicationRegistrationStats(
|
||||
@Args('id') id: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
return this.applicationRegistrationService.getStatsGlobal(id);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => ApplicationRegistrationInstalledWorkspacesDTO)
|
||||
async findAdminApplicationRegistrationInstalledWorkspaces(
|
||||
@Args('input')
|
||||
{
|
||||
id,
|
||||
page,
|
||||
searchTerm,
|
||||
}: FindApplicationRegistrationInstalledWorkspacesInput,
|
||||
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
|
||||
return this.applicationRegistrationService.getInstalledWorkspacesGlobal(
|
||||
id,
|
||||
page ?? 1,
|
||||
INSTALLED_WORKSPACES_PAGE_SIZE,
|
||||
searchTerm,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Mutation(() => ApplicationRegistrationVariableDTO)
|
||||
async updateAdminApplicationRegistrationVariable(
|
||||
|
||||
+84
-1
@@ -6,7 +6,7 @@ import crypto from 'crypto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { ILike, type FindOptionsWhere, type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { type ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
@@ -398,6 +399,21 @@ export class ApplicationRegistrationService {
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
|
||||
|
||||
return this.computeStats(applicationRegistrationId);
|
||||
}
|
||||
|
||||
// Admin panel views apps across all workspaces, so ownership is not enforced.
|
||||
async getStatsGlobal(
|
||||
applicationRegistrationId: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
await this.findOneByIdGlobal(applicationRegistrationId);
|
||||
|
||||
return this.computeStats(applicationRegistrationId);
|
||||
}
|
||||
|
||||
private async computeStats(
|
||||
applicationRegistrationId: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
const versionDistribution: { version: string; count: number }[] =
|
||||
await this.applicationRepository
|
||||
.createQueryBuilder('application')
|
||||
@@ -426,6 +442,73 @@ export class ApplicationRegistrationService {
|
||||
};
|
||||
}
|
||||
|
||||
// Installed workspaces are only exposed in the admin panel, which views apps
|
||||
// across all workspaces, so ownership is not enforced.
|
||||
async getInstalledWorkspacesGlobal(
|
||||
applicationRegistrationId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
searchTerm?: string,
|
||||
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
|
||||
await this.findOneByIdGlobal(applicationRegistrationId);
|
||||
|
||||
return this.computeInstalledWorkspaces(
|
||||
applicationRegistrationId,
|
||||
page,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
);
|
||||
}
|
||||
|
||||
private async computeInstalledWorkspaces(
|
||||
applicationRegistrationId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
searchTerm?: string,
|
||||
): Promise<ApplicationRegistrationInstalledWorkspacesDTO> {
|
||||
const safePage = page < 1 ? 1 : page;
|
||||
const offset = (safePage - 1) * pageSize;
|
||||
|
||||
const trimmedSearch = searchTerm?.trim();
|
||||
|
||||
const where: FindOptionsWhere<ApplicationEntity> = {
|
||||
applicationRegistrationId,
|
||||
};
|
||||
|
||||
const whereClauses: FindOptionsWhere<ApplicationEntity>[] =
|
||||
isDefined(trimmedSearch) && trimmedSearch.length > 0
|
||||
? [
|
||||
{
|
||||
...where,
|
||||
workspace: { displayName: ILike(`%${trimmedSearch}%`) },
|
||||
},
|
||||
{ ...where, version: ILike(`%${trimmedSearch}%`) },
|
||||
]
|
||||
: [where];
|
||||
|
||||
const [applications, totalCount] =
|
||||
await this.applicationRepository.findAndCount({
|
||||
where: whereClauses,
|
||||
relations: { workspace: true },
|
||||
order: { workspace: { displayName: 'ASC' }, id: 'ASC' },
|
||||
skip: offset,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const workspaces = applications.map((application) => ({
|
||||
id: application.workspace.id,
|
||||
displayName: application.workspace.displayName ?? null,
|
||||
logo: application.workspace.logo ?? null,
|
||||
version: application.version ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
totalCount,
|
||||
hasMore: offset + workspaces.length < totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async transferOwnership(params: {
|
||||
applicationRegistrationId: string;
|
||||
targetWorkspaceSubdomain: string;
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('InstalledWorkspace')
|
||||
export class InstalledWorkspaceDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('ApplicationRegistrationInstalledWorkspaces')
|
||||
export class ApplicationRegistrationInstalledWorkspacesDTO {
|
||||
@Field(() => [InstalledWorkspaceDTO])
|
||||
workspaces: InstalledWorkspaceDTO[];
|
||||
|
||||
@Field(() => Int)
|
||||
totalCount: number;
|
||||
|
||||
@Field(() => Boolean)
|
||||
hasMore: boolean;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||
|
||||
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class FindApplicationRegistrationInstalledWorkspacesInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@Field(() => Int, { defaultValue: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
searchTerm?: string;
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { gql } from 'graphql-tag';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util';
|
||||
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
|
||||
|
||||
import {
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
|
||||
|
||||
// displayNames of the seeded workspaces (see seeder-workspaces.constant.ts).
|
||||
// They drive the ASC ordering and the searchTerm-by-workspace-name assertions.
|
||||
const APPLE_WORKSPACE_DISPLAY_NAME = 'Apple';
|
||||
const YCOMBINATOR_WORKSPACE_DISPLAY_NAME = 'YCombinator';
|
||||
|
||||
const FIND_STATS = gql`
|
||||
query FindAdminApplicationRegistrationStats($id: String!) {
|
||||
findAdminApplicationRegistrationStats(id: $id) {
|
||||
activeInstalls
|
||||
mostInstalledVersion
|
||||
versionDistribution {
|
||||
version
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const FIND_INSTALLED_WORKSPACES = gql`
|
||||
query FindAdminApplicationRegistrationInstalledWorkspaces(
|
||||
$input: FindApplicationRegistrationInstalledWorkspacesInput!
|
||||
) {
|
||||
findAdminApplicationRegistrationInstalledWorkspaces(input: $input) {
|
||||
totalCount
|
||||
hasMore
|
||||
workspaces {
|
||||
id
|
||||
displayName
|
||||
logo
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type SeededApplication = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
describe('Admin panel application registration stats and installed workspaces (integration)', () => {
|
||||
let dataSource: DataSource;
|
||||
let applicationRegistrationId: string;
|
||||
const seededApplicationIds: string[] = [];
|
||||
|
||||
const insertApplication = async ({
|
||||
workspaceId,
|
||||
version,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
version: string;
|
||||
}): Promise<SeededApplication> => {
|
||||
const id = randomUUID();
|
||||
|
||||
await dataSource.query(
|
||||
`INSERT INTO core."application"
|
||||
(id, "universalIdentifier", name, version, "sourcePath",
|
||||
"sourceType", "workspaceId", "applicationRegistrationId")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
id,
|
||||
randomUUID(),
|
||||
'admin-panel-stats-integration-test-app',
|
||||
version,
|
||||
'',
|
||||
'local',
|
||||
workspaceId,
|
||||
applicationRegistrationId,
|
||||
],
|
||||
);
|
||||
|
||||
seededApplicationIds.push(id);
|
||||
|
||||
return { id, workspaceId, version };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dataSource = global.testDataSource;
|
||||
|
||||
applicationRegistrationId = randomUUID();
|
||||
|
||||
await dataSource.query(
|
||||
`INSERT INTO core."applicationRegistration"
|
||||
(id, "universalIdentifier", name, "oAuthClientId",
|
||||
"oAuthRedirectUris", "oAuthScopes", "sourceType")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
applicationRegistrationId,
|
||||
randomUUID(),
|
||||
'admin-panel-stats-integration-test-registration',
|
||||
randomUUID(),
|
||||
[],
|
||||
[],
|
||||
'local',
|
||||
],
|
||||
);
|
||||
|
||||
// Two installs on version 2.0.0 (one per seeded workspace) and one on
|
||||
// 1.0.0, so the distribution is deterministic and 2.0.0 is the most
|
||||
// installed version.
|
||||
await insertApplication({
|
||||
workspaceId: SEED_APPLE_WORKSPACE_ID,
|
||||
version: '2.0.0',
|
||||
});
|
||||
await insertApplication({
|
||||
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
version: '2.0.0',
|
||||
});
|
||||
await insertApplication({
|
||||
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (seededApplicationIds.length > 0) {
|
||||
await dataSource.query(
|
||||
`DELETE FROM core."application" WHERE id = ANY($1)`,
|
||||
[seededApplicationIds],
|
||||
);
|
||||
}
|
||||
|
||||
await dataSource.query(
|
||||
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
|
||||
[applicationRegistrationId],
|
||||
);
|
||||
});
|
||||
|
||||
describe('findAdminApplicationRegistrationStats', () => {
|
||||
it('returns active installs, version distribution and the most installed version', async () => {
|
||||
const response = await makeAdminPanelAPIRequest({
|
||||
query: FIND_STATS,
|
||||
variables: { id: applicationRegistrationId },
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const stats = response.body.data?.findAdminApplicationRegistrationStats;
|
||||
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats.activeInstalls).toBe(3);
|
||||
expect(stats.mostInstalledVersion).toBe('2.0.0');
|
||||
|
||||
const distributionByVersion = Object.fromEntries(
|
||||
stats.versionDistribution.map(
|
||||
(entry: { version: string; count: number }) => [
|
||||
entry.version,
|
||||
entry.count,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(distributionByVersion).toEqual({ '2.0.0': 2, '1.0.0': 1 });
|
||||
// Distribution is ordered by count DESC, so the top entry matches
|
||||
// mostInstalledVersion.
|
||||
expect(stats.versionDistribution[0].version).toBe('2.0.0');
|
||||
});
|
||||
|
||||
it('rejects a caller without the SECURITY permission flag', async () => {
|
||||
const response = await makeAdminPanelAPIRequestWithGuestRole({
|
||||
query: FIND_STATS,
|
||||
variables: { id: applicationRegistrationId },
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(
|
||||
response.body.data?.findAdminApplicationRegistrationStats,
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAdminApplicationRegistrationInstalledWorkspaces', () => {
|
||||
it('returns installed workspaces ordered by display name with totalCount and hasMore', async () => {
|
||||
const response = await makeAdminPanelAPIRequest({
|
||||
query: FIND_INSTALLED_WORKSPACES,
|
||||
variables: { input: { id: applicationRegistrationId, page: 1 } },
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const result =
|
||||
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.totalCount).toBe(3);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.workspaces).toHaveLength(3);
|
||||
|
||||
// Ordered by workspace.displayName ASC — Apple before YCombinator.
|
||||
expect(result.workspaces[0].displayName).toBe(
|
||||
APPLE_WORKSPACE_DISPLAY_NAME,
|
||||
);
|
||||
expect(result.workspaces[0].version).toBe('2.0.0');
|
||||
expect(result.workspaces[1].displayName).toBe(
|
||||
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
|
||||
);
|
||||
expect(result.workspaces[2].displayName).toBe(
|
||||
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by workspace display name via searchTerm', async () => {
|
||||
const response = await makeAdminPanelAPIRequest({
|
||||
query: FIND_INSTALLED_WORKSPACES,
|
||||
variables: {
|
||||
input: {
|
||||
id: applicationRegistrationId,
|
||||
page: 1,
|
||||
searchTerm: APPLE_WORKSPACE_DISPLAY_NAME,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const result =
|
||||
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
|
||||
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.workspaces).toHaveLength(1);
|
||||
expect(result.workspaces[0].displayName).toBe(
|
||||
APPLE_WORKSPACE_DISPLAY_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('filters by application version via searchTerm', async () => {
|
||||
const response = await makeAdminPanelAPIRequest({
|
||||
query: FIND_INSTALLED_WORKSPACES,
|
||||
variables: {
|
||||
input: {
|
||||
id: applicationRegistrationId,
|
||||
page: 1,
|
||||
searchTerm: '1.0.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const result =
|
||||
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
|
||||
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result.workspaces).toHaveLength(1);
|
||||
expect(result.workspaces[0].version).toBe('1.0.0');
|
||||
expect(result.workspaces[0].displayName).toBe(
|
||||
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a caller without the SECURITY permission flag', async () => {
|
||||
const response = await makeAdminPanelAPIRequestWithGuestRole({
|
||||
query: FIND_INSTALLED_WORKSPACES,
|
||||
variables: { input: { id: applicationRegistrationId, page: 1 } },
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(
|
||||
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces,
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { type ASTNode, print } from 'graphql';
|
||||
import request from 'supertest';
|
||||
|
||||
/* global APP_PORT, APPLE_PHIL_GUEST_ACCESS_TOKEN */
|
||||
|
||||
type GraphqlOperation = {
|
||||
query: ASTNode;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Hits the admin GraphQL schema (/admin-panel) with a guest user that lacks the
|
||||
// SECURITY permission flag, so it can assert the resolver-level guards reject it.
|
||||
export const makeAdminPanelAPIRequestWithGuestRole = (
|
||||
graphqlOperation: GraphqlOperation,
|
||||
) => {
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
return client
|
||||
.post('/admin-panel')
|
||||
.set('Authorization', `Bearer ${APPLE_PHIL_GUEST_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: print(graphqlOperation.query),
|
||||
variables: graphqlOperation.variables || {},
|
||||
})
|
||||
.expect(200);
|
||||
};
|
||||
Reference in New Issue
Block a user