Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 42be30b681 fix: hide Notes/Tasks tabs and prevent assignment when relation field is deactivated
https://sonarly.com/issue/18870?type=bug

When a user deactivates a relation field (e.g., Notes on People) in Settings > Data Model, the Notes tab still appears on the Person record page, notes can still be created for that person, and notes can still be assigned to people.
2026-03-27 01:19:41 +00:00
7 changed files with 1137 additions and 1086 deletions
@@ -6,13 +6,11 @@ import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelate
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { logicFunctionsState } from '@/settings/logic-functions/states/logicFunctionsState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
FindAllViewsDocument,
FindManyCommandMenuItemsDocument,
FindAllRecordPageLayoutsDocument,
@@ -57,9 +55,6 @@ export const useLoadStaleMetadataEntities = () => {
const client = useApolloClient();
const store = useStore();
const { replaceDraft, applyChanges } = useUpdateMetadataStoreDraft();
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
const loadStaleMetadataEntities = useCallback(
async (staleEntityKeys: MetadataEntityKey[]) => {
@@ -199,10 +194,7 @@ export const useLoadStaleMetadataEntities = () => {
);
}
if (
staleEntityKeys.includes('commandMenuItems') &&
isCommandMenuItemEnabled
) {
if (staleEntityKeys.includes('commandMenuItems')) {
fetchPromises.push(
client
.query({
@@ -222,7 +214,7 @@ export const useLoadStaleMetadataEntities = () => {
await Promise.all(fetchPromises);
applyChanges();
},
[client, store, replaceDraft, applyChanges, isCommandMenuItemEnabled],
[client, store, replaceDraft, applyChanges],
);
return { loadStaleMetadataEntities };
@@ -1,5 +1,6 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { PageLayoutLeftPanel } from '@/page-layout/components/PageLayoutLeftPanel';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect';
@@ -10,6 +11,8 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn
import { useReorderRecordPageLayoutTabs } from '@/page-layout/hooks/useReorderRecordPageLayoutTabs';
import { PageLayoutMainContent } from '@/page-layout/PageLayoutMainContent';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { filterTabsByDeactivatedWidgetTypes } from '@/page-layout/utils/filterTabsByDeactivatedWidgetTypes';
import { getDeactivatedActivityWidgetTypes } from '@/page-layout/utils/getDeactivatedActivityWidgetTypes';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
@@ -30,7 +33,7 @@ import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { FeatureFlagKey, WidgetType } from '~/generated-metadata/graphql';
const StyledContainer = styled.div<{ hasPinnedTab: boolean }>`
display: grid;
@@ -90,6 +93,8 @@ export const PageLayoutTabsRenderer = () => {
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const { objectMetadataItems } = useObjectMetadataItems();
const metadataStore = useAtomFamilyStateValue(
metadataStoreState,
'objectMetadataItems',
@@ -131,13 +136,28 @@ export const PageLayoutTabsRenderer = () => {
isEditMode: isPageLayoutInEditMode,
});
const deactivatedWidgetTypes =
!isPageLayoutInEditMode &&
isDefined(targetRecordIdentifier?.targetObjectNameSingular)
? getDeactivatedActivityWidgetTypes({
targetObjectNameSingular:
targetRecordIdentifier.targetObjectNameSingular,
objectMetadataItems,
})
: new Set<WidgetType>();
const tabsWithActiveWidgets = filterTabsByDeactivatedWidgetTypes({
tabs: tabsWithVisibleWidgets,
deactivatedWidgetTypes,
});
const SYSTEM_OBJECT_TABS = ['Home', 'Timeline', 'Overview', 'Flow'];
const tabsForCurrentObject = isSystemObject
? tabsWithVisibleWidgets.filter((tab) =>
? tabsWithActiveWidgets.filter((tab) =>
SYSTEM_OBJECT_TABS.includes(tab.title),
)
: tabsWithVisibleWidgets;
: tabsWithActiveWidgets;
const { tabsToRenderInTabList, pinnedLeftTab } = getTabsByDisplayMode({
tabs: tabsForCurrentObject,
@@ -0,0 +1,23 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { type WidgetType } from '~/generated-metadata/graphql';
export const filterTabsByDeactivatedWidgetTypes = ({
tabs,
deactivatedWidgetTypes,
}: {
tabs: PageLayoutTab[];
deactivatedWidgetTypes: Set<WidgetType>;
}): PageLayoutTab[] => {
if (deactivatedWidgetTypes.size === 0) {
return tabs;
}
return tabs
.map((tab) => ({
...tab,
widgets: tab.widgets.filter(
(widget) => !deactivatedWidgetTypes.has(widget.type),
),
}))
.filter((tab) => tab.widgets.length > 0);
};
@@ -0,0 +1,56 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { WidgetType } from '~/generated-metadata/graphql';
const ACTIVITY_WIDGET_TO_TARGET_OBJECT: Partial<
Record<WidgetType, CoreObjectNameSingular>
> = {
[WidgetType.NOTES]: CoreObjectNameSingular.NoteTarget,
[WidgetType.TASKS]: CoreObjectNameSingular.TaskTarget,
};
export const getDeactivatedActivityWidgetTypes = ({
targetObjectNameSingular,
objectMetadataItems,
}: {
targetObjectNameSingular: string;
objectMetadataItems: EnrichedObjectMetadataItem[];
}): Set<WidgetType> => {
const deactivatedWidgetTypes = new Set<WidgetType>();
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(targetObjectMetadata)) {
return deactivatedWidgetTypes;
}
for (const [widgetType, activityTargetObjectName] of Object.entries(
ACTIVITY_WIDGET_TO_TARGET_OBJECT,
)) {
const activityTargetObjectMetadata = objectMetadataItems.find(
(item) => item.nameSingular === activityTargetObjectName,
);
if (!isDefined(activityTargetObjectMetadata)) {
continue;
}
// Check for a relation field on the activity target object (e.g. NoteTarget)
// that links to the current target object (e.g. Person).
// In the legacy model, this is a RELATION field with targetObjectMetadata matching.
// In the morph model, this is a field named after the target object (e.g. personId).
const relationFieldToTarget = activityTargetObjectMetadata.fields.find(
(field) =>
field.relation?.targetObjectMetadata.id === targetObjectMetadata.id,
);
if (isDefined(relationFieldToTarget) && !relationFieldToTarget.isActive) {
deactivatedWidgetTypes.add(widgetType as WidgetType);
}
}
return deactivatedWidgetTypes;
};
@@ -1,4 +1,4 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createWriteStream } from 'fs';
@@ -22,7 +22,6 @@ import {
SdkClientException,
SdkClientExceptionCode,
} from 'src/engine/core-modules/sdk-client/exceptions/sdk-client.exception';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -30,15 +29,11 @@ const SDK_CLIENT_ARCHIVE_NAME = 'twenty-client-sdk.zip';
@Injectable()
export class SdkClientArchiveService {
private readonly logger = new Logger(SdkClientArchiveService.name);
constructor(
private readonly fileStorageService: FileStorageService,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
@Inject(forwardRef(() => SdkClientGenerationService))
private readonly sdkClientGenerationService: SdkClientGenerationService,
) {}
async downloadAndExtractToPackage({
@@ -159,49 +154,13 @@ export class SdkClientArchiveService {
error instanceof FileStorageException &&
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
) {
return this.generateAndRetryReadArchiveStream({
workspaceId,
applicationUniversalIdentifier,
});
throw new SdkClientException(
`SDK client archive "${SDK_CLIENT_ARCHIVE_NAME}" not found for application "${applicationUniversalIdentifier}" in workspace "${workspaceId}".`,
SdkClientExceptionCode.ARCHIVE_NOT_FOUND,
);
}
throw error;
}
}
private async generateAndRetryReadArchiveStream({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): Promise<Readable> {
const application = await this.applicationRepository.findOne({
where: { workspaceId, universalIdentifier: applicationUniversalIdentifier },
});
if (!application) {
throw new SdkClientException(
`SDK client archive "${SDK_CLIENT_ARCHIVE_NAME}" not found for application "${applicationUniversalIdentifier}" in workspace "${workspaceId}".`,
SdkClientExceptionCode.ARCHIVE_NOT_FOUND,
);
}
this.logger.log(
`SDK client archive missing for application "${applicationUniversalIdentifier}", generating on-the-fly`,
);
await this.sdkClientGenerationService.generateSdkClientForApplication({
workspaceId,
applicationId: application.id,
applicationUniversalIdentifier,
});
return this.fileStorageService.readFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.GeneratedSdkClient,
resourcePath: SDK_CLIENT_ARCHIVE_NAME,
});
}
}
File diff suppressed because it is too large Load Diff
@@ -4,4 +4,5 @@ export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
] as const satisfies FeatureFlagKey[];