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:
+4
@@ -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;
|
||||
}
|
||||
|
||||
+34
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+29
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
@@ -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 };
|
||||
};
|
||||
+15
@@ -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 };
|
||||
};
|
||||
+36
@@ -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 };
|
||||
};
|
||||
+25
@@ -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 };
|
||||
};
|
||||
+26
@@ -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 };
|
||||
};
|
||||
+35
@@ -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 };
|
||||
};
|
||||
+162
@@ -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 };
|
||||
};
|
||||
+86
@@ -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 };
|
||||
};
|
||||
+306
@@ -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 };
|
||||
};
|
||||
+22
@@ -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 };
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+208
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+163
@@ -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);
|
||||
});
|
||||
});
|
||||
+87
@@ -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: [] },
|
||||
);
|
||||
};
|
||||
+8
@@ -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);
|
||||
};
|
||||
+39
@@ -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;
|
||||
};
|
||||
};
|
||||
+9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user