Fix orphan views by deferring record table widget view creation to dashboard save (#20006)

This commit is contained in:
Abdul Rahman
2026-04-28 15:36:26 +05:30
committed by GitHub
parent a5cd64daf5
commit a710c105cf
35 changed files with 1130 additions and 183 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}
/>
);