feat: kanban, calendar and grouped-table layouts for relation field widgets (#23112)

## Context

A relation field widget on a record page can already embed a
record-scoped view rendered as a **table** (`FieldDisplayMode.TABLE`) —
e.g. a Company's Opportunities. This brings **kanban, calendar and
grouped-table** to that same embedded view, so the board/calendar stays
scoped to *this* record's related records (not a standalone all-records
widget — that was the earlier #23003 approach, closed).

Builds directly on the merged dashboard widget layouts (#22963), reusing
its renderer, draft/save pipeline, and settings dropdowns.

## Approach — extend the existing "Table" display mode

The relation field widget already stores a `viewId` and renders it
through the layout-agnostic `RecordTableWidgetRendererContent` (which
branches on the embedded view's `type`), scoped to the current record
via `RecordFilterValueDependenciesContext`. So rendering + persistence
already work for any widget view type — only the authoring UI and one
server gate were missing. **No new `FieldDisplayMode`, no data
migration.**

## Server

- `view-widget-upsert.service.ts`: a field widget in table display mode
(`isFieldTableWidget`) could already persist viewFields/filters/sorts
through this path, but was **blocked from updating view settings**
(`type` / group-by / calendar), pinning its embedded view to a table.
The widget-type guard earlier in the method already rejects every widget
kind other than record-table and field-table, so the now-redundant
record-table-only guard on the view-settings branch is dropped. The
allowed-widget-view-types check and the downstream group-by /
calendar-field validations still apply equally.

## Frontend

- **One merged Layout picker.** The field widget's Layout dropdown lists
**Field / Card / Table / Kanban / Calendar** in a single flat list — you
pick Kanban directly, instead of "Display as: Table" first and a
separate embedded-view layout second. Picking a view layout selects the
`TABLE` display mode under the hood, seeds the record-scoped embedded
view on first use (with a default group-by / date field), and applies
the layout in the same click. Kanban/Calendar are disabled with a hint
("Needs a Select field" / "Needs a Date field") when the relation target
can't support them — same gating as the dashboard picker. The row's icon
and description reflect the effective selection (e.g. Kanban), and the
dropdown mounts the draft-init effect so switching straight from
Field/Card to Kanban works before the table renderer has ever mounted.
- **Contextual rows** (Group by / Date field / Calendar view / Hide
empty groups) extracted from the dashboard panel into a reusable
`WidgetViewLayoutSettingsRows` (source object passed in — fixed to the
relation target; no Source / Limit rows) and surfaced under the picker
while a view layout is active. Its standalone layout row is hidden here
(`isLayoutRowHidden`) since layout lives in the merged picker.
- Reuses the dashboard draft snapshot + `upsertViewWidget` save pipeline
and the group-by/calendar dropdown components unchanged.

## Scope

- **One-to-many relations only** (matches the existing
`getFieldWidgetAvailableDisplayModes` gate; junction / many-to-many stay
table-only — a pre-existing inconsistency left untouched here).
- Field-widget **calendars inherit the dashboard's behavior** (month
read-only by default; day/week + drag-to-reschedule only behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`), since it's literally the same
renderer.

## Tests

- Server integration
(`upsert-view-widget-view-settings.integration-spec.ts`): a FIELD +
TABLE widget can switch its embedded view to `KANBAN_WIDGET` (with
group-by) and `CALENDAR_WIDGET` (with date field), and the kanban
group-by validation still applies through the newly-opened path.
- Front unit: `getWidgetViewLayoutSettingsItemIds` (keyboard-nav row ids
per layout/flag/group state).

## Follow-ups (intentionally not in this PR)

- Migrate the dashboard settings panel onto the shared
`WidgetViewLayoutSettingsRows` (kept out to avoid churning the
just-merged #22963 file; behavior-preserving refactor).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
This commit is contained in:
Félix Malfait
2026-07-24 12:01:53 +02:00
committed by GitHub
parent a3a6a55051
commit 1fdb5605f1
7 changed files with 693 additions and 45 deletions
@@ -1,6 +1,15 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
import { getFieldWidgetAvailableDisplayModes } from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect';
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
import {
type RecordTableWidgetLayoutViewType,
useRecordTableWidgetLayoutCallbacks,
} from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { isFieldMetadataItemAvailableAsWidgetGroupByField } from '@/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField';
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';
@@ -13,11 +22,12 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type IconComponent,
IconCalendar,
IconFileText,
IconId,
IconLayoutKanban,
IconListDetails,
IconTable,
@@ -25,17 +35,22 @@ import {
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
FieldDisplayMode,
ViewType,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
const DISPLAY_MODE_ICONS: Record<FieldDisplayMode, IconComponent> = {
[FieldDisplayMode.FIELD]: IconListDetails,
[FieldDisplayMode.CARD]: IconLayoutKanban,
[FieldDisplayMode.CARD]: IconId,
[FieldDisplayMode.EDITOR]: IconFileText,
[FieldDisplayMode.VIEW]: IconListDetails,
[FieldDisplayMode.TABLE]: IconTable,
};
// One flat picker: inline display modes (Field / Card / Editor) followed by the
// embedded-view layouts (Table / Kanban / Calendar). Picking a layout selects
// the TABLE display mode under the hood — users choose "Kanban" directly
// instead of "Table" first and a layout second.
export const FieldWidgetLayoutDropdownContent = () => {
const { t } = useLingui();
@@ -49,21 +64,64 @@ export const FieldWidgetLayoutDropdownContent = () => {
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentViewId = fieldConfiguration?.viewId ?? null;
const { fieldMetadataItem } = useFieldMetadataItemById(
currentFieldMetadataId ?? '',
);
const layoutOptions = useMemo(
() =>
fieldMetadataItem
? getFieldWidgetAvailableDisplayModes(
fieldMetadataItem.type,
fieldMetadataItem.relation?.type,
)
: [FieldDisplayMode.FIELD],
[fieldMetadataItem],
const availableDisplayModes = fieldMetadataItem
? getFieldWidgetAvailableDisplayModes(
fieldMetadataItem.type,
fieldMetadataItem.relation?.type,
)
: [FieldDisplayMode.FIELD];
const inlineDisplayModes = availableDisplayModes.filter(
(displayMode) => displayMode !== FieldDisplayMode.TABLE,
);
const hasEmbeddedViewLayouts = availableDisplayModes.includes(
FieldDisplayMode.TABLE,
);
const targetObjectMetadataId =
fieldMetadataItem?.relation?.targetObjectMetadata.id;
const inverseFieldMetadataId =
fieldMetadataItem?.relation?.targetFieldMetadata.id;
const { objectMetadataItems } = useObjectMetadataItems();
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === targetObjectMetadataId,
);
const defaultGroupByFieldMetadataItem =
(targetObjectMetadataItem?.readableFields ?? []).find(
isFieldMetadataItemAvailableAsWidgetGroupByField,
) ?? null;
const defaultCalendarFieldMetadataItem =
(targetObjectMetadataItem?.readableFields ?? []).find(
isFieldMetadataItemAvailableAsCalendarField,
) ?? null;
const isKanbanAvailable = isDefined(defaultGroupByFieldMetadataItem);
const isCalendarAvailable = isDefined(defaultCalendarFieldMetadataItem);
const { view: embeddedWidgetView } = useRecordTableWidgetViewForDisplay({
viewId: currentViewId ?? '',
widgetId: widgetInEditMode?.id ?? '',
pageLayoutId,
});
const isTableDisplayMode = currentDisplayMode === FieldDisplayMode.TABLE;
const currentEmbeddedViewType: RecordTableWidgetLayoutViewType =
embeddedWidgetView?.type === ViewType.KANBAN_WIDGET
? ViewType.KANBAN_WIDGET
: embeddedWidgetView?.type === ViewType.CALENDAR_WIDGET
? ViewType.CALENDAR_WIDGET
: ViewType.TABLE_WIDGET;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
@@ -80,18 +138,37 @@ export const FieldWidgetLayoutDropdownContent = () => {
const { addDraftViewForFieldRelationTableWidget } =
useAddDraftViewForFieldRelationTableWidget(pageLayoutId);
const { handleLayoutChange } = useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId: widgetInEditMode?.id ?? '',
});
const { closeDropdown } = useCloseDropdown();
const handleSelectLayout = (fieldDisplayMode: FieldDisplayMode) => {
const targetObjectMetadataId =
fieldMetadataItem?.relation?.targetObjectMetadata.id;
const inverseFieldMetadataId =
fieldMetadataItem?.relation?.targetFieldMetadata.id;
const handleSelectDisplayMode = (fieldDisplayMode: FieldDisplayMode) => {
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode,
},
});
closeDropdown();
};
const handleSelectViewLayout = (
targetViewType: RecordTableWidgetLayoutViewType,
) => {
if (!isDefined(widgetInEditMode)) {
return;
}
if (targetViewType === ViewType.KANBAN_WIDGET && !isKanbanAvailable) {
return;
}
if (targetViewType === ViewType.CALENDAR_WIDGET && !isCalendarAvailable) {
return;
}
if (
fieldDisplayMode === FieldDisplayMode.TABLE &&
!isDefined(fieldConfiguration?.viewId) &&
isDefined(widgetInEditMode) &&
!isDefined(currentViewId) &&
isDefined(targetObjectMetadataId) &&
isDefined(inverseFieldMetadataId)
) {
@@ -103,55 +180,136 @@ export const FieldWidgetLayoutDropdownContent = () => {
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode,
fieldDisplayMode: FieldDisplayMode.TABLE,
viewId,
},
});
closeDropdown();
return;
} else {
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode: FieldDisplayMode.TABLE,
},
});
}
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode,
},
handleLayoutChange({
targetViewType,
defaultGroupByFieldMetadataItem,
defaultCalendarFieldMetadataItem,
});
closeDropdown();
};
const layoutLabels: Record<string, string> = {
const displayModeLabels: Record<string, string> = {
[FieldDisplayMode.FIELD]: t`Field`,
[FieldDisplayMode.CARD]: t`Card`,
[FieldDisplayMode.EDITOR]: t`Editor`,
[FieldDisplayMode.TABLE]: t`Table`,
};
return (
<DropdownMenuItemsContainer>
{/* The widget's draft snapshot is normally seeded by the table-family
renderer; while displayed as Field/Card that renderer isn't mounted,
so seed the draft here (idempotent) for direct e.g. Card -> Kanban
switches. */}
{isDefined(currentViewId) && isDefined(widgetInEditMode) && (
<RecordTableWidgetViewDraftInitEffect
widgetId={widgetInEditMode.id}
viewId={currentViewId}
/>
)}
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={layoutOptions}
selectableItemIdArray={[
...inlineDisplayModes,
...(hasEmbeddedViewLayouts
? [
ViewType.TABLE_WIDGET,
...(isKanbanAvailable ? [ViewType.KANBAN_WIDGET] : []),
...(isCalendarAvailable ? [ViewType.CALENDAR_WIDGET] : []),
]
: []),
]}
>
{layoutOptions.map((displayMode) => (
{inlineDisplayModes.map((displayMode) => (
<SelectableListItem
key={displayMode}
itemId={displayMode}
onEnter={() => {
handleSelectLayout(displayMode);
handleSelectDisplayMode(displayMode);
}}
>
<MenuItemSelect
text={layoutLabels[displayMode]}
text={displayModeLabels[displayMode]}
selected={currentDisplayMode === displayMode}
focused={selectedItemId === displayMode}
LeftIcon={DISPLAY_MODE_ICONS[displayMode]}
onClick={() => {
handleSelectLayout(displayMode);
handleSelectDisplayMode(displayMode);
}}
/>
</SelectableListItem>
))}
{hasEmbeddedViewLayouts && (
<>
<SelectableListItem
itemId={ViewType.TABLE_WIDGET}
onEnter={() => handleSelectViewLayout(ViewType.TABLE_WIDGET)}
>
<MenuItemSelect
text={t`Table`}
LeftIcon={IconTable}
selected={
isTableDisplayMode &&
currentEmbeddedViewType === ViewType.TABLE_WIDGET
}
focused={selectedItemId === ViewType.TABLE_WIDGET}
onClick={() => handleSelectViewLayout(ViewType.TABLE_WIDGET)}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewType.KANBAN_WIDGET}
onEnter={() => handleSelectViewLayout(ViewType.KANBAN_WIDGET)}
>
<MenuItemSelect
text={t`Kanban`}
LeftIcon={IconLayoutKanban}
disabled={!isKanbanAvailable}
contextualText={
!isKanbanAvailable ? t`Needs a Select field` : undefined
}
contextualTextPosition="right"
selected={
isTableDisplayMode &&
currentEmbeddedViewType === ViewType.KANBAN_WIDGET
}
focused={selectedItemId === ViewType.KANBAN_WIDGET}
onClick={() => handleSelectViewLayout(ViewType.KANBAN_WIDGET)}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewType.CALENDAR_WIDGET}
onEnter={() => handleSelectViewLayout(ViewType.CALENDAR_WIDGET)}
>
<MenuItemSelect
text={t`Calendar`}
LeftIcon={IconCalendar}
disabled={!isCalendarAvailable}
contextualText={
!isCalendarAvailable ? t`Needs a Date field` : undefined
}
contextualTextPosition="right"
selected={
isTableDisplayMode &&
currentEmbeddedViewType === ViewType.CALENDAR_WIDGET
}
focused={selectedItemId === ViewType.CALENDAR_WIDGET}
onClick={() => handleSelectViewLayout(ViewType.CALENDAR_WIDGET)}
/>
</SelectableListItem>
</>
)}
</SelectableList>
</DropdownMenuItemsContainer>
);
@@ -3,30 +3,39 @@ import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuIt
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { useRecordTableWidgetViewFieldItems } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewFieldItems';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
import { FieldWidgetFieldDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent';
import { FieldWidgetLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent';
import { WidgetViewLayoutSettingsRows } from '@/side-panel/pages/page-layout/components/record-table-settings/WidgetViewLayoutSettingsRows';
import { WidgetSettingsManageSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsManageSection';
import { WidgetSettingsPlacementSection } from '@/side-panel/pages/page-layout/components/WidgetSettingsPlacementSection';
import { WIDGET_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/WidgetSettingsSelectableItemIds';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useWidgetSettingsPlacementSelectableItemIds } from '@/side-panel/pages/page-layout/hooks/useWidgetSettingsPlacementSelectableItemIds';
import { getWidgetViewLayoutSettingsItemIds } from '@/side-panel/pages/page-layout/utils/getWidgetViewLayoutSettingsItemIds';
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconCalendar,
IconLayoutKanban,
IconLayoutSidebarRight,
IconList,
IconListDetails,
IconTable,
} from 'twenty-ui/icon';
import {
FeatureFlagKey,
FieldDisplayMode,
ViewType,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
@@ -74,12 +83,41 @@ export const SidePanelRecordPageFieldSettings = () => {
pageLayoutId,
});
// A relation field widget in table display mode embeds a widget view scoped to
// the current record's related records; its source object is the relation
// target, not the record page's own object.
const targetObjectMetadataId =
currentFieldMetadataItem?.relation?.targetObjectMetadata.id;
const { view: embeddedWidgetView } = useRecordTableWidgetViewForDisplay({
viewId: currentViewId ?? '',
widgetId: widgetInEditMode?.id ?? '',
pageLayoutId,
});
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
if (!isDefined(widgetInEditMode)) {
return null;
}
const isTableDisplayMode = currentDisplayMode === FieldDisplayMode.TABLE;
const showViewLayoutRows =
isTableDisplayMode &&
isDefined(targetObjectMetadataId) &&
isDefined(currentViewId);
const isEmbeddedViewKanbanLayout =
embeddedWidgetView?.type === ViewType.KANBAN_WIDGET;
const isEmbeddedViewCalendarLayout =
embeddedWidgetView?.type === ViewType.CALENDAR_WIDGET;
const embeddedViewHasGroupBy = isDefined(
embeddedWidgetView?.mainGroupByFieldMetadataId,
);
const visibleFieldsCount = recordTableWidgetViewFieldItems.filter(
(item) => item.viewField.isVisible,
).length;
@@ -99,13 +137,35 @@ export const SidePanelRecordPageFieldSettings = () => {
[FieldDisplayMode.TABLE]: t`Table`,
};
const layoutLabel = isDefined(currentDisplayMode)
? (displayModeLabels[currentDisplayMode] ?? '')
: '';
const layoutLabel = isTableDisplayMode
? isEmbeddedViewKanbanLayout
? t`Kanban`
: isEmbeddedViewCalendarLayout
? t`Calendar`
: t`Table`
: isDefined(currentDisplayMode)
? (displayModeLabels[currentDisplayMode] ?? '')
: '';
const layoutRowIcon = isTableDisplayMode
? isEmbeddedViewKanbanLayout
? IconLayoutKanban
: isEmbeddedViewCalendarLayout
? IconCalendar
: IconTable
: IconLayoutSidebarRight;
const selectableItemIds = [
'field',
'layout',
...(showViewLayoutRows
? getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: isEmbeddedViewCalendarLayout,
isCalendarWeekViewEnabled,
hasGroupBy: embeddedViewHasGroupBy,
isLayoutRowHidden: true,
})
: []),
...(isTableDisplayMode ? ['fields'] : []),
WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.VISIBILITY_RESTRICTION,
WIDGET_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT,
@@ -139,7 +199,7 @@ export const SidePanelRecordPageFieldSettings = () => {
<CommandMenuItemDropdown
id="layout"
label={t`Layout`}
Icon={IconLayoutSidebarRight}
Icon={layoutRowIcon}
dropdownId="layout"
dropdownComponents={
<DropdownContent>
@@ -151,6 +211,17 @@ export const SidePanelRecordPageFieldSettings = () => {
contextualTextPosition="right"
/>
</SelectableListItem>
{isTableDisplayMode &&
isDefined(targetObjectMetadataId) &&
isDefined(currentViewId) && (
<WidgetViewLayoutSettingsRows
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
objectMetadataId={targetObjectMetadataId}
viewId={currentViewId}
isLayoutRowHidden
/>
)}
{isTableDisplayMode && (
<SelectableListItem
itemId="fields"
@@ -0,0 +1,235 @@
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableCalendarFieldDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent';
import { RecordTableCalendarLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent';
import { RecordTableGroupByDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent';
import { RecordTableLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconCalendar,
IconCalendarEvent,
IconEyeOff,
IconLayoutKanban,
IconLayoutList,
IconTable,
} from 'twenty-ui/icon';
import {
FeatureFlagKey,
ViewCalendarLayout,
ViewType,
} from '~/generated-metadata/graphql';
type WidgetViewLayoutSettingsRowsProps = {
pageLayoutId: string;
widgetId: string;
objectMetadataId: string;
viewId: string;
isLayoutRowHidden?: boolean;
};
// The embedded-view layout rows (layout type + group-by / calendar field /
// calendar view / hide-empty-groups) shared by widgets backed by a record table
// view: dashboard record table widgets and relation field widgets in table
// display mode. The source object is passed in (fixed to the relation target
// for field widgets), so this component makes no assumption about where the
// widget lives. Hosts that surface the layout choice elsewhere (the field
// widget's merged layout picker) hide the layout row via isLayoutRowHidden.
export const WidgetViewLayoutSettingsRows = ({
pageLayoutId,
widgetId,
objectMetadataId,
viewId,
isLayoutRowHidden = false,
}: WidgetViewLayoutSettingsRowsProps) => {
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const { handleShouldHideEmptyGroupsChange } =
useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId,
});
const { view: widgetView } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const mainGroupByFieldMetadataId =
widgetView?.mainGroupByFieldMetadataId ?? null;
const shouldHideEmptyGroups = widgetView?.shouldHideEmptyGroups ?? false;
const isKanbanLayout = widgetView?.type === ViewType.KANBAN_WIDGET;
const isCalendarLayout = widgetView?.type === ViewType.CALENDAR_WIDGET;
const currentLayoutViewType = isKanbanLayout
? ViewType.KANBAN_WIDGET
: isCalendarLayout
? ViewType.CALENDAR_WIDGET
: ViewType.TABLE_WIDGET;
const calendarFieldMetadataId = widgetView?.calendarFieldMetadataId ?? null;
const currentCalendarLayout =
widgetView?.calendarLayout ?? ViewCalendarLayout.MONTH;
const calendarLayoutLabel =
currentCalendarLayout === ViewCalendarLayout.DAY
? t`Day`
: currentCalendarLayout === ViewCalendarLayout.WEEK
? t`Week`
: t`Month`;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === objectMetadataId,
);
const mainGroupByFieldLabel = isDefined(mainGroupByFieldMetadataId)
? (objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === mainGroupByFieldMetadataId,
)?.label ?? t`None`)
: t`None`;
const calendarFieldLabel = isDefined(calendarFieldMetadataId)
? (objectMetadataItem?.fields.find(
(fieldMetadataItem) => fieldMetadataItem.id === calendarFieldMetadataId,
)?.label ?? t`None`)
: t`None`;
const hasGroupBy = isDefined(mainGroupByFieldMetadataId);
return (
<>
{!isLayoutRowHidden && (
<SelectableListItem itemId="object-view-layout">
<CommandMenuItemDropdown
Icon={
isKanbanLayout
? IconLayoutKanban
: isCalendarLayout
? IconCalendar
: IconTable
}
label={t`Layout`}
id="object-view-layout"
dropdownId="object-view-layout"
dropdownComponents={
<DropdownContent>
<RecordTableLayoutDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetId}
objectMetadataId={objectMetadataId}
currentLayoutViewType={currentLayoutViewType}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={
isKanbanLayout
? t`Kanban`
: isCalendarLayout
? t`Calendar`
: t`Table`
}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{isCalendarLayout && (
<SelectableListItem itemId="record-table-calendar-field">
<CommandMenuItemDropdown
Icon={IconCalendarEvent}
label={t`Date field`}
id="record-table-calendar-field"
dropdownId="record-table-calendar-field"
dropdownComponents={
<DropdownContent>
<RecordTableCalendarFieldDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetId}
objectMetadataId={objectMetadataId}
currentCalendarFieldMetadataId={calendarFieldMetadataId}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={calendarFieldLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{isCalendarLayout && isCalendarWeekViewEnabled && (
<SelectableListItem itemId="record-table-calendar-layout">
<CommandMenuItemDropdown
Icon={IconCalendar}
label={t`Calendar view`}
id="record-table-calendar-layout"
dropdownId="record-table-calendar-layout"
dropdownComponents={
<DropdownContent>
<RecordTableCalendarLayoutDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetId}
currentCalendarLayout={currentCalendarLayout}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={calendarLayoutLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{!isCalendarLayout && (
<SelectableListItem itemId="record-table-group-by">
<CommandMenuItemDropdown
Icon={IconLayoutList}
label={t`Group by`}
id="record-table-group-by"
dropdownId="record-table-group-by"
dropdownComponents={
<DropdownContent>
<RecordTableGroupByDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetId}
objectMetadataId={objectMetadataId}
currentMainGroupByFieldMetadataId={mainGroupByFieldMetadataId}
isClearable={!isKanbanLayout}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={mainGroupByFieldLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{!isCalendarLayout && hasGroupBy && (
<SelectableListItem itemId="record-table-hide-empty-groups">
<CommandMenuItemToggle
LeftIcon={IconEyeOff}
text={t`Hide empty groups`}
id="record-table-hide-empty-groups"
toggled={shouldHideEmptyGroups}
onToggleChange={handleShouldHideEmptyGroupsChange}
/>
</SelectableListItem>
)}
</>
);
};
@@ -0,0 +1,62 @@
import { getWidgetViewLayoutSettingsItemIds } from '@/side-panel/pages/page-layout/utils/getWidgetViewLayoutSettingsItemIds';
describe('getWidgetViewLayoutSettingsItemIds', () => {
it('returns the layout and group-by rows for a table layout', () => {
expect(
getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: false,
isCalendarWeekViewEnabled: false,
hasGroupBy: false,
}),
).toEqual(['object-view-layout', 'record-table-group-by']);
});
it('appends the hide-empty-groups row while grouped', () => {
expect(
getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: false,
isCalendarWeekViewEnabled: false,
hasGroupBy: true,
}),
).toEqual([
'object-view-layout',
'record-table-group-by',
'record-table-hide-empty-groups',
]);
});
it('swaps group-by rows for the calendar field row on a calendar layout', () => {
expect(
getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: true,
isCalendarWeekViewEnabled: false,
hasGroupBy: true,
}),
).toEqual(['object-view-layout', 'record-table-calendar-field']);
});
it('includes the calendar view row when the week/day flag is enabled', () => {
expect(
getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: true,
isCalendarWeekViewEnabled: true,
hasGroupBy: false,
}),
).toEqual([
'object-view-layout',
'record-table-calendar-field',
'record-table-calendar-layout',
]);
});
it('omits the layout row when it is hidden', () => {
expect(
getWidgetViewLayoutSettingsItemIds({
isCalendarLayout: false,
isCalendarWeekViewEnabled: false,
hasGroupBy: false,
isLayoutRowHidden: true,
}),
).toEqual(['record-table-group-by']);
});
});
@@ -0,0 +1,22 @@
export const getWidgetViewLayoutSettingsItemIds = ({
isCalendarLayout,
isCalendarWeekViewEnabled,
hasGroupBy,
isLayoutRowHidden = false,
}: {
isCalendarLayout: boolean;
isCalendarWeekViewEnabled: boolean;
hasGroupBy: boolean;
isLayoutRowHidden?: boolean;
}) => [
...(isLayoutRowHidden ? [] : ['object-view-layout']),
...(isCalendarLayout
? [
'record-table-calendar-field',
...(isCalendarWeekViewEnabled ? ['record-table-calendar-layout'] : []),
]
: ['record-table-group-by']),
...(!isCalendarLayout && hasGroupBy
? ['record-table-hide-empty-groups']
: []),
];
@@ -192,13 +192,6 @@ export class ViewWidgetUpsertService {
};
if (isDefined(input.view)) {
if (!isRecordTableWidget) {
throw new ViewException(
t`View settings can only be updated on record table widgets`,
ViewExceptionCode.INVALID_VIEW_DATA,
);
}
if (
isDefined(input.view.type) &&
!ALLOWED_WIDGET_VIEW_TYPES.includes(input.view.type)
@@ -20,6 +20,7 @@ import {
ViewType,
} from 'twenty-shared/types';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
@@ -404,4 +405,110 @@ describe('upsertViewWidget view settings', () => {
),
).toBe(true);
});
describe('field widget in table display mode', () => {
let fieldWidgetId: string;
let fieldWidgetViewId: string;
beforeAll(async () => {
const { data: fieldViewData } = await createOneView({
expectToFail: false,
input: {
name: 'testFieldWidgetEmbeddedView',
objectMetadataId,
icon: 'IconTable',
type: ViewType.TABLE_WIDGET,
},
});
fieldWidgetViewId = fieldViewData.createView.id;
const { data: fieldWidgetData } = await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Test Field Table Widget For View Settings',
type: WidgetType.FIELD,
pageLayoutTabId,
objectMetadataId,
gridPosition: { row: 1, column: 0, rowSpan: 1, columnSpan: 1 },
configuration: {
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: selectFieldMetadataId,
fieldDisplayMode: FieldDisplayMode.TABLE,
viewId: fieldWidgetViewId,
},
},
});
fieldWidgetId = fieldWidgetData.createPageLayoutWidget.id;
});
afterAll(async () => {
await destroyOnePageLayoutWidget({
expectToFail: false,
input: { id: fieldWidgetId },
});
await destroyOneView({
expectToFail: false,
viewId: fieldWidgetViewId,
});
});
it('should switch a field table widget embedded view to KANBAN_WIDGET', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId: fieldWidgetId,
view: {
type: ViewType.KANBAN_WIDGET,
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.KANBAN_WIDGET);
expect(data.upsertViewWidget.mainGroupByFieldMetadataId).toBe(
selectFieldMetadataId,
);
});
it('should switch a field table widget embedded view to CALENDAR_WIDGET', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId: fieldWidgetId,
view: {
type: ViewType.CALENDAR_WIDGET,
calendarLayout: ViewCalendarLayout.MONTH,
calendarFieldMetadataId: dateFieldMetadataId,
mainGroupByFieldMetadataId: null,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.CALENDAR_WIDGET);
expect(data.upsertViewWidget.calendarFieldMetadataId).toBe(
dateFieldMetadataId,
);
});
it('should still reject a field table widget switching to KANBAN_WIDGET without a main group by field', async () => {
const { errors } = await upsertViewWidget({
expectToFail: true,
input: {
widgetId: fieldWidgetId,
view: {
type: ViewType.KANBAN_WIDGET,
mainGroupByFieldMetadataId: null,
},
},
});
expect(JSON.stringify(errors)).toContain(
'Kanban view must have a main group by field',
);
});
});
});