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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user