feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context Dashboard view widgets previously only rendered flat tables. This PR ships the full feature: **Table with group-by**, **Kanban**, and **Calendar** layouts for dashboard view widgets — server API + frontend, end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968 — consolidated here per review.) ## Server / API - **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to `ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing views keep their layout in `view.type` while staying excluded from record-index pickers. Shared `getViewLayoutFromViewType()` maps widget types to their base layout; `isWidgetViewType()` centralizes the exclusions that were previously hardcoded per-site. - **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE core.view_type_enum ADD VALUE` for both values, and a widened `CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET` (entity `@Check` updated for fresh installs). - **Validation.** `FlatViewValidatorService` keys kanban/calendar validation on the mapped layout, so widget views get the same invariants as index views (kanban needs a groupable group-by field; calendar needs a date field + layout). Calendar widget views default to month; a non-month (DAY/WEEK) layout is rejected at the API level **unless** the `IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the workspace — the same flag that gates day/week on index calendars. - **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested `view` settings input (`type`, `mainGroupByFieldMetadataId`, `shouldHideEmptyGroups`, kanban aggregate/column-width, calendar layout/fields). Routes through the standard update path, so `viewGroups` auto-generate from SELECT options exactly like index views. Only widget view types accepted; only `RECORD_TABLE` widgets can change view settings. - **AI tools.** `create-complete-dashboard` + `create_view` now use/allow the `*_WIDGET` types (previously they created plain `TABLE` views that leak into index pickers). ## Frontend **Settings panel.** The **Source** (object) row comes first, since which layouts are available depends on it. The **Layout** row below is a working dropdown (Table / Kanban / Calendar); layouts the source object can't support are **disabled with a hint** ("Needs a Select field" / "Needs a Date field") rather than hidden. Group-by row (select fields; searchable) with a **Hide empty groups** toggle while grouped; **Date field** row replaces Group by while Calendar is active, and — when the `IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row (Day / Week / Month) appears beside it; **Limit** row hidden while grouped (only the flat virtualized loader enforces it). Kanban keeps its group-by locked (no `None` option). **Instant edit-mode preview.** Draft snapshots carry `viewGroups`; picking a group-by synthesizes them client-side (`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server's generation), so grouped tables/boards preview immediately before dashboard save. On save, `upsertViewWidget` responses hand back the server-generated groups, which replace the client-generated ones in the persisted snapshot. **Renderers.** `RecordTableWidgetRendererContent` branches on the backing view's layout: `RecordBoardWidget` (wraps the standard `RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing `RecordCalendar`, which renders month / day / week) inside the same per-widget provider sandbox the table uses. **Read-only semantics.** Two flags with distinct scopes, each documented on its state: - `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board chrome that edits view settings (add group, column reorder/resize/menu, aggregates); **card drag still updates records** under object permissions. - `isRecordCalendarReadOnlyComponentState` — widget calendars are read-only by default (no drag, no add-new, no in-calendar layout switch); cards open the side panel. The one exception, behind `IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week** widget calendar allows drag-to-reschedule and record creation under object permissions. Month calendars and edit-mode previews stay read-only. **Calendar state componentization.** The calendar module's three settings move from global atoms to component states keyed on `RecordCalendarComponentInstanceContext` (same pattern as record-board), so several calendar widgets and an index-page calendar can coexist without leaking state. All readers resolve the ambient instance; calendar unit tests updated. **Multi-instance fixes that also fix index pages:** record drag states were written against a different instance than every reader resolves (now use the ambient instance); the board sticky-header DOM id is namespaced per board; dragged board cards portal to `document.body` while dragging so react-grid-layout's transforms can't offset the clone from the pointer. ## Scope (v1) - Widget calendars are month-only and read-only by default. With `IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become selectable (UI + API) and live day/week widget calendars support drag-to-reschedule and record creation under object permissions. - Widget group-by offers SELECT fields only (server auto-generates groups from options; widgets have no per-record add-group flow). ## Tests - Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9 tests — group auto-creation, invalid type/field rejections, non-month calendar widget rejected while the week/day flag is off and accepted once it's enabled, combined settings+fields call); pre-existing `upsert-view-widget` suite (20) green. - Front: new suites for draft view-group generation and snapshot clone/build utils; calendar suites componentized; full `twenty-front` jest, typecheck, oxlint green; `twenty-server` typecheck + lint green. - Browser-verified end-to-end (real dev server + seeded workspace): configure → live edit-mode preview → save → reload for all three layouts; measured drag with pointer inside the card; index-page calendar re-verified (with the week/day flag enabled). https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
This commit is contained in:
@@ -844,6 +844,8 @@ enum ViewType {
|
||||
CALENDAR
|
||||
FIELDS_WIDGET
|
||||
TABLE_WIDGET
|
||||
KANBAN_WIDGET
|
||||
CALENDAR_WIDGET
|
||||
}
|
||||
|
||||
enum ViewKey {
|
||||
@@ -3668,6 +3670,11 @@ input UpsertViewWidgetInput {
|
||||
"""The id of the view widget (page layout widget)."""
|
||||
widgetId: UUID!
|
||||
|
||||
"""
|
||||
View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view.
|
||||
"""
|
||||
view: UpsertViewWidgetViewSettingsInput
|
||||
|
||||
"""The view fields to upsert."""
|
||||
viewFields: [UpsertViewWidgetViewFieldInput!]
|
||||
|
||||
@@ -3681,6 +3688,22 @@ input UpsertViewWidgetInput {
|
||||
viewSorts: [UpsertViewWidgetViewSortInput!]
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewSettingsInput {
|
||||
"""
|
||||
The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed.
|
||||
"""
|
||||
type: ViewType
|
||||
mainGroupByFieldMetadataId: UUID
|
||||
shouldHideEmptyGroups: Boolean
|
||||
openRecordIn: ViewOpenRecordIn
|
||||
kanbanAggregateOperation: AggregateOperations
|
||||
kanbanAggregateOperationFieldMetadataId: UUID
|
||||
kanbanColumnWidth: Int
|
||||
calendarLayout: ViewCalendarLayout
|
||||
calendarFieldMetadataId: UUID
|
||||
calendarEndFieldMetadataId: UUID
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewFieldInput {
|
||||
"""The id of an existing view field to update."""
|
||||
viewFieldId: UUID
|
||||
|
||||
@@ -601,7 +601,7 @@ export interface View {
|
||||
__typename: 'View'
|
||||
}
|
||||
|
||||
export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'FIELDS_WIDGET' | 'TABLE_WIDGET'
|
||||
export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'FIELDS_WIDGET' | 'TABLE_WIDGET' | 'KANBAN_WIDGET' | 'CALENDAR_WIDGET'
|
||||
|
||||
export type ViewKey = 'INDEX'
|
||||
|
||||
@@ -6328,6 +6328,8 @@ export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars[
|
||||
export interface UpsertViewWidgetInput {
|
||||
/** The id of the view widget (page layout widget). */
|
||||
widgetId: Scalars['UUID'],
|
||||
/** View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view. */
|
||||
view?: (UpsertViewWidgetViewSettingsInput | null),
|
||||
/** The view fields to upsert. */
|
||||
viewFields?: (UpsertViewWidgetViewFieldInput[] | null),
|
||||
/** The view filters to upsert. */
|
||||
@@ -6337,6 +6339,10 @@ viewFilterGroups?: (UpsertViewWidgetViewFilterGroupInput[] | null),
|
||||
/** The view sorts to upsert. */
|
||||
viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
|
||||
|
||||
export interface UpsertViewWidgetViewSettingsInput {
|
||||
/** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */
|
||||
type?: (ViewType | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null)}
|
||||
|
||||
export interface UpsertViewWidgetViewFieldInput {
|
||||
/** The id of an existing view field to update. */
|
||||
viewFieldId?: (Scalars['UUID'] | null),
|
||||
@@ -8979,7 +8985,9 @@ export const enumViewType = {
|
||||
KANBAN: 'KANBAN' as const,
|
||||
CALENDAR: 'CALENDAR' as const,
|
||||
FIELDS_WIDGET: 'FIELDS_WIDGET' as const,
|
||||
TABLE_WIDGET: 'TABLE_WIDGET' as const
|
||||
TABLE_WIDGET: 'TABLE_WIDGET' as const,
|
||||
KANBAN_WIDGET: 'KANBAN_WIDGET' as const,
|
||||
CALENDAR_WIDGET: 'CALENDAR_WIDGET' as const
|
||||
}
|
||||
|
||||
export const enumViewKey = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ export default defineView({
|
||||
|
||||
| Property | Values | Description |
|
||||
|----------|--------|-------------|
|
||||
| `type` | `ViewType.TABLE` (default), `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET` / `TABLE_WIDGET` also exist but are used internally by page-layout widgets.) |
|
||||
| `type` | `ViewType.TABLE` (default), `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, and `CALENDAR_WIDGET` also exist but are used internally by page-layout widgets.) |
|
||||
| `visibility` | `ViewVisibility.WORKSPACE` (default), `ViewVisibility.UNLISTED` | Whether the view is listed for the whole workspace or hidden from pickers. |
|
||||
| `openRecordIn` | `ViewOpenRecordIn.SIDE_PANEL` (default), `ViewOpenRecordIn.RECORD_PAGE` | Where clicking a record opens it. |
|
||||
| `sorts` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | Default sort order. |
|
||||
|
||||
@@ -5828,6 +5828,8 @@ export type UpsertRowLevelPermissionPredicatesResult = {
|
||||
};
|
||||
|
||||
export type UpsertViewWidgetInput = {
|
||||
/** View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view. */
|
||||
view?: InputMaybe<UpsertViewWidgetViewSettingsInput>;
|
||||
/** The view fields to upsert. */
|
||||
viewFields?: InputMaybe<Array<UpsertViewWidgetViewFieldInput>>;
|
||||
/** The view filter groups to upsert. */
|
||||
@@ -5868,6 +5870,20 @@ export type UpsertViewWidgetViewFilterInput = {
|
||||
viewFilterGroupId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
};
|
||||
|
||||
export type UpsertViewWidgetViewSettingsInput = {
|
||||
calendarEndFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
calendarFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
calendarLayout?: InputMaybe<ViewCalendarLayout>;
|
||||
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
|
||||
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
kanbanColumnWidth?: InputMaybe<Scalars['Int']['input']>;
|
||||
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
|
||||
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
|
||||
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */
|
||||
type?: InputMaybe<ViewType>;
|
||||
};
|
||||
|
||||
export type UpsertViewWidgetViewSortInput = {
|
||||
direction?: InputMaybe<ViewSortDirection>;
|
||||
fieldMetadataId: Scalars['UUID']['input'];
|
||||
@@ -6182,8 +6198,10 @@ export enum ViewSortDirection {
|
||||
|
||||
export enum ViewType {
|
||||
CALENDAR = 'CALENDAR',
|
||||
CALENDAR_WIDGET = 'CALENDAR_WIDGET',
|
||||
FIELDS_WIDGET = 'FIELDS_WIDGET',
|
||||
KANBAN = 'KANBAN',
|
||||
KANBAN_WIDGET = 'KANBAN_WIDGET',
|
||||
TABLE = 'TABLE',
|
||||
TABLE_WIDGET = 'TABLE_WIDGET'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum ContextStoreViewType {
|
||||
Table = 'table',
|
||||
Kanban = 'kanban',
|
||||
Calendar = 'calendar',
|
||||
}
|
||||
|
||||
+6
-2
@@ -45,7 +45,11 @@ const PAGE_LAYOUTS_GROUP_KEYS: MetadataEntityKey[] = [
|
||||
|
||||
const INDEX_VIEW_TYPES = [ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR];
|
||||
const FIELDS_WIDGET_VIEW_TYPES = [ViewType.FIELDS_WIDGET];
|
||||
const TABLE_WIDGET_VIEW_TYPES = [ViewType.TABLE_WIDGET];
|
||||
const WIDGET_VIEW_TYPES = [
|
||||
ViewType.TABLE_WIDGET,
|
||||
ViewType.KANBAN_WIDGET,
|
||||
ViewType.CALENDAR_WIDGET,
|
||||
];
|
||||
|
||||
const hasOverlap = (
|
||||
staleKeys: MetadataEntityKey[],
|
||||
@@ -97,7 +101,7 @@ export const useLoadStaleMetadataEntities = () => {
|
||||
}),
|
||||
client.query({
|
||||
query: FindTableWidgetViewsDocument,
|
||||
variables: { viewTypes: TABLE_WIDGET_VIEW_TYPES },
|
||||
variables: { viewTypes: WIDGET_VIEW_TYPES },
|
||||
fetchPolicy: 'network-only',
|
||||
}),
|
||||
]).then(
|
||||
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
import { isWidgetViewType } from 'twenty-shared/utils';
|
||||
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { ViewVisibility } from '~/generated-metadata/graphql';
|
||||
|
||||
export const isViewDisplayableInNavigationMenu = (view: View): boolean =>
|
||||
view.type !== ViewType.FIELDS_WIDGET &&
|
||||
view.type !== ViewType.TABLE_WIDGET &&
|
||||
view.visibility === ViewVisibility.WORKSPACE;
|
||||
!isWidgetViewType(view.type) && view.visibility === ViewVisibility.WORKSPACE;
|
||||
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
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 { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
|
||||
@@ -34,8 +34,8 @@ export const ObjectOptionsDropdownCalendarEndFieldsContent = () => {
|
||||
const { updateCurrentView } = useUpdateCurrentView();
|
||||
const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar();
|
||||
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomComponentState(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const availableCalendarEndFieldMetadataItems =
|
||||
|
||||
+7
-7
@@ -1,7 +1,8 @@
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
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';
|
||||
@@ -11,7 +12,6 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -31,11 +31,11 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
|
||||
const { availableFieldsForCalendar, navigateToDateFieldSettings } =
|
||||
useGetAvailableFieldsForCalendar();
|
||||
|
||||
const setRecordIndexCalendarFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const setRecordIndexCalendarFieldMetadataId = useSetAtomComponentState(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomComponentState(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadata = currentView?.calendarFieldMetadataId
|
||||
|
||||
+6
-7
@@ -1,7 +1,8 @@
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
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';
|
||||
@@ -10,8 +11,6 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -33,8 +32,8 @@ const RECORD_CALENDAR_TIMELINE_VIEW_ID = 'record-calendar-timeline-view';
|
||||
|
||||
export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
const { resetContent } = useObjectOptionsDropdown();
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
const recordIndexCalendarLayout = useAtomComponentStateValue(
|
||||
recordIndexCalendarLayoutComponentState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
@@ -43,8 +42,8 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
const setRecordIndexCalendarLayout = useSetAtomState(
|
||||
recordIndexCalendarLayoutState,
|
||||
const setRecordIndexCalendarLayout = useSetAtomComponentState(
|
||||
recordIndexCalendarLayoutComponentState,
|
||||
);
|
||||
const { updateCurrentView } = useUpdateCurrentView();
|
||||
|
||||
|
||||
+3
-4
@@ -3,7 +3,7 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropd
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -89,8 +88,8 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
const isDefaultView = currentView?.key === ViewKey.INDEX;
|
||||
const isLastView = viewsOnCurrentObject.length <= 1;
|
||||
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
const recordIndexCalendarLayout = useAtomComponentStateValue(
|
||||
recordIndexCalendarLayoutComponentState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropd
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
@@ -72,8 +72,8 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
const recordIndexOpenRecordIn = useAtomStateValue(
|
||||
recordIndexOpenRecordInState,
|
||||
);
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
const recordIndexCalendarLayout = useAtomComponentStateValue(
|
||||
recordIndexCalendarLayoutComponentState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
|
||||
+15
-8
@@ -8,7 +8,7 @@ const mockResetContent = jest.fn();
|
||||
const mockSetRecordIndexCalendarLayout = jest.fn();
|
||||
const mockUpdateCurrentView = jest.fn();
|
||||
const mockUseIsFeatureEnabled = jest.fn();
|
||||
const mockUseAtomStateValue = jest.fn();
|
||||
const mockUseCalendarLayoutValue = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown',
|
||||
@@ -45,13 +45,20 @@ jest.mock('@/ui/layout/selectable-list/components/SelectableListItem', () => ({
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
() => ({ useAtomComponentStateValue: jest.fn(() => null) }),
|
||||
() => ({
|
||||
useAtomComponentStateValue: (state: unknown) => {
|
||||
const { recordIndexCalendarLayoutComponentState } = jest.requireActual(
|
||||
'@/object-record/record-index/states/recordIndexCalendarLayoutComponentState',
|
||||
);
|
||||
|
||||
return state === recordIndexCalendarLayoutComponentState
|
||||
? mockUseCalendarLayoutValue()
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomState', () => ({
|
||||
useSetAtomState: jest.fn(() => mockSetRecordIndexCalendarLayout),
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomComponentState', () => ({
|
||||
useSetAtomComponentState: jest.fn(() => mockSetRecordIndexCalendarLayout),
|
||||
}));
|
||||
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
|
||||
useUpdateCurrentView: jest.fn(() => ({
|
||||
@@ -88,7 +95,7 @@ jest.mock('twenty-ui/navigation', () => ({
|
||||
describe('ObjectOptionsDropdownCalendarViewContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseAtomStateValue.mockReturnValue(ViewCalendarLayout.MONTH);
|
||||
mockUseCalendarLayoutValue.mockReturnValue(ViewCalendarLayout.MONTH);
|
||||
mockUseIsFeatureEnabled.mockReturnValue(true);
|
||||
mockUpdateCurrentView.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
+2
@@ -115,6 +115,8 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
|
||||
return await updateCurrentView(updateCurrentViewParams);
|
||||
}
|
||||
case ViewType.TABLE_WIDGET:
|
||||
case ViewType.KANBAN_WIDGET:
|
||||
case ViewType.CALENDAR_WIDGET:
|
||||
case ViewType.FIELDS_WIDGET: {
|
||||
return;
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { RecordBoardContainer } from '@/object-record/record-board/components/RecordBoardContainer';
|
||||
import { RecordBoardWidgetViewSettingsReadOnlyEffect } from '@/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledBoardContainer = styled.div`
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
type RecordBoardWidgetProps = {
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export const RecordBoardWidget = ({
|
||||
isReadOnly = true,
|
||||
}: RecordBoardWidgetProps) => {
|
||||
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
|
||||
useRecordIndexContextOrThrow();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecordBoardWidgetViewSettingsReadOnlyEffect
|
||||
recordBoardId={recordIndexId}
|
||||
isViewSettingsReadOnly={isReadOnly}
|
||||
/>
|
||||
<StyledBoardContainer>
|
||||
<RecordBoardContainer
|
||||
recordBoardId={recordIndexId}
|
||||
viewBarId={viewBarInstanceId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
</StyledBoardContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
type RecordBoardWidgetViewSettingsReadOnlyEffectProps = {
|
||||
recordBoardId: string;
|
||||
isViewSettingsReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const RecordBoardWidgetViewSettingsReadOnlyEffect = ({
|
||||
recordBoardId,
|
||||
isViewSettingsReadOnly,
|
||||
}: RecordBoardWidgetViewSettingsReadOnlyEffectProps) => {
|
||||
const setIsRecordBoardViewSettingsReadOnly = useSetAtomComponentState(
|
||||
isRecordBoardViewSettingsReadOnlyComponentState,
|
||||
recordBoardId,
|
||||
);
|
||||
|
||||
// Synchronized before paint so read-only widgets never flash (or
|
||||
// briefly accept interaction on) their editable controls.
|
||||
useLayoutEffect(() => {
|
||||
setIsRecordBoardViewSettingsReadOnly(isViewSettingsReadOnly);
|
||||
|
||||
// Reset to the default on unmount so the flag cannot outlive the
|
||||
// widget and leak into a later board mounted on the same instance id.
|
||||
return () => {
|
||||
setIsRecordBoardViewSettingsReadOnly(false);
|
||||
};
|
||||
}, [isViewSettingsReadOnly, setIsRecordBoardViewSettingsReadOnly]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+9
-1
@@ -11,6 +11,8 @@ import { RecordBoardEffects } from '@/object-record/record-board/components/Reco
|
||||
import { RecordBoardFetchMoreInViewTriggerComponent } from '@/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent';
|
||||
import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader';
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
import { getRecordBoardHtmlId } from '@/object-record/record-board/utils/getRecordBoardHtmlId';
|
||||
@@ -40,6 +42,10 @@ export const RecordBoard = () => {
|
||||
const { recordBoardId } = useContext(RecordBoardContext);
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
|
||||
isRecordBoardViewSettingsReadOnlyComponentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollWrapper
|
||||
@@ -54,7 +60,9 @@ export const RecordBoard = () => {
|
||||
<RecordBoardDragDropContext>
|
||||
<RecordBoardColumns />
|
||||
</RecordBoardDragDropContext>
|
||||
<RecordBoardDragSelect boardRef={boardRef} />
|
||||
{!isRecordBoardViewSettingsReadOnly && (
|
||||
<RecordBoardDragSelect boardRef={boardRef} />
|
||||
)}
|
||||
<RecordBoardFetchMoreInViewTriggerComponent />
|
||||
</StyledContainer>
|
||||
</StyledBoardContentContainer>
|
||||
|
||||
+10
@@ -1,6 +1,8 @@
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
|
||||
import { AddRecordGroupButton } from '@/object-record/record-group/components/AddRecordGroupButton';
|
||||
import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -16,6 +18,14 @@ const StyledColumn = styled.div`
|
||||
export const RecordBoardAddGroupColumn = () => {
|
||||
const { selectFieldMetadataItem } = useContext(RecordBoardContext);
|
||||
|
||||
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
|
||||
isRecordBoardViewSettingsReadOnlyComponentState,
|
||||
);
|
||||
|
||||
if (isRecordBoardViewSettingsReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!canAddRecordGroupForFieldMetadataItem(selectFieldMetadataItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+2
-3
@@ -39,11 +39,10 @@ export const RecordBoardDragDropContext = ({
|
||||
|
||||
const originalDragSelectionCallbackState = useAtomComponentStateCallbackState(
|
||||
originalDragSelectionComponentState,
|
||||
recordBoardId,
|
||||
);
|
||||
|
||||
const { startRecordDrag } = useStartRecordDrag(recordBoardId);
|
||||
const { endRecordDrag } = useEndRecordDrag(recordBoardId);
|
||||
const { startRecordDrag } = useStartRecordDrag();
|
||||
const { endRecordDrag } = useEndRecordDrag();
|
||||
|
||||
const { processBoardCardDrop } = useProcessBoardCardDrop();
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,9 @@
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { RecordBoardColumnDnd } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDnd';
|
||||
import { getRecordBoardHeaderHtmlId } from '@/object-record/record-board/utils/getRecordBoardHeaderHtmlId';
|
||||
import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
const StyledHeaderContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -18,8 +21,10 @@ const StyledHeaderContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const RecordBoardHeader = () => {
|
||||
const { recordBoardId } = useContext(RecordBoardContext);
|
||||
|
||||
return (
|
||||
<StyledHeaderContainer id="record-board-header">
|
||||
<StyledHeaderContainer id={getRecordBoardHeaderHtmlId(recordBoardId)}>
|
||||
<RecordBoardColumnDnd />
|
||||
<RecordIndexGroupAggregatesDataLoader />
|
||||
</StyledHeaderContainer>
|
||||
|
||||
+8
-4
@@ -1,8 +1,12 @@
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { getRecordBoardHeaderHtmlId } from '@/object-record/record-board/utils/getRecordBoardHeaderHtmlId';
|
||||
import { scrollWrapperScrollTopComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollTopComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useEffect } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
export const RecordBoardStickyHeaderEffect = () => {
|
||||
const { recordBoardId } = useContext(RecordBoardContext);
|
||||
|
||||
const scrollWrapperScrollTop = useAtomComponentStateValue(
|
||||
scrollWrapperScrollTopComponentState,
|
||||
);
|
||||
@@ -11,14 +15,14 @@ export const RecordBoardStickyHeaderEffect = () => {
|
||||
useEffect(() => {
|
||||
if (scrollWrapperScrollTop > 0) {
|
||||
document
|
||||
.getElementById('record-board-header')
|
||||
.getElementById(getRecordBoardHeaderHtmlId(recordBoardId))
|
||||
?.classList.add('header-sticky');
|
||||
} else {
|
||||
document
|
||||
.getElementById('record-board-header')
|
||||
.getElementById(getRecordBoardHeaderHtmlId(recordBoardId))
|
||||
?.classList.remove('header-sticky');
|
||||
}
|
||||
}, [scrollWrapperScrollTop]);
|
||||
}, [scrollWrapperScrollTop, recordBoardId]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
+35
-21
@@ -1,6 +1,7 @@
|
||||
import { Draggable } from '@hello-pangea/dnd';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps';
|
||||
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
||||
@@ -64,27 +65,40 @@ export const RecordBoardCardDraggableContainer = ({
|
||||
index={rowIndex}
|
||||
isDragDisabled={isRecordBoardDropProcessing}
|
||||
>
|
||||
{(draggableProvided) => (
|
||||
<StyledDraggableContainer
|
||||
isDragDisabled={isRecordBoardDropProcessing}
|
||||
id={`record-board-card-${columnIndex}-${rowIndex}`}
|
||||
ref={draggableProvided?.innerRef}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...draggableProvided?.dragHandleProps}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getCssCompatibleDraggableProps(
|
||||
draggableProvided.draggableProps,
|
||||
)}
|
||||
data-selectable-id={recordId}
|
||||
data-select-disable
|
||||
>
|
||||
<DragAndDropLibraryLegacyReRenderBreaker memoizationId={recordId}>
|
||||
{isRecordBoardCardFocused && <RecordBoardCardHotkeysEffect />}
|
||||
<RecordBoardCard />
|
||||
<RecordBoardCardMultiDragPreview />
|
||||
</DragAndDropLibraryLegacyReRenderBreaker>
|
||||
</StyledDraggableContainer>
|
||||
)}
|
||||
{(draggableProvided, draggableSnapshot) => {
|
||||
const draggableContent = (
|
||||
<StyledDraggableContainer
|
||||
isDragDisabled={isRecordBoardDropProcessing}
|
||||
id={`record-board-card-${columnIndex}-${rowIndex}`}
|
||||
ref={draggableProvided?.innerRef}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...draggableProvided?.dragHandleProps}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getCssCompatibleDraggableProps(
|
||||
draggableProvided.draggableProps,
|
||||
)}
|
||||
data-selectable-id={recordId}
|
||||
data-select-disable
|
||||
>
|
||||
<DragAndDropLibraryLegacyReRenderBreaker memoizationId={recordId}>
|
||||
{isRecordBoardCardFocused && <RecordBoardCardHotkeysEffect />}
|
||||
<RecordBoardCard />
|
||||
<RecordBoardCardMultiDragPreview />
|
||||
</DragAndDropLibraryLegacyReRenderBreaker>
|
||||
</StyledDraggableContainer>
|
||||
);
|
||||
|
||||
// The drag clone is position: fixed, which resolves against the
|
||||
// nearest transformed ancestor — dashboard widgets are translated
|
||||
// by react-grid-layout, offsetting the clone from the pointer.
|
||||
// Portal it to the body while dragging so the viewport stays its
|
||||
// containing block.
|
||||
if (draggableSnapshot.isDragging) {
|
||||
return createPortal(draggableContent, document.body);
|
||||
}
|
||||
|
||||
return draggableContent;
|
||||
}}
|
||||
</Draggable>
|
||||
</RecordBoardCardContext.Provider>
|
||||
);
|
||||
|
||||
+72
-43
@@ -18,6 +18,7 @@ import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFie
|
||||
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
|
||||
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
|
||||
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
|
||||
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
|
||||
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown';
|
||||
@@ -108,6 +109,14 @@ const StyledTagContainer = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledAggregateDropdownContainer = styled.div<{
|
||||
isNonInteractive: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
pointer-events: ${({ isNonInteractive }) =>
|
||||
isNonInteractive ? 'none' : 'auto'};
|
||||
`;
|
||||
|
||||
const StyledDropdownContainer = styled.div`
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -127,6 +136,10 @@ export const RecordBoardColumnHeader = () => {
|
||||
const { objectMetadataItem, selectFieldMetadataItem } =
|
||||
useContext(RecordBoardContext);
|
||||
|
||||
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
|
||||
isRecordBoardViewSettingsReadOnlyComponentState,
|
||||
);
|
||||
|
||||
const objectPermissions = useObjectPermissionsForObject(
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
@@ -182,58 +195,74 @@ export const RecordBoardColumnHeader = () => {
|
||||
<StyledHeaderContainer>
|
||||
<StyledLeftContainer>
|
||||
<StyledDropdownContainer>
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
dropdownOffset={{
|
||||
x: 0,
|
||||
y: 10,
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledTagContainer>
|
||||
<RecordGroupChip
|
||||
recordGroupDefinition={columnDefinition}
|
||||
fieldMetadataItem={selectFieldMetadataItem}
|
||||
/>
|
||||
</StyledTagContainer>
|
||||
}
|
||||
dropdownComponents={<RecordBoardColumnDropdownMenu />}
|
||||
/>
|
||||
{isRecordBoardViewSettingsReadOnly ? (
|
||||
<StyledTagContainer>
|
||||
<RecordGroupChip
|
||||
recordGroupDefinition={columnDefinition}
|
||||
fieldMetadataItem={selectFieldMetadataItem}
|
||||
/>
|
||||
</StyledTagContainer>
|
||||
) : (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
dropdownOffset={{
|
||||
x: 0,
|
||||
y: 10,
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledTagContainer>
|
||||
<RecordGroupChip
|
||||
recordGroupDefinition={columnDefinition}
|
||||
fieldMetadataItem={selectFieldMetadataItem}
|
||||
/>
|
||||
</StyledTagContainer>
|
||||
}
|
||||
dropdownComponents={<RecordBoardColumnDropdownMenu />}
|
||||
/>
|
||||
)}
|
||||
</StyledDropdownContainer>
|
||||
|
||||
<RecordBoardColumnHeaderAggregateDropdown
|
||||
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
|
||||
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
aggregateLabel={recordIndexAggregateDisplayLabel}
|
||||
/>
|
||||
<StyledAggregateDropdownContainer
|
||||
isNonInteractive={isRecordBoardViewSettingsReadOnly}
|
||||
inert={isRecordBoardViewSettingsReadOnly || undefined}
|
||||
>
|
||||
<RecordBoardColumnHeaderAggregateDropdown
|
||||
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
|
||||
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
aggregateLabel={recordIndexAggregateDisplayLabel}
|
||||
/>
|
||||
</StyledAggregateDropdownContainer>
|
||||
</StyledLeftContainer>
|
||||
<StyledHeaderActions
|
||||
data-dropdown-open={isDropdownOpen ? 'true' : undefined}
|
||||
>
|
||||
<LightIconButton
|
||||
accent="tertiary"
|
||||
aria-label={t`More options`}
|
||||
Icon={IconDotsVertical}
|
||||
onClick={() => {
|
||||
toggleDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
|
||||
{!isRecordBoardViewSettingsReadOnly && (
|
||||
<StyledHeaderActions
|
||||
data-dropdown-open={isDropdownOpen ? 'true' : undefined}
|
||||
>
|
||||
<LightIconButton
|
||||
accent="tertiary"
|
||||
aria-label={t`Add new`}
|
||||
Icon={IconPlus}
|
||||
onClick={handleCreateNewRecordClick}
|
||||
aria-label={t`More options`}
|
||||
Icon={IconDotsVertical}
|
||||
onClick={() => {
|
||||
toggleDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledHeaderActions>
|
||||
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
|
||||
<LightIconButton
|
||||
accent="tertiary"
|
||||
aria-label={t`Add new`}
|
||||
Icon={IconPlus}
|
||||
onClick={handleCreateNewRecordClick}
|
||||
/>
|
||||
)}
|
||||
</StyledHeaderActions>
|
||||
)}
|
||||
</StyledHeaderContainer>
|
||||
</StyledHeader>
|
||||
</DragDropColumnSortableHandle>
|
||||
<RecordBoardColumnResizeHandler />
|
||||
{!isRecordBoardViewSettingsReadOnly && <RecordBoardColumnResizeHandler />}
|
||||
</StyledColumn>
|
||||
);
|
||||
};
|
||||
|
||||
+7
-1
@@ -1,7 +1,9 @@
|
||||
import { DragDropProvider } from '@dnd-kit/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
|
||||
import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal';
|
||||
import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext';
|
||||
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
|
||||
@@ -17,11 +19,15 @@ export const RecordBoardColumnDndKitProvider = ({
|
||||
const { contextValues, handlers, handlePendingReorderConfirmClick } =
|
||||
useRecordBoardColumnDndKit();
|
||||
|
||||
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
|
||||
isRecordBoardViewSettingsReadOnlyComponentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropColumnDndContext.Provider value={contextValues}>
|
||||
<DragDropProvider<DragDropColumnData>
|
||||
sensors={DND_KIT_SENSORS}
|
||||
sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragMove={handlers.onDragMove}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
|
||||
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
|
||||
|
||||
// Locks the board chrome that edits the backing view's settings (add
|
||||
// group, column reorder/resize/menu, aggregate pickers) plus the bulk
|
||||
// selection and per-column record creation affordances that dashboard
|
||||
// widgets don't wire up. Record data itself stays editable: card drag
|
||||
// still updates records, gated by object permissions as usual.
|
||||
export const isRecordBoardViewSettingsReadOnlyComponentState =
|
||||
createAtomComponentState<boolean>({
|
||||
key: 'isRecordBoardViewSettingsReadOnlyComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordBoardComponentInstanceContext,
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const getRecordBoardHeaderHtmlId = (recordBoardId: string) =>
|
||||
`record-board-header-${recordBoardId}`;
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
|
||||
import { RecordCalendarSSESubscribeEffect } from '@/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect';
|
||||
import { RecordIndexCalendarDataLoaderEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect';
|
||||
import { RecordIndexCalendarSelectedDateInitEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect';
|
||||
import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { RecordCalendarWidgetReadOnlyEffect } from '@/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledCalendarContainer = styled.div`
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
type RecordCalendarWidgetProps = {
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
|
||||
export const RecordCalendarWidget = ({
|
||||
isReadOnly = true,
|
||||
}: RecordCalendarWidgetProps) => {
|
||||
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
|
||||
useRecordIndexContextOrThrow();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const objectPermissions = useObjectPermissionsForObject(
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
|
||||
// Hydrated per widget instance from the backing view (draft or persisted)
|
||||
// by the widget view load effect, so edit-mode previews work before save.
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
recordIndexId,
|
||||
);
|
||||
|
||||
if (!isDefined(recordIndexCalendarFieldMetadataId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecordCalendarWidgetReadOnlyEffect
|
||||
recordCalendarId={recordIndexId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
<StyledCalendarContainer>
|
||||
<RecordCalendarContextProvider
|
||||
value={{
|
||||
viewBarInstanceId,
|
||||
objectNameSingular,
|
||||
visibleRecordFields: [],
|
||||
objectMetadataItem,
|
||||
objectPermissions,
|
||||
}}
|
||||
>
|
||||
<RecordCalendar />
|
||||
<RecordCalendarSSESubscribeEffect />
|
||||
<RecordIndexCalendarDataLoaderEffect />
|
||||
<RecordIndexCalendarSelectedDateInitEffect />
|
||||
</RecordCalendarContextProvider>
|
||||
</StyledCalendarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isRecordCalendarReadOnlyComponentState } from '@/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
type RecordCalendarWidgetReadOnlyEffectProps = {
|
||||
recordCalendarId: string;
|
||||
isReadOnly: boolean;
|
||||
};
|
||||
|
||||
export const RecordCalendarWidgetReadOnlyEffect = ({
|
||||
recordCalendarId,
|
||||
isReadOnly,
|
||||
}: RecordCalendarWidgetReadOnlyEffectProps) => {
|
||||
const setIsRecordCalendarReadOnly = useSetAtomComponentState(
|
||||
isRecordCalendarReadOnlyComponentState,
|
||||
recordCalendarId,
|
||||
);
|
||||
|
||||
// Synchronized before paint so read-only widgets never flash (or
|
||||
// briefly accept interaction on) their editable controls.
|
||||
useLayoutEffect(() => {
|
||||
setIsRecordCalendarReadOnly(isReadOnly);
|
||||
|
||||
// Reset to the default on unmount so the flag cannot outlive the
|
||||
// widget and leak into a later calendar mounted on the same instance id.
|
||||
return () => {
|
||||
setIsRecordCalendarReadOnly(false);
|
||||
};
|
||||
}, [isReadOnly, setIsRecordCalendarReadOnly]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+4
-4
@@ -1,3 +1,4 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
|
||||
@@ -8,7 +9,7 @@ import { RecordCalendarDay } from '@/object-record/record-calendar/day/component
|
||||
import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth';
|
||||
import { RecordCalendarWeek } from '@/object-record/record-calendar/week/components/RecordCalendarWeek';
|
||||
import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { useRecordCalendarSelection } from '@/object-record/record-calendar/states/selectors/useRecordCalendarSelection';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
@@ -17,7 +18,6 @@ import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constan
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useEffect } from 'react';
|
||||
import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/data-display';
|
||||
@@ -44,8 +44,8 @@ export const RecordCalendar = () => {
|
||||
|
||||
const { resetRecordSelection } = useRecordCalendarSelection(recordCalendarId);
|
||||
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
const recordIndexCalendarLayout = useAtomComponentStateValue(
|
||||
recordIndexCalendarLayoutComponentState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
|
||||
+13
-7
@@ -1,14 +1,15 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { isRecordCalendarReadOnlyComponentState } from '@/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState';
|
||||
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
|
||||
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
@@ -35,6 +36,10 @@ export const RecordCalendarAddNew = ({
|
||||
cardTime,
|
||||
compact = false,
|
||||
}: RecordCalendarAddNewProps) => {
|
||||
const isRecordCalendarReadOnly = useAtomComponentStateValue(
|
||||
isRecordCalendarReadOnlyComponentState,
|
||||
);
|
||||
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
@@ -50,11 +55,11 @@ export const RecordCalendarAddNew = ({
|
||||
hasAnySoftDeleteFilterOnViewComponentSelector,
|
||||
);
|
||||
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
@@ -81,6 +86,7 @@ export const RecordCalendarAddNew = ({
|
||||
: false;
|
||||
|
||||
if (
|
||||
isRecordCalendarReadOnly ||
|
||||
hasAnySoftDeleteFilterOnView === true ||
|
||||
!canCreateRecordsForObjectMetadataItem({
|
||||
objectPermissions,
|
||||
|
||||
+34
-17
@@ -1,10 +1,12 @@
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { isRecordCalendarReadOnlyComponentState } from '@/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
|
||||
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
|
||||
import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -13,9 +15,10 @@ import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
|
||||
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
@@ -70,8 +73,20 @@ export const RecordCalendarTopBar = () => {
|
||||
const [recordCalendarSelectedDate, setRecordCalendarSelectedDate] =
|
||||
useAtomComponentState(recordCalendarSelectedDateComponentState);
|
||||
|
||||
const isRecordCalendarReadOnly = useAtomComponentStateValue(
|
||||
isRecordCalendarReadOnlyComponentState,
|
||||
);
|
||||
|
||||
// The layout switcher persists via updateCurrentView (an index-page write),
|
||||
// so it must never render inside a dashboard widget; widget calendars drive
|
||||
// their layout from the side-panel settings instead.
|
||||
const widgetInstanceId = useAvailableComponentInstanceId(
|
||||
WidgetComponentInstanceContext,
|
||||
);
|
||||
const isInWidget = isDefined(widgetInstanceId);
|
||||
|
||||
const [recordIndexCalendarLayout, setRecordIndexCalendarLayout] =
|
||||
useAtomState(recordIndexCalendarLayoutState);
|
||||
useAtomComponentState(recordIndexCalendarLayoutComponentState);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
@@ -161,20 +176,22 @@ export const RecordCalendarTopBar = () => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLeftSection>
|
||||
{isCalendarWeekViewEnabled && (
|
||||
<Select
|
||||
dropdownId={`record-calendar-layout-${recordCalendarId}`}
|
||||
value={supportedCalendarLayout}
|
||||
options={[
|
||||
{ label: t`Day`, value: ViewCalendarLayout.DAY },
|
||||
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
|
||||
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
|
||||
]}
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={120}
|
||||
onChange={handleCalendarLayoutChange}
|
||||
/>
|
||||
)}
|
||||
{isCalendarWeekViewEnabled &&
|
||||
!isRecordCalendarReadOnly &&
|
||||
!isInWidget && (
|
||||
<Select
|
||||
dropdownId={`record-calendar-layout-${recordCalendarId}`}
|
||||
value={supportedCalendarLayout}
|
||||
options={[
|
||||
{ label: t`Day`, value: ViewCalendarLayout.DAY },
|
||||
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
|
||||
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
|
||||
]}
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={120}
|
||||
onChange={handleCalendarLayoutChange}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
dropdownId={datePickerDropdownId}
|
||||
clickableComponent={
|
||||
|
||||
+12
-9
@@ -50,16 +50,19 @@ jest.mock(
|
||||
jest.mock('@/ui/utilities/pointer-event/hooks/useListenClickOutside', () => ({
|
||||
useListenClickOutside: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: jest.fn(),
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
() => ({
|
||||
useAtomComponentStateValue: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const useAtomStateValueMock = jest.requireMock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomStateValue',
|
||||
).useAtomStateValue;
|
||||
const useAtomComponentStateValueMock = jest.requireMock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
).useAtomComponentStateValue;
|
||||
const useIsFeatureEnabledMock = jest.requireMock(
|
||||
'@/workspace/hooks/useIsFeatureEnabled',
|
||||
).useIsFeatureEnabled;
|
||||
@@ -67,13 +70,13 @@ const useIsFeatureEnabledMock = jest.requireMock(
|
||||
describe('RecordCalendar', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
|
||||
useAtomComponentStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
|
||||
});
|
||||
|
||||
it.each([ViewCalendarLayout.DAY, ViewCalendarLayout.WEEK])(
|
||||
'renders month when a persisted %s layout is disabled',
|
||||
(calendarLayout) => {
|
||||
useAtomStateValueMock.mockReturnValue(calendarLayout);
|
||||
useAtomComponentStateValueMock.mockReturnValue(calendarLayout);
|
||||
useIsFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
@@ -88,7 +91,7 @@ describe('RecordCalendar', () => {
|
||||
);
|
||||
|
||||
it('renders day when the day layout is enabled', () => {
|
||||
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
|
||||
useAtomComponentStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
|
||||
useIsFeatureEnabledMock.mockReturnValue(true);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
|
||||
+16
-12
@@ -3,13 +3,14 @@ import { enUS } from 'date-fns/locale';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
const mockSetRecordCalendarSelectedDate = jest.fn();
|
||||
const mockSetRecordIndexCalendarLayout = jest.fn();
|
||||
const mockUpdateCurrentView = jest.fn();
|
||||
const mockUseAtomComponentState = jest.fn();
|
||||
const mockUseAtomState = jest.fn();
|
||||
const mockUseAtomStateValue = jest.fn();
|
||||
const mockUseRecordCalendarWeekDaysRange = jest.fn();
|
||||
|
||||
@@ -68,9 +69,6 @@ jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentState', () => ({
|
||||
useAtomComponentState: (...args: unknown[]) =>
|
||||
mockUseAtomComponentState(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomState', () => ({
|
||||
useAtomState: (...args: unknown[]) => mockUseAtomState(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
|
||||
}));
|
||||
@@ -101,14 +99,20 @@ jest.mock('twenty-ui/input', () => ({
|
||||
describe('RecordCalendarTopBar', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseAtomComponentState.mockReturnValue([
|
||||
Temporal.PlainDate.from('2026-07-15'),
|
||||
mockSetRecordCalendarSelectedDate,
|
||||
]);
|
||||
mockUseAtomState.mockReturnValue([
|
||||
ViewCalendarLayout.DAY,
|
||||
mockSetRecordIndexCalendarLayout,
|
||||
]);
|
||||
mockUseAtomComponentState.mockImplementation((state: unknown) => {
|
||||
if (state === recordIndexCalendarLayoutComponentState) {
|
||||
return [ViewCalendarLayout.DAY, mockSetRecordIndexCalendarLayout];
|
||||
}
|
||||
|
||||
if (state === recordCalendarSelectedDateComponentState) {
|
||||
return [
|
||||
Temporal.PlainDate.from('2026-07-15'),
|
||||
mockSetRecordCalendarSelectedDate,
|
||||
];
|
||||
}
|
||||
|
||||
return [undefined, jest.fn()];
|
||||
});
|
||||
mockUseAtomStateValue.mockReturnValue({
|
||||
locale: 'en-US',
|
||||
localeCatalog: enUS,
|
||||
|
||||
+7
-7
@@ -1,3 +1,4 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
|
||||
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
|
||||
@@ -5,12 +6,11 @@ import { useGroupByRecordsQuery } from '@/object-record/hooks/useGroupByRecordsQ
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
|
||||
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useMemo } from 'react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
@@ -27,11 +27,11 @@ export const useRecordCalendarGroupByRecords = (
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const recordGqlFields = useRelevantRecordsGqlFields({
|
||||
|
||||
+13
-12
@@ -2,7 +2,8 @@ import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/fl
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
|
||||
import { getRecordCalendarDateRangeOverlapFilter } from '@/object-record/record-calendar/month/utils/getRecordCalendarDateRangeOverlapFilter';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
|
||||
@@ -12,7 +13,6 @@ import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordF
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
@@ -37,8 +37,6 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
|
||||
const currentRecordFilterGroups = useAtomComponentStateValue(
|
||||
currentRecordFilterGroupsComponentState,
|
||||
viewBarInstanceId,
|
||||
@@ -54,8 +52,14 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
// Read per calendar instance (hydrated from the draft view in widget
|
||||
// edit mode, from the persisted view elsewhere) instead of the current
|
||||
// view store: a widget's pending view only exists after dashboard save.
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const anyFieldFilterValue = useAtomComponentStateValue(
|
||||
@@ -63,10 +67,7 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
viewBarInstanceId,
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(currentView) ||
|
||||
!isDefined(currentView.calendarFieldMetadataId)
|
||||
) {
|
||||
if (!isDefined(recordIndexCalendarFieldMetadataId)) {
|
||||
return {
|
||||
dateRangeFilter: {},
|
||||
};
|
||||
@@ -84,11 +85,11 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
const dateRangeFilterFieldMetadataId = currentView.calendarFieldMetadataId;
|
||||
const dateRangeFilterFieldMetadataId = recordIndexCalendarFieldMetadataId;
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === currentView.calendarFieldMetadataId,
|
||||
fieldMetadataItem.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
|
||||
+16
-8
@@ -1,10 +1,11 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isRecordCalendarReadOnlyComponentState } from '@/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useIsRecordCalendarCardDragDisabled = (recordId: string) => {
|
||||
@@ -16,11 +17,15 @@ export const useIsRecordCalendarCardDragDisabled = (recordId: string) => {
|
||||
const objectPermissions = useObjectPermissionsForObject(
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const isRecordCalendarReadOnly = useAtomComponentStateValue(
|
||||
isRecordCalendarReadOnlyComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
@@ -46,6 +51,9 @@ export const useIsRecordCalendarCardDragDisabled = (recordId: string) => {
|
||||
}));
|
||||
|
||||
return (
|
||||
recordIsReadOnly || calendarFieldIsReadOnly || calendarEndFieldIsReadOnly
|
||||
isRecordCalendarReadOnly ||
|
||||
recordIsReadOnly ||
|
||||
calendarFieldIsReadOnly ||
|
||||
calendarEndFieldIsReadOnly
|
||||
);
|
||||
};
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
|
||||
|
||||
// A fully read-only calendar: no card drag, no per-day record creation,
|
||||
// no layout switching — dashboard widgets render calendars as pure
|
||||
// projections. Unlike the board's view-settings flag
|
||||
// (isRecordBoardViewSettingsReadOnlyComponentState, which keeps record
|
||||
// data editable), this one disables data interactions too, which is why
|
||||
// it keeps the plain "read-only" name.
|
||||
export const isRecordCalendarReadOnlyComponentState =
|
||||
createAtomComponentState<boolean>({
|
||||
key: 'isRecordCalendarReadOnlyComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
+6
-4
@@ -5,8 +5,8 @@ import { RecordCalendarComponentInstanceContext } from '@/object-record/record-c
|
||||
import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState';
|
||||
import { isRecordCalendarDayInDateRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateRange';
|
||||
import { isRecordCalendarDayInDateTimeRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateTimeRange';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
@@ -26,10 +26,12 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
({ instanceId, familyKey: { day, timeZone } }) =>
|
||||
({ get }) => {
|
||||
const calendarFieldMetadataId = get(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
{ instanceId },
|
||||
);
|
||||
const calendarEndFieldMetadataId = get(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
{ instanceId },
|
||||
);
|
||||
|
||||
const objectMetadataItems = get(objectMetadataItemsSelector);
|
||||
|
||||
+7
-7
@@ -1,3 +1,4 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
@@ -14,12 +15,11 @@ import {
|
||||
type RecordCalendarWeekSlotInteractionMode,
|
||||
updateRecordCalendarWeekActiveSlot,
|
||||
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { format } from 'date-fns';
|
||||
@@ -441,11 +441,11 @@ export const RecordCalendarTimeGrid = ({
|
||||
}: RecordCalendarTimeGridProps) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { timeFormat, timeZone } = useDateTimeFormat();
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarFieldMetadataIdComponentState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import {
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
// Single availability rule for calendar date fields, shared by the
|
||||
// record index calendar picker and dashboard widget calendar settings:
|
||||
// active date fields, excluding system ones like deletedAt.
|
||||
export const isFieldMetadataItemAvailableAsCalendarField = (
|
||||
fieldMetadataItem: FieldMetadataItem,
|
||||
) =>
|
||||
fieldMetadataItem.isActive === true &&
|
||||
isFieldMetadataDateKind(fieldMetadataItem.type) &&
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: fieldMetadataItem.type,
|
||||
name: fieldMetadataItem.name,
|
||||
isSystem: fieldMetadataItem.isSystem ?? false,
|
||||
});
|
||||
+4
-4
@@ -1,3 +1,4 @@
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { type DropResult } from '@hello-pangea/dnd';
|
||||
import { useCallback } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
@@ -9,12 +10,11 @@ import { getRecordIdFromRecordCalendarCardDraggableId } from '@/object-record/re
|
||||
|
||||
import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions';
|
||||
import { getShiftedRecordCalendarDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarDateTime';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
@@ -25,8 +25,8 @@ export const useProcessCalendarCardDrop = () => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState,
|
||||
);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
+24
-6
@@ -13,12 +13,12 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
|
||||
import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { getEffectiveRecordCalendarEndFieldMetadataId } from '@/object-record/record-calendar/utils/getEffectiveRecordCalendarEndFieldMetadataId';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
|
||||
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
|
||||
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
|
||||
import { clampRecordBoardColumnWidth } from '@/object-record/record-board/utils/clampRecordBoardColumnWidth';
|
||||
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
|
||||
@@ -33,6 +33,8 @@ import { type ColumnDefinition } from '@/object-record/record-table/types/Column
|
||||
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
|
||||
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
|
||||
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
||||
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||
import { hasInitializedCurrentRecordFieldsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFieldsComponentFamilyState';
|
||||
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
|
||||
@@ -84,6 +86,10 @@ export const useLoadRecordIndexStates = () => {
|
||||
recordIndexKanbanColumnWidthComponentState,
|
||||
);
|
||||
|
||||
const ambientViewInstanceId = useAvailableComponentInstanceId(
|
||||
ViewComponentInstanceContext,
|
||||
);
|
||||
|
||||
const { getFieldMetadataItemByIdOrThrow } =
|
||||
useGetFieldMetadataItemByIdOrThrow();
|
||||
|
||||
@@ -327,20 +333,31 @@ export const useLoadRecordIndexStates = () => {
|
||||
if (!skipGlobalIndexStates) {
|
||||
batchSet(recordIndexViewTypeState.atom, view.type);
|
||||
batchSet(recordIndexOpenRecordInState.atom, view.openRecordIn);
|
||||
}
|
||||
|
||||
const recordCalendarInstanceId =
|
||||
options?.recordIndexId ?? ambientViewInstanceId;
|
||||
|
||||
if (isDefined(recordCalendarInstanceId)) {
|
||||
batchSet(
|
||||
recordIndexCalendarFieldMetadataIdState.atom,
|
||||
recordIndexCalendarFieldMetadataIdComponentState.atomFamily({
|
||||
instanceId: recordCalendarInstanceId,
|
||||
}),
|
||||
view.calendarFieldMetadataId ?? null,
|
||||
);
|
||||
batchSet(
|
||||
recordIndexCalendarEndFieldMetadataIdState.atom,
|
||||
recordIndexCalendarEndFieldMetadataIdComponentState.atomFamily({
|
||||
instanceId: recordCalendarInstanceId,
|
||||
}),
|
||||
getEffectiveRecordCalendarEndFieldMetadataId({
|
||||
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId,
|
||||
isCalendarWeekViewEnabled,
|
||||
}),
|
||||
);
|
||||
batchSet(
|
||||
recordIndexCalendarLayoutState.atom,
|
||||
recordIndexCalendarLayoutComponentState.atomFamily({
|
||||
instanceId: recordCalendarInstanceId,
|
||||
}),
|
||||
getSupportedRecordCalendarLayout({
|
||||
calendarLayout: view.calendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
@@ -393,6 +410,7 @@ export const useLoadRecordIndexStates = () => {
|
||||
},
|
||||
[
|
||||
store,
|
||||
ambientViewInstanceId,
|
||||
contextStoreTargetedRecordsRuleAtom,
|
||||
recordIndexGroupFieldMetadataItemAtom,
|
||||
recordIndexGroupAggregateOperationAtom,
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
|
||||
|
||||
export const recordIndexCalendarEndFieldMetadataIdComponentState =
|
||||
createAtomComponentState<string | null>({
|
||||
key: 'recordIndexCalendarEndFieldMetadataIdComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const recordIndexCalendarEndFieldMetadataIdState = createAtomState<
|
||||
string | null
|
||||
>({
|
||||
key: 'recordIndexCalendarEndFieldMetadataIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
|
||||
|
||||
export const recordIndexCalendarFieldMetadataIdComponentState =
|
||||
createAtomComponentState<string | null>({
|
||||
key: 'recordIndexCalendarFieldMetadataIdComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const recordIndexCalendarFieldMetadataIdState = createAtomState<
|
||||
string | null
|
||||
>({
|
||||
key: 'recordIndexCalendarFieldMetadataIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
export const recordIndexCalendarLayoutComponentState =
|
||||
createAtomComponentState<ViewCalendarLayout>({
|
||||
key: 'recordIndexCalendarLayoutComponentState',
|
||||
defaultValue: ViewCalendarLayout.MONTH,
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
export const recordIndexCalendarLayoutState =
|
||||
createAtomState<ViewCalendarLayout>({
|
||||
key: 'recordIndexCalendarLayoutState',
|
||||
defaultValue: ViewCalendarLayout.MONTH,
|
||||
});
|
||||
+4
-1
@@ -8,11 +8,13 @@ import { useEffect } from 'react';
|
||||
type RecordTableWidgetContextStoreInitEffectProps = {
|
||||
objectMetadataItemId: string;
|
||||
viewId: string;
|
||||
contextStoreViewType?: ContextStoreViewType;
|
||||
};
|
||||
|
||||
export const RecordTableWidgetContextStoreInitEffect = ({
|
||||
objectMetadataItemId,
|
||||
viewId,
|
||||
contextStoreViewType = ContextStoreViewType.Table,
|
||||
}: RecordTableWidgetContextStoreInitEffectProps) => {
|
||||
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
@@ -29,10 +31,11 @@ export const RecordTableWidgetContextStoreInitEffect = ({
|
||||
useEffect(() => {
|
||||
setContextStoreCurrentObjectMetadataItemId(objectMetadataItemId);
|
||||
setContextStoreCurrentViewId(viewId);
|
||||
setContextStoreCurrentViewType(ContextStoreViewType.Table);
|
||||
setContextStoreCurrentViewType(contextStoreViewType);
|
||||
}, [
|
||||
objectMetadataItemId,
|
||||
viewId,
|
||||
contextStoreViewType,
|
||||
setContextStoreCurrentObjectMetadataItemId,
|
||||
setContextStoreCurrentViewId,
|
||||
setContextStoreCurrentViewType,
|
||||
|
||||
+4
@@ -1,4 +1,5 @@
|
||||
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||
import { type ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
|
||||
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
|
||||
@@ -20,6 +21,7 @@ type RecordTableWidgetProviderProps = PropsWithChildren<{
|
||||
widgetId: string;
|
||||
recordLimit?: number;
|
||||
instanceIdSuffix?: string;
|
||||
contextStoreViewType?: ContextStoreViewType;
|
||||
}>;
|
||||
|
||||
export const RecordTableWidgetProvider = ({
|
||||
@@ -28,6 +30,7 @@ export const RecordTableWidgetProvider = ({
|
||||
widgetId,
|
||||
recordLimit,
|
||||
instanceIdSuffix,
|
||||
contextStoreViewType,
|
||||
children,
|
||||
}: RecordTableWidgetProviderProps) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
@@ -87,6 +90,7 @@ export const RecordTableWidgetProvider = ({
|
||||
<RecordTableWidgetContextStoreInitEffect
|
||||
objectMetadataItemId={objectMetadataItem.id}
|
||||
viewId={viewId}
|
||||
contextStoreViewType={contextStoreViewType}
|
||||
/>
|
||||
<RecordIndexContextProvider
|
||||
value={{
|
||||
|
||||
+19
@@ -31,4 +31,23 @@ export const computeRecordTableWidgetViewLoadContentSignature = (
|
||||
fieldMetadataId: sort.fieldMetadataId,
|
||||
direction: sort.direction,
|
||||
})),
|
||||
layout: {
|
||||
type: view.type,
|
||||
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId,
|
||||
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
|
||||
kanbanAggregateOperation: view.kanbanAggregateOperation,
|
||||
kanbanAggregateOperationFieldMetadataId:
|
||||
view.kanbanAggregateOperationFieldMetadataId,
|
||||
calendarLayout: view.calendarLayout,
|
||||
calendarFieldMetadataId: view.calendarFieldMetadataId,
|
||||
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId,
|
||||
},
|
||||
groups: [...(view.viewGroups ?? [])]
|
||||
.sort((groupA, groupB) => groupA.position - groupB.position)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
fieldValue: group.fieldValue,
|
||||
isVisible: group.isVisible,
|
||||
position: group.position,
|
||||
})),
|
||||
});
|
||||
|
||||
+9
@@ -1,6 +1,7 @@
|
||||
import { AddRecordGroupButton } from '@/object-record/record-group/components/AddRecordGroupButton';
|
||||
import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -17,6 +18,14 @@ export const RecordTableRecordGroupAddNewGroup = () => {
|
||||
recordIndexGroupFieldMetadataItemComponentState,
|
||||
);
|
||||
|
||||
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
|
||||
isRecordTableCellsNonEditableComponentState,
|
||||
);
|
||||
|
||||
if (isRecordTableCellsNonEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!canAddRecordGroupForFieldMetadataItem(recordIndexGroupFieldMetadataItem)
|
||||
) {
|
||||
|
||||
+24
-6
@@ -23,6 +23,7 @@ import { RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE } from '@/object-r
|
||||
|
||||
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
|
||||
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
|
||||
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
|
||||
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
|
||||
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
@@ -91,6 +92,14 @@ const StyledTagContainer = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledAggregateDropdownContainer = styled.div<{
|
||||
isNonInteractive: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
pointer-events: ${({ isNonInteractive }) =>
|
||||
isNonInteractive ? 'none' : 'auto'};
|
||||
`;
|
||||
|
||||
const StyledFieldPlaceholderCell = styled.div<{ widthOfFields: number }>`
|
||||
height: ${RECORD_TABLE_ROW_HEIGHT}px;
|
||||
min-width: ${({ widthOfFields }) => widthOfFields}px;
|
||||
@@ -164,6 +173,10 @@ export const RecordTableRecordGroupSection = () => {
|
||||
currentRecordGroupId,
|
||||
);
|
||||
|
||||
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
|
||||
isRecordTableCellsNonEditableComponentState,
|
||||
);
|
||||
|
||||
const handleDropdownToggle = useCallback(() => {
|
||||
setIsRecordGroupTableSectionToggled((prevState) => !prevState);
|
||||
}, [setIsRecordGroupTableSectionToggled]);
|
||||
@@ -216,12 +229,17 @@ export const RecordTableRecordGroupSection = () => {
|
||||
valueTagWeight="medium"
|
||||
/>
|
||||
</StyledTagContainer>
|
||||
<RecordBoardColumnHeaderAggregateDropdown
|
||||
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
|
||||
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
aggregateLabel={recordIndexAggregateDisplayLabel}
|
||||
/>
|
||||
<StyledAggregateDropdownContainer
|
||||
isNonInteractive={isRecordTableCellsNonEditable}
|
||||
inert={isRecordTableCellsNonEditable || undefined}
|
||||
>
|
||||
<RecordBoardColumnHeaderAggregateDropdown
|
||||
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
|
||||
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
aggregateLabel={recordIndexAggregateDisplayLabel}
|
||||
/>
|
||||
</StyledAggregateDropdownContainer>
|
||||
</StyledRecordGroupSection>
|
||||
<StyledFieldPlaceholderCell widthOfFields={fieldsPlaceholderWidth} />
|
||||
<RecordTableAddButtonPlaceholderCell />
|
||||
|
||||
+9
@@ -6,6 +6,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
|
||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
|
||||
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
|
||||
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
|
||||
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
|
||||
@@ -15,6 +16,10 @@ import { IconPlus } from 'twenty-ui/icon';
|
||||
export const RecordTableRecordGroupSectionAddNew = () => {
|
||||
const { objectMetadataItem } = useRecordTableContextOrThrow();
|
||||
|
||||
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
|
||||
isRecordTableCellsNonEditableComponentState,
|
||||
);
|
||||
|
||||
const currentRecordGroupId = useCurrentRecordGroupId();
|
||||
|
||||
const recordGroupDefinition = useAtomFamilyStateValue(
|
||||
@@ -47,6 +52,10 @@ export const RecordTableRecordGroupSectionAddNew = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRecordTableCellsNonEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordTableActionRow
|
||||
LeftIcon={IconPlus}
|
||||
|
||||
+1
@@ -132,6 +132,7 @@ const sourceRecordTableViewSnapshot: RecordTableWidgetViewSnapshot = {
|
||||
viewFilterGroups: [],
|
||||
viewFilters: [],
|
||||
viewSorts: [],
|
||||
viewGroups: [],
|
||||
};
|
||||
|
||||
describe('useDuplicatePageLayoutTab', () => {
|
||||
|
||||
+12
@@ -96,6 +96,18 @@ export const useCreatePendingRecordTableWidgetViews = () => {
|
||||
openRecordIn: view.openRecordIn,
|
||||
visibility: view.visibility,
|
||||
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
|
||||
mainGroupByFieldMetadataId:
|
||||
view.mainGroupByFieldMetadataId ?? undefined,
|
||||
kanbanAggregateOperation:
|
||||
view.kanbanAggregateOperation ?? undefined,
|
||||
kanbanAggregateOperationFieldMetadataId:
|
||||
view.kanbanAggregateOperationFieldMetadataId ?? undefined,
|
||||
kanbanColumnWidth: view.kanbanColumnWidth ?? undefined,
|
||||
calendarLayout: view.calendarLayout ?? undefined,
|
||||
calendarFieldMetadataId:
|
||||
view.calendarFieldMetadataId ?? undefined,
|
||||
calendarEndFieldMetadataId:
|
||||
view.calendarEndFieldMetadataId ?? undefined,
|
||||
},
|
||||
},
|
||||
view.objectMetadataId,
|
||||
|
||||
+44
-5
@@ -6,11 +6,13 @@ import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/r
|
||||
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
|
||||
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
|
||||
import { widgetUsesRecordTableView } from '@/page-layout/utils/widgetUsesRecordTableView';
|
||||
import { buildUpsertViewWidgetViewSettingsInput } from '@/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput';
|
||||
import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
import {
|
||||
type UpsertViewWidgetInput,
|
||||
type ViewFragmentFragment,
|
||||
@@ -45,6 +47,12 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const recordTableWidgetViewPersisted = store.get(
|
||||
recordTableWidgetViewPersistedComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
);
|
||||
|
||||
const draftRecordTableWidgets = draft.tabs
|
||||
.flatMap((tab) => tab.widgets)
|
||||
.filter(widgetUsesRecordTableView);
|
||||
@@ -84,15 +92,24 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
viewFields: normalizedViewFields,
|
||||
};
|
||||
|
||||
normalizedRecordTableWidgetViewDraft = {
|
||||
...normalizedRecordTableWidgetViewDraft,
|
||||
[widget.id]: normalizedWidgetViewDraft,
|
||||
};
|
||||
const persistedView = recordTableWidgetViewPersisted[widget.id]?.view;
|
||||
const draftView = widgetViewDraft.view;
|
||||
|
||||
await upsertViewWidgetMutation({
|
||||
const draftViewSettings =
|
||||
buildUpsertViewWidgetViewSettingsInput(draftView);
|
||||
|
||||
const hasViewSettingsChanges =
|
||||
!isDefined(persistedView) ||
|
||||
!isDeeplyEqual(
|
||||
buildUpsertViewWidgetViewSettingsInput(persistedView),
|
||||
draftViewSettings,
|
||||
);
|
||||
|
||||
const { data } = await upsertViewWidgetMutation({
|
||||
variables: {
|
||||
input: {
|
||||
widgetId: widget.id,
|
||||
...(hasViewSettingsChanges ? { view: draftViewSettings } : {}),
|
||||
viewFields: normalizedWidgetViewDraft.viewFields.map((field) => ({
|
||||
fieldMetadataId: field.fieldMetadataId,
|
||||
isVisible: field.isVisible,
|
||||
@@ -127,6 +144,28 @@ export const useSaveRecordTableWidgetViews = () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// View groups are not part of the upsert input: the server
|
||||
// regenerates them from mainGroupByFieldMetadataId. Store the
|
||||
// server rows instead of the locally generated draft groups so
|
||||
// the persisted snapshot never claims client-side ids were saved.
|
||||
const upsertedViewGroups = data?.upsertViewWidget.viewGroups;
|
||||
|
||||
normalizedRecordTableWidgetViewDraft = {
|
||||
...normalizedRecordTableWidgetViewDraft,
|
||||
[widget.id]: {
|
||||
...normalizedWidgetViewDraft,
|
||||
viewGroups: isDefined(upsertedViewGroups)
|
||||
? upsertedViewGroups.map((viewGroup) => ({
|
||||
id: viewGroup.id,
|
||||
viewId: viewGroup.viewId,
|
||||
fieldValue: viewGroup.fieldValue,
|
||||
position: viewGroup.position,
|
||||
isVisible: viewGroup.isVisible,
|
||||
}))
|
||||
: normalizedWidgetViewDraft.viewGroups,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
store.set(
|
||||
|
||||
+73
-4
@@ -1,7 +1,22 @@
|
||||
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { RecordBoardWidget } from '@/object-record/record-board-widget/components/RecordBoardWidget';
|
||||
import { RecordCalendarWidget } from '@/object-record/record-calendar-widget/components/RecordCalendarWidget';
|
||||
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
|
||||
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
|
||||
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
|
||||
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
|
||||
import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect';
|
||||
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { useViewById } from '@/views/hooks/useViewById';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { getViewLayoutFromViewType, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
ViewType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type RecordTableWidgetRendererContentProps = {
|
||||
objectMetadataId: string;
|
||||
@@ -26,6 +41,47 @@ export const RecordTableWidgetRendererContent = ({
|
||||
objectId: objectMetadataId,
|
||||
});
|
||||
|
||||
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
|
||||
|
||||
const draftSnapshot = useAtomComponentFamilySelectorValue(
|
||||
recordTableWidgetViewDraftByWidgetIdComponentFamilySelector,
|
||||
{ widgetId },
|
||||
);
|
||||
|
||||
const { view: persistedView } = useViewById(viewId);
|
||||
|
||||
const widgetView =
|
||||
isPageLayoutInEditMode && isDefined(draftSnapshot)
|
||||
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
|
||||
: persistedView;
|
||||
|
||||
const widgetViewLayout = getViewLayoutFromViewType(
|
||||
widgetView?.type ?? ViewType.TABLE_WIDGET,
|
||||
);
|
||||
|
||||
const isKanbanLayout = widgetViewLayout === ViewType.KANBAN;
|
||||
const isCalendarLayout = widgetViewLayout === ViewType.CALENDAR;
|
||||
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
// Widget calendars are read-only month projections, except live (non
|
||||
// edit-mode) day/week calendars, which allow drag-to-reschedule and
|
||||
// record creation under the usual object permissions.
|
||||
const isCalendarDayOrWeek =
|
||||
widgetView?.calendarLayout === ViewCalendarLayout.DAY ||
|
||||
widgetView?.calendarLayout === ViewCalendarLayout.WEEK;
|
||||
const canEditCalendar =
|
||||
isCalendarLayout &&
|
||||
!isPageLayoutInEditMode &&
|
||||
isCalendarWeekViewEnabled &&
|
||||
isCalendarDayOrWeek;
|
||||
// Read-only unless this is the explicitly allowed live day/week calendar;
|
||||
// a caller passing isReadOnly={false} must not make month (or flag-off)
|
||||
// widget calendars editable. Object permissions still gate the drag.
|
||||
const calendarIsReadOnly = !canEditCalendar;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecordTableWidgetViewDraftInitEffect
|
||||
@@ -38,11 +94,24 @@ export const RecordTableWidgetRendererContent = ({
|
||||
widgetId={widgetId}
|
||||
recordLimit={recordLimit}
|
||||
instanceIdSuffix={instanceIdSuffix}
|
||||
contextStoreViewType={
|
||||
isKanbanLayout
|
||||
? ContextStoreViewType.Kanban
|
||||
: isCalendarLayout
|
||||
? ContextStoreViewType.Calendar
|
||||
: ContextStoreViewType.Table
|
||||
}
|
||||
>
|
||||
<RecordTableWidget
|
||||
isReadOnly={isReadOnly}
|
||||
isEmptyStateHidden={isEmptyStateHidden}
|
||||
/>
|
||||
{isKanbanLayout ? (
|
||||
<RecordBoardWidget isReadOnly={isReadOnly} />
|
||||
) : isCalendarLayout ? (
|
||||
<RecordCalendarWidget isReadOnly={calendarIsReadOnly} />
|
||||
) : (
|
||||
<RecordTableWidget
|
||||
isReadOnly={isReadOnly}
|
||||
isEmptyStateHidden={isEmptyStateHidden}
|
||||
/>
|
||||
)}
|
||||
</RecordTableWidgetProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
|
||||
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
|
||||
import { buildDraftViewGroupsForFieldMetadataItem } from '@/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem';
|
||||
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useStore } from 'jotai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
type ViewCalendarLayout,
|
||||
ViewType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type RecordTableWidgetLayoutViewType =
|
||||
| ViewType.TABLE_WIDGET
|
||||
| ViewType.KANBAN_WIDGET
|
||||
| ViewType.CALENDAR_WIDGET;
|
||||
|
||||
type UseRecordTableWidgetLayoutCallbacksParams = {
|
||||
pageLayoutId: string;
|
||||
widgetId: string;
|
||||
};
|
||||
|
||||
export const useRecordTableWidgetLayoutCallbacks = ({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
}: UseRecordTableWidgetLayoutCallbacksParams) => {
|
||||
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
|
||||
recordTableWidgetViewDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
// Clamp a widget calendar's layout to what the workspace supports (MONTH
|
||||
// while the day/week feature is off), reusing the shared resolver so widget
|
||||
// and index calendars can't diverge and no stale day/week value is sent back
|
||||
// to — and rejected by — the server.
|
||||
const resolveCalendarLayout = (
|
||||
calendarLayout: ViewCalendarLayout | null | undefined,
|
||||
) =>
|
||||
getSupportedRecordCalendarLayout({
|
||||
calendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
|
||||
// Returning the received snapshot unchanged from the updater leaves the
|
||||
// whole draft map untouched (no state update is published).
|
||||
const setWidgetViewDraft = (
|
||||
updater: (
|
||||
widgetViewDraft: RecordTableWidgetViewSnapshot,
|
||||
) => RecordTableWidgetViewSnapshot,
|
||||
) => {
|
||||
store.set(recordTableWidgetViewDraftState, (prev) => {
|
||||
const widgetViewDraft = prev[widgetId];
|
||||
|
||||
if (!isDefined(widgetViewDraft)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const updatedWidgetViewDraft = updater(widgetViewDraft);
|
||||
|
||||
if (updatedWidgetViewDraft === widgetViewDraft) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[widgetId]: updatedWidgetViewDraft,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupByFieldChange = (
|
||||
fieldMetadataItem: FieldMetadataItem | null,
|
||||
) => {
|
||||
setWidgetViewDraft((widgetViewDraft) => ({
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
mainGroupByFieldMetadataId: fieldMetadataItem?.id ?? null,
|
||||
},
|
||||
viewGroups: isDefined(fieldMetadataItem)
|
||||
? buildDraftViewGroupsForFieldMetadataItem({
|
||||
viewId: widgetViewDraft.view.id,
|
||||
fieldMetadataItem,
|
||||
})
|
||||
: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLayoutChange = ({
|
||||
targetViewType,
|
||||
defaultGroupByFieldMetadataItem,
|
||||
defaultCalendarFieldMetadataItem,
|
||||
}: {
|
||||
targetViewType: RecordTableWidgetLayoutViewType;
|
||||
defaultGroupByFieldMetadataItem: FieldMetadataItem | null;
|
||||
defaultCalendarFieldMetadataItem?: FieldMetadataItem | null;
|
||||
}) => {
|
||||
setWidgetViewDraft((widgetViewDraft) => {
|
||||
if (targetViewType === ViewType.KANBAN_WIDGET) {
|
||||
const hasGroupBy = isDefined(
|
||||
widgetViewDraft.view.mainGroupByFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!hasGroupBy && !isDefined(defaultGroupByFieldMetadataItem)) {
|
||||
return widgetViewDraft;
|
||||
}
|
||||
|
||||
return {
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
type: targetViewType,
|
||||
mainGroupByFieldMetadataId: hasGroupBy
|
||||
? widgetViewDraft.view.mainGroupByFieldMetadataId
|
||||
: defaultGroupByFieldMetadataItem?.id,
|
||||
},
|
||||
viewGroups:
|
||||
hasGroupBy || !isDefined(defaultGroupByFieldMetadataItem)
|
||||
? widgetViewDraft.viewGroups
|
||||
: buildDraftViewGroupsForFieldMetadataItem({
|
||||
viewId: widgetViewDraft.view.id,
|
||||
fieldMetadataItem: defaultGroupByFieldMetadataItem,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetViewType === ViewType.CALENDAR_WIDGET) {
|
||||
const hasCalendarField = isDefined(
|
||||
widgetViewDraft.view.calendarFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!hasCalendarField && !isDefined(defaultCalendarFieldMetadataItem)) {
|
||||
return widgetViewDraft;
|
||||
}
|
||||
|
||||
return {
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
type: targetViewType,
|
||||
calendarLayout: resolveCalendarLayout(
|
||||
widgetViewDraft.view.calendarLayout,
|
||||
),
|
||||
calendarFieldMetadataId: hasCalendarField
|
||||
? widgetViewDraft.view.calendarFieldMetadataId
|
||||
: defaultCalendarFieldMetadataItem?.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
type: targetViewType,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleCalendarFieldChange = (fieldMetadataItem: FieldMetadataItem) => {
|
||||
setWidgetViewDraft((widgetViewDraft) => ({
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
calendarFieldMetadataId: fieldMetadataItem.id,
|
||||
calendarLayout: resolveCalendarLayout(
|
||||
widgetViewDraft.view.calendarLayout,
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
|
||||
setWidgetViewDraft((widgetViewDraft) => ({
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
calendarLayout: resolveCalendarLayout(calendarLayout),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShouldHideEmptyGroupsChange = (
|
||||
shouldHideEmptyGroups: boolean,
|
||||
) => {
|
||||
setWidgetViewDraft((widgetViewDraft) => ({
|
||||
...widgetViewDraft,
|
||||
view: {
|
||||
...widgetViewDraft.view,
|
||||
shouldHideEmptyGroups,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
handleCalendarFieldChange,
|
||||
handleCalendarLayoutChange,
|
||||
handleGroupByFieldChange,
|
||||
handleLayoutChange,
|
||||
handleShouldHideEmptyGroupsChange,
|
||||
};
|
||||
};
|
||||
+2
@@ -2,6 +2,7 @@ import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
|
||||
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
|
||||
import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
|
||||
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
|
||||
|
||||
export type RecordTableWidgetDraftViewField = Omit<FlatViewField, 'id'> & {
|
||||
@@ -15,4 +16,5 @@ export type RecordTableWidgetViewSnapshot = {
|
||||
viewFilters: FlatViewFilter[];
|
||||
viewFilterGroups: FlatViewFilterGroup[];
|
||||
viewSorts: FlatViewSort[];
|
||||
viewGroups: FlatViewGroup[];
|
||||
};
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { buildDraftViewGroupsForFieldMetadataItem } from '@/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
|
||||
const VIEW_ID = 'view-id';
|
||||
|
||||
const baseFieldMetadataItem = {
|
||||
id: 'field-metadata-id',
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
isNullable: false,
|
||||
} as FieldMetadataItem;
|
||||
|
||||
describe('buildDraftViewGroupsForFieldMetadataItem', () => {
|
||||
it('should build one group per select option, in option order', () => {
|
||||
const viewGroups = buildDraftViewGroupsForFieldMetadataItem({
|
||||
viewId: VIEW_ID,
|
||||
fieldMetadataItem: {
|
||||
...baseFieldMetadataItem,
|
||||
options: [
|
||||
{ id: '1', label: 'New', value: 'NEW', color: 'blue', position: 0 },
|
||||
{
|
||||
id: '2',
|
||||
label: 'Done',
|
||||
value: 'DONE',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewGroups).toHaveLength(2);
|
||||
expect(viewGroups[0]).toMatchObject({
|
||||
viewId: VIEW_ID,
|
||||
fieldValue: 'NEW',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
});
|
||||
expect(viewGroups[1]).toMatchObject({
|
||||
viewId: VIEW_ID,
|
||||
fieldValue: 'DONE',
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should append an empty group for nullable fields', () => {
|
||||
const viewGroups = buildDraftViewGroupsForFieldMetadataItem({
|
||||
viewId: VIEW_ID,
|
||||
fieldMetadataItem: {
|
||||
...baseFieldMetadataItem,
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '1', label: 'New', value: 'NEW', color: 'blue', position: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewGroups).toHaveLength(2);
|
||||
expect(viewGroups[1]).toMatchObject({
|
||||
fieldValue: '',
|
||||
position: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should build no option groups for many-to-one relation fields', () => {
|
||||
const viewGroups = buildDraftViewGroupsForFieldMetadataItem({
|
||||
viewId: VIEW_ID,
|
||||
fieldMetadataItem: {
|
||||
...baseFieldMetadataItem,
|
||||
type: FieldMetadataType.RELATION,
|
||||
relation: {
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
} as FieldMetadataItem['relation'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewGroups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { buildUpsertViewWidgetViewSettingsInput } from '@/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput';
|
||||
import {
|
||||
ViewCalendarLayout,
|
||||
ViewOpenRecordIn,
|
||||
ViewType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const buildFlatView = (overrides: Partial<FlatView>): FlatView =>
|
||||
({
|
||||
type: ViewType.TABLE_WIDGET,
|
||||
shouldHideEmptyGroups: false,
|
||||
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
|
||||
...overrides,
|
||||
}) as FlatView;
|
||||
|
||||
describe('buildUpsertViewWidgetViewSettingsInput', () => {
|
||||
it('normalizes every nullable setting to null when unset', () => {
|
||||
const input = buildUpsertViewWidgetViewSettingsInput(buildFlatView({}));
|
||||
|
||||
expect(input).toEqual({
|
||||
type: ViewType.TABLE_WIDGET,
|
||||
mainGroupByFieldMetadataId: null,
|
||||
shouldHideEmptyGroups: false,
|
||||
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
|
||||
kanbanAggregateOperation: null,
|
||||
kanbanAggregateOperationFieldMetadataId: null,
|
||||
kanbanColumnWidth: null,
|
||||
calendarLayout: null,
|
||||
calendarFieldMetadataId: null,
|
||||
calendarEndFieldMetadataId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through defined settings', () => {
|
||||
const input = buildUpsertViewWidgetViewSettingsInput(
|
||||
buildFlatView({
|
||||
type: ViewType.CALENDAR_WIDGET,
|
||||
shouldHideEmptyGroups: true,
|
||||
calendarLayout: ViewCalendarLayout.MONTH,
|
||||
calendarFieldMetadataId: 'calendar-field-id',
|
||||
mainGroupByFieldMetadataId: 'group-by-id',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(input.type).toBe(ViewType.CALENDAR_WIDGET);
|
||||
expect(input.shouldHideEmptyGroups).toBe(true);
|
||||
expect(input.calendarLayout).toBe(ViewCalendarLayout.MONTH);
|
||||
expect(input.calendarFieldMetadataId).toBe('calendar-field-id');
|
||||
expect(input.mainGroupByFieldMetadataId).toBe('group-by-id');
|
||||
});
|
||||
});
|
||||
+21
@@ -74,6 +74,15 @@ const sourceSnapshot: RecordTableWidgetViewSnapshot = {
|
||||
direction: ViewSortDirection.ASC,
|
||||
},
|
||||
],
|
||||
viewGroups: [
|
||||
{
|
||||
id: 'view-group-id',
|
||||
viewId: SOURCE_VIEW_ID,
|
||||
fieldValue: 'OPTION_1',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('cloneRecordTableWidgetViewSnapshot', () => {
|
||||
@@ -88,6 +97,7 @@ describe('cloneRecordTableWidgetViewSnapshot', () => {
|
||||
expect(clonedSnapshot.viewFilterGroups[0].viewId).toBe(newViewId);
|
||||
expect(clonedSnapshot.viewFilters[0].viewId).toBe(newViewId);
|
||||
expect(clonedSnapshot.viewSorts[0].viewId).toBe(newViewId);
|
||||
expect(clonedSnapshot.viewGroups[0].viewId).toBe(newViewId);
|
||||
});
|
||||
|
||||
it('should regenerate row ids so the duplicate persists without colliding with the source', () => {
|
||||
@@ -96,6 +106,7 @@ describe('cloneRecordTableWidgetViewSnapshot', () => {
|
||||
expect(clonedSnapshot.viewFields[0].id).not.toBe('view-field-id');
|
||||
expect(clonedSnapshot.viewFilters[0].id).not.toBe('view-filter-id');
|
||||
expect(clonedSnapshot.viewSorts[0].id).not.toBe('view-sort-id');
|
||||
expect(clonedSnapshot.viewGroups[0].id).not.toBe('view-group-id');
|
||||
expect(clonedSnapshot.viewFilterGroups[0].id).not.toBe(
|
||||
PARENT_FILTER_GROUP_ID,
|
||||
);
|
||||
@@ -113,6 +124,16 @@ describe('cloneRecordTableWidgetViewSnapshot', () => {
|
||||
expect(clonedSnapshot.viewFilters[0].viewFilterGroupId).toBe(childGroup.id);
|
||||
});
|
||||
|
||||
it('should preserve group content', () => {
|
||||
const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot);
|
||||
|
||||
expect(clonedSnapshot.viewGroups[0]).toMatchObject({
|
||||
fieldValue: 'OPTION_1',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve filter, sort and field content', () => {
|
||||
const clonedSnapshot = cloneRecordTableWidgetViewSnapshot(sourceSnapshot);
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
|
||||
import { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from 'twenty-shared/constants';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
// Mirrors the server-side computeFlatViewGroupsOnViewCreate so the edit-mode
|
||||
// draft preview matches what the server generates on save: one group per
|
||||
// select option (in option order) plus an empty group for nullable fields.
|
||||
// Relation group-by generates no option groups.
|
||||
export const buildDraftViewGroupsForFieldMetadataItem = ({
|
||||
viewId,
|
||||
fieldMetadataItem,
|
||||
}: {
|
||||
viewId: string;
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
}): FlatViewGroup[] => {
|
||||
if (isManyToOneRelationField(fieldMetadataItem)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const viewGroupsFromOptions: FlatViewGroup[] = (
|
||||
fieldMetadataItem.options ?? []
|
||||
).map((option, index) => ({
|
||||
id: v4(),
|
||||
viewId,
|
||||
fieldValue: option.value,
|
||||
position: index,
|
||||
isVisible: index < VIEW_GROUP_VISIBLE_OPTIONS_MAX,
|
||||
}));
|
||||
|
||||
if (fieldMetadataItem.isNullable === true) {
|
||||
viewGroupsFromOptions.push({
|
||||
id: v4(),
|
||||
viewId,
|
||||
fieldValue: '',
|
||||
position: viewGroupsFromOptions.length,
|
||||
isVisible: viewGroupsFromOptions.length < VIEW_GROUP_VISIBLE_OPTIONS_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
return viewGroupsFromOptions;
|
||||
};
|
||||
+1
@@ -67,5 +67,6 @@ export const buildRecordTableWidgetViewSnapshot = (
|
||||
viewFilters: [],
|
||||
viewFilterGroups: [],
|
||||
viewSorts: [],
|
||||
viewGroups: [],
|
||||
};
|
||||
};
|
||||
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
|
||||
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
|
||||
import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
|
||||
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
|
||||
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
|
||||
import { type View } from '@/views/types/View';
|
||||
@@ -13,7 +14,7 @@ export const buildRecordTableWidgetViewSnapshotFromView = (
|
||||
viewFilters,
|
||||
viewFilterGroups,
|
||||
viewSorts,
|
||||
viewGroups: _viewGroups,
|
||||
viewGroups,
|
||||
viewFieldGroups: _viewFieldGroups,
|
||||
...viewProps
|
||||
} = view;
|
||||
@@ -40,11 +41,19 @@ export const buildRecordTableWidgetViewSnapshotFromView = (
|
||||
viewId: view.id,
|
||||
}));
|
||||
|
||||
const flatViewGroups: FlatViewGroup[] = (viewGroups ?? []).map(
|
||||
(viewGroup) => ({
|
||||
...viewGroup,
|
||||
viewId: view.id,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
view: viewProps,
|
||||
viewFields: flatViewFields,
|
||||
viewFilters: flatViewFilters,
|
||||
viewFilterGroups: flatViewFilterGroups,
|
||||
viewSorts: flatViewSorts,
|
||||
viewGroups: flatViewGroups,
|
||||
};
|
||||
};
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { type UpsertViewWidgetViewSettingsInput } from '~/generated-metadata/graphql';
|
||||
|
||||
export const buildUpsertViewWidgetViewSettingsInput = (
|
||||
view: FlatView,
|
||||
): UpsertViewWidgetViewSettingsInput => ({
|
||||
type: view.type,
|
||||
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId ?? null,
|
||||
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
|
||||
openRecordIn: view.openRecordIn,
|
||||
kanbanAggregateOperation: view.kanbanAggregateOperation ?? null,
|
||||
kanbanAggregateOperationFieldMetadataId:
|
||||
view.kanbanAggregateOperationFieldMetadataId ?? null,
|
||||
kanbanColumnWidth: view.kanbanColumnWidth ?? null,
|
||||
calendarLayout: view.calendarLayout ?? null,
|
||||
calendarFieldMetadataId: view.calendarFieldMetadataId ?? null,
|
||||
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId ?? null,
|
||||
});
|
||||
+5
@@ -47,5 +47,10 @@ export const cloneRecordTableWidgetViewSnapshot = (
|
||||
id: uuidv4(),
|
||||
viewId: newViewId,
|
||||
})),
|
||||
viewGroups: sourceSnapshot.viewGroups.map((viewGroup) => ({
|
||||
...viewGroup,
|
||||
id: uuidv4(),
|
||||
viewId: newViewId,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -18,5 +18,5 @@ export const constructViewFromRecordTableWidgetViewSnapshot = (
|
||||
viewFilters: snapshot.viewFilters,
|
||||
viewFilterGroups: snapshot.viewFilterGroups,
|
||||
viewSorts: snapshot.viewSorts,
|
||||
viewGroups: [],
|
||||
viewGroups: snapshot.viewGroups,
|
||||
});
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { canGroupRecordsByFieldMetadataItem } from '@/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
// Widgets only offer select-field grouping: the server auto-generates
|
||||
// view groups from select options, and widgets have no per-record
|
||||
// add-group flow like the record index page.
|
||||
export const isFieldMetadataItemAvailableAsWidgetGroupByField = (
|
||||
fieldMetadataItem: FieldMetadataItem,
|
||||
) =>
|
||||
fieldMetadataItem.isActive === true &&
|
||||
fieldMetadataItem.type === FieldMetadataType.SELECT &&
|
||||
canGroupRecordsByFieldMetadataItem(fieldMetadataItem);
|
||||
+234
-25
@@ -1,13 +1,21 @@
|
||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
|
||||
import { CommandMenuItemNumberInput } from '@/command-menu/components/CommandMenuItemNumberInput';
|
||||
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { useRecordTableWidgetFieldCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks';
|
||||
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
|
||||
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
|
||||
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 { RecordTableGroupByDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent';
|
||||
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 { RecordTableLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent';
|
||||
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
|
||||
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
|
||||
import { useRecordTableSettingsDescriptions } from '@/side-panel/pages/page-layout/hooks/useRecordTableSettingsDescriptions';
|
||||
@@ -16,6 +24,7 @@ import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWid
|
||||
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 { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -23,11 +32,21 @@ import {
|
||||
IconArrowBarToDownDashed,
|
||||
IconArrowsSort,
|
||||
IconBox,
|
||||
IconCalendar,
|
||||
IconCalendarEvent,
|
||||
IconEyeOff,
|
||||
IconFilter,
|
||||
IconLayoutKanban,
|
||||
IconLayoutList,
|
||||
IconListDetails,
|
||||
IconTable,
|
||||
} from 'twenty-ui/icon';
|
||||
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
ViewType,
|
||||
WidgetConfigurationType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -96,11 +115,71 @@ export const SidePanelDashboardRecordTableSettings = () => {
|
||||
viewId: viewId ?? '',
|
||||
});
|
||||
|
||||
const { handleShouldHideEmptyGroupsChange } =
|
||||
useRecordTableWidgetLayoutCallbacks({
|
||||
pageLayoutId,
|
||||
widgetId: widgetInEditMode?.id ?? '',
|
||||
});
|
||||
|
||||
const { view: widgetView } = useRecordTableWidgetViewForDisplay({
|
||||
viewId: viewId ?? '',
|
||||
widgetId: widgetInEditMode?.id ?? '',
|
||||
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 isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
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 === widgetInEditMode?.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`;
|
||||
|
||||
if (!isDefined(widgetInEditMode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasViewId = isDefined(viewId);
|
||||
const hasGroupBy = isDefined(mainGroupByFieldMetadataId);
|
||||
|
||||
const selectableItemIds = [
|
||||
'record-table-source',
|
||||
@@ -109,7 +188,18 @@ export const SidePanelDashboardRecordTableSettings = () => {
|
||||
'record-table-fields',
|
||||
'record-table-filter',
|
||||
'record-table-sort',
|
||||
'record-table-limit',
|
||||
...(isCalendarLayout
|
||||
? [
|
||||
'record-table-calendar-field',
|
||||
...(isCalendarWeekViewEnabled
|
||||
? ['record-table-calendar-layout']
|
||||
: []),
|
||||
]
|
||||
: ['record-table-group-by']),
|
||||
...(!isCalendarLayout && hasGroupBy
|
||||
? ['record-table-hide-empty-groups']
|
||||
: []),
|
||||
...(!isCalendarLayout && !hasGroupBy ? ['record-table-limit'] : []),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -130,19 +220,6 @@ export const SidePanelDashboardRecordTableSettings = () => {
|
||||
<StyledSettingsContainer>
|
||||
<SidePanelList selectableItemIds={selectableItemIds}>
|
||||
<SidePanelGroup heading={t`Settings`}>
|
||||
<SelectableListItem itemId="object-view-layout">
|
||||
<CommandMenuItemDropdown
|
||||
Icon={IconTable}
|
||||
label={t`Layout`}
|
||||
id="object-view-layout"
|
||||
dropdownId="object-view-layout"
|
||||
dropdownComponents={<></>}
|
||||
dropdownPlacement="bottom-end"
|
||||
description={t`Table`}
|
||||
disabled={true}
|
||||
contextualTextPosition="right"
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem itemId="record-table-source">
|
||||
<CommandMenuItemDropdown
|
||||
Icon={IconBox}
|
||||
@@ -160,6 +237,45 @@ export const SidePanelDashboardRecordTableSettings = () => {
|
||||
contextualTextPosition="right"
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem itemId="object-view-layout">
|
||||
<CommandMenuItemDropdown
|
||||
Icon={
|
||||
isKanbanLayout
|
||||
? IconLayoutKanban
|
||||
: isCalendarLayout
|
||||
? IconCalendar
|
||||
: IconTable
|
||||
}
|
||||
label={t`Layout`}
|
||||
id="object-view-layout"
|
||||
dropdownId="object-view-layout"
|
||||
dropdownComponents={
|
||||
hasViewId ? (
|
||||
<DropdownContent>
|
||||
<RecordTableLayoutDropdownContent
|
||||
pageLayoutId={pageLayoutId}
|
||||
widgetId={widgetInEditMode.id}
|
||||
objectMetadataId={widgetInEditMode.objectMetadataId!}
|
||||
currentLayoutViewType={currentLayoutViewType}
|
||||
/>
|
||||
</DropdownContent>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
dropdownPlacement="bottom-end"
|
||||
hasSubMenu={hasViewId}
|
||||
description={
|
||||
isKanbanLayout
|
||||
? t`Kanban`
|
||||
: isCalendarLayout
|
||||
? t`Calendar`
|
||||
: t`Table`
|
||||
}
|
||||
disabled={!hasViewId}
|
||||
contextualTextPosition="right"
|
||||
/>
|
||||
</SelectableListItem>
|
||||
{hasViewId && (
|
||||
<>
|
||||
<SelectableListItem itemId="record-table-fields">
|
||||
@@ -210,16 +326,109 @@ export const SidePanelDashboardRecordTableSettings = () => {
|
||||
contextualTextPosition="right"
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem itemId="record-table-limit">
|
||||
<CommandMenuItemNumberInput
|
||||
id="record-table-limit"
|
||||
label={t`Limit`}
|
||||
Icon={IconArrowBarToDownDashed}
|
||||
value={isDefined(limit) ? `${limit}` : ''}
|
||||
onChange={handleLimitChange}
|
||||
placeholder={t`No limit`}
|
||||
/>
|
||||
</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={widgetInEditMode.id}
|
||||
objectMetadataId={
|
||||
widgetInEditMode.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={widgetInEditMode.id}
|
||||
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={widgetInEditMode.id}
|
||||
objectMetadataId={
|
||||
widgetInEditMode.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>
|
||||
)}
|
||||
{!isCalendarLayout && !hasGroupBy && (
|
||||
<SelectableListItem itemId="record-table-limit">
|
||||
<CommandMenuItemNumberInput
|
||||
id="record-table-limit"
|
||||
label={t`Limit`}
|
||||
Icon={IconArrowBarToDownDashed}
|
||||
value={isDefined(limit) ? `${limit}` : ''}
|
||||
onChange={handleLimitChange}
|
||||
placeholder={t`No limit`}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SidePanelGroup>
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
|
||||
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
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 { useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
|
||||
type RecordTableCalendarFieldDropdownContentProps = {
|
||||
pageLayoutId: string;
|
||||
widgetId: string;
|
||||
objectMetadataId: string;
|
||||
currentCalendarFieldMetadataId: string | null;
|
||||
};
|
||||
|
||||
export const RecordTableCalendarFieldDropdownContent = ({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
objectMetadataId,
|
||||
currentCalendarFieldMetadataId,
|
||||
}: RecordTableCalendarFieldDropdownContentProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItemToFind) =>
|
||||
objectMetadataItemToFind.id === objectMetadataId,
|
||||
);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { handleCalendarFieldChange } = useRecordTableWidgetLayoutCallbacks({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
const dateFields = (objectMetadataItem?.readableFields ?? []).filter(
|
||||
isFieldMetadataItemAvailableAsCalendarField,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={dateFields.map(
|
||||
(fieldMetadataItem) => fieldMetadataItem.id,
|
||||
)}
|
||||
focusId={dropdownId}
|
||||
>
|
||||
{dateFields.map((fieldMetadataItem) => (
|
||||
<SelectableListItem
|
||||
key={fieldMetadataItem.id}
|
||||
itemId={fieldMetadataItem.id}
|
||||
onEnter={() => {
|
||||
handleCalendarFieldChange(fieldMetadataItem);
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={fieldMetadataItem.label}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
selected={currentCalendarFieldMetadataId === fieldMetadataItem.id}
|
||||
focused={selectedItemId === fieldMetadataItem.id}
|
||||
onClick={() => {
|
||||
handleCalendarFieldChange(fieldMetadataItem);
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
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 { t } from '@lingui/core/macro';
|
||||
import {
|
||||
IconCalendarEvent,
|
||||
IconCalendarMonth,
|
||||
IconCalendarWeek,
|
||||
} from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
type RecordTableCalendarLayoutDropdownContentProps = {
|
||||
pageLayoutId: string;
|
||||
widgetId: string;
|
||||
currentCalendarLayout: ViewCalendarLayout;
|
||||
};
|
||||
|
||||
export const RecordTableCalendarLayoutDropdownContent = ({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
currentCalendarLayout,
|
||||
}: RecordTableCalendarLayoutDropdownContentProps) => {
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { handleCalendarLayoutChange } = useRecordTableWidgetLayoutCallbacks({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
const handleSelect = (calendarLayout: ViewCalendarLayout) => {
|
||||
handleCalendarLayoutChange(calendarLayout);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const calendarLayoutOptions = [
|
||||
{ value: ViewCalendarLayout.DAY, label: t`Day`, Icon: IconCalendarEvent },
|
||||
{ value: ViewCalendarLayout.WEEK, label: t`Week`, Icon: IconCalendarWeek },
|
||||
{
|
||||
value: ViewCalendarLayout.MONTH,
|
||||
label: t`Month`,
|
||||
Icon: IconCalendarMonth,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={calendarLayoutOptions.map(({ value }) => value)}
|
||||
focusId={dropdownId}
|
||||
>
|
||||
{calendarLayoutOptions.map(({ value, label, Icon }) => (
|
||||
<SelectableListItem
|
||||
key={value}
|
||||
itemId={value}
|
||||
onEnter={() => handleSelect(value)}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={label}
|
||||
LeftIcon={Icon}
|
||||
selected={currentCalendarLayout === value}
|
||||
focused={selectedItemId === value}
|
||||
onClick={() => handleSelect(value)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
|
||||
import { isFieldMetadataItemAvailableAsWidgetGroupByField } from '@/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField';
|
||||
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 { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
|
||||
|
||||
const NO_GROUP_BY_ITEM_ID = 'no-group-by';
|
||||
|
||||
type RecordTableGroupByDropdownContentProps = {
|
||||
pageLayoutId: string;
|
||||
widgetId: string;
|
||||
objectMetadataId: string;
|
||||
currentMainGroupByFieldMetadataId: string | null;
|
||||
isClearable?: boolean;
|
||||
};
|
||||
|
||||
export const RecordTableGroupByDropdownContent = ({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
objectMetadataId,
|
||||
currentMainGroupByFieldMetadataId,
|
||||
isClearable = true,
|
||||
}: RecordTableGroupByDropdownContentProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItemToFind) =>
|
||||
objectMetadataItemToFind.id === objectMetadataId,
|
||||
);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { handleGroupByFieldChange } = useRecordTableWidgetLayoutCallbacks({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
const groupableFields = (objectMetadataItem?.readableFields ?? []).filter(
|
||||
isFieldMetadataItemAvailableAsWidgetGroupByField,
|
||||
);
|
||||
|
||||
const filteredFields = filterBySearchQuery({
|
||||
items: groupableFields,
|
||||
searchQuery,
|
||||
getSearchableValues: (fieldMetadataItem) => [fieldMetadataItem.label],
|
||||
});
|
||||
|
||||
const selectableItemIds = [
|
||||
...(isClearable ? [NO_GROUP_BY_ITEM_ID] : []),
|
||||
...filteredFields.map((fieldMetadataItem) => fieldMetadataItem.id),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={selectableItemIds}
|
||||
focusId={dropdownId}
|
||||
>
|
||||
{isClearable && (
|
||||
<SelectableListItem
|
||||
itemId={NO_GROUP_BY_ITEM_ID}
|
||||
onEnter={() => {
|
||||
handleGroupByFieldChange(null);
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={t`None`}
|
||||
selected={!isDefined(currentMainGroupByFieldMetadataId)}
|
||||
focused={selectedItemId === NO_GROUP_BY_ITEM_ID}
|
||||
onClick={() => {
|
||||
handleGroupByFieldChange(null);
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
)}
|
||||
{filteredFields.map((fieldMetadataItem) => (
|
||||
<SelectableListItem
|
||||
key={fieldMetadataItem.id}
|
||||
itemId={fieldMetadataItem.id}
|
||||
onEnter={() => {
|
||||
handleGroupByFieldChange(fieldMetadataItem);
|
||||
closeDropdown();
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={fieldMetadataItem.label}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
selected={
|
||||
currentMainGroupByFieldMetadataId === fieldMetadataItem.id
|
||||
}
|
||||
focused={selectedItemId === fieldMetadataItem.id}
|
||||
onClick={() => {
|
||||
handleGroupByFieldChange(fieldMetadataItem);
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
|
||||
import {
|
||||
type RecordTableWidgetLayoutViewType,
|
||||
useRecordTableWidgetLayoutCallbacks,
|
||||
} from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
|
||||
import { isFieldMetadataItemAvailableAsWidgetGroupByField } from '@/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
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 { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCalendar, IconLayoutKanban, IconTable } from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { ViewType } from '~/generated-metadata/graphql';
|
||||
|
||||
type RecordTableLayoutDropdownContentProps = {
|
||||
pageLayoutId: string;
|
||||
widgetId: string;
|
||||
objectMetadataId: string;
|
||||
currentLayoutViewType: RecordTableWidgetLayoutViewType;
|
||||
};
|
||||
|
||||
export const RecordTableLayoutDropdownContent = ({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
objectMetadataId,
|
||||
currentLayoutViewType,
|
||||
}: RecordTableLayoutDropdownContentProps) => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItemToFind) =>
|
||||
objectMetadataItemToFind.id === objectMetadataId,
|
||||
);
|
||||
|
||||
const dropdownId = useAvailableComponentInstanceIdOrThrow(
|
||||
DropdownComponentInstanceContext,
|
||||
);
|
||||
|
||||
const selectedItemId = useAtomComponentStateValue(
|
||||
selectedItemIdComponentState,
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { handleLayoutChange } = useRecordTableWidgetLayoutCallbacks({
|
||||
pageLayoutId,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
const defaultGroupByFieldMetadataItem =
|
||||
(objectMetadataItem?.readableFields ?? []).find(
|
||||
isFieldMetadataItemAvailableAsWidgetGroupByField,
|
||||
) ?? null;
|
||||
|
||||
const defaultCalendarFieldMetadataItem =
|
||||
(objectMetadataItem?.readableFields ?? []).find(
|
||||
isFieldMetadataItemAvailableAsCalendarField,
|
||||
) ?? null;
|
||||
|
||||
const isKanbanAvailable = isDefined(defaultGroupByFieldMetadataItem);
|
||||
const isCalendarAvailable = isDefined(defaultCalendarFieldMetadataItem);
|
||||
|
||||
const handleSelectLayout = (
|
||||
targetViewType: RecordTableWidgetLayoutViewType,
|
||||
) => {
|
||||
if (targetViewType === ViewType.KANBAN_WIDGET && !isKanbanAvailable) {
|
||||
return;
|
||||
}
|
||||
if (targetViewType === ViewType.CALENDAR_WIDGET && !isCalendarAvailable) {
|
||||
return;
|
||||
}
|
||||
handleLayoutChange({
|
||||
targetViewType,
|
||||
defaultGroupByFieldMetadataItem,
|
||||
defaultCalendarFieldMetadataItem,
|
||||
});
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer>
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
selectableItemIdArray={[
|
||||
ViewType.TABLE_WIDGET,
|
||||
...(isKanbanAvailable ? [ViewType.KANBAN_WIDGET] : []),
|
||||
...(isCalendarAvailable ? [ViewType.CALENDAR_WIDGET] : []),
|
||||
]}
|
||||
focusId={dropdownId}
|
||||
>
|
||||
<SelectableListItem
|
||||
itemId={ViewType.TABLE_WIDGET}
|
||||
onEnter={() => handleSelectLayout(ViewType.TABLE_WIDGET)}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={t`Table`}
|
||||
LeftIcon={IconTable}
|
||||
selected={currentLayoutViewType === ViewType.TABLE_WIDGET}
|
||||
focused={selectedItemId === ViewType.TABLE_WIDGET}
|
||||
onClick={() => handleSelectLayout(ViewType.TABLE_WIDGET)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId={ViewType.KANBAN_WIDGET}
|
||||
onEnter={() => handleSelectLayout(ViewType.KANBAN_WIDGET)}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={t`Kanban`}
|
||||
LeftIcon={IconLayoutKanban}
|
||||
disabled={!isKanbanAvailable}
|
||||
contextualText={
|
||||
!isKanbanAvailable ? t`Needs a Select field` : undefined
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
selected={currentLayoutViewType === ViewType.KANBAN_WIDGET}
|
||||
focused={selectedItemId === ViewType.KANBAN_WIDGET}
|
||||
onClick={() => handleSelectLayout(ViewType.KANBAN_WIDGET)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId={ViewType.CALENDAR_WIDGET}
|
||||
onEnter={() => handleSelectLayout(ViewType.CALENDAR_WIDGET)}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={t`Calendar`}
|
||||
LeftIcon={IconCalendar}
|
||||
disabled={!isCalendarAvailable}
|
||||
contextualText={
|
||||
!isCalendarAvailable ? t`Needs a Date field` : undefined
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
selected={currentLayoutViewType === ViewType.CALENDAR_WIDGET}
|
||||
focused={selectedItemId === ViewType.CALENDAR_WIDGET}
|
||||
onClick={() => handleSelectLayout(ViewType.CALENDAR_WIDGET)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -1,7 +1,8 @@
|
||||
import { isWidgetViewType } from 'twenty-shared/utils';
|
||||
|
||||
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
|
||||
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
|
||||
export const viewsFromObjectMetadataItemFamilySelector =
|
||||
createAtomFamilySelector<View[], { objectMetadataItemId: string }>({
|
||||
@@ -14,8 +15,7 @@ export const viewsFromObjectMetadataItemFamilySelector =
|
||||
.filter(
|
||||
(view) =>
|
||||
view.objectMetadataId === objectMetadataItemId &&
|
||||
view.type !== ViewType.FIELDS_WIDGET &&
|
||||
view.type !== ViewType.TABLE_WIDGET,
|
||||
!isWidgetViewType(view.type),
|
||||
)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@ export const VIEW_TYPE_LABELS = {
|
||||
[ViewType.CALENDAR]: msg`Calendar`,
|
||||
[ViewType.FIELDS_WIDGET]: msg`Fields widget`,
|
||||
[ViewType.TABLE_WIDGET]: msg`Table widget`,
|
||||
[ViewType.KANBAN_WIDGET]: msg`Kanban widget`,
|
||||
[ViewType.CALENDAR_WIDGET]: msg`Calendar widget`,
|
||||
} satisfies Record<ViewTypeLabelKey, MessageDescriptor>;
|
||||
|
||||
export const getViewTypeLabel = (
|
||||
|
||||
+3
-12
@@ -2,17 +2,14 @@ import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
|
||||
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState';
|
||||
import { FieldMetadataType, SettingsPath } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataSupportedInGroupBy,
|
||||
} from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const useGetAvailableFieldsForCalendar = () => {
|
||||
@@ -31,13 +28,7 @@ export const useGetAvailableFieldsForCalendar = () => {
|
||||
|
||||
const availableFieldsForCalendar =
|
||||
objectMetadataItem?.readableFields.filter(
|
||||
(field) =>
|
||||
isFieldMetadataDateKind(field.type) &&
|
||||
isFieldMetadataSupportedInGroupBy({
|
||||
type: field.type,
|
||||
name: field.name,
|
||||
isSystem: field.isSystem ?? false,
|
||||
}),
|
||||
isFieldMetadataItemAvailableAsCalendarField,
|
||||
) ?? [];
|
||||
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.23.0', 1784620626405)
|
||||
export class AddKanbanAndCalendarWidgetViewTypesFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "core"."view_type_enum" ADD VALUE IF NOT EXISTS 'KANBAN_WIDGET' AFTER 'TABLE_WIDGET'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "core"."view_type_enum" ADD VALUE IF NOT EXISTS 'CALENDAR_WIDGET' AFTER 'KANBAN_WIDGET'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
"CREATE TYPE \"core\".\"view_type_enum_old\" AS ENUM('TABLE', 'KANBAN', 'CALENDAR', 'FIELDS_WIDGET', 'TABLE_WIDGET')",
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."view" ALTER COLUMN "type" DROP DEFAULT',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."view" ALTER COLUMN "type" TYPE "core"."view_type_enum_old" USING "type"::"text"::"core"."view_type_enum_old"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."view" ALTER COLUMN "type" SET DEFAULT \'TABLE\'',
|
||||
);
|
||||
await queryRunner.query('DROP TYPE "core"."view_type_enum"');
|
||||
await queryRunner.query(
|
||||
'ALTER TYPE "core"."view_type_enum_old" RENAME TO "view_type_enum"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.23.0', 1784620626406)
|
||||
export class WidenViewCalendarIntegrityConstraintFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."view" DROP CONSTRAINT IF EXISTS "CHK_VIEW_CALENDAR_INTEGRITY"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."view" ADD CONSTRAINT "CHK_VIEW_CALENDAR_INTEGRITY" CHECK ("type" NOT IN ('CALENDAR', 'CALENDAR_WIDGET') OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."view" DROP CONSTRAINT IF EXISTS "CHK_VIEW_CALENDAR_INTEGRITY"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."view" ADD CONSTRAINT "CHK_VIEW_CALENDAR_INTEGRITY" CHECK ("type" != 'CALENDAR' OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -114,6 +114,8 @@ import { AddCalendarEndFieldMetadataIdToViewFastInstanceCommand } from 'src/data
|
||||
import { AddCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-22/2-22-instance-command-slow-1784106205000-add-created-workspace-activation-status';
|
||||
import { UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784322591746-unlist-unclaimed-npm-application-registrations';
|
||||
import { BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784286705000-backfill-created-workspace-activation-status';
|
||||
import { AddKanbanAndCalendarWidgetViewTypesFastInstanceCommand } from './2-23/2-23-instance-command-fast-1784620626405-add-kanban-and-calendar-widget-view-types';
|
||||
import { WidenViewCalendarIntegrityConstraintFastInstanceCommand } from './2-23/2-23-instance-command-fast-1784620626406-widen-view-calendar-integrity-constraint';
|
||||
import { AddAutoUpgradeToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784297307235-add-auto-upgrade-to-application';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
@@ -231,5 +233,7 @@ export const INSTANCE_COMMANDS = [
|
||||
AddCreatedWorkspaceActivationStatusSlowInstanceCommand,
|
||||
UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand,
|
||||
BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand,
|
||||
AddKanbanAndCalendarWidgetViewTypesFastInstanceCommand,
|
||||
WidenViewCalendarIntegrityConstraintFastInstanceCommand,
|
||||
AddAutoUpgradeToApplicationFastInstanceCommand,
|
||||
];
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
AggregateOperations,
|
||||
ViewCalendarLayout,
|
||||
ViewOpenRecordIn,
|
||||
ViewType,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { KANBAN_COLUMN_MAX_WIDTH } from 'src/engine/metadata-modules/view/constants/kanban-column-max-width.constant';
|
||||
import { KANBAN_COLUMN_MIN_WIDTH } from 'src/engine/metadata-modules/view/constants/kanban-column-min-width.constant';
|
||||
|
||||
@InputType()
|
||||
export class UpsertViewWidgetViewSettingsInput {
|
||||
@IsOptional()
|
||||
@IsEnum(ViewType)
|
||||
@Field(() => ViewType, {
|
||||
nullable: true,
|
||||
description:
|
||||
'The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed.',
|
||||
})
|
||||
type?: ViewType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
mainGroupByFieldMetadataId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
shouldHideEmptyGroups?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ViewOpenRecordIn)
|
||||
@Field(() => ViewOpenRecordIn, { nullable: true })
|
||||
openRecordIn?: ViewOpenRecordIn;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AggregateOperations)
|
||||
@Field(() => AggregateOperations, { nullable: true })
|
||||
kanbanAggregateOperation?: AggregateOperations;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
kanbanAggregateOperationFieldMetadataId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(KANBAN_COLUMN_MIN_WIDTH)
|
||||
@Max(KANBAN_COLUMN_MAX_WIDTH)
|
||||
@Field(() => Int, { nullable: true })
|
||||
kanbanColumnWidth?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ViewCalendarLayout)
|
||||
@Field(() => ViewCalendarLayout, { nullable: true })
|
||||
calendarLayout?: ViewCalendarLayout;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
calendarFieldMetadataId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
calendarEndFieldMetadataId?: string | null;
|
||||
}
|
||||
+11
@@ -9,6 +9,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { UpsertViewWidgetViewSettingsInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input';
|
||||
import { UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
|
||||
import { UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
|
||||
import { UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
|
||||
@@ -23,6 +24,16 @@ export class UpsertViewWidgetInput {
|
||||
})
|
||||
widgetId: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpsertViewWidgetViewSettingsInput)
|
||||
@Field(() => UpsertViewWidgetViewSettingsInput, {
|
||||
nullable: true,
|
||||
description:
|
||||
'View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view.',
|
||||
})
|
||||
view?: UpsertViewWidgetViewSettingsInput;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpsertViewWidgetViewFieldInput)
|
||||
|
||||
@@ -72,7 +72,7 @@ export type ViewOverrides = {
|
||||
@Index('IDX_VIEW_CREATED_BY_USER_WORKSPACE', ['createdByUserWorkspaceId'])
|
||||
@Check(
|
||||
'CHK_VIEW_CALENDAR_INTEGRITY',
|
||||
`("type" != 'CALENDAR' OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
|
||||
`("type" NOT IN ('CALENDAR', 'CALENDAR_WIDGET') OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
|
||||
)
|
||||
export class ViewEntity
|
||||
extends OverridableEntity<ViewOverrides>
|
||||
|
||||
+63
-1
@@ -5,6 +5,7 @@ import {
|
||||
ViewFilterGroupLogicalOperator,
|
||||
ViewFilterOperand,
|
||||
ViewSortDirection,
|
||||
ViewType,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull } from 'typeorm';
|
||||
@@ -29,6 +30,7 @@ import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filte
|
||||
import { getDefaultViewFilterOperand } from 'src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util';
|
||||
import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type';
|
||||
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
|
||||
import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
|
||||
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
|
||||
@@ -70,6 +72,12 @@ const EMPTY_SORT_OPS = {
|
||||
sortsToRemove: [] as FlatViewSort[],
|
||||
};
|
||||
|
||||
const ALLOWED_WIDGET_VIEW_TYPES: ViewType[] = [
|
||||
ViewType.TABLE_WIDGET,
|
||||
ViewType.KANBAN_WIDGET,
|
||||
ViewType.CALENDAR_WIDGET,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ViewWidgetUpsertService {
|
||||
constructor(
|
||||
@@ -102,6 +110,7 @@ export class ViewWidgetUpsertService {
|
||||
flatViewFilterGroupMaps,
|
||||
flatViewSortMaps,
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
} =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
@@ -114,6 +123,7 @@ export class ViewWidgetUpsertService {
|
||||
'flatViewFilterGroupMaps',
|
||||
'flatViewSortMaps',
|
||||
'flatViewMaps',
|
||||
'flatViewGroupMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -181,11 +191,31 @@ export class ViewWidgetUpsertService {
|
||||
now: new Date().toISOString(),
|
||||
};
|
||||
|
||||
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)
|
||||
) {
|
||||
throw new ViewException(
|
||||
t`Widget views must use a widget view type`,
|
||||
ViewExceptionCode.INVALID_VIEW_DATA,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(input.viewFields) &&
|
||||
!isDefined(input.viewFilterGroups) &&
|
||||
!isDefined(input.viewFilters) &&
|
||||
!isDefined(input.viewSorts)
|
||||
!isDefined(input.viewSorts) &&
|
||||
!isDefined(input.view)
|
||||
) {
|
||||
const view = await this.viewRepository.findOne(
|
||||
upsertContext.workspaceId,
|
||||
@@ -282,6 +312,22 @@ export class ViewWidgetUpsertService {
|
||||
})
|
||||
: EMPTY_SORT_OPS;
|
||||
|
||||
const viewUpdateOperations = isDefined(input.view)
|
||||
? fromUpdateViewInputToFlatViewToUpdateOrThrow({
|
||||
updateViewInput: {
|
||||
id: viewId,
|
||||
...input.view,
|
||||
},
|
||||
flatViewMaps,
|
||||
flatViewGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
callerApplicationUniversalIdentifier:
|
||||
upsertContext.applicationUniversalIdentifier,
|
||||
workspaceCustomApplicationUniversalIdentifier:
|
||||
upsertContext.applicationUniversalIdentifier,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
toHardDelete: filterGroupsToDelete,
|
||||
toDeactivate: filterGroupsToDeactivate,
|
||||
@@ -312,6 +358,22 @@ export class ViewWidgetUpsertService {
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
view: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: isDefined(viewUpdateOperations)
|
||||
? [viewUpdateOperations.flatViewToUpdate]
|
||||
: [],
|
||||
},
|
||||
viewGroup: {
|
||||
flatEntityToCreate: isDefined(viewUpdateOperations)
|
||||
? viewUpdateOperations.flatViewGroupsToCreate
|
||||
: [],
|
||||
flatEntityToDelete: isDefined(viewUpdateOperations)
|
||||
? viewUpdateOperations.flatViewGroupsToDelete
|
||||
: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
viewField: {
|
||||
flatEntityToCreate: viewFieldOperations.fieldsToCreate,
|
||||
flatEntityToDelete: [],
|
||||
|
||||
+17
-4
@@ -28,6 +28,15 @@ import {
|
||||
isNonEmptyArray,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const CREATABLE_VIEW_TYPES = [
|
||||
ViewType.TABLE,
|
||||
ViewType.KANBAN,
|
||||
ViewType.CALENDAR,
|
||||
ViewType.TABLE_WIDGET,
|
||||
ViewType.KANBAN_WIDGET,
|
||||
ViewType.CALENDAR_WIDGET,
|
||||
] as const;
|
||||
|
||||
const GetViewsInputSchema = z.object({
|
||||
objectNameSingular: z
|
||||
.string()
|
||||
@@ -64,10 +73,12 @@ const CreateViewInputSchema = z.object({
|
||||
.default('IconList')
|
||||
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.enum(CREATABLE_VIEW_TYPES)
|
||||
.optional()
|
||||
.default(ViewType.TABLE)
|
||||
.describe('View type'),
|
||||
.describe(
|
||||
'View type. Use the *_WIDGET variants (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) for views backing a dashboard widget so they stay out of record index view pickers.',
|
||||
),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
@@ -213,9 +224,11 @@ const UpsertCompleteViewInputSchema = z.object({
|
||||
name: z.string().optional().describe('View name'),
|
||||
icon: z.string().optional().describe('Icon identifier (e.g. "IconList")'),
|
||||
type: z
|
||||
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
|
||||
.enum(CREATABLE_VIEW_TYPES)
|
||||
.optional()
|
||||
.describe('View type. Defaults to TABLE on create.'),
|
||||
.describe(
|
||||
'View type. Defaults to TABLE on create. Use the *_WIDGET variants for views backing a dashboard widget so they stay out of record index view pickers.',
|
||||
),
|
||||
visibility: z
|
||||
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
|
||||
.optional()
|
||||
|
||||
+37
-6
@@ -1,7 +1,13 @@
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { type ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
||||
import { FieldMetadataType, RelationType, ViewType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
FieldMetadataType,
|
||||
RelationType,
|
||||
ViewCalendarLayout,
|
||||
ViewType,
|
||||
} from 'twenty-shared/types';
|
||||
import { getViewLayoutFromViewType, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { isMorphOrRelationUniversalFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
@@ -23,11 +29,13 @@ export class FlatViewValidatorService {
|
||||
private validateCalendarFields({
|
||||
flatView,
|
||||
flatFieldMetadataMaps,
|
||||
isCalendarWeekViewEnabled,
|
||||
}: {
|
||||
flatView: UniversalFlatView;
|
||||
flatFieldMetadataMaps: AllUniversalFlatEntityMaps['flatFieldMetadataMaps'];
|
||||
isCalendarWeekViewEnabled: boolean;
|
||||
}): FlatEntityValidationError[] {
|
||||
if (flatView.type !== ViewType.CALENDAR) {
|
||||
if (getViewLayoutFromViewType(flatView.type) !== ViewType.CALENDAR) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -41,6 +49,22 @@ export class FlatViewValidatorService {
|
||||
});
|
||||
}
|
||||
|
||||
// Widget calendars only allow non-month layouts when the day/week
|
||||
// calendar feature is enabled; while it is off the month grid stays a
|
||||
// data invariant rather than a UI-only convention.
|
||||
if (
|
||||
flatView.type === ViewType.CALENDAR_WIDGET &&
|
||||
!isCalendarWeekViewEnabled &&
|
||||
isDefined(flatView.calendarLayout) &&
|
||||
flatView.calendarLayout !== ViewCalendarLayout.MONTH
|
||||
) {
|
||||
errors.push({
|
||||
code: ViewExceptionCode.INVALID_VIEW_DATA,
|
||||
message: t`Calendar widget views only support the month layout`,
|
||||
userFriendlyMessage: msg`Calendar widget views only support the month layout`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDefined(flatView.calendarFieldMetadataUniversalIdentifier)) {
|
||||
errors.push({
|
||||
code: ViewExceptionCode.INVALID_VIEW_DATA,
|
||||
@@ -185,6 +209,7 @@ export class FlatViewValidatorService {
|
||||
flatViewMaps: optimisticFlatViewMaps,
|
||||
flatFieldMetadataMaps,
|
||||
},
|
||||
additionalCacheDataMaps: { featureFlagsMap },
|
||||
}: FlatEntityUpdateValidationArgs<
|
||||
typeof ALL_METADATA_NAME.view
|
||||
>): FailedFlatEntityValidation<'view', 'update'> {
|
||||
@@ -240,8 +265,8 @@ export class FlatViewValidatorService {
|
||||
}
|
||||
|
||||
const viewBecomesKanban =
|
||||
updatedFlatView.type === ViewType.KANBAN &&
|
||||
existingFlatView.type !== ViewType.KANBAN;
|
||||
getViewLayoutFromViewType(updatedFlatView.type) === ViewType.KANBAN &&
|
||||
getViewLayoutFromViewType(existingFlatView.type) !== ViewType.KANBAN;
|
||||
|
||||
if (viewBecomesKanban) {
|
||||
if (
|
||||
@@ -318,6 +343,8 @@ export class FlatViewValidatorService {
|
||||
...this.validateCalendarFields({
|
||||
flatView: updatedFlatView,
|
||||
flatFieldMetadataMaps,
|
||||
isCalendarWeekViewEnabled:
|
||||
featureFlagsMap[FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -392,6 +419,7 @@ export class FlatViewValidatorService {
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
},
|
||||
additionalCacheDataMaps: { featureFlagsMap },
|
||||
}: UniversalFlatEntityValidationArgs<
|
||||
typeof ALL_METADATA_NAME.view
|
||||
>): FailedFlatEntityValidation<'view', 'create'> {
|
||||
@@ -450,7 +478,8 @@ export class FlatViewValidatorService {
|
||||
});
|
||||
}
|
||||
|
||||
const isKanban = flatViewToValidate.type === ViewType.KANBAN;
|
||||
const isKanban =
|
||||
getViewLayoutFromViewType(flatViewToValidate.type) === ViewType.KANBAN;
|
||||
|
||||
if (isKanban) {
|
||||
if (
|
||||
@@ -496,6 +525,8 @@ export class FlatViewValidatorService {
|
||||
...this.validateCalendarFields({
|
||||
flatView: flatViewToValidate,
|
||||
flatFieldMetadataMaps,
|
||||
isCalendarWeekViewEnabled:
|
||||
featureFlagsMap[FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
+3
-2
@@ -98,11 +98,12 @@ CHART FILTERS (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART):
|
||||
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } }
|
||||
|
||||
7. RECORD_TABLE: displays a live, filterable record list directly on the dashboard.
|
||||
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE view for the object, then pass its ID as viewId. Never reuse an existing index-page view — widget views and record index views must not overlap.
|
||||
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE_WIDGET view for the object, then pass its ID as viewId. Never reuse an existing index-page view and never use plain TABLE/KANBAN/CALENDAR types for widget-backing views — non-widget view types leak into record index view pickers.
|
||||
- Requires: objectMetadataId (top-level, UUID of the object to display) AND configuration.viewId (UUID of the dedicated view you just created)
|
||||
- configuration.configurationType must be "RECORD_TABLE"
|
||||
- Recommended size: rowSpan 8-10, columnSpan 12 (full width)
|
||||
- Workflow: (1) call create_view with type TABLE for the object → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
|
||||
- Workflow: (1) call create_view with the appropriate *_WIDGET type (TABLE_WIDGET for a table, KANBAN_WIDGET for a board, CALENDAR_WIDGET for a calendar — kanban requires mainGroupByFieldName, calendar requires calendarFieldName) → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
|
||||
- The widget renders according to its view type: TABLE_WIDGET renders a table, KANBAN_WIDGET a board (requires mainGroupByFieldName pointing at a SELECT or many-to-one relation field), CALENDAR_WIDGET a calendar (requires a date calendar field)
|
||||
- Example: { type: "RECORD_TABLE", objectMetadataId: "<object-uuid>", configuration: { configurationType: "RECORD_TABLE", viewId: "<dedicated-view-uuid>" } }
|
||||
|
||||
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
||||
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
|
||||
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
|
||||
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
|
||||
import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util';
|
||||
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
|
||||
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
|
||||
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
||||
import { findViewGroups } from 'test/integration/metadata/suites/view-group/utils/find-view-groups.util';
|
||||
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
|
||||
import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util';
|
||||
import { upsertViewWidget } from 'test/integration/metadata/suites/view/utils/upsert-view-widget.util';
|
||||
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
FieldMetadataType,
|
||||
ViewCalendarLayout,
|
||||
ViewType,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
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';
|
||||
|
||||
const VIEW_SETTINGS_GQL_FIELDS = `
|
||||
id
|
||||
type
|
||||
mainGroupByFieldMetadataId
|
||||
shouldHideEmptyGroups
|
||||
kanbanAggregateOperation
|
||||
kanbanAggregateOperationFieldMetadataId
|
||||
calendarLayout
|
||||
calendarFieldMetadataId
|
||||
calendarEndFieldMetadataId
|
||||
`;
|
||||
|
||||
describe('upsertViewWidget view settings', () => {
|
||||
let objectMetadataId: string;
|
||||
let selectFieldMetadataId: string;
|
||||
let dateFieldMetadataId: string;
|
||||
let pageLayoutId: string;
|
||||
let pageLayoutTabId: string;
|
||||
let widgetId: string;
|
||||
let viewId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const {
|
||||
data: {
|
||||
createOneObject: { id: createdObjectMetadataId },
|
||||
},
|
||||
} = await createOneObjectMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
nameSingular: 'widgetViewSettingsTestObject',
|
||||
namePlural: 'widgetViewSettingsTestObjects',
|
||||
labelSingular: 'Widget View Settings Test Object',
|
||||
labelPlural: 'Widget View Settings Test Objects',
|
||||
icon: 'IconTestTube',
|
||||
},
|
||||
});
|
||||
|
||||
objectMetadataId = createdObjectMetadataId;
|
||||
|
||||
const {
|
||||
data: {
|
||||
createOneField: { id: createdSelectFieldMetadataId },
|
||||
},
|
||||
} = await createOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Stage',
|
||||
objectMetadataId,
|
||||
options: [
|
||||
{ label: 'New', value: 'NEW', color: 'blue', position: 0 },
|
||||
{ label: 'Ongoing', value: 'ONGOING', color: 'red', position: 1 },
|
||||
{ label: 'Done', value: 'DONE', color: 'green', position: 2 },
|
||||
],
|
||||
},
|
||||
gqlFields: 'id',
|
||||
});
|
||||
|
||||
selectFieldMetadataId = createdSelectFieldMetadataId;
|
||||
|
||||
const {
|
||||
data: {
|
||||
createOneField: { id: createdDateFieldMetadataId },
|
||||
},
|
||||
} = await createOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'eventDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
label: 'Event Date',
|
||||
objectMetadataId,
|
||||
},
|
||||
gqlFields: 'id',
|
||||
});
|
||||
|
||||
dateFieldMetadataId = createdDateFieldMetadataId;
|
||||
|
||||
const { data: layoutData } = await createOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: { name: 'Test Page Layout For View Settings' },
|
||||
});
|
||||
|
||||
pageLayoutId = layoutData.createPageLayout.id;
|
||||
|
||||
const { data: tabData } = await createOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
title: 'Test Tab For View Settings',
|
||||
pageLayoutId,
|
||||
},
|
||||
});
|
||||
|
||||
pageLayoutTabId = tabData.createPageLayoutTab.id;
|
||||
|
||||
const { data: viewData } = await createOneView({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'testWidgetViewSettingsView',
|
||||
objectMetadataId,
|
||||
icon: 'IconTable',
|
||||
type: ViewType.TABLE_WIDGET,
|
||||
},
|
||||
});
|
||||
|
||||
viewId = viewData.createView.id;
|
||||
|
||||
const { data: widgetData } = await createOnePageLayoutWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
title: 'Test Record Table Widget For View Settings',
|
||||
type: WidgetType.RECORD_TABLE,
|
||||
pageLayoutTabId,
|
||||
objectMetadataId,
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.RECORD_TABLE,
|
||||
viewId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
widgetId = widgetData.createPageLayoutWidget.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyOnePageLayoutWidget({
|
||||
expectToFail: false,
|
||||
input: { id: widgetId },
|
||||
});
|
||||
await destroyOneView({
|
||||
expectToFail: false,
|
||||
viewId,
|
||||
});
|
||||
await destroyOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: { id: pageLayoutTabId },
|
||||
});
|
||||
await destroyOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: { id: pageLayoutId },
|
||||
});
|
||||
await updateOneObjectMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToUpdate: objectMetadataId,
|
||||
updatePayload: { isActive: false },
|
||||
},
|
||||
});
|
||||
await deleteOneObjectMetadata({
|
||||
expectToFail: false,
|
||||
input: { idToDelete: objectMetadataId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a non-widget view type', async () => {
|
||||
const { errors } = await upsertViewWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.KANBAN,
|
||||
mainGroupByFieldMetadataId: selectFieldMetadataId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors?.[0]?.message).toContain(
|
||||
'Widget views must use a widget view type',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject switching to KANBAN_WIDGET without a main group by field', async () => {
|
||||
const { errors } = await upsertViewWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.KANBAN_WIDGET,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.stringify(errors)).toContain(
|
||||
'Kanban view must have a main group by field',
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch the widget view to KANBAN_WIDGET and auto-create view groups', async () => {
|
||||
const { data } = await upsertViewWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId,
|
||||
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,
|
||||
);
|
||||
|
||||
const { data: viewGroupsData } = await findViewGroups({
|
||||
expectToFail: false,
|
||||
viewId,
|
||||
});
|
||||
|
||||
const fieldValues = viewGroupsData.getViewGroups.map(
|
||||
(viewGroup) => viewGroup.fieldValue,
|
||||
);
|
||||
|
||||
expect(fieldValues).toEqual(
|
||||
expect.arrayContaining(['NEW', 'ONGOING', 'DONE']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject switching to CALENDAR_WIDGET without a calendar field', async () => {
|
||||
const { errors } = await upsertViewWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.CALENDAR_WIDGET,
|
||||
mainGroupByFieldMetadataId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.stringify(errors)).toContain(
|
||||
'Calendar view must have a calendar field',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a non-month calendar layout on a CALENDAR_WIDGET view', async () => {
|
||||
// This suite shares its workspace with the rest of the shard, and the
|
||||
// "allow non-month" test below flips IS_CALENDAR_WEEK_VIEW_ENABLED on. Pin
|
||||
// it off here so the rejection path is exercised deterministically, whatever
|
||||
// the test/retry ordering leaves behind in the workspace.
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const { errors } = await upsertViewWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.CALENDAR_WIDGET,
|
||||
calendarLayout: ViewCalendarLayout.DAY,
|
||||
calendarFieldMetadataId: dateFieldMetadataId,
|
||||
mainGroupByFieldMetadataId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.stringify(errors)).toContain(
|
||||
'Calendar widget views only support the month layout',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a non-month calendar layout on a CALENDAR_WIDGET view when the week/day calendar feature is enabled', async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
value: true,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await upsertViewWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.CALENDAR_WIDGET,
|
||||
calendarLayout: ViewCalendarLayout.WEEK,
|
||||
calendarFieldMetadataId: dateFieldMetadataId,
|
||||
mainGroupByFieldMetadataId: null,
|
||||
},
|
||||
},
|
||||
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
|
||||
});
|
||||
|
||||
expect(data.upsertViewWidget.type).toBe(ViewType.CALENDAR_WIDGET);
|
||||
expect(data.upsertViewWidget.calendarLayout).toBe(
|
||||
ViewCalendarLayout.WEEK,
|
||||
);
|
||||
} finally {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should switch the widget view to CALENDAR_WIDGET with a date field and layout', async () => {
|
||||
const { data } = await upsertViewWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId,
|
||||
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.calendarLayout).toBe(ViewCalendarLayout.MONTH);
|
||||
expect(data.upsertViewWidget.calendarFieldMetadataId).toBe(
|
||||
dateFieldMetadataId,
|
||||
);
|
||||
|
||||
const { data: viewGroupsData } = await findViewGroups({
|
||||
expectToFail: false,
|
||||
viewId,
|
||||
});
|
||||
|
||||
expect(viewGroupsData.getViewGroups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should switch the widget view back to TABLE_WIDGET', async () => {
|
||||
const { data } = await upsertViewWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.TABLE_WIDGET,
|
||||
},
|
||||
},
|
||||
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
|
||||
});
|
||||
|
||||
expect(data.upsertViewWidget.type).toBe(ViewType.TABLE_WIDGET);
|
||||
});
|
||||
|
||||
it('should update view settings and view fields in a single call', async () => {
|
||||
const { data } = await upsertViewWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
widgetId,
|
||||
view: {
|
||||
type: ViewType.KANBAN_WIDGET,
|
||||
mainGroupByFieldMetadataId: selectFieldMetadataId,
|
||||
shouldHideEmptyGroups: true,
|
||||
},
|
||||
viewFields: [
|
||||
{
|
||||
fieldMetadataId: selectFieldMetadataId,
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
gqlFields: `
|
||||
${VIEW_SETTINGS_GQL_FIELDS}
|
||||
viewFields {
|
||||
id
|
||||
fieldMetadataId
|
||||
isVisible
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(data.upsertViewWidget.type).toBe(ViewType.KANBAN_WIDGET);
|
||||
expect(data.upsertViewWidget.shouldHideEmptyGroups).toBe(true);
|
||||
expect(
|
||||
(data.upsertViewWidget.viewFields ?? []).some(
|
||||
(viewField) => viewField.fieldMetadataId === selectFieldMetadataId,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,4 +4,6 @@ export enum ViewType {
|
||||
CALENDAR = 'CALENDAR',
|
||||
FIELDS_WIDGET = 'FIELDS_WIDGET',
|
||||
TABLE_WIDGET = 'TABLE_WIDGET',
|
||||
KANBAN_WIDGET = 'KANBAN_WIDGET',
|
||||
CALENDAR_WIDGET = 'CALENDAR_WIDGET',
|
||||
}
|
||||
|
||||
@@ -248,3 +248,5 @@ export { normalizeLocale } from './validation/normalizeLocale';
|
||||
export { getCountryCodesForCallingCode } from './validation/phones-value/getCountryCodesForCallingCode';
|
||||
export { isValidCountryCode } from './validation/phones-value/isValidCountryCode';
|
||||
export { resolveInput } from './variable-resolver';
|
||||
export { getViewLayoutFromViewType } from './views/getViewLayoutFromViewType';
|
||||
export { isWidgetViewType } from './views/isWidgetViewType';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ViewType } from '@/types';
|
||||
|
||||
type ViewTypeValue = `${ViewType}`;
|
||||
|
||||
const VIEW_TYPE_TO_LAYOUT_MAPPING: Record<ViewTypeValue, ViewTypeValue> = {
|
||||
[ViewType.TABLE]: ViewType.TABLE,
|
||||
[ViewType.KANBAN]: ViewType.KANBAN,
|
||||
[ViewType.CALENDAR]: ViewType.CALENDAR,
|
||||
[ViewType.FIELDS_WIDGET]: ViewType.FIELDS_WIDGET,
|
||||
[ViewType.TABLE_WIDGET]: ViewType.TABLE,
|
||||
[ViewType.KANBAN_WIDGET]: ViewType.KANBAN,
|
||||
[ViewType.CALENDAR_WIDGET]: ViewType.CALENDAR,
|
||||
};
|
||||
|
||||
export const getViewLayoutFromViewType = (
|
||||
viewType: ViewTypeValue,
|
||||
): ViewTypeValue => {
|
||||
return VIEW_TYPE_TO_LAYOUT_MAPPING[viewType];
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ViewType } from '@/types';
|
||||
|
||||
type ViewTypeValue = `${ViewType}`;
|
||||
|
||||
const WIDGET_VIEW_TYPES: ViewTypeValue[] = [
|
||||
ViewType.FIELDS_WIDGET,
|
||||
ViewType.TABLE_WIDGET,
|
||||
ViewType.KANBAN_WIDGET,
|
||||
ViewType.CALENDAR_WIDGET,
|
||||
];
|
||||
|
||||
export const isWidgetViewType = (viewType: ViewTypeValue): boolean => {
|
||||
return WIDGET_VIEW_TYPES.includes(viewType);
|
||||
};
|
||||
Reference in New Issue
Block a user