feat: optimistic metadata store updates for navigation menu items (#18710)

## Summary

- **Optimistic metadata store updates**: Replace `refetchQueries` with
direct `addToDraft`/`applyChanges` calls in create, update, and delete
navigation menu item mutation hooks for instant UI feedback. Client-side
UUID generation enables optimistic creates before the server responds.
- **SSE event enrichment with `targetRecordIdentifier`**: Introduce
`NavigationMenuItemRecordIdentifierService` to resolve record display
info (label, image) and enrich SSE metadata events at emission time, so
the sidebar shows record names immediately without a page refresh.
- **Centralized role permission resolution**: Add
`resolveRolePermissionConfigFromAuthContext` to `PermissionsService`,
removing duplicated role resolution logic from individual services.
- **Mutation fragments include `targetRecordIdentifier`**: Switch
create/update/delete mutations from `NavigationMenuItemFields` to
`NavigationMenuItemQueryFields` so the mutation response includes
`targetRecordIdentifier`, preventing a brief gap where RECORD favorites
are invisible in the sidebar.
- **Folder UI fixes**: Remove transparent border on
`StyledFolderContainer` that caused a 1px size inconsistency between
folder and non-folder items in Favorites. Make the folder kebab menu
hover-only instead of always visible.
This commit is contained in:
Charles Bochet
2026-03-17 23:18:07 +01:00
committed by GitHub
parent 058414fae5
commit a82739cdb1
27 changed files with 1496 additions and 1416 deletions
@@ -25,7 +25,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatViewMaps'
>): FlatNavigationMenuItem => {
const id = uuidv4();
const id = createNavigationMenuItemInput.id ?? uuidv4();
const now = new Date().toISOString();
let position = createNavigationMenuItemInput.position;
@@ -13,6 +13,11 @@ import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-m
@InputType()
export class CreateNavigationMenuItemInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -12,8 +11,8 @@ import { NavigationMenuItemResolver } from 'src/engine/metadata-modules/navigati
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -25,8 +24,6 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FlatNavigationMenuItemModule,
PermissionsModule,
FileModule,
UserRoleModule,
ApiKeyModule,
],
providers: [
NavigationMenuItemService,
@@ -35,9 +32,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
NavigationMenuItemDeletionListener,
NavigationMenuItemDeletionJob,
NavigationMenuItemResolver,
NavigationMenuItemRecordIdentifierService,
NavigationMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [NavigationMenuItemService],
exports: [
NavigationMenuItemService,
NavigationMenuItemRecordIdentifierService,
],
})
export class NavigationMenuItemModule {}
@@ -2,15 +2,8 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -27,12 +20,8 @@ import {
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -43,10 +32,7 @@ export class NavigationMenuItemService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly navigationMenuItemAccessService: NavigationMenuItemAccessService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly userRoleService: UserRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async findAll({
@@ -379,41 +365,6 @@ export class NavigationMenuItemService {
);
}
private async getRoleId(
authContext: WorkspaceAuthContext,
workspaceId: string,
): Promise<string | undefined> {
if (isApiKeyAuthContext(authContext)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return authContext.application.defaultRoleId;
}
if (isUserAuthContext(authContext)) {
try {
return await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return undefined;
}
throw error;
}
}
return undefined;
}
async findTargetRecord({
targetRecordId,
targetObjectMetadataId,
@@ -425,104 +376,15 @@ export class NavigationMenuItemService {
workspaceId: string;
authContext: WorkspaceAuthContext;
}): Promise<RecordIdentifierDTO | null> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
try {
return await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
authContext,
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: targetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata)) {
return null;
}
try {
const roleId = await this.getRoleId(authContext, workspaceId);
if (!isDefined(roleId)) {
return null;
}
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [roleId],
};
const minimalSelectColumns = getMinimalSelectForRecordIdentifier({
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
});
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const repository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig,
);
const alias = objectMetadata.nameSingular;
const queryBuilder = repository.createQueryBuilder(alias);
queryBuilder.select([]);
for (const column of minimalSelectColumns) {
queryBuilder.addSelect(`"${alias}"."${column}"`, column);
}
const rawResult = await queryBuilder
.where(`${alias}.id = :id`, { id: targetRecordId })
.getRawOne();
if (!isDefined(rawResult)) {
return null;
}
const formattedRecord = formatResult<Record<string, unknown>>(
rawResult,
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return formattedRecord;
},
authContext,
);
if (!isDefined(record)) {
return null;
}
const labelIdentifier = getRecordDisplayName(
record,
objectMetadata,
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
signUrl: (url: string) =>
this.fileService.signFileUrl({
url,
workspaceId,
}),
});
return {
id: record.id as string,
labelIdentifier,
imageIdentifier,
};
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return null;
@@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class NavigationMenuItemRecordIdentifierService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly permissionsService: PermissionsService,
) {}
async resolveRecordIdentifier({
targetRecordId,
targetObjectMetadataId,
workspaceId,
authContext,
}: {
targetRecordId: string;
targetObjectMetadataId: string;
workspaceId: string;
authContext?: WorkspaceAuthContext;
}): Promise<RecordIdentifierDTO | null> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: targetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata)) {
return null;
}
const minimalSelectColumns = getMinimalSelectForRecordIdentifier({
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
});
const resolvedAuthContext: WorkspaceAuthContext =
authContext ??
({
type: 'system',
workspace: { id: workspaceId },
} as WorkspaceAuthContext);
const rolePermissionConfig =
await this.permissionsService.resolveRolePermissionConfigFromAuthContext(
resolvedAuthContext,
);
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig ?? { shouldBypassPermissionChecks: true },
);
const alias = objectMetadata.nameSingular;
const queryBuilder = repository.createQueryBuilder(alias);
queryBuilder.select([]);
for (const column of minimalSelectColumns) {
queryBuilder.addSelect(`"${alias}"."${column}"`, column);
}
const rawResult = await queryBuilder
.where(`${alias}.id = :id`, { id: targetRecordId })
.getRawOne();
if (!isDefined(rawResult)) {
return null;
}
return formatResult<Record<string, unknown>>(
rawResult,
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
},
resolvedAuthContext,
);
if (!isDefined(record)) {
return null;
}
const labelIdentifier = getRecordDisplayName(
record,
objectMetadata,
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
signUrl: (url: string) =>
this.fileService.signFileUrl({
url,
workspaceId,
}),
});
return {
id: record.id as string,
labelIdentifier,
imageIdentifier,
};
}
}
@@ -12,6 +12,11 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isSystemAuthContext } from 'src/engine/core-modules/auth/guards/is-system-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags';
import {
PermissionsException,
@@ -129,6 +134,48 @@ export class PermissionsService {
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;
// TODO: this could likely be handled in the ORM layer
public async resolveRolePermissionConfigFromAuthContext(
authContext: WorkspaceAuthContext,
): Promise<RolePermissionConfig | null> {
const workspaceId = authContext.workspace.id;
if (isSystemAuthContext(authContext)) {
return { shouldBypassPermissionChecks: true };
}
if (isApiKeyAuthContext(authContext)) {
const roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
return { intersectionOf: [roleId] };
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return { intersectionOf: [authContext.application.defaultRoleId] };
}
if (isUserAuthContext(authContext)) {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(roleId)) {
return null;
}
return { intersectionOf: [roleId] };
}
return null;
}
public async userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,