Add record table widget to dashboards (#18747)

## Demo



https://github.com/user-attachments/assets/584de452-544a-41f8-ae9f-4be9e9d0cd9f


## Problem
- Dashboards only supported chart widgets — tabular record data had no
inline widget type
- `RecordTable` was tightly coupled to the record index: HTML IDs, CSS
variables, and hover portals were global strings with no per-instance
scoping, so multiple tables on the same page would collide
- `updateRecordTableCSSVariable`, `RECORD_TABLE_HTML_ID`, and cell
portal IDs were hardcoded — placing two tables caused hover portals and
CSS column widths to bleed across instances
- Grid drag-select captured record UUIDs as cell IDs, producing `NaN`
layout coordinates and a full-page freeze on second widget creation

## Fix
- `RECORD_TABLE` is now a valid widget type across the full stack —
server DTOs, DB enum migration, universal config mapping, GraphQL
codegen, shared types (`RecordTableConfigurationDto`, `WidgetType`,
`addRecordTableWidgetType` migration)
- A record table widget can be placed on a dashboard and boots from a
View ID with no record index dependency —
`StandaloneRecordTableProvider` + `StandaloneRecordTableViewLoadEffect`
(wraps existing `RecordTableWithWrappers` unchanged)
- Selecting a data source auto-creates a dedicated View with up to 6
initial fields; switching source or deleting the widget cleans up the
View — `useCreateViewForRecordTableWidget` +
`useDeleteViewForRecordTableWidget`
- The settings panel exposes source, field visibility/reorder, filter
conditions, sort rules, and editable widget title —
`SidePanelPageLayoutRecordTableSettings` + sub-pages, matching chart
widget pattern
- Filters, sorts, and aggregate operations update the table in real time
but only persist to the View on explicit dashboard save —
`useSaveRecordTableWidgetsViewDataOnDashboardSave` (diff + flush on
save)
- Headers are always non-interactive (no dropdown, no cursor pointer);
columns are resizable only in edit mode; cells are non-editable in both
modes — `isRecordTableColumnHeadersReadOnlyComponentState`,
`isRecordTableColumnResizableComponentState`,
`isRecordTableCellsNonEditableComponentState` (Jotai component states)
- Hover portals and CSS column widths no longer bleed between multiple
table widgets — `getRecordTableHtmlId(tableId)`,
`getRecordTableCellId(tableId, …)`,
`updateRecordTableCSSVariable(tableId, …)` scope all DOM IDs and CSS
variables per instance
- Clicking inside a widget's content area no longer opens the settings
panel — `WidgetCardContent` stops click propagation when editable,
limiting settings-open to the card header and chrome
- Second widget creation no longer freezes the page —
`PageLayoutGridLayout` drag-select filters by `cell-` prefix to exclude
record UUIDs from grid cell detection

## Follow-up fixes

**Widget save flow**
- Saving a dashboard silently dropped record table widget changes
(column visibility, order, filters, sorts, aggregates) because widget
data save was bundled inside the layout save and only ran when layout
structure changed
- Widget data now persists independently via
`useSavePageLayoutWidgetsData`, called in all save paths (dashboard
save, record page save, layout customization save); saves are also
skipped when nothing has changed

**Drag-and-drop / checkbox columns in widget**
- Record table widgets showed the drag handle column and checkbox
selection column even though row reordering and multi-select are
meaningless in a read-only widget
- Two new component states
(`isRecordTableDragColumnHiddenComponentState`,
`isRecordTableCheckboxColumnHiddenComponentState`) hide each column
independently; widget tables now display only data columns

**Sticky column layout**
- Sticky positioning of the first three columns used `:nth-of-type` CSS
selectors — when drag or checkbox columns were hidden, the selector
targeted the wrong column and the first data column didn't stick
- Sticky CSS now targets semantic class names
(`RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME`, etc.) so sticky
behavior is correct regardless of which columns are hidden

**Save/Cancel buttons during edit mode**
- Save and Cancel command-menu buttons were unpinned during dashboard
edit mode because the pin logic excluded all items while
`isPageInEditMode` was true
- Items whose availability expression contains `isPageInEditMode` are
now exempted from the unpin rule; Save/Cancel stay pinned during editing

**Title input auto-focus**
- Selecting "Record Table" as widget type auto-focused the title input,
interrupting the configuration flow
- `focusTitleInput` is now `false` when navigating to record table
settings

**Morph relation field error**
- A field with missing `morphRelations` metadata crashed the page with a
"refresh" error from `mapObjectMetadataToGraphQLQuery`
- Now returns an empty array and silently omits the field from the query
instead of crashing

**`updateRecordMutation` prop removal**
- `RecordTableWithWrappers` required callers to pass an
`updateRecordMutation` callback, duplicating `useUpdateOneRecord` at
every usage site
- The mutation is now owned inside `RecordTableContextProvider` via
`RecordTableUpdateContext`; the prop is gone

**Standalone → Widget module rename**
- `record-table-standalone` module renamed to `record-table-widget` —
`StandaloneRecordTable` → `RecordTableWidget`,
`StandaloneRecordTableViewLoadEffect` →
`RecordTableWidgetViewLoadEffect`, etc.

**RecordTableRow cell extraction**
- Row rendering logic (`RecordTableCellDragAndDrop`,
`RecordTableCellCheckbox`, `RecordTableFieldsCells`, hotkey/arrow-key
effects) was duplicated between `RecordTableRow` and
`RecordTableRowVirtualizedFullData`
- Extracted `RecordTableRowCells` (shared cell content) and
`RecordTableStaticTr` (non-draggable `<tr>` wrapper); when drag column
is hidden, rows render inside a static `<tr>` instead of the draggable
wrapper

**View load effect metadata tracking**
- `RecordTableWidgetViewLoadEffect` now tracks
`objectMetadataItem.updatedAt` alongside `viewId` to re-load states when
metadata changes (e.g. field additions), preventing stale column data

**Data source dropdown deduplication**
- Extracted `filterReadableActiveObjectMetadataItems` util, shared by
both chart and record table data source dropdowns — removes duplicated
permission-filtering logic

**RECORD_TABLE view identifier mapping (server)**
- Added `RECORD_TABLE` case to
`fromPageLayoutWidgetConfigurationToUniversalConfiguration` and
`fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration` so
widget views are properly mapped during workspace import/export

**GraphQL error handler typing (server)**
- `formatError` parameter changed from `any` to `unknown`;
`workspaceQueryRunnerGraphqlApiExceptionHandler` broadened from
`QueryFailedErrorWithCode` to `Error | QueryFailedError` — removes
unsafe type casts

**Save hook signature**
- `useSaveRecordTableWidgetsViewDataOnDashboardSave` no longer takes
`pageLayoutId` in constructor; receives it as a callback parameter,
eliminating the need for `useAtomComponentStateCallbackState`

**Customize Dashboard hidden during edit mode**
- The "Customize Dashboard" command was still visible while already
editing — its `conditionalAvailabilityExpression` now includes `not
isPageInEditMode`

**Fields dropdown split**
- `RecordTableFieldsDropdownContent` (300+ lines) split into
`RecordTableFieldsDropdownVisibleFieldsContent` and
`RecordTableFieldsDropdownHiddenFieldsContent`

**Checkbox placeholder cleanup**
- Removed unnecessary `StyledRecordTableTdContainer` wrapper from
`RecordTableCellCheckboxPlaceholder`
This commit is contained in:
Lucas Bordeau
2026-03-24 18:28:56 +01:00
committed by GitHub
parent c94aa7316a
commit 96f3ff0e90
152 changed files with 5369 additions and 1048 deletions
@@ -14,6 +14,7 @@ import { TaskWidget } from '@/page-layout/widgets/tasks/components/TaskWidget';
import { TimelineWidget } from '@/page-layout/widgets/timeline/components/TimelineWidget';
import { WorkflowRunWidget } from '@/page-layout/widgets/workflow/components/WorkflowRunWidget';
import { WorkflowVersionWidget } from '@/page-layout/widgets/workflow/components/WorkflowVersionWidget';
import { RecordTableWidgetRenderer } from '@/page-layout/widgets/record-table/components/RecordTableWidgetRenderer';
import { WorkflowWidget } from '@/page-layout/widgets/workflow/components/WorkflowWidget';
import { WidgetType } from '~/generated-metadata/graphql';
@@ -73,6 +74,9 @@ export const WidgetContentRenderer = ({
case WidgetType.FRONT_COMPONENT:
return <FrontComponentWidgetRenderer widget={widget} />;
case WidgetType.RECORD_TABLE:
return <RecordTableWidgetRenderer widget={widget} />;
default:
return null;
}
@@ -0,0 +1,34 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { RecordTableWidgetRendererContent } from '@/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
type RecordTableWidgetRendererProps = {
widget: PageLayoutWidget;
};
export const RecordTableWidgetRenderer = ({
widget,
}: RecordTableWidgetRendererProps) => {
const { configuration } = widget;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration && 'viewId' in configuration
? (configuration.viewId as string | undefined)
: undefined;
if (!isDefined(widget.objectMetadataId) || !isDefined(viewId)) {
return null;
}
return (
<RecordTableWidgetRendererContent
objectMetadataId={widget.objectMetadataId}
viewId={viewId}
widgetId={widget.id}
/>
);
};
@@ -0,0 +1,29 @@
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';
type RecordTableWidgetRendererContentProps = {
objectMetadataId: string;
viewId: string;
widgetId: string;
};
export const RecordTableWidgetRendererContent = ({
objectMetadataId,
viewId,
widgetId,
}: RecordTableWidgetRendererContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
return (
<RecordTableWidgetProvider
objectNameSingular={objectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<RecordTableWidget />
</RecordTableWidgetProvider>
);
};
@@ -0,0 +1,83 @@
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 { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { 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 { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const createViewForRecordTableWidget = useCallback(
async (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,
},
},
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 });
updateCurrentWidgetConfig({
configToUpdate: {
viewId: newViewId,
},
});
} catch (error) {
throw new Error(
'Failed to create view fields for record table widget',
{ cause: error },
);
}
},
[
performViewAPICreate,
performViewFieldAPICreate,
updateCurrentWidgetConfig,
],
);
return { createViewForRecordTableWidget };
};
@@ -0,0 +1,15 @@
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,36 @@
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState';
import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation';
import { type ViewField } from '@/views/types/ViewField';
import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useMapRecordFieldToViewFieldWithCurrentAggregateOperation = () => {
const store = useStore();
const mapRecordFieldToViewFieldWithCurrentAggregateOperation = useCallback(
(recordField: RecordField): Omit<ViewField, 'definition'> => {
const viewField = mapRecordFieldToViewField(recordField);
const extendedAggregateOperation = store.get(
viewFieldAggregateOperationState.atomFamily({
viewFieldId: recordField.id,
}),
);
return {
...viewField,
aggregateOperation: isDefined(extendedAggregateOperation)
? convertExtendedAggregateOperationToAggregateOperation(
extendedAggregateOperation,
)
: viewField.aggregateOperation,
};
},
[store],
);
return { mapRecordFieldToViewFieldWithCurrentAggregateOperation };
};
@@ -0,0 +1,25 @@
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { type ViewField } from '@/views/types/ViewField';
import { useCallback } from 'react';
export const useMapViewFieldToRecordTableWidgetViewFieldItem = () => {
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
const mapViewFieldToRecordTableWidgetViewFieldItem = useCallback(
(viewField: ViewField): RecordTableWidgetViewFieldItem | null => {
try {
const { fieldMetadataItem } = getFieldMetadataItemByIdOrThrow(
viewField.fieldMetadataId,
);
return { viewField, fieldMetadataItem };
} catch {
return null;
}
},
[getFieldMetadataItemByIdOrThrow],
);
return { mapViewFieldToRecordTableWidgetViewFieldItem };
};
@@ -0,0 +1,26 @@
import { useMapViewFieldToRecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/hooks/useMapViewFieldToRecordTableWidgetViewFieldItem';
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);
const { mapViewFieldToRecordTableWidgetViewFieldItem } =
useMapViewFieldToRecordTableWidgetViewFieldItem();
const recordTableWidgetViewFieldItems: RecordTableWidgetViewFieldItem[] =
useMemo(() => {
if (!view) {
return [];
}
return view.viewFields
.toSorted(sortByProperty('position'))
.map(mapViewFieldToRecordTableWidgetViewFieldItem)
.filter(isDefined);
}, [view, mapViewFieldToRecordTableWidgetViewFieldItem]);
return { recordTableWidgetViewFieldItems };
};
@@ -0,0 +1,35 @@
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
export const useReorderRecordTableWidgetFields = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
const reorderRecordTableWidgetFields = useCallback(
async (
sourceIndex: number,
destinationIndex: number,
visibleFieldItems: RecordTableWidgetViewFieldItem[],
) => {
if (sourceIndex === destinationIndex) {
return;
}
const reorderedFields = [...visibleFieldItems];
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 },
},
}));
await performViewFieldAPIUpdate(updates);
},
[performViewFieldAPIUpdate],
);
return { reorderRecordTableWidgetFields };
};
@@ -0,0 +1,162 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { mapRecordFilterToViewFilter } from '@/views/utils/mapRecordFilterToViewFilter';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetFiltersToView = (
viewId: string,
instanceId: string,
) => {
const {
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const {
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDelete,
} = usePerformViewFilterGroupAPIPersist();
const currentRecordFiltersCallbackState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
instanceId,
);
const currentRecordFilterGroupsCallbackState =
useAtomComponentStateCallbackState(
currentRecordFilterGroupsComponentState,
instanceId,
);
const store = useStore();
const saveRecordTableWidgetFiltersToView = useCallback(async () => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentRecordFilters = store.get(currentRecordFiltersCallbackState);
const currentRecordFilterGroups = store.get(
currentRecordFilterGroupsCallbackState,
);
const newViewFilterGroups = currentRecordFilterGroups.map(
(recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: currentView,
}),
);
const currentViewFilterGroups = currentView.viewFilterGroups ?? [];
const viewFilterGroupsToCreate = getViewFilterGroupsToCreate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
currentViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToDelete = getViewFilterGroupsToDelete(
currentViewFilterGroups,
newViewFilterGroups,
);
await performViewFilterGroupAPICreate(
viewFilterGroupsToCreate,
currentView,
);
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
const newViewFilters = currentRecordFilters.map(
mapRecordFilterToViewFilter,
);
const currentViewFilters = currentView.viewFilters ?? [];
const viewFiltersToCreate = getViewFiltersToCreate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToUpdate = getViewFiltersToUpdate(
currentViewFilters,
newViewFilters,
);
const viewFiltersToDelete = getViewFiltersToDelete(
currentViewFilters,
newViewFilters,
);
await performViewFilterAPIDelete(
viewFiltersToDelete.map((viewFilter) => ({
input: { id: viewFilter.id },
})),
);
await performViewFilterAPIUpdate(
viewFiltersToUpdate.map((viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
})),
);
await performViewFilterAPICreate(
viewFiltersToCreate.map((viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
})),
);
await performViewFilterGroupAPIDelete(
viewFilterGroupsToDelete.map((viewFilterGroup) => viewFilterGroup.id),
);
}, [
store,
viewId,
currentRecordFiltersCallbackState,
currentRecordFilterGroupsCallbackState,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDelete,
]);
return { saveRecordTableWidgetFiltersToView };
};
@@ -0,0 +1,86 @@
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { usePerformViewSortAPIPersist } from '@/views/hooks/internal/usePerformViewSortAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSaveRecordTableWidgetSortsToView = (viewId: string) => {
const {
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
} = usePerformViewSortAPIPersist();
const store = useStore();
const saveRecordTableWidgetSortsToView = useCallback(
async (recordSorts: RecordSort[]) => {
const views = store.get(viewsSelector.atom);
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView)) {
return;
}
const currentViewSorts = currentView.viewSorts ?? [];
const newViewSorts = recordSorts.map(mapRecordSortToViewSort);
const viewSortsToCreate = getViewSortsToCreate(
currentViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
currentViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
currentViewSorts,
newViewSorts,
);
await performViewSortAPICreate(
viewSortsToCreate.map((viewSort) => ({
input: {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
},
})),
);
await performViewSortAPIUpdate(
viewSortsToUpdate.map((viewSort) => ({
input: {
id: viewSort.id,
update: {
direction: viewSort.direction,
},
},
})),
);
await performViewSortAPIDelete(
viewSortsToDelete.map((viewSort) => ({
input: {
id: viewSort.id,
},
})),
);
},
[
store,
viewId,
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
],
);
return { saveRecordTableWidgetSortsToView };
};
@@ -0,0 +1,306 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
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';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { usePerformViewSortAPIPersist } from '@/views/hooks/internal/usePerformViewSortAPIPersist';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
import { getViewSortsToCreate } from '@/views/utils/getViewSortsToCreate';
import { getViewSortsToDelete } from '@/views/utils/getViewSortsToDelete';
import { getViewSortsToUpdate } from '@/views/utils/getViewSortsToUpdate';
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
import { mapRecordFilterToViewFilter } from '@/views/utils/mapRecordFilterToViewFilter';
import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
const {
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const {
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDelete,
} = usePerformViewFilterGroupAPIPersist();
const {
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
} = usePerformViewSortAPIPersist();
const { performViewFieldAPICreate, performViewFieldAPIUpdate } =
usePerformViewFieldAPIPersist();
const { mapRecordFieldToViewFieldWithCurrentAggregateOperation } =
useMapRecordFieldToViewFieldWithCurrentAggregateOperation();
const store = useStore();
const saveRecordTableWidgetsViewDataOnDashboardSave = useCallback(
async (pageLayoutId: string) => {
const pageLayoutDraft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const views = store.get(viewsSelector.atom);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const recordTableWidgets = pageLayoutDraft.tabs.flatMap((tab) =>
tab.widgets.filter(
(widget) =>
widget.configuration.configurationType ===
WidgetConfigurationType.RECORD_TABLE,
),
);
for (const widget of recordTableWidgets) {
const configuration = widget.configuration;
if (!('viewId' in configuration) || !isDefined(configuration.viewId)) {
continue;
}
const viewId = configuration.viewId as string;
const currentView = views.find((view) => view.id === viewId);
if (!isDefined(currentView) || !isDefined(widget.objectMetadataId)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === widget.objectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
const currentRecordFilters = store.get(
currentRecordFiltersComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const currentRecordFilterGroups = store.get(
currentRecordFilterGroupsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const currentRecordSorts = store.get(
currentRecordSortsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const newViewFilterGroups = currentRecordFilterGroups.map(
(recordFilterGroup) =>
mapRecordFilterGroupToViewFilterGroup({
recordFilterGroup,
view: currentView,
}),
);
const existingViewFilterGroups = currentView.viewFilterGroups ?? [];
const viewFilterGroupsToCreate = getViewFilterGroupsToCreate(
existingViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
existingViewFilterGroups,
newViewFilterGroups,
);
const viewFilterGroupsToDelete = getViewFilterGroupsToDelete(
existingViewFilterGroups,
newViewFilterGroups,
);
const newViewFilters = currentRecordFilters.map(
mapRecordFilterToViewFilter,
);
const existingViewFilters = currentView.viewFilters ?? [];
const viewFiltersToCreate = getViewFiltersToCreate(
existingViewFilters,
newViewFilters,
);
const viewFiltersToUpdate = getViewFiltersToUpdate(
existingViewFilters,
newViewFilters,
);
const viewFiltersToDelete = getViewFiltersToDelete(
existingViewFilters,
newViewFilters,
);
const newViewSorts = currentRecordSorts.map(mapRecordSortToViewSort);
const existingViewSorts = currentView.viewSorts ?? [];
const viewSortsToCreate = getViewSortsToCreate(
existingViewSorts,
newViewSorts,
);
const viewSortsToUpdate = getViewSortsToUpdate(
existingViewSorts,
newViewSorts,
);
const viewSortsToDelete = getViewSortsToDelete(
existingViewSorts,
newViewSorts,
);
const currentRecordFields = store.get(
currentRecordFieldsComponentState.atomFamily({
instanceId: recordIndexId,
}),
);
const newViewFields = currentRecordFields.map(
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
);
const { viewFieldsToCreate, viewFieldsToUpdate } =
computeViewFieldsToCreateAndUpdate({
newViewFields,
existingViewFields: currentView.viewFields ?? [],
viewId,
});
const hasNoRecordTableWidgetChanges =
viewFilterGroupsToCreate.length === 0 &&
viewFilterGroupsToUpdate.length === 0 &&
viewFilterGroupsToDelete.length === 0 &&
viewFiltersToCreate.length === 0 &&
viewFiltersToUpdate.length === 0 &&
viewFiltersToDelete.length === 0 &&
viewSortsToCreate.length === 0 &&
viewSortsToUpdate.length === 0 &&
viewSortsToDelete.length === 0 &&
viewFieldsToCreate.length === 0 &&
viewFieldsToUpdate.length === 0;
if (hasNoRecordTableWidgetChanges) {
continue;
}
await performViewFilterGroupAPICreate(
viewFilterGroupsToCreate,
currentView,
);
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
await performViewFilterAPIDelete(
viewFiltersToDelete.map((viewFilter) => ({
input: { id: viewFilter.id },
})),
);
await performViewFilterAPIUpdate(
viewFiltersToUpdate.map((viewFilter) => ({
input: {
id: viewFilter.id,
update: {
value: viewFilter.value,
operand: viewFilter.operand,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
},
},
})),
);
await performViewFilterAPICreate(
viewFiltersToCreate.map((viewFilter) => ({
input: {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
viewId: currentView.id,
value: viewFilter.value,
operand: viewFilter.operand,
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
},
})),
);
await performViewFilterGroupAPIDelete(
viewFilterGroupsToDelete.map((viewFilterGroup) => viewFilterGroup.id),
);
await performViewSortAPICreate(
viewSortsToCreate.map((viewSort) => ({
input: {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
},
})),
);
await performViewSortAPIUpdate(
viewSortsToUpdate.map((viewSort) => ({
input: {
id: viewSort.id,
update: {
direction: viewSort.direction,
},
},
})),
);
await performViewSortAPIDelete(
viewSortsToDelete.map((viewSort) => ({
input: { id: viewSort.id },
})),
);
await Promise.all([
performViewFieldAPICreate({ inputs: viewFieldsToCreate }),
performViewFieldAPIUpdate(viewFieldsToUpdate),
]);
}
},
[
store,
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
performViewFieldAPICreate,
performViewFieldAPIUpdate,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDelete,
performViewSortAPICreate,
performViewSortAPIUpdate,
performViewSortAPIDelete,
],
);
return { saveRecordTableWidgetsViewDataOnDashboardSave };
};
@@ -0,0 +1,22 @@
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
export const useToggleRecordTableWidgetFieldVisibility = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
const toggleRecordTableWidgetFieldVisibility = useCallback(
async (viewFieldId: string, isVisible: boolean) => {
await performViewFieldAPIUpdate([
{
input: {
id: viewFieldId,
update: { isVisible },
},
},
]);
},
[performViewFieldAPIUpdate],
);
return { toggleRecordTableWidgetFieldVisibility };
};
@@ -0,0 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ViewField } from '@/views/types/ViewField';
export type RecordTableWidgetViewFieldItem = {
viewField: ViewField;
fieldMetadataItem: FieldMetadataItem;
};
@@ -0,0 +1,208 @@
import { v4 } from 'uuid';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { computeViewFieldsToCreateAndUpdate } from '@/page-layout/widgets/record-table/utils/computeViewFieldsToCreateAndUpdate';
import { type ViewField } from '@/views/types/ViewField';
const buildViewField = (
overrides: Partial<Omit<ViewField, 'definition'>>,
): Omit<ViewField, 'definition'> => ({
id: v4(),
fieldMetadataId: v4(),
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
isOverridden: false,
...overrides,
});
const buildExistingViewField = (overrides: Partial<ViewField>): ViewField => ({
id: v4(),
fieldMetadataId: v4(),
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
isOverridden: false,
...overrides,
});
describe('computeViewFieldsToCreateAndUpdate', () => {
it('adds new fields with no matching fieldMetadataId to viewFieldsToCreate', () => {
const viewId = v4();
const newViewField = buildViewField({
position: 0,
isVisible: true,
size: 150,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(1);
expect(result.viewFieldsToCreate[0]).toEqual({
id: newViewField.id,
fieldMetadataId: newViewField.fieldMetadataId,
position: newViewField.position,
isVisible: newViewField.isVisible,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
viewId,
});
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('adds existing fields with changed properties to viewFieldsToUpdate', () => {
const viewId = v4();
const fieldMetadataId = v4();
const existingFieldId = v4();
const existingViewField = buildExistingViewField({
id: existingFieldId,
fieldMetadataId,
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
});
const newViewField = buildViewField({
fieldMetadataId,
position: 2,
isVisible: false,
size: 200,
aggregateOperation: AggregateOperations.COUNT,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(1);
expect(result.viewFieldsToUpdate[0]).toEqual({
input: {
id: existingFieldId,
update: {
isVisible: false,
position: 2,
size: 200,
aggregateOperation: AggregateOperations.COUNT,
},
},
});
});
it('skips existing fields with identical properties', () => {
const viewId = v4();
const fieldMetadataId = v4();
const existingViewField = buildExistingViewField({
fieldMetadataId,
position: 1,
isVisible: true,
size: 120,
aggregateOperation: AggregateOperations.SUM,
});
const newViewField = buildViewField({
fieldMetadataId,
position: 1,
isVisible: true,
size: 120,
aggregateOperation: AggregateOperations.SUM,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [newViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('returns empty arrays when newViewFields is empty', () => {
const viewId = v4();
const existingViewField = buildExistingViewField({});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(0);
expect(result.viewFieldsToUpdate).toHaveLength(0);
});
it('handles a mix of creates and updates in a single call', () => {
const viewId = v4();
const existingFieldMetadataId = v4();
const newFieldMetadataId = v4();
const existingFieldId = v4();
const existingViewField = buildExistingViewField({
id: existingFieldId,
fieldMetadataId: existingFieldMetadataId,
position: 0,
isVisible: true,
size: 100,
aggregateOperation: null,
});
const updatedNewViewField = buildViewField({
fieldMetadataId: existingFieldMetadataId,
position: 5,
isVisible: false,
size: 100,
aggregateOperation: null,
});
const brandNewViewField = buildViewField({
fieldMetadataId: newFieldMetadataId,
position: 1,
isVisible: true,
size: 80,
aggregateOperation: AggregateOperations.MIN,
});
const result = computeViewFieldsToCreateAndUpdate({
newViewFields: [updatedNewViewField, brandNewViewField],
existingViewFields: [existingViewField],
viewId,
});
expect(result.viewFieldsToCreate).toHaveLength(1);
expect(result.viewFieldsToCreate[0]).toEqual({
id: brandNewViewField.id,
fieldMetadataId: newFieldMetadataId,
position: 1,
isVisible: true,
size: 80,
aggregateOperation: AggregateOperations.MIN,
viewId,
});
expect(result.viewFieldsToUpdate).toHaveLength(1);
expect(result.viewFieldsToUpdate[0]).toEqual({
input: {
id: existingFieldId,
update: {
isVisible: false,
position: 5,
size: 100,
aggregateOperation: null,
},
},
});
});
});
@@ -0,0 +1,163 @@
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
const labelIdentifierFieldMetadataId = 'label-identifier-id';
const labelIdentifierField: Pick<
FieldMetadataItem,
'id' | 'type' | 'settings'
> = {
id: labelIdentifierFieldMetadataId,
type: FieldMetadataType.TEXT,
settings: null,
};
const regularTextField: Pick<FieldMetadataItem, 'id' | 'type' | 'settings'> = {
id: 'regular-text-field-id',
type: FieldMetadataType.TEXT,
settings: null,
};
const anotherRegularTextField: Pick<
FieldMetadataItem,
'id' | 'type' | 'settings'
> = {
id: 'another-regular-text-field-id',
type: FieldMetadataType.TEXT,
settings: null,
};
const oneToManyRelationField: Pick<
FieldMetadataItem,
'id' | 'type' | 'settings'
> = {
id: 'one-to-many-relation-field-id',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
};
const manyToOneRelationField: Pick<
FieldMetadataItem,
'id' | 'type' | 'settings'
> = {
id: 'many-to-one-relation-field-id',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
};
const anotherManyToOneRelationField: Pick<
FieldMetadataItem,
'id' | 'type' | 'settings'
> = {
id: 'another-many-to-one-relation-field-id',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
};
describe('sortFieldsByRelevanceForWidget', () => {
it('returns -1 when fieldA is the label identifier field', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
labelIdentifierField as FieldMetadataItem,
regularTextField as FieldMetadataItem,
);
expect(result).toBe(-1);
});
it('returns 1 when fieldB is the label identifier field', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
regularTextField as FieldMetadataItem,
labelIdentifierField as FieldMetadataItem,
);
expect(result).toBe(1);
});
it('returns 1 when fieldA is a ONE_TO_MANY relation and fieldB is not', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
oneToManyRelationField as FieldMetadataItem,
regularTextField as FieldMetadataItem,
);
expect(result).toBe(1);
});
it('returns -1 when fieldB is a ONE_TO_MANY relation and fieldA is not', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
regularTextField as FieldMetadataItem,
oneToManyRelationField as FieldMetadataItem,
);
expect(result).toBe(-1);
});
it('returns 1 when fieldA is a non-ONE_TO_MANY relation and fieldB is a non-relation field', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
manyToOneRelationField as FieldMetadataItem,
regularTextField as FieldMetadataItem,
);
expect(result).toBe(1);
});
it('returns -1 when fieldB is a non-ONE_TO_MANY relation and fieldA is a non-relation field', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
regularTextField as FieldMetadataItem,
manyToOneRelationField as FieldMetadataItem,
);
expect(result).toBe(-1);
});
it('returns 0 when both fields are non-relation non-label-identifier fields', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
regularTextField as FieldMetadataItem,
anotherRegularTextField as FieldMetadataItem,
);
expect(result).toBe(0);
});
it('returns 0 when both fields are non-ONE_TO_MANY relation fields', () => {
const comparator = sortFieldsByRelevanceForRecordTableWidget(
labelIdentifierFieldMetadataId,
);
const result = comparator(
manyToOneRelationField as FieldMetadataItem,
anotherManyToOneRelationField as FieldMetadataItem,
);
expect(result).toBe(0);
});
});
@@ -0,0 +1,87 @@
import { type ViewField } from '@/views/types/ViewField';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateViewFieldInput,
type UpdateViewFieldMutationVariables,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type ViewFieldsToCreateAndUpdate = {
viewFieldsToCreate: CreateViewFieldInput[];
viewFieldsToUpdate: UpdateViewFieldMutationVariables[];
};
export const computeViewFieldsToCreateAndUpdate = ({
newViewFields,
existingViewFields,
viewId,
}: {
newViewFields: Omit<ViewField, 'definition'>[];
existingViewFields: ViewField[];
viewId: string;
}): ViewFieldsToCreateAndUpdate => {
return newViewFields.reduce<ViewFieldsToCreateAndUpdate>(
(accumulator, newViewField) => {
const existingViewField = existingViewFields.find(
(existingField) =>
existingField.fieldMetadataId === newViewField.fieldMetadataId,
);
if (!isDefined(existingViewField)) {
return {
viewFieldsToCreate: [
...accumulator.viewFieldsToCreate,
{
id: newViewField.id,
fieldMetadataId: newViewField.fieldMetadataId,
position: newViewField.position,
isVisible: newViewField.isVisible,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
viewId,
},
],
viewFieldsToUpdate: accumulator.viewFieldsToUpdate,
};
}
if (
isDeeplyEqual(
{
position: existingViewField.position,
size: existingViewField.size,
isVisible: existingViewField.isVisible,
aggregateOperation: existingViewField.aggregateOperation,
},
{
position: newViewField.position,
size: newViewField.size,
isVisible: newViewField.isVisible,
aggregateOperation: newViewField.aggregateOperation,
},
)
) {
return accumulator;
}
return {
viewFieldsToCreate: accumulator.viewFieldsToCreate,
viewFieldsToUpdate: [
...accumulator.viewFieldsToUpdate,
{
input: {
id: existingViewField.id,
update: {
isVisible: newViewField.isVisible,
position: newViewField.position,
size: newViewField.size,
aggregateOperation: newViewField.aggregateOperation,
},
},
},
],
};
},
{ viewFieldsToCreate: [], viewFieldsToUpdate: [] },
);
};
@@ -0,0 +1,8 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
export const filterFieldsForRecordTableViewCreation = (
field: FieldMetadataItem,
) => {
return field.isActive && !field.isSystem && !isHiddenSystemField(field);
};
@@ -0,0 +1,39 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
export const sortFieldsByRelevanceForRecordTableWidget = (
labelIdentifierFieldMetadataId: string,
) => {
return (fieldA: FieldMetadataItem, fieldB: FieldMetadataItem): number => {
if (fieldA.id === labelIdentifierFieldMetadataId) {
return -1;
}
if (fieldB.id === labelIdentifierFieldMetadataId) {
return 1;
}
const isFieldAReverseSide =
fieldA.type === FieldMetadataType.RELATION &&
fieldA.settings?.relationType === RelationType.ONE_TO_MANY;
const isFieldBReverseSide =
fieldB.type === FieldMetadataType.RELATION &&
fieldB.settings?.relationType === RelationType.ONE_TO_MANY;
if (isFieldAReverseSide && !isFieldBReverseSide) return 1;
if (!isFieldAReverseSide && isFieldBReverseSide) return -1;
const isFieldARelation = fieldA.type === FieldMetadataType.RELATION;
const isFieldBRelation = fieldB.type === FieldMetadataType.RELATION;
if (isFieldARelation && !isFieldBRelation) {
return 1;
}
if (!isFieldARelation && isFieldBRelation) {
return -1;
}
return 0;
};
};
@@ -62,12 +62,21 @@ export const WidgetCardContent = ({
className,
children,
}: WidgetCardContentProps) => {
const handleContentClick = (event: React.MouseEvent) => {
if (!isEditable) {
return;
}
event.stopPropagation();
};
return (
<StyledWidgetCardContent
variant={variant}
hasHeader={hasHeader}
isEditable={isEditable}
className={className}
onClick={handleContentClick}
>
{children}
</StyledWidgetCardContent>