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
@@ -101,6 +101,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
SidePanelPages.PageLayoutIframeSettings,
SidePanelPages.PageLayoutTabSettings,
SidePanelPages.PageLayoutFieldsSettings,
SidePanelPages.PageLayoutRecordTableSettings,
].includes(pageChip.page?.page)
: false;
@@ -12,6 +12,7 @@ import {
IconFrame,
IconList,
IconPlus,
IconTable,
type IconComponent,
} from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
@@ -174,6 +175,36 @@ export const usePageLayoutHeaderInfo = ({
};
}
case SidePanelPages.PageLayoutRecordTableSettings: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
}
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const title = isDefined(editedTitle)
? editedTitle
: isDefined(widgetInEditMode.title) && widgetInEditMode.title !== ''
? widgetInEditMode.title
: '';
return {
headerIcon: IconTable,
headerIconColor: iconColor,
headerType: t`Record Table`,
title,
isReadonly: false,
tab: undefined,
widgetInEditMode,
};
}
case SidePanelPages.PageLayoutWidgetTypeSelect: {
return {
headerIcon: IconPlus,
@@ -7,6 +7,7 @@ import { SidePanelNavigationMenuItemEditPage } from '@/navigation-menu-item/edit
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldsSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings';
import { SidePanelPageLayoutRecordTableSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings';
import { SidePanelPageLayoutIframeSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings';
import { SidePanelPageLayoutTabSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutTabSettings';
import { SidePanelPageLayoutWidgetTypeSelect } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect';
@@ -62,6 +63,10 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
SidePanelPages.PageLayoutFieldsSettings,
<SidePanelPageLayoutFieldsSettings />,
],
[
SidePanelPages.PageLayoutRecordTableSettings,
<SidePanelPageLayoutRecordTableSettings />,
],
[SidePanelPages.ViewFrontComponent, <SidePanelFrontComponentPage />],
[
SidePanelPages.NavigationMenuItemEdit,
@@ -7,6 +7,8 @@ import { SidePanelNewSidebarItemViewPickerSubPage } from '@/navigation-menu-item
import { SidePanelNewSidebarItemViewSystemPickerSubPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemViewSystemPickerSubPage';
import { SidePanelChartFilterSubPage } from '@/side-panel/pages/page-layout/components/SidePanelChartFilterSubPage';
import { SidePanelFieldsLayoutSubPage } from '@/side-panel/pages/page-layout/components/SidePanelFieldsLayoutSubPage';
import { SidePanelRecordTableFilterSubPage } from '@/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableFilterSubPage';
import { SidePanelRecordTableSortSubPage } from '@/side-panel/pages/page-layout/components/record-table-settings/SidePanelRecordTableSortSubPage';
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import React from 'react';
@@ -16,6 +18,14 @@ export const SIDE_PANEL_SUB_PAGES_CONFIG = new Map<
>([
[SidePanelSubPages.PageLayoutGraphFilter, <SidePanelChartFilterSubPage />],
[SidePanelSubPages.PageLayoutFieldsLayout, <SidePanelFieldsLayoutSubPage />],
[
SidePanelSubPages.PageLayoutRecordTableFilter,
<SidePanelRecordTableFilterSubPage />,
],
[
SidePanelSubPages.PageLayoutRecordTableSort,
<SidePanelRecordTableSortSubPage />,
],
[
SidePanelSubPages.NewSidebarItemViewObjectPicker,
<SidePanelNewSidebarItemViewObjectPickerSubPage />,
@@ -0,0 +1,158 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
import { RecordTableDataSourceDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableDataSourceDropdownContent';
import { RecordTableFieldsDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownContent';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconArrowsSort,
IconDatabase,
IconEye,
IconFilter,
} from 'twenty-ui/display';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledSettingsContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
`;
export const SidePanelPageLayoutRecordTableSettings = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const { navigateToSidePanelSubPage } = useSidePanelSubPageHistory();
if (!isDefined(widgetInEditMode)) {
return null;
}
const { configuration } = widgetInEditMode;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration &&
'viewId' in configuration &&
isDefined(configuration.viewId)
? (configuration.viewId as string)
: undefined;
const hasViewId = isDefined(viewId);
const selectableItemIds = [
'record-table-source',
...(hasViewId
? ['record-table-fields', 'record-table-filter', 'record-table-sort']
: []),
];
const handleFilterClick = () => {
navigateToSidePanelSubPage(SidePanelSubPages.PageLayoutRecordTableFilter);
};
const handleSortClick = () => {
navigateToSidePanelSubPage(SidePanelSubPages.PageLayoutRecordTableSort);
};
return (
<StyledContainer>
<WidgetComponentInstanceContext.Provider
value={{ instanceId: widgetInEditMode.id }}
>
<StyledSettingsContainer>
<SidePanelList
commandGroups={[]}
selectableItemIds={selectableItemIds}
>
<SidePanelGroup heading={t`Data`}>
<SelectableListItem itemId="record-table-source">
<CommandMenuItemDropdown
Icon={IconDatabase}
label={t`Source`}
id="record-table-source"
dropdownId="record-table-source"
dropdownComponents={
<DropdownContent>
<RecordTableDataSourceDropdownContent />
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
contextualTextPosition="right"
/>
</SelectableListItem>
{hasViewId && (
<>
<SelectableListItem itemId="record-table-fields">
<CommandMenuItemDropdown
Icon={IconEye}
label={t`Fields`}
id="record-table-fields"
dropdownId="record-table-fields"
dropdownComponents={
<RecordTableFieldsDropdownContent
viewId={viewId}
objectMetadataId={widgetInEditMode.objectMetadataId!}
/>
}
dropdownPlacement="bottom-end"
hasSubMenu
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem
itemId="record-table-filter"
onEnter={handleFilterClick}
>
<CommandMenuItem
id="record-table-filter"
label={t`Filter`}
Icon={IconFilter}
hasSubMenu
onClick={handleFilterClick}
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem
itemId="record-table-sort"
onEnter={handleSortClick}
>
<CommandMenuItem
id="record-table-sort"
label={t`Sort`}
Icon={IconArrowsSort}
hasSubMenu
onClick={handleSortClick}
contextualTextPosition="right"
/>
</SelectableListItem>
</>
)}
</SidePanelGroup>
</SidePanelList>
</StyledSettingsContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</WidgetComponentInstanceContext.Provider>
</StyledContainer>
);
};
@@ -3,6 +3,7 @@ import { FIND_MANY_FRONT_COMPONENTS } from '@/front-components/graphql/queries/f
import { useCreatePageLayoutFrontComponentWidget } from '@/page-layout/hooks/useCreatePageLayoutFrontComponentWidget';
import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget';
import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { useCreatePageLayoutRecordTableWidget } from '@/page-layout/hooks/useCreatePageLayoutRecordTableWidget';
import { useCreatePageLayoutStandaloneRichTextWidget } from '@/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget';
import { useOpportunityDefaultChartConfig } from '@/page-layout/hooks/useOpportunityDefaultChartConfig';
import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition';
@@ -29,6 +30,7 @@ import {
IconApps,
IconChartPie,
IconFrame,
IconTable,
} from 'twenty-ui/display';
import {
FeatureFlagKey,
@@ -78,6 +80,9 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
tabListInstanceId,
});
const { createPageLayoutRecordTableWidget } =
useCreatePageLayoutRecordTableWidget(pageLayoutId);
const { removePageLayoutWidgetAndPreservePosition } =
useRemovePageLayoutWidgetAndPreservePosition(pageLayoutId);
@@ -176,6 +181,28 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
closeSidePanelMenu();
};
const handleNavigateToRecordTableSettings = () => {
if (
isExistingWidgetMissingOrDifferentType(
existingWidget?.type,
WidgetType.RECORD_TABLE,
)
) {
if (isDefined(pageLayoutEditingWidgetId)) {
removePageLayoutWidgetAndPreservePosition(pageLayoutEditingWidgetId);
}
const newRecordTableWidget = createPageLayoutRecordTableWidget();
setPageLayoutEditingWidgetId(newRecordTableWidget.id);
}
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutRecordTableSettings,
focusTitleInput: false,
});
};
const handleCreateFrontComponentWidget = (frontComponent: FrontComponent) => {
if (
isExistingWidgetMissingOrDifferentType(
@@ -199,6 +226,7 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
const selectableItemIds = [
'chart',
'record-table',
'iframe',
'rich-text',
...frontComponentsWithSelectItemId.map(({ selectItemId }) => selectItemId),
@@ -218,6 +246,17 @@ export const SidePanelPageLayoutWidgetTypeSelect = () => {
onClick={handleNavigateToGraphTypeSelect}
/>
</SelectableListItem>
<SelectableListItem
itemId="record-table"
onEnter={handleNavigateToRecordTableSettings}
>
<CommandMenuItem
Icon={IconTable}
label={t`Record Table`}
id="record-table"
onClick={handleNavigateToRecordTableSettings}
/>
</SelectableListItem>
<SelectableListItem
itemId="iframe"
onEnter={handleNavigateToIframeSettings}
@@ -3,6 +3,7 @@ import { useResetChartDraftFiltersSettings } from '@/side-panel/pages/page-layou
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@@ -49,17 +50,9 @@ export const ChartDataSourceDropdownContent = () => {
dropdownId,
);
const objectsWithReadAccess = objectMetadataItems.filter(
(objectMetadataItem) => {
const objectPermissions =
objectPermissionsByObjectMetadataId[objectMetadataItem.id];
return (
isDefined(objectPermissions) &&
objectPermissions.canReadObjectRecords &&
objectMetadataItem.isActive
);
},
const objectsWithReadAccess = filterReadableActiveObjectMetadataItems(
objectMetadataItems,
objectPermissionsByObjectMetadataId,
);
const regularObjects = objectsWithReadAccess
@@ -0,0 +1,162 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const RecordTableDataSourceDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const currentObjectMetadataItemId = widgetInEditMode?.objectMetadataId as
| string
| undefined;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { deleteViewForRecordTableWidget } =
useDeleteViewForRecordTableWidget();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
const readableActiveObjectMetadataItems =
filterReadableActiveObjectMetadataItems(
objectMetadataItems,
objectPermissionsByObjectMetadataId,
);
const objectsWithReadAccess = readableActiveObjectMetadataItems.filter(
(objectMetadataItem) => !objectMetadataItem.isSystem,
);
const sortedObjects = objectsWithReadAccess.sort((first, second) =>
first.labelPlural.localeCompare(second.labelPlural),
);
const filteredObjects = filterBySearchQuery({
items: sortedObjects,
searchQuery,
getSearchableValues: (item) => [item.labelPlural, item.namePlural],
});
const currentViewId =
widgetInEditMode?.configuration &&
'viewId' in widgetInEditMode.configuration
? (widgetInEditMode.configuration.viewId as string | undefined)
: undefined;
const handleSelectSource = async (newObjectMetadataItemId: string) => {
if (currentObjectMetadataItemId === newObjectMetadataItemId) {
closeDropdown();
return;
}
if (isDefined(currentViewId)) {
await deleteViewForRecordTableWidget(currentViewId);
}
updateCurrentWidgetConfig({
objectMetadataId: newObjectMetadataItemId,
configToUpdate: {
viewId: undefined,
},
});
const selectedObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === newObjectMetadataItemId,
);
if (isDefined(selectedObjectMetadataItem)) {
await createViewForRecordTableWidget(selectedObjectMetadataItem);
if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedObjectMetadataItem.labelPlural,
});
}
}
closeDropdown();
};
return (
<>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search objects`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={filteredObjects.map(
(objectMetadataItem) => objectMetadataItem.id,
)}
>
{filteredObjects.map((objectMetadataItem) => (
<SelectableListItem
key={objectMetadataItem.id}
itemId={objectMetadataItem.id}
onEnter={() => {
handleSelectSource(objectMetadataItem.id);
}}
>
<MenuItemSelect
text={objectMetadataItem.labelPlural}
selected={currentObjectMetadataItemId === objectMetadataItem.id}
focused={selectedItemId === objectMetadataItem.id}
LeftIcon={getIcon(objectMetadataItem.icon)}
onClick={() => {
handleSelectSource(objectMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,47 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { RecordFieldsComponentInstanceContext } from '@/object-record/record-field/states/context/RecordFieldsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { RecordTableFieldsDropdownHiddenFieldsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownHiddenFieldsContent';
import { RecordTableFieldsDropdownVisibleFieldsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownVisibleFieldsContent';
import { useState } from 'react';
type RecordTableFieldsDropdownContentProps = {
viewId: string;
objectMetadataId: string;
};
export const RecordTableFieldsDropdownContent = ({
viewId,
objectMetadataId,
}: RecordTableFieldsDropdownContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
const [showHiddenFields, setShowHiddenFields] = useState(false);
return (
<RecordFieldsComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
{showHiddenFields ? (
<RecordTableFieldsDropdownHiddenFieldsContent
objectMetadataId={objectMetadataId}
recordIndexId={recordIndexId}
onBack={() => setShowHiddenFields(false)}
/>
) : (
<RecordTableFieldsDropdownVisibleFieldsContent
objectMetadataId={objectMetadataId}
recordIndexId={recordIndexId}
onShowHiddenFields={() => setShowHiddenFields(true)}
/>
)}
</RecordFieldsComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,110 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField';
import { useUpsertRecordField } from '@/object-record/record-field/hooks/useUpsertRecordField';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconEye, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { v4 } from 'uuid';
import { sortByProperty } from '~/utils/array/sortByProperty';
type RecordTableFieldsDropdownHiddenFieldsContentProps = {
objectMetadataId: string;
recordIndexId: string;
onBack: () => void;
};
export const RecordTableFieldsDropdownHiddenFieldsContent = ({
objectMetadataId,
recordIndexId,
onBack,
}: RecordTableFieldsDropdownHiddenFieldsContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const { getIcon } = useIcons();
const { upsertRecordField } = useUpsertRecordField(recordIndexId);
const { updateRecordField } = useUpdateRecordField(recordIndexId);
const currentRecordFields = useAtomComponentStateValue(
currentRecordFieldsComponentState,
);
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const visibleFieldMetadataItemIds = currentRecordFields
.filter((recordField) => recordField.isVisible)
.map((recordField) => recordField.fieldMetadataItemId);
const hiddenFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItem) =>
!visibleFieldMetadataItemIds.includes(fieldMetadataItem.id),
);
const handleShowField = (fieldMetadataId: string) => {
const existingRecordField = currentRecordFields.find(
(recordField) => recordField.fieldMetadataItemId === fieldMetadataId,
);
if (isDefined(existingRecordField)) {
updateRecordField(fieldMetadataId, { isVisible: true });
} else {
const lastPosition =
currentRecordFields.toSorted(sortByProperty('position', 'desc'))?.[0]
?.position ?? 0;
const newRecordField: RecordField = {
id: v4(),
fieldMetadataItemId: fieldMetadataId,
size: 100,
isVisible: true,
position: lastPosition + 1,
};
upsertRecordField(newRecordField);
}
};
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Hidden Fields`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{hiddenFieldMetadataItems.map((fieldMetadataItem) => (
<MenuItem
key={fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEye,
onClick: () => handleShowField(fieldMetadataItem.id),
},
]}
text={fieldMetadataItem.label}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,149 @@
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields';
import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpdateRecordField';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { type DropResult } from '@hello-pangea/dnd';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconEyeOff, useIcons } from 'twenty-ui/display';
import { MenuItemDraggable, MenuItemNavigate } from 'twenty-ui/navigation';
import { sortByProperty } from '~/utils/array/sortByProperty';
type RecordTableFieldsDropdownVisibleFieldsContentProps = {
objectMetadataId: string;
recordIndexId: string;
onShowHiddenFields: () => void;
};
export const RecordTableFieldsDropdownVisibleFieldsContent = ({
objectMetadataId,
recordIndexId,
onShowHiddenFields,
}: RecordTableFieldsDropdownVisibleFieldsContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const { getIcon } = useIcons();
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
const { reorderVisibleRecordFields } =
useReorderVisibleRecordFields(recordIndexId);
const { updateRecordField } = useUpdateRecordField(recordIndexId);
const fieldMetadataItemLabelIdentifier =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const visibleRecordFields = useAtomComponentSelectorValue(
visibleRecordFieldsComponentSelector,
);
const nonDraggableRecordField = visibleRecordFields.find(
(recordField) =>
recordField.fieldMetadataItemId === fieldMetadataItemLabelIdentifier?.id,
);
const draggableRecordFields = visibleRecordFields
.filter(
(recordField) =>
nonDraggableRecordField?.fieldMetadataItemId !==
recordField.fieldMetadataItemId,
)
.toSorted(sortByProperty('position'));
const handleDragEnd = (result: DropResult) => {
if (
!result.destination ||
result.destination.index === 1 ||
result.source.index === 1
) {
return;
}
reorderVisibleRecordFields({
fromIndex: result.source.index - 1,
toIndex: result.destination.index - 1,
});
};
const handleHideField = (fieldMetadataId: string) => {
updateRecordField(fieldMetadataId, { isVisible: false });
};
return (
<DropdownContent>
<DropdownMenuItemsContainer>
{isDefined(fieldMetadataItemLabelIdentifier) && (
<MenuItemDraggable
LeftIcon={getIcon(fieldMetadataItemLabelIdentifier.icon)}
text={fieldMetadataItemLabelIdentifier.label}
accent="placeholder"
gripMode="always"
isDragDisabled
/>
)}
{draggableRecordFields.length > 0 && (
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{draggableRecordFields.map((recordField, index) => {
const fieldIndex =
index +
(isDefined(fieldMetadataItemLabelIdentifier) ? 1 : 0);
const { fieldMetadataItem } = getFieldMetadataItemByIdOrThrow(
recordField.fieldMetadataItemId,
);
return (
<DraggableItem
key={recordField.fieldMetadataItemId}
draggableId={recordField.fieldMetadataItemId}
index={fieldIndex + 1}
itemComponent={
<MenuItemDraggable
LeftIcon={getIcon(fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEyeOff,
onClick: () =>
handleHideField(
recordField.fieldMetadataItemId,
),
},
]}
text={fieldMetadataItem.label}
gripMode="always"
/>
}
/>
);
})}
</>
}
/>
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={onShowHiddenFields}
LeftIcon={IconEyeOff}
text={t`Hidden Fields`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,140 @@
import { useRecordTableWidgetViewFieldItems } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewFieldItems';
import { useReorderRecordTableWidgetFields } from '@/page-layout/widgets/record-table/hooks/useReorderRecordTableWidgetFields';
import { useToggleRecordTableWidgetFieldVisibility } from '@/page-layout/widgets/record-table/hooks/useToggleRecordTableWidgetFieldVisibility';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { type DropResult } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useMemo } from 'react';
import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/display';
import { MenuItemDraggable } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFieldListContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
`;
const StyledSectionLabel = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
text-transform: uppercase;
`;
type RecordTableSettingsFieldVisibilityProps = {
viewId: string;
};
export const RecordTableSettingsFieldVisibility = ({
viewId,
}: RecordTableSettingsFieldVisibilityProps) => {
const { recordTableWidgetViewFieldItems } =
useRecordTableWidgetViewFieldItems(viewId);
const { toggleRecordTableWidgetFieldVisibility } =
useToggleRecordTableWidgetFieldVisibility();
const { reorderRecordTableWidgetFields } =
useReorderRecordTableWidgetFields();
const { getIcon } = useIcons();
const visibleFieldItems = useMemo(
() =>
recordTableWidgetViewFieldItems.filter(
(item) => item.viewField.isVisible,
),
[recordTableWidgetViewFieldItems],
);
const hiddenFieldItems = useMemo(
() =>
recordTableWidgetViewFieldItems.filter(
(item) => !item.viewField.isVisible,
),
[recordTableWidgetViewFieldItems],
);
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result;
if (!destination) {
return;
}
reorderRecordTableWidgetFields(
source.index,
destination.index,
visibleFieldItems,
);
};
return (
<StyledFieldListContainer>
<StyledSectionLabel>Visible</StyledSectionLabel>
{visibleFieldItems.length > 0 && (
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{visibleFieldItems.map((fieldItem, index) => (
<DraggableItem
key={fieldItem.viewField.id}
draggableId={fieldItem.viewField.id}
index={index}
itemComponent={
<MenuItemDraggable
LeftIcon={getIcon(fieldItem.fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEyeOff,
onClick: () => {
toggleRecordTableWidgetFieldVisibility(
fieldItem.viewField.id,
false,
);
},
},
]}
text={fieldItem.fieldMetadataItem.label}
gripMode="always"
/>
}
/>
))}
</>
}
/>
)}
{hiddenFieldItems.length > 0 && (
<>
<StyledSectionLabel>Hidden</StyledSectionLabel>
{hiddenFieldItems.map((fieldItem) => (
<MenuItemDraggable
key={fieldItem.viewField.id}
LeftIcon={getIcon(fieldItem.fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEye,
onClick: () => {
toggleRecordTableWidgetFieldVisibility(
fieldItem.viewField.id,
true,
);
},
},
]}
text={fieldItem.fieldMetadataItem.label}
accent="placeholder"
isDragDisabled
/>
))}
</>
)}
</StyledFieldListContainer>
);
};
@@ -0,0 +1,65 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { AdvancedFilterSidePanelContainer } from '@/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelContainer';
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { RecordTableSettingsFiltersInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFiltersInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFilterSettingsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
type RecordTableSettingsFiltersProps = {
viewId: string;
objectMetadataId: string;
};
export const RecordTableSettingsFilters = ({
viewId,
objectMetadataId,
}: RecordTableSettingsFiltersProps) => {
const { view } = useViewById(viewId);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
if (!isDefined(view)) {
return null;
}
return (
<StyledFilterSettingsContainer>
<InputLabel>{t`Conditions`}</InputLabel>
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<AdvancedFilterSidePanelContainer
objectMetadataItem={objectMetadataItem}
isWorkflowFindRecords={false}
/>
<RecordTableSettingsFiltersInitializeStateEffect
view={view}
objectMetadataItem={objectMetadataItem}
/>
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</StyledFilterSettingsContainer>
);
};
@@ -0,0 +1,94 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
type RecordTableSettingsFiltersInitializeStateEffectProps = {
view: View;
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const RecordTableSettingsFiltersInitializeStateEffect = ({
view,
objectMetadataItem,
}: RecordTableSettingsFiltersInitializeStateEffectProps) => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
);
const setCurrentRecordFilterGroups = useSetAtomComponentState(
currentRecordFilterGroupsComponentState,
);
const { setAdvancedFilterDropdownStates } =
useSetAdvancedFilterDropdownStates();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const currentRecordFilterGroups = useAtomComponentStateValue(
currentRecordFilterGroupsComponentState,
);
const [hasInitializedFilters, setHasInitializedFilters] = useState(false);
const stateAlreadyHasFilters =
currentRecordFilters.length > 0 || currentRecordFilterGroups.length > 0;
useEffect(() => {
if (hasInitializedFilters) {
return;
}
if (stateAlreadyHasFilters) {
setHasInitializedFilters(true);
return;
}
const filterableFields = getFilterableFields(objectMetadataItem);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFields,
);
setCurrentRecordFilters(recordFilters);
if (isDefined(view.viewFilterGroups) && view.viewFilterGroups.length > 0) {
setCurrentRecordFilterGroups(
mapViewFilterGroupsToRecordFilterGroups(view.viewFilterGroups),
);
}
setHasInitializedFilters(true);
}, [
view,
objectMetadataItem,
hasInitializedFilters,
stateAlreadyHasFilters,
setCurrentRecordFilters,
setCurrentRecordFilterGroups,
]);
useEffect(() => {
if (!hasInitializedFilters) {
return;
}
setAdvancedFilterDropdownStates();
}, [
currentRecordFilters,
hasInitializedFilters,
setAdvancedFilterDropdownStates,
]);
return null;
};
@@ -0,0 +1,82 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filterSortableFieldMetadataItems';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { RecordTableSettingsSortsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsContent';
import { RecordTableSettingsSortsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ViewSortDirection } from '~/generated-metadata/graphql';
const StyledSortSettingsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
type RecordTableSettingsSortsProps = {
viewId: string;
objectMetadataId: string;
};
export const RecordTableSettingsSorts = ({
viewId,
objectMetadataId,
}: RecordTableSettingsSortsProps) => {
const { view } = useViewById(viewId);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const { getIcon } = useIcons();
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
if (!isDefined(view)) {
return null;
}
const sortableFieldOptions = objectMetadataItem.fields
.filter(filterSortableFieldMetadataItems)
.map((field) => ({
Icon: getIcon(field.icon),
label: field.label,
value: field.id,
}));
const directionOptions: Array<SelectOption<ViewSortDirection>> = [
{
label: t`Ascending`,
value: ViewSortDirection.ASC,
},
{
label: t`Descending`,
value: ViewSortDirection.DESC,
},
];
return (
<StyledSortSettingsContainer>
<InputLabel>{t`Sorts`}</InputLabel>
<RecordSortsComponentInstanceContext.Provider
value={{ instanceId: recordIndexId }}
>
<RecordTableSettingsSortsContent
sortableFieldOptions={sortableFieldOptions}
directionOptions={directionOptions}
/>
<RecordTableSettingsSortsInitializeStateEffect view={view} />
</RecordSortsComponentInstanceContext.Provider>
</StyledSortSettingsContainer>
);
};
@@ -0,0 +1,115 @@
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowsSort, IconTrash } from 'twenty-ui/display';
import { Button, type SelectOption } from 'twenty-ui/input';
import { Select } from '@/ui/input/components/Select';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { v4 as uuidv4 } from 'uuid';
import { ViewSortDirection } from '~/generated-metadata/graphql';
const StyledSortItemContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledAddButtonContainer = styled.div`
margin-top: ${themeCssVariables.spacing[1]};
`;
type RecordTableSettingsSortsContentProps = {
sortableFieldOptions: Array<SelectOption<string>>;
directionOptions: Array<SelectOption<ViewSortDirection>>;
};
export const RecordTableSettingsSortsContent = ({
sortableFieldOptions,
directionOptions,
}: RecordTableSettingsSortsContentProps) => {
const [currentRecordSorts, setCurrentRecordSorts] = useAtomComponentState(
currentRecordSortsComponentState,
);
const handleAddSort = () => {
const firstAvailableField = sortableFieldOptions[0]?.value;
if (!isDefined(firstAvailableField)) {
return;
}
const newSort: RecordSort = {
id: uuidv4(),
fieldMetadataId: firstAvailableField,
direction: ViewSortDirection.ASC,
};
setCurrentRecordSorts([...currentRecordSorts, newSort]);
};
const handleRemoveSort = (sortId: string) => {
setCurrentRecordSorts(
currentRecordSorts.filter((sort) => sort.id !== sortId),
);
};
const handleFieldChange = (sortId: string, fieldMetadataId: string) => {
setCurrentRecordSorts(
currentRecordSorts.map((sort) =>
sort.id === sortId ? { ...sort, fieldMetadataId } : sort,
),
);
};
const handleDirectionChange = (
sortId: string,
direction: ViewSortDirection,
) => {
setCurrentRecordSorts(
currentRecordSorts.map((sort) =>
sort.id === sortId ? { ...sort, direction } : sort,
),
);
};
return (
<>
{currentRecordSorts.map((sort) => (
<StyledSortItemContainer key={sort.id}>
<Select
dropdownId={`record-table-widget-sort-field-${sort.id}`}
fullWidth
value={sort.fieldMetadataId}
options={sortableFieldOptions}
onChange={(value) => handleFieldChange(sort.id, value)}
withSearchInput
/>
<Select
dropdownId={`record-table-widget-sort-direction-${sort.id}`}
fullWidth
value={sort.direction}
options={directionOptions}
onChange={(value) =>
handleDirectionChange(sort.id, value as ViewSortDirection)
}
/>
<Button onClick={() => handleRemoveSort(sort.id)} Icon={IconTrash} />
</StyledSortItemContainer>
))}
<StyledAddButtonContainer>
<Button
Icon={IconArrowsSort}
size="small"
variant="secondary"
accent="default"
onClick={handleAddSort}
ariaLabel={t`Add sort`}
title={t`Add sort`}
/>
</StyledAddButtonContainer>
</>
);
};
@@ -0,0 +1,51 @@
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type View } from '@/views/types/View';
import { useEffect, useState } from 'react';
import { type ViewSortDirection } from '~/generated-metadata/graphql';
type RecordTableSettingsSortsInitializeStateEffectProps = {
view: View;
};
export const RecordTableSettingsSortsInitializeStateEffect = ({
view,
}: RecordTableSettingsSortsInitializeStateEffectProps) => {
const setCurrentRecordSorts = useSetAtomComponentState(
currentRecordSortsComponentState,
);
const currentRecordSorts = useAtomComponentStateValue(
currentRecordSortsComponentState,
);
const [hasInitializedSorts, setHasInitializedSorts] = useState(false);
const stateAlreadyHasSorts = currentRecordSorts.length > 0;
useEffect(() => {
if (hasInitializedSorts) {
return;
}
if (stateAlreadyHasSorts) {
setHasInitializedSorts(true);
return;
}
const recordSorts: RecordSort[] = (view.viewSorts ?? []).map(
(viewSort) => ({
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
direction: viewSort.direction as ViewSortDirection,
}),
);
setCurrentRecordSorts(recordSorts);
setHasInitializedSorts(true);
}, [view, hasInitializedSorts, stateAlreadyHasSorts, setCurrentRecordSorts]);
return null;
};
@@ -0,0 +1,32 @@
import { RecordTableSettingsFieldVisibility } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFieldVisibility';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const SidePanelRecordTableFieldsSubPage = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const { configuration } = widgetInEditMode;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration &&
'viewId' in configuration &&
isDefined(configuration.viewId)
? (configuration.viewId as string)
: undefined;
if (!isDefined(viewId)) {
return null;
}
return <RecordTableSettingsFieldVisibility viewId={viewId} />;
};
@@ -0,0 +1,37 @@
import { RecordTableSettingsFilters } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFilters';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const SidePanelRecordTableFilterSubPage = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const { configuration } = widgetInEditMode;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration &&
'viewId' in configuration &&
isDefined(configuration.viewId)
? (configuration.viewId as string)
: undefined;
if (!isDefined(viewId) || !isDefined(widgetInEditMode.objectMetadataId)) {
return null;
}
return (
<RecordTableSettingsFilters
viewId={viewId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
};
@@ -0,0 +1,38 @@
import { RecordTableSettingsSorts } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSorts';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { isDefined } from 'twenty-shared/utils';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const SidePanelRecordTableSortSubPage = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const { configuration } = widgetInEditMode;
const isRecordTableConfiguration =
configuration.configurationType === WidgetConfigurationType.RECORD_TABLE;
const viewId =
isRecordTableConfiguration &&
'viewId' in configuration &&
isDefined(configuration.viewId)
? (configuration.viewId as string)
: undefined;
if (!isDefined(viewId) || !isDefined(widgetInEditMode.objectMetadataId)) {
return null;
}
return (
<RecordTableSettingsSorts
viewId={viewId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
};
@@ -5,4 +5,5 @@ export type PageLayoutSidePanelPage =
| SidePanelPages.PageLayoutGraphTypeSelect
| SidePanelPages.PageLayoutIframeSettings
| SidePanelPages.PageLayoutTabSettings
| SidePanelPages.PageLayoutFieldsSettings;
| SidePanelPages.PageLayoutFieldsSettings
| SidePanelPages.PageLayoutRecordTableSettings;
@@ -6,6 +6,7 @@ import {
IconChartPie,
IconFrame,
IconList,
IconTable,
} from 'twenty-ui/display';
export const getPageLayoutIcon = (page: PageLayoutSidePanelPage) => {
@@ -20,6 +21,8 @@ export const getPageLayoutIcon = (page: PageLayoutSidePanelPage) => {
return IconAppWindow;
case SidePanelPages.PageLayoutFieldsSettings:
return IconList;
case SidePanelPages.PageLayoutRecordTableSettings:
return IconTable;
default:
assertUnreachable(page);
}
@@ -15,6 +15,8 @@ export const getPageLayoutPageTitle = (page: PageLayoutSidePanelPage) => {
return t`Tab Settings`;
case SidePanelPages.PageLayoutFieldsSettings:
return t`Fields Settings`;
case SidePanelPages.PageLayoutRecordTableSettings:
return t`Record Table Settings`;
default:
assertUnreachable(page);
}
@@ -13,6 +13,7 @@ import {
type LineChartConfiguration,
type NotesConfiguration,
type PieChartConfiguration,
type RecordTableConfiguration,
type StandaloneRichTextConfiguration,
type TasksConfiguration,
type TimelineConfiguration,
@@ -79,6 +80,12 @@ type WidgetConfigurationTypenameMap = {
PieChartConfiguration: Omit<PieChartConfiguration, 'configurationType'> & {
configurationType: WidgetConfigurationType.PIE_CHART;
};
RecordTableConfiguration: Omit<
RecordTableConfiguration,
'configurationType'
> & {
configurationType: WidgetConfigurationType.RECORD_TABLE;
};
StandaloneRichTextConfiguration: Omit<
StandaloneRichTextConfiguration,
'configurationType'
@@ -1,6 +1,8 @@
export enum SidePanelSubPages {
PageLayoutGraphFilter = 'page-layout-graph-filter',
PageLayoutFieldsLayout = 'page-layout-fields-layout',
PageLayoutRecordTableFilter = 'page-layout-record-table-filter',
PageLayoutRecordTableSort = 'page-layout-record-table-sort',
NewSidebarItemViewObjectPicker = 'new-sidebar-item-view-object-picker',
NewSidebarItemViewPicker = 'new-sidebar-item-view-picker',
NewSidebarItemViewSystemPicker = 'new-sidebar-item-view-system-picker',
@@ -10,6 +10,10 @@ export const getSidePanelSubPageTitle = (
return t`Filters`;
case SidePanelSubPages.PageLayoutFieldsLayout:
return t`Layout`;
case SidePanelSubPages.PageLayoutRecordTableFilter:
return t`Filters`;
case SidePanelSubPages.PageLayoutRecordTableSort:
return t`Sorts`;
case SidePanelSubPages.NewSidebarItemViewObjectPicker:
return t`Pick an object`;
case SidePanelSubPages.NewSidebarItemViewPicker: