27dea0ed0b
## 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
279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|