Skip to content

Commit 96f3ff0

Browse files
authored
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`
1 parent c94aa73 commit 96f3ff0

File tree

152 files changed

+5359
-1038
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

152 files changed

+5359
-1038
lines changed

.claude/settings.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
**/**/.env
22
.DS_Store
33
/.idea
4+
.claude/settings.json
45
**/**/node_modules/
56
.cache
67

packages/twenty-front/src/generated-metadata/graphql.ts

Lines changed: 23 additions & 15 deletions
Large diffs are not rendered by default.

packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
22
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
33
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
4+
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
45
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
56
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
67
import { isDefined } from 'twenty-shared/utils';
@@ -17,6 +18,8 @@ export const SaveDashboardSingleRecordCommand = () => {
1718

1819
const { savePageLayout } = useSavePageLayout(pageLayoutId);
1920

21+
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
22+
2023
const { setIsPageLayoutInEditMode } =
2124
useSetIsPageLayoutInEditMode(pageLayoutId);
2225

@@ -26,6 +29,7 @@ export const SaveDashboardSingleRecordCommand = () => {
2629
const result = await savePageLayout();
2730

2831
if (result.status === 'successful') {
32+
await savePageLayoutWidgetsData(pageLayoutId);
2933
closeSidePanelMenu();
3034
setIsPageLayoutInEditMode(false);
3135
}

packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
22
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
33
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
4-
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
54
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
5+
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
66
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
77
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
88
import { isDefined } from 'twenty-shared/utils';
@@ -22,7 +22,7 @@ export const SaveRecordPageLayoutSingleRecordCommand = () => {
2222

2323
const { savePageLayout } = useSavePageLayout(pageLayoutId);
2424

25-
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
25+
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
2626

2727
const { setIsPageLayoutInEditMode } =
2828
useSetIsPageLayoutInEditMode(pageLayoutId);
@@ -33,7 +33,7 @@ export const SaveRecordPageLayoutSingleRecordCommand = () => {
3333
const result = await savePageLayout();
3434

3535
if (result.status === 'successful') {
36-
await saveFieldsWidgetGroups(pageLayoutId);
36+
await savePageLayoutWidgetsData(pageLayoutId);
3737

3838
closeSidePanelMenu();
3939
setIsPageLayoutInEditMode(false);

packages/twenty-front/src/modules/command-menu-item/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-re
33
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
44
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
55
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
6+
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
67
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
78
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
89

@@ -15,6 +16,8 @@ export const SaveDashboardSingleRecordCommand = () => {
1516

1617
const { savePageLayout } = useSavePageLayout(pageLayoutId);
1718

19+
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
20+
1821
const { setIsPageLayoutInEditMode } =
1922
useSetIsPageLayoutInEditMode(pageLayoutId);
2023

@@ -24,6 +27,7 @@ export const SaveDashboardSingleRecordCommand = () => {
2427
const result = await savePageLayout();
2528

2629
if (result.status === 'successful') {
30+
await savePageLayoutWidgetsData(pageLayoutId);
2731
closeSidePanelMenu();
2832
setIsPageLayoutInEditMode(false);
2933
}

packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,13 @@ export const useConvertBackendItemToCommandMenuItemConfig = () => {
8282
return null;
8383
}
8484

85+
const isEditModeItem =
86+
item.conditionalAvailabilityExpression?.includes('isPageInEditMode') ??
87+
false;
88+
8589
const isPinned =
8690
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK &&
87-
!contextStoreIsPageInEditMode &&
91+
(!contextStoreIsPageInEditMode || isEditModeItem) &&
8892
item.isPinned;
8993

9094
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);

packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/sta
44
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
55
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
66
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
7-
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
7+
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
88
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
99
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
1010
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
@@ -35,7 +35,7 @@ export const useSaveLayoutCustomization = () => {
3535
const { updatePageLayoutWithTabsAndWidgets } =
3636
useUpdatePageLayoutWithTabsAndWidgets();
3737
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
38-
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
38+
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
3939

4040
const featureFlags = useFeatureFlagsMap();
4141
const isRecordPageLayoutEditingEnabled =
@@ -143,7 +143,7 @@ export const useSaveLayoutCustomization = () => {
143143
}
144144
}
145145

146-
await saveFieldsWidgetGroups(pageLayoutId);
146+
await savePageLayoutWidgetsData(pageLayoutId);
147147
}
148148

149149
if (hasAnyFailure) {
@@ -165,7 +165,7 @@ export const useSaveLayoutCustomization = () => {
165165
}, [
166166
saveDraft,
167167
updatePageLayoutWithTabsAndWidgets,
168-
saveFieldsWidgetGroups,
168+
savePageLayoutWidgetsData,
169169
exitLayoutCustomizationMode,
170170
enqueueErrorSnackBar,
171171
isRecordPageLayoutEditingEnabled,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
2+
import { type ObjectPermissions } from 'twenty-shared/types';
3+
import { isDefined } from 'twenty-shared/utils';
4+
5+
export const filterReadableActiveObjectMetadataItems = (
6+
objectMetadataItems: EnrichedObjectMetadataItem[],
7+
objectPermissionsByObjectMetadataId: Record<
8+
string,
9+
ObjectPermissions & { objectMetadataId: string }
10+
>,
11+
): EnrichedObjectMetadataItem[] =>
12+
objectMetadataItems.filter((objectMetadataItem) => {
13+
const objectPermissions =
14+
objectPermissionsByObjectMetadataId[objectMetadataItem.id];
15+
16+
return (
17+
isDefined(objectPermissions) &&
18+
objectPermissions.canReadObjectRecords &&
19+
objectMetadataItem.isActive
20+
);
21+
});

packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ export const mapObjectMetadataToGraphQLQuery = ({
6565
}
6666

6767
if (!isDefined(fieldMetadata.morphRelations)) {
68-
throw new Error(
69-
`Field ${fieldMetadata.name} is missing, please refresh the page. If the problem persists, please contact support.`,
70-
);
68+
return [];
7169
}
7270

7371
return fieldMetadata.morphRelations.map((morphRelation) => ({
@@ -100,9 +98,7 @@ export const mapObjectMetadataToGraphQLQuery = ({
10098
}
10199

102100
if (!isDefined(fieldMetadata.morphRelations)) {
103-
throw new Error(
104-
`Field ${fieldMetadata.name} is missing, please refresh the page. If the problem persists, please contact support.`,
105-
);
101+
return [];
106102
}
107103

108104
return fieldMetadata.morphRelations.map((morphRelation) => ({

0 commit comments

Comments
 (0)