Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 78c4f9c73c fix: add defensive check for package.json in copyDependenciesInMemory
https://sonarly.com/issue/32080?type=bug

Logic function execution fails with "File not found" error when attempting to build Lambda dependency layers because the code unconditionally downloads package.json from S3 without checking if it exists first. Upgraded workspaces lack this file entirely, causing FileStorageException.

Fix: The fix implements a defensive check for the `package.json` file in `copyDependenciesInMemory()` method, preventing FileStorageException when the file is missing from S3.

**Changes made:**

1. Added import for `SEED_DEPENDENCIES_DIRNAME` constant which points to the seed-dependencies directory containing default dependency files
2. Added `path` to the path module import (previously only importing `dirname` and `join`)
3. Modified `copyDependenciesInMemory()` to check if both `package.json` and `yarn.lock` exist in parallel using `Promise.all()`
4. Added conditional logic for `package.json`: if it exists in S3, download it; otherwise, copy the seed package.json from the local seed-dependencies directory
5. Kept existing conditional logic for `yarn.lock` unchanged

**Why this fixes the issue:**

Workspaces created before the application-system feature, or upgraded from earlier versions, do not have dependency files in S3. When logic functions are executed, the system attempts to retrieve these files. Previously, the code unconditionally tried to download `package.json` without checking if it existed first, causing FileStorageException(FILE_NOT_FOUND) which prevented the entire logic function build process.

By checking file existence first and falling back to the seed package.json (which contains all necessary production dependencies), logic functions can now execute successfully on any workspace, regardless of when it was created or whether it was upgraded.

This follows the exact same defensive pattern already implemented for `yarn.lock` handling and matches the reference fix that was previously developed (commit a730cf3f7d).
2026-04-28 12:50:14 +00:00
Abdul RahmanandGitHub a710c105cf Fix orphan views by deferring record table widget view creation to dashboard save (#20006) 2026-04-28 10:06:26 +00:00
36 changed files with 1166 additions and 197 deletions
@@ -1,5 +1,4 @@
import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetEditorModePersistedComponentState } from '@/page-layout/states/fieldsWidgetEditorModePersistedComponentState';
@@ -10,6 +9,9 @@ import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layou
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -90,6 +92,18 @@ export const useCancelLayoutCustomization = () => {
}),
fieldsWidgetEditorModePersisted,
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewPersisted,
);
}
exitLayoutCustomizationMode();
@@ -1,6 +1,5 @@
import { useCommandMenuItemsDraftState } from '@/command-menu-item/hooks/useCommandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
@@ -8,6 +7,9 @@ import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/st
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { atom, useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -86,6 +88,26 @@ export const useIsLayoutCustomizationDirty = () => {
if (!isDeeplyEqual(ungroupedFieldsDraft, ungroupedFieldsPersisted)) {
return true;
}
const recordTableWidgetViewDraft = get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewPersisted = get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
if (
!isDeeplyEqual(
recordTableWidgetViewDraft,
recordTableWidgetViewPersisted,
)
) {
return true;
}
}
return false;
@@ -7,6 +7,7 @@ import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/state
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useCreatePendingRecordTableWidgetViews } from '@/page-layout/hooks/useCreatePendingRecordTableWidgetViews';
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -42,6 +43,8 @@ export const useSaveLayoutCustomization = () => {
useUpdatePageLayoutWithTabsAndWidgets();
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
@@ -113,6 +116,7 @@ export const useSaveLayoutCustomization = () => {
);
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft, {
@@ -185,6 +189,7 @@ export const useSaveLayoutCustomization = () => {
saveCommandMenuItemsDraft,
isCommandMenuItemsDirty,
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
updatePageLayoutWithTabsAndWidgets,
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,
@@ -98,6 +98,7 @@ export const RecordTableWidgetProvider = ({
>
<RecordTableWidgetViewLoadEffect
viewId={viewId}
widgetId={widgetId}
objectMetadataItem={objectMetadataItem}
/>
{children}
@@ -1,6 +1,10 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
@@ -9,11 +13,13 @@ import { isDefined } from 'twenty-shared/utils';
type RecordTableWidgetViewLoadEffectProps = {
viewId: string;
widgetId: string;
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const RecordTableWidgetViewLoadEffect = ({
viewId,
widgetId,
objectMetadataItem,
}: RecordTableWidgetViewLoadEffectProps) => {
const { loadRecordIndexStates } = useLoadRecordIndexStates();
@@ -23,18 +29,30 @@ export const RecordTableWidgetViewLoadEffect = ({
setLastLoadedRecordTableWidgetViewId,
] = useAtomComponentState(lastLoadedRecordTableWidgetViewIdComponentState);
const viewFromViewId = useAtomFamilySelectorValue(
viewFromViewIdFamilySelector,
{
viewId,
},
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const draftSnapshot = useAtomComponentFamilySelectorValue(
recordTableWidgetViewDraftByWidgetIdComponentFamilySelector,
{ widgetId },
);
const viewFromDraft =
isPageLayoutInEditMode && isDefined(draftSnapshot)
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
: undefined;
const viewFromSelector = useAtomFamilySelectorValue(
viewFromViewIdFamilySelector,
{ viewId },
);
const currentView = viewFromDraft ?? viewFromSelector;
const viewHasFields =
isDefined(viewFromViewId) && viewFromViewId.viewFields.length > 0;
isDefined(currentView) && currentView.viewFields.length > 0;
useEffect(() => {
if (!isDefined(viewFromViewId)) {
if (!isDefined(currentView)) {
return;
}
@@ -50,7 +68,7 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
loadRecordIndexStates(viewFromViewId, objectMetadataItem);
loadRecordIndexStates(currentView, objectMetadataItem);
setLastLoadedRecordTableWidgetViewId({
viewId,
@@ -60,7 +78,7 @@ export const RecordTableWidgetViewLoadEffect = ({
viewId,
lastLoadedRecordTableWidgetViewId,
setLastLoadedRecordTableWidgetViewId,
viewFromViewId,
currentView,
viewHasFields,
objectMetadataItem,
loadRecordIndexStates,
@@ -9,10 +9,10 @@ import {
} from './PageLayoutTestWrapper';
jest.mock(
'@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget',
'@/page-layout/widgets/record-table/hooks/useRemoveDraftViewForRecordTableWidget',
() => ({
useDeleteViewForRecordTableWidget: () => ({
deleteViewForRecordTableWidget: jest.fn(),
useRemoveDraftViewForRecordTableWidget: () => ({
removeDraftViewForRecordTableWidget: jest.fn(),
}),
}),
);
@@ -0,0 +1,141 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetType } from '~/generated-metadata/graphql';
export const useCreatePendingRecordTableWidgetViews = () => {
const { performViewAPICreate, performViewAPIDestroy } =
usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const store = useStore();
const createPendingRecordTableWidgetViews = useCallback(
async (pageLayoutId: string) => {
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persisted = store.get(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persistedRecordTableWidgets = new Map(
(persisted?.tabs ?? [])
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE)
.map((widget) => [
widget.id,
getWidgetConfigurationViewId(widget.configuration),
]),
);
const draftRecordTableWidgets = draft.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE);
const draftWidgetIds = new Set(
draftRecordTableWidgets.map((widget) => widget.id),
);
for (const widget of draftRecordTableWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (!isDefined(viewId)) {
continue;
}
const persistedViewId = persistedRecordTableWidgets.get(widget.id);
if (persistedViewId === viewId) {
continue;
}
if (isDefined(persistedViewId)) {
await performViewAPIDestroy({ id: persistedViewId });
}
const widgetViewDraft = recordTableWidgetViewDraft[widget.id];
if (!isDefined(widgetViewDraft)) {
continue;
}
const { view } = widgetViewDraft;
const result = await performViewAPICreate(
{
input: {
id: view.id,
name: view.name,
icon: view.icon,
objectMetadataId: view.objectMetadataId,
type: view.type,
isCompact: view.isCompact,
position: view.position,
openRecordIn: view.openRecordIn,
visibility: view.visibility,
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
},
},
view.objectMetadataId,
);
if (result.status === 'failed') {
throw new Error(
`Failed to create view for RECORD_TABLE widget ${widget.id}`,
);
}
const viewFieldInputs = widgetViewDraft.viewFields.map((field) => ({
id: field.id,
viewId: field.viewId,
fieldMetadataId: field.fieldMetadataId,
position: field.position,
size: field.size,
isVisible: field.isVisible,
}));
if (viewFieldInputs.length > 0) {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
}
}
for (const [widgetId, viewId] of persistedRecordTableWidgets) {
if (!draftWidgetIds.has(widgetId) && isDefined(viewId)) {
await performViewAPIDestroy({ id: viewId });
}
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[
performViewAPICreate,
performViewAPIDestroy,
performViewFieldAPICreate,
store,
],
);
return { createPendingRecordTableWidgetViews };
};
@@ -1,4 +1,5 @@
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useCreatePendingRecordTableWidgetViews } from '@/page-layout/hooks/useCreatePendingRecordTableWidgetViews';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -45,6 +46,9 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
@@ -52,6 +56,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const savePageLayout = useCallback(async () => {
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
const pageLayoutDraft = store.get(pageLayoutDraftCallbackState);
const updateInput = convertPageLayoutDraftToUpdateInput(pageLayoutDraft, {
@@ -91,6 +96,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
return result;
}, [
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
isRecordPageLayoutEditingEnabled,
pageLayoutCurrentLayoutsCallbackState,
pageLayoutDraftCallbackState,
@@ -0,0 +1,11 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewDraftComponentState =
createAtomComponentState<Record<string, RecordTableWidgetViewSnapshot>>({
key: 'recordTableWidgetViewDraftComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,11 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewPersistedComponentState =
createAtomComponentState<Record<string, RecordTableWidgetViewSnapshot>>({
key: 'recordTableWidgetViewPersistedComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,23 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
import { PageLayoutComponentInstanceContext } from '../contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewDraftByWidgetIdComponentFamilySelector =
createAtomComponentFamilySelector<
RecordTableWidgetViewSnapshot | undefined,
{ widgetId: string }
>({
key: 'recordTableWidgetViewDraftByWidgetIdComponentFamilySelector',
componentInstanceContext: PageLayoutComponentInstanceContext,
get:
({ instanceId, familyKey }) =>
({ get }) => {
const draftMap = get(recordTableWidgetViewDraftComponentState, {
instanceId,
});
return draftMap[familyKey.widgetId];
},
});
@@ -0,0 +1,281 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { buildFieldsWidgetGroupsFromFlatViewData } from '@/page-layout/utils/buildFieldsWidgetGroupsFromFlatViewData';
import { FieldMetadataType } from 'twenty-shared/types';
const createFieldMetadata = (
overrides: Partial<FieldMetadataItem> & { id: string },
): FieldMetadataItem =>
({
name: 'field',
label: 'Field',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
...overrides,
}) as FieldMetadataItem;
const createFlatViewField = (
overrides: Partial<FlatViewField> & {
id: string;
fieldMetadataId: string;
viewId: string;
},
): FlatViewField =>
({
position: 0,
isVisible: true,
isActive: true,
...overrides,
}) as FlatViewField;
const createFlatViewFieldGroup = (
overrides: Partial<FlatViewFieldGroup> & { id: string },
): FlatViewFieldGroup =>
({
name: 'Group',
position: 0,
isVisible: true,
...overrides,
}) as FlatViewFieldGroup;
describe('buildFieldsWidgetGroupsFromFlatViewData', () => {
const fm1 = createFieldMetadata({ id: 'fm-1', name: 'name', label: 'Name' });
const fm2 = createFieldMetadata({
id: 'fm-2',
name: 'email',
label: 'Email',
});
const fm3 = createFieldMetadata({
id: 'fm-3',
name: 'phone',
label: 'Phone',
});
describe('ungrouped mode', () => {
it('should return ungrouped fields sorted by position when no groups exist', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 1,
}),
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.editorMode).toBe('ungrouped');
expect(result.groups).toEqual([]);
expect(result.ungroupedFields).toHaveLength(2);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-1');
expect(result.ungroupedFields[1].fieldMetadataItem.id).toBe('fm-2');
});
it('should assign sequential globalIndex based on sorted position', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 5,
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 2,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.ungroupedFields[0].globalIndex).toBe(0);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-2');
expect(result.ungroupedFields[1].globalIndex).toBe(1);
expect(result.ungroupedFields[1].fieldMetadataItem.id).toBe('fm-1');
});
it('should skip fields whose fieldMetadataId has no matching metadata', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
}),
createFlatViewField({
id: 'vf-orphan',
fieldMetadataId: 'fm-nonexistent',
viewId: 'v1',
position: 1,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1],
});
expect(result.ungroupedFields).toHaveLength(1);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-1');
});
it('should return empty ungroupedFields when there are no view fields', () => {
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields: [],
fieldMetadataItems: [fm1],
});
expect(result.editorMode).toBe('ungrouped');
expect(result.ungroupedFields).toHaveLength(0);
});
});
describe('grouped mode', () => {
it('should return grouped fields when groups exist', () => {
const group1 = createFlatViewFieldGroup({
id: 'g1',
name: 'General',
position: 0,
});
const group2 = createFlatViewFieldGroup({
id: 'g2',
name: 'Details',
position: 1,
});
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g2',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group1, group2],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.editorMode).toBe('grouped');
expect(result.ungroupedFields).toEqual([]);
expect(result.groups).toHaveLength(2);
expect(result.groups[0].name).toBe('General');
expect(result.groups[0].fields).toHaveLength(1);
expect(result.groups[0].fields[0].fieldMetadataItem.id).toBe('fm-1');
expect(result.groups[1].name).toBe('Details');
expect(result.groups[1].fields[0].fieldMetadataItem.id).toBe('fm-2');
});
it('should sort fields within each group by position', () => {
const group = createFlatViewFieldGroup({ id: 'g1', name: 'All' });
const flatViewFields = [
createFlatViewField({
id: 'vf-3',
fieldMetadataId: 'fm-3',
viewId: 'v1',
position: 2,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 1,
viewFieldGroupId: 'g1',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields,
fieldMetadataItems: [fm1, fm2, fm3],
});
expect(
result.groups[0].fields.map((f) => f.fieldMetadataItem.id),
).toEqual(['fm-1', 'fm-2', 'fm-3']);
});
it('should skip fields with missing metadata in grouped mode', () => {
const group = createFlatViewFieldGroup({ id: 'g1', name: 'All' });
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-orphan',
fieldMetadataId: 'fm-nonexistent',
viewId: 'v1',
position: 1,
viewFieldGroupId: 'g1',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields,
fieldMetadataItems: [fm1],
});
expect(result.groups[0].fields).toHaveLength(1);
});
it('should preserve group visibility in the output', () => {
const group = createFlatViewFieldGroup({
id: 'g1',
name: 'Hidden Group',
isVisible: false,
});
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields: [],
fieldMetadataItems: [],
});
expect(result.groups[0].isVisible).toBe(false);
});
});
});
@@ -1,6 +1,7 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect';
type RecordTableWidgetRendererContentProps = {
objectMetadataId: string;
@@ -18,12 +19,18 @@ export const RecordTableWidgetRendererContent = ({
});
return (
<RecordTableWidgetProvider
objectNameSingular={objectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<RecordTableWidget />
</RecordTableWidgetProvider>
<>
<RecordTableWidgetViewDraftInitEffect
widgetId={widgetId}
viewId={viewId}
/>
<RecordTableWidgetProvider
objectNameSingular={objectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<RecordTableWidget />
</RecordTableWidgetProvider>
</>
);
};
@@ -0,0 +1,23 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { useInitializeRecordTableWidgetViewDraft } from '@/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft';
import { useViewById } from '@/views/hooks/useViewById';
type RecordTableWidgetViewDraftInitEffectProps = {
widgetId: string;
viewId: string;
};
export const RecordTableWidgetViewDraftInitEffect = ({
widgetId,
viewId,
}: RecordTableWidgetViewDraftInitEffectProps) => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { view } = useViewById(viewId);
useInitializeRecordTableWidgetViewDraft({
widgetId,
view: isPageLayoutInEditMode ? view : undefined,
});
return null;
};
@@ -0,0 +1,89 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import {
ViewOpenRecordIn,
ViewType,
ViewVisibility,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
export const useAddDraftViewForRecordTableWidget = (pageLayoutId: string) => {
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const addDraftViewForRecordTableWidget = useCallback(
(widgetId: string, objectMetadataItem: EnrichedObjectMetadataItem) => {
const newViewId = v4();
const flatView: FlatView = {
id: newViewId,
name: `${objectMetadataItem.labelPlural} Table`,
icon: objectMetadataItem.icon ?? 'IconTable',
objectMetadataId: objectMetadataItem.id,
type: ViewType.TABLE_WIDGET,
isCompact: false,
position: 0,
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
visibility: ViewVisibility.UNLISTED,
shouldHideEmptyGroups: false,
};
const eligibleFields = objectMetadataItem.fields.filter(
filterFieldsForRecordTableViewCreation,
);
const sortedFields = eligibleFields.toSorted(
sortFieldsByRelevanceForRecordTableWidget(
objectMetadataItem.labelIdentifierFieldMetadataId,
),
);
const flatViewFields: FlatViewField[] = sortedFields.map(
(field, index) => ({
id: v4(),
viewId: newViewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
isActive: true,
}),
);
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: { view: flatView, viewFields: flatViewFields },
}));
requestAnimationFrame(() => {
updatePageLayoutWidget(widgetId, {
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: newViewId,
},
});
});
},
[store, recordTableWidgetViewDraftState, updatePageLayoutWidget],
);
return { addDraftViewForRecordTableWidget };
};
@@ -1,85 +0,0 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import {
WidgetConfigurationType,
ViewType,
} from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
export const useCreateViewForRecordTableWidget = (pageLayoutId: string) => {
const { performViewAPICreate } = usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const createViewForRecordTableWidget = useCallback(
async (
widgetId: string,
objectMetadataItem: EnrichedObjectMetadataItem,
) => {
const newViewId = v4();
const viewResult = await performViewAPICreate(
{
input: {
id: newViewId,
name: `${objectMetadataItem.labelPlural} Table`,
icon: objectMetadataItem.icon ?? 'IconTable',
objectMetadataId: objectMetadataItem.id,
type: ViewType.TABLE_WIDGET,
},
},
objectMetadataItem.id,
);
if (viewResult.status !== 'successful') {
return;
}
const eligibleFields = objectMetadataItem.fields.filter(
filterFieldsForRecordTableViewCreation,
);
const sortedFields = eligibleFields.toSorted(
sortFieldsByRelevanceForRecordTableWidget(
objectMetadataItem.labelIdentifierFieldMetadataId,
),
);
const viewFieldInputs = sortedFields.map((field, index) => ({
id: v4(),
viewId: newViewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
}));
try {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
updatePageLayoutWidget(widgetId, {
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: newViewId,
},
});
} catch (error) {
throw new Error(
'Failed to create view fields for record table widget',
{ cause: error },
);
}
},
[performViewAPICreate, performViewFieldAPICreate, updatePageLayoutWidget],
);
return { createViewForRecordTableWidget };
};
@@ -1,15 +0,0 @@
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { useCallback } from 'react';
export const useDeleteViewForRecordTableWidget = () => {
const { performViewAPIDestroy } = usePerformViewAPIPersist();
const deleteViewForRecordTableWidget = useCallback(
async (viewId: string) => {
await performViewAPIDestroy({ id: viewId });
},
[performViewAPIDestroy],
);
return { deleteViewForRecordTableWidget };
};
@@ -0,0 +1,71 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { type View } from '@/views/types/View';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
type UseInitializeRecordTableWidgetViewDraftParams = {
widgetId: string;
view: View | undefined;
};
export const useInitializeRecordTableWidgetViewDraft = ({
widgetId,
view,
}: UseInitializeRecordTableWidgetViewDraftParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
);
const recordTableWidgetViewPersistedState =
useAtomComponentStateCallbackState(
recordTableWidgetViewPersistedComponentState,
);
const store = useStore();
const initializeDraft = useCallback(() => {
const currentDraft = store.get(recordTableWidgetViewDraftState);
if (widgetId in currentDraft) {
return;
}
if (!view || view.viewFields.length === 0) {
return;
}
const { viewFields, ...viewProps } = view;
const flatViewFields: FlatViewField[] = viewFields.map((field) => ({
...field,
viewId: view.id,
}));
const snapshot: RecordTableWidgetViewSnapshot = {
view: viewProps,
viewFields: flatViewFields,
};
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: snapshot,
}));
store.set(recordTableWidgetViewPersistedState, (prev) => ({
...prev,
[widgetId]: snapshot,
}));
}, [
recordTableWidgetViewDraftState,
recordTableWidgetViewPersistedState,
widgetId,
view,
store,
]);
useEffect(initializeDraft, [initializeDraft]);
};
@@ -1,18 +1,33 @@
import { useMapViewFieldToRecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/hooks/useMapViewFieldToRecordTableWidgetViewFieldItem';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { sortByProperty } from '~/utils/array/sortByProperty';
export const useRecordTableWidgetViewFieldItems = (viewId: string) => {
const { view } = useViewById(viewId);
type UseRecordTableWidgetViewFieldItemsParams = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const useRecordTableWidgetViewFieldItems = ({
viewId,
widgetId,
pageLayoutId,
}: UseRecordTableWidgetViewFieldItemsParams) => {
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { mapViewFieldToRecordTableWidgetViewFieldItem } =
useMapViewFieldToRecordTableWidgetViewFieldItem();
const recordTableWidgetViewFieldItems: RecordTableWidgetViewFieldItem[] =
useMemo(() => {
if (!view) {
if (!isDefined(view)) {
return [];
}
@@ -0,0 +1,35 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewById } from '@/views/hooks/useViewById';
import { type View } from '@/views/types/View';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetViewForDisplayParams = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const useRecordTableWidgetViewForDisplay = ({
viewId,
widgetId,
pageLayoutId,
}: UseRecordTableWidgetViewForDisplayParams): {
view: View | undefined;
} => {
const { view } = useViewById(viewId);
const recordTableWidgetViewDraft = useAtomComponentStateValue(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const draftSnapshot = recordTableWidgetViewDraft[widgetId];
const viewFromDraft = isDefined(draftSnapshot)
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
: undefined;
return { view: viewFromDraft ?? view };
};
@@ -0,0 +1,27 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export const useRemoveDraftViewForRecordTableWidget = (
pageLayoutId: string,
) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const removeDraftViewForRecordTableWidget = useCallback(
(widgetId: string) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const { [widgetId]: _, ...rest } = prev;
return rest;
});
},
[store, recordTableWidgetViewDraftState],
);
return { removeDraftViewForRecordTableWidget };
};
@@ -1,12 +1,28 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useReorderRecordTableWidgetFields = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
type UseReorderRecordTableWidgetFieldsParams = {
pageLayoutId: string;
widgetId: string;
};
export const useReorderRecordTableWidgetFields = ({
pageLayoutId,
widgetId,
}: UseReorderRecordTableWidgetFieldsParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const reorderRecordTableWidgetFields = useCallback(
async (
(
sourceIndex: number,
destinationIndex: number,
visibleFieldItems: RecordTableWidgetViewFieldItem[],
@@ -19,16 +35,35 @@ export const useReorderRecordTableWidgetFields = () => {
const [movedField] = reorderedFields.splice(sourceIndex, 1);
reorderedFields.splice(destinationIndex, 0, movedField);
const updates = reorderedFields.map((fieldItem, index) => ({
input: {
id: fieldItem.viewField.id,
update: { position: index },
},
}));
const updatedPositions = new Map(
reorderedFields.map((fieldItem, index) => [
fieldItem.viewField.id,
index,
]),
);
await performViewFieldAPIUpdate(updates);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) => {
const newPosition = updatedPositions.get(field.id);
return newPosition !== undefined
? { ...field, position: newPosition }
: field;
}),
},
};
});
},
[performViewFieldAPIUpdate],
[store, recordTableWidgetViewDraftState, widgetId],
);
return { reorderRecordTableWidgetFields };
@@ -5,6 +5,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useMapRecordFieldToViewFieldWithCurrentAggregateOperation } from '@/page-layout/widgets/record-table/hooks/useMapRecordFieldToViewFieldWithCurrentAggregateOperation';
import { computeViewFieldsToCreateAndUpdate } from '@/page-layout/widgets/record-table/utils/computeViewFieldsToCreateAndUpdate';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
@@ -65,6 +66,11 @@ export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
);
const views = store.get(viewsSelector.atom);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgets = pageLayoutDraft.tabs.flatMap((tab) =>
tab.widgets.filter(
@@ -182,16 +188,21 @@ export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
}),
);
const newViewFields = currentRecordFields.map(
const recordIndexViewFields = currentRecordFields.map(
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
);
const existingViewFields = currentView.viewFields ?? [];
const draftSnapshot = recordTableWidgetViewDraft[widget.id];
const draftViewFields = draftSnapshot?.viewFields ?? [];
const metadataStoreViewFields =
draftViewFields.length > 0
? draftViewFields
: (currentView.viewFields ?? []);
const { viewFieldsToCreate, viewFieldsToUpdate } =
computeViewFieldsToCreateAndUpdate({
newViewFields,
existingViewFields,
newViewFields: metadataStoreViewFields,
existingViewFields: recordIndexViewFields,
viewId,
});
@@ -1,21 +1,46 @@
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useToggleRecordTableWidgetFieldVisibility = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
type UseToggleRecordTableWidgetFieldVisibilityParams = {
pageLayoutId: string;
widgetId: string;
};
export const useToggleRecordTableWidgetFieldVisibility = ({
pageLayoutId,
widgetId,
}: UseToggleRecordTableWidgetFieldVisibilityParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const toggleRecordTableWidgetFieldVisibility = useCallback(
async (viewFieldId: string, isVisible: boolean) => {
await performViewFieldAPIUpdate([
{
input: {
id: viewFieldId,
update: { isVisible },
(viewFieldId: string, isVisible: boolean) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) =>
field.id === viewFieldId ? { ...field, isVisible } : field,
),
},
},
]);
};
});
},
[performViewFieldAPIUpdate],
[store, recordTableWidgetViewDraftState, widgetId],
);
return { toggleRecordTableWidgetFieldVisibility };
@@ -0,0 +1,7 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
export type RecordTableWidgetViewSnapshot = {
view: FlatView;
viewFields: FlatViewField[];
};
@@ -0,0 +1,130 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
const createField = (
overrides: Partial<FieldMetadataItem> & { id: string },
): FieldMetadataItem =>
({
name: 'field',
label: 'Field',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
settings: null,
...overrides,
}) as FieldMetadataItem;
describe('sortFieldsByRelevanceForRecordTableWidget', () => {
const labelIdentifierId = 'label-field-id';
const sorter = sortFieldsByRelevanceForRecordTableWidget(labelIdentifierId);
it('should place the label identifier field first', () => {
const labelField = createField({ id: labelIdentifierId });
const textField = createField({ id: 'text-1' });
expect(sorter(labelField, textField)).toBe(-1);
expect(sorter(textField, labelField)).toBe(1);
});
it('should place ONE_TO_MANY relation fields after non-relation fields', () => {
const reverseSide = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const textField = createField({ id: 'text-1' });
expect(sorter(reverseSide, textField)).toBe(1);
expect(sorter(textField, reverseSide)).toBe(-1);
});
it('should place regular relation fields after non-relation fields', () => {
const relation = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
const textField = createField({ id: 'text-1' });
expect(sorter(relation, textField)).toBe(1);
expect(sorter(textField, relation)).toBe(-1);
});
it('should place ONE_TO_MANY relations after MANY_TO_ONE relations', () => {
const reverseSide = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const manyToOne = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
expect(sorter(reverseSide, manyToOne)).toBe(1);
expect(sorter(manyToOne, reverseSide)).toBe(-1);
});
it('should return 0 for two non-relation fields of equal priority', () => {
const fieldA = createField({ id: 'a' });
const fieldB = createField({ id: 'b' });
expect(sorter(fieldA, fieldB)).toBe(0);
});
it('should return 0 for two ONE_TO_MANY relation fields', () => {
const reverseA = createField({
id: 'reverse-a',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const reverseB = createField({
id: 'reverse-b',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
expect(sorter(reverseA, reverseB)).toBe(0);
});
it('should produce a correct full sort order', () => {
const labelField = createField({ id: labelIdentifierId });
const textField = createField({ id: 'text-1' });
const numberField = createField({
id: 'number-1',
type: FieldMetadataType.NUMBER,
});
const manyToOneField = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
const oneToManyField = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const fields = [
oneToManyField,
manyToOneField,
textField,
numberField,
labelField,
];
const sorted = [...fields].sort(sorter);
expect(sorted[0].id).toBe(labelIdentifierId);
expect(sorted[sorted.length - 1].id).toBe('reverse-1');
const labelIdx = sorted.findIndex((f) => f.id === labelIdentifierId);
const textIdx = sorted.findIndex((f) => f.id === 'text-1');
const relIdx = sorted.findIndex((f) => f.id === 'rel-1');
const reverseIdx = sorted.findIndex((f) => f.id === 'reverse-1');
expect(labelIdx).toBeLessThan(textIdx);
expect(textIdx).toBeLessThan(relIdx);
expect(relIdx).toBeLessThan(reverseIdx);
});
});
@@ -0,0 +1,13 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { type View } from '@/views/types/View';
export const constructViewFromRecordTableWidgetViewSnapshot = (
snapshot: RecordTableWidgetViewSnapshot,
): View => ({
...snapshot.view,
viewFields: snapshot.viewFields,
viewFilters: [],
viewSorts: [],
viewGroups: [],
viewFilterGroups: [],
});
@@ -11,7 +11,7 @@ import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hook
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { useAddDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForRecordTableWidget';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
@@ -83,8 +83,8 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
const { removePageLayoutWidgetAndPreservePosition } =
useRemovePageLayoutWidgetAndPreservePosition(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { addDraftViewForRecordTableWidget } =
useAddDraftViewForRecordTableWidget(pageLayoutId);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const firstAvailableObjectMetadataItem =
@@ -185,7 +185,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
closeSidePanelMenu();
};
const handleNavigateToRecordTableSettings = async () => {
const handleNavigateToRecordTableSettings = () => {
if (
isExistingWidgetMissingOrDifferentType(
existingWidget?.type,
@@ -202,7 +202,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
setPageLayoutEditingWidgetId(newRecordTableWidget.id);
await createViewForRecordTableWidget(
addDraftViewForRecordTableWidget(
newRecordTableWidget.id,
firstAvailableObjectMetadataItem,
);
@@ -3,8 +3,8 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget';
import { useAddDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForRecordTableWidget';
import { useRemoveDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useRemoveDraftViewForRecordTableWidget';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
@@ -47,11 +47,11 @@ export const RecordTableDataSourceDropdownContent = () => {
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { addDraftViewForRecordTableWidget } =
useAddDraftViewForRecordTableWidget(pageLayoutId);
const { deleteViewForRecordTableWidget } =
useDeleteViewForRecordTableWidget();
const { removeDraftViewForRecordTableWidget } =
useRemoveDraftViewForRecordTableWidget(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
@@ -77,20 +77,14 @@ export const RecordTableDataSourceDropdownContent = () => {
getSearchableValues: (item) => [item.labelPlural, item.namePlural],
});
const currentViewId =
widgetInEditMode?.configuration &&
'viewId' in widgetInEditMode.configuration
? (widgetInEditMode.configuration.viewId as string | undefined)
: undefined;
const handleSelectSource = async (newObjectMetadataItemId: string) => {
const handleSelectSource = (newObjectMetadataItemId: string) => {
if (currentObjectMetadataItemId === newObjectMetadataItemId) {
closeDropdown();
return;
}
if (isDefined(currentViewId)) {
await deleteViewForRecordTableWidget(currentViewId);
if (isDefined(widgetInEditMode)) {
removeDraftViewForRecordTableWidget(widgetInEditMode.id);
}
updateCurrentWidgetConfig({
@@ -105,7 +99,7 @@ export const RecordTableDataSourceDropdownContent = () => {
);
if (isDefined(selectedObjectMetadataItem) && isDefined(widgetInEditMode)) {
await createViewForRecordTableWidget(
addDraftViewForRecordTableWidget(
widgetInEditMode.id,
selectedObjectMetadataItem,
);
@@ -27,19 +27,25 @@ const StyledSectionLabel = styled.div`
type RecordTableSettingsFieldVisibilityProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const RecordTableSettingsFieldVisibility = ({
viewId,
widgetId,
pageLayoutId,
}: RecordTableSettingsFieldVisibilityProps) => {
const { recordTableWidgetViewFieldItems } =
useRecordTableWidgetViewFieldItems(viewId);
useRecordTableWidgetViewFieldItems({ viewId, widgetId, pageLayoutId });
const { toggleRecordTableWidgetFieldVisibility } =
useToggleRecordTableWidgetFieldVisibility();
useToggleRecordTableWidgetFieldVisibility({ pageLayoutId, widgetId });
const { reorderRecordTableWidgetFields } =
useReorderRecordTableWidgetFields();
const { reorderRecordTableWidgetFields } = useReorderRecordTableWidgetFields({
pageLayoutId,
widgetId,
});
const { getIcon } = useIcons();
@@ -3,9 +3,9 @@ import { AdvancedFilterSidePanelContainer } from '@/object-record/advanced-filte
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableSettingsFiltersInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFiltersInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -20,14 +20,22 @@ const StyledFilterSettingsContainer = styled.div`
type RecordTableSettingsFiltersProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
objectMetadataId: string;
};
export const RecordTableSettingsFilters = ({
viewId,
widgetId,
pageLayoutId,
objectMetadataId,
}: RecordTableSettingsFiltersProps) => {
const { view } = useViewById(viewId);
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@@ -2,10 +2,10 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMeta
import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filterSortableFieldMetadataItems';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableSettingsSortsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsContent';
import { RecordTableSettingsSortsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -23,14 +23,22 @@ const StyledSortSettingsContainer = styled.div`
type RecordTableSettingsSortsProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
objectMetadataId: string;
};
export const RecordTableSettingsSorts = ({
viewId,
widgetId,
pageLayoutId,
objectMetadataId,
}: RecordTableSettingsSortsProps) => {
const { view } = useViewById(viewId);
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@@ -28,5 +28,11 @@ export const SidePanelRecordTableFieldsSubPage = () => {
return null;
}
return <RecordTableSettingsFieldVisibility viewId={viewId} />;
return (
<RecordTableSettingsFieldVisibility
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
/>
);
};
@@ -31,6 +31,8 @@ export const SidePanelRecordTableFilterSubPage = () => {
return (
<RecordTableSettingsFilters
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
@@ -32,6 +32,8 @@ export const SidePanelRecordTableSortSubPage = () => {
return (
<RecordTableSettingsSorts
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { dirname, join } from 'path';
import path, { dirname, join } from 'path';
import { type QueryRunner } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
@@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/seed-dependencies-dirname';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import {
getLogicFunctionSeedProjectFiles,
@@ -257,24 +258,45 @@ export class LogicFunctionResourceService {
workspaceId: string;
inMemoryFolderPath: string;
}) {
const yarnLockExists = await this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'yarn.lock',
});
const promises = [];
promises.push(
this.fileStorageService.downloadFile({
const [packageJsonExists, yarnLockExists] = await Promise.all([
this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'package.json',
localPath: join(inMemoryFolderPath, 'package.json'),
}),
);
this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'yarn.lock',
}),
]);
const promises = [];
if (packageJsonExists) {
promises.push(
this.fileStorageService.downloadFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'package.json',
localPath: join(inMemoryFolderPath, 'package.json'),
}),
);
} else {
const packageJsonPath = join(inMemoryFolderPath, 'package.json');
promises.push(
fs.mkdir(dirname(packageJsonPath), { recursive: true }).then(() =>
fs.copyFile(
path.join(SEED_DEPENDENCIES_DIRNAME, 'package.json'),
packageJsonPath,
),
),
);
}
if (yarnLockExists) {
promises.push(