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:
+192
-34
@@ -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>
|
||||
);
|
||||
|
||||
+75
-4
@@ -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"
|
||||
|
||||
+235
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+62
@@ -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']);
|
||||
});
|
||||
});
|
||||
+22
@@ -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']
|
||||
: []),
|
||||
];
|
||||
-7
@@ -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)
|
||||
|
||||
+107
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user