From 3ad3e8bd1a8ad45a9774928562e68f7037547255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 21 Jul 2026 15:41:08 +0200 Subject: [PATCH] feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../src/metadata/generated/schema.graphql | 23 + .../src/metadata/generated/schema.ts | 12 +- .../src/metadata/generated/types.ts | 308 +++++++------ .../developers/extend/apps/layout/views.mdx | 2 +- .../src/generated-metadata/graphql.ts | 18 + .../types/ContextStoreViewType.ts | 1 + .../hooks/useLoadStaleMetadataEntities.ts | 8 +- .../isViewDisplayableInNavigationMenu.ts | 7 +- ...ptionsDropdownCalendarEndFieldsContent.tsx | 8 +- ...ctOptionsDropdownCalendarFieldsContent.tsx | 14 +- ...jectOptionsDropdownCalendarViewContent.tsx | 13 +- .../ObjectOptionsDropdownCustomView.tsx | 7 +- .../ObjectOptionsDropdownLayoutContent.tsx | 6 +- ...ptionsDropdownCalendarViewContent.test.tsx | 23 +- .../useSetViewTypeFromLayoutOptionsMenu.ts | 2 + .../components/RecordBoardWidget.tsx | 42 ++ ...dBoardWidgetViewSettingsReadOnlyEffect.tsx | 32 ++ .../record-board/components/RecordBoard.tsx | 10 +- .../components/RecordBoardAddGroupColumn.tsx | 10 + .../components/RecordBoardDragDropContext.tsx | 5 +- .../components/RecordBoardHeader.tsx | 7 +- .../RecordBoardStickyHeaderEffect.tsx | 12 +- .../RecordBoardCardDraggableContainer.tsx | 56 ++- .../components/RecordBoardColumnHeader.tsx | 115 +++-- .../RecordBoardColumnDndKitProvider.tsx | 8 +- ...BoardViewSettingsReadOnlyComponentState.ts | 14 + .../utils/getRecordBoardHeaderHtmlId.ts | 2 + .../components/RecordCalendarWidget.tsx | 79 ++++ .../RecordCalendarWidgetReadOnlyEffect.tsx | 32 ++ .../components/RecordCalendar.tsx | 8 +- .../components/RecordCalendarAddNew.tsx | 20 +- .../components/RecordCalendarTopBar.tsx | 51 ++- .../__tests__/RecordCalendar.test.tsx | 21 +- .../__tests__/RecordCalendarTopBar.test.tsx | 28 +- .../hooks/useRecordCalendarGroupByRecords.ts | 14 +- .../useRecordCalendarQueryDateRangeFilter.tsx | 25 +- .../useIsRecordCalendarCardDragDisabled.ts | 24 +- .../isRecordCalendarReadOnlyComponentState.ts | 15 + ...lendarDayRecordsComponentFamilySelector.ts | 10 +- .../components/RecordCalendarTimeGrid.tsx | 14 +- ...eldMetadataItemAvailableAsCalendarField.ts | 19 + .../hooks/useProcessCalendarCardDrop.ts | 8 +- .../hooks/useLoadRecordIndexStates.ts | 30 +- ...alendarEndFieldMetadataIdComponentState.ts | 9 + ...ordIndexCalendarEndFieldMetadataIdState.ts | 8 - ...exCalendarFieldMetadataIdComponentState.ts | 9 + ...recordIndexCalendarFieldMetadataIdState.ts | 8 - ...recordIndexCalendarLayoutComponentState.ts | 10 + .../states/recordIndexCalendarLayoutState.ts | 8 - ...ecordTableWidgetContextStoreInitEffect.tsx | 5 +- .../components/RecordTableWidgetProvider.tsx | 4 + ...cordTableWidgetViewLoadContentSignature.ts | 19 + .../RecordTableRecordGroupAddNewGroup.tsx | 9 + .../RecordTableRecordGroupSection.tsx | 30 +- .../RecordTableRecordGroupSectionAddNew.tsx | 9 + .../useDuplicatePageLayoutTab.test.tsx | 1 + .../useCreatePendingRecordTableWidgetViews.ts | 12 + .../hooks/useSaveRecordTableWidgetViews.ts | 49 ++- .../RecordTableWidgetRendererContent.tsx | 77 +++- .../useRecordTableWidgetLayoutCallbacks.ts | 212 +++++++++ .../types/RecordTableWidgetViewSnapshot.ts | 2 + ...raftViewGroupsForFieldMetadataItem.test.ts | 82 ++++ ...dUpsertViewWidgetViewSettingsInput.test.ts | 52 +++ ...cloneRecordTableWidgetViewSnapshot.test.ts | 21 + ...uildDraftViewGroupsForFieldMetadataItem.ts | 43 ++ .../buildRecordTableWidgetViewSnapshot.ts | 1 + ...ldRecordTableWidgetViewSnapshotFromView.ts | 11 +- .../buildUpsertViewWidgetViewSettingsInput.ts | 18 + .../cloneRecordTableWidgetViewSnapshot.ts | 5 + ...ctViewFromRecordTableWidgetViewSnapshot.ts | 2 +- ...tadataItemAvailableAsWidgetGroupByField.ts | 13 + .../SidePanelDashboardRecordTableSettings.tsx | 259 +++++++++-- ...ecordTableCalendarFieldDropdownContent.tsx | 89 ++++ ...cordTableCalendarLayoutDropdownContent.tsx | 86 ++++ .../RecordTableGroupByDropdownContent.tsx | 138 ++++++ .../RecordTableLayoutDropdownContent.tsx | 147 +++++++ ...ewsFromObjectMetadataItemFamilySelector.ts | 6 +- .../src/modules/views/types/ViewType.ts | 2 + .../hooks/useGetAvailableFieldsForCalendar.ts | 15 +- ...d-kanban-and-calendar-widget-view-types.ts | 37 ++ ...iden-view-calendar-integrity-constraint.ts | 27 ++ .../instance-commands.constant.ts | 4 + .../upsert-view-widget-view-settings.input.ts | 80 ++++ .../dtos/inputs/upsert-view-widget.input.ts | 11 + .../view/entities/view.entity.ts | 2 +- .../services/view-widget-upsert.service.ts | 64 ++- .../view/tools/view-tools.factory.ts | 21 +- .../services/flat-view-validator.service.ts | 43 +- .../tools/create-complete-dashboard.tool.ts | 5 +- ...w-widget-view-settings.integration-spec.ts | 407 ++++++++++++++++++ packages/twenty-shared/src/types/ViewType.ts | 2 + packages/twenty-shared/src/utils/index.ts | 2 + .../utils/views/getViewLayoutFromViewType.ts | 19 + .../src/utils/views/isWidgetViewType.ts | 14 + 94 files changed, 2928 insertions(+), 444 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidget.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/utils/getRecordBoardHeaderHtmlId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidget.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutComponentState.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildUpsertViewWidgetViewSettingsInput.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField.ts create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent.tsx create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626405-add-kanban-and-calendar-widget-view-types.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626406-widen-view-calendar-integrity-constraint.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget-view-settings.integration-spec.ts create mode 100644 packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts create mode 100644 packages/twenty-shared/src/utils/views/isWidgetViewType.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 4134d0e67e..9ea567c252 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -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 diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 56728c9221..7222517b14 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -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 = { diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 3f5ed57ebd..136e3b66b1 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -82,7 +82,7 @@ export default { 352, 359, 360, - 487 + 488 ], "types": { "BillingProductDTO": { @@ -7390,7 +7390,7 @@ export default { 62, { "input": [ - 375, + 376, "CreateViewSortInput!" ] } @@ -7399,7 +7399,7 @@ export default { 62, { "input": [ - 376, + 377, "UpdateViewSortInput!" ] } @@ -7408,7 +7408,7 @@ export default { 6, { "input": [ - 378, + 379, "DeleteViewSortInput!" ] } @@ -7417,7 +7417,7 @@ export default { 6, { "input": [ - 379, + 380, "DestroyViewSortInput!" ] } @@ -7426,7 +7426,7 @@ export default { 55, { "input": [ - 380, + 381, "UpdateViewFieldInput!" ] } @@ -7435,7 +7435,7 @@ export default { 55, { "input": [ - 382, + 383, "CreateViewFieldInput!" ] } @@ -7444,7 +7444,7 @@ export default { 55, { "inputs": [ - 382, + 383, "[CreateViewFieldInput!]!" ] } @@ -7453,7 +7453,7 @@ export default { 55, { "input": [ - 383, + 384, "DeleteViewFieldInput!" ] } @@ -7462,7 +7462,7 @@ export default { 55, { "input": [ - 384, + 385, "DestroyViewFieldInput!" ] } @@ -7471,7 +7471,7 @@ export default { 64, { "input": [ - 385, + 386, "UpdateViewFieldGroupInput!" ] } @@ -7480,7 +7480,7 @@ export default { 64, { "input": [ - 387, + 388, "CreateViewFieldGroupInput!" ] } @@ -7489,7 +7489,7 @@ export default { 64, { "inputs": [ - 387, + 388, "[CreateViewFieldGroupInput!]!" ] } @@ -7498,7 +7498,7 @@ export default { 64, { "input": [ - 388, + 389, "DeleteViewFieldGroupInput!" ] } @@ -7507,7 +7507,7 @@ export default { 64, { "input": [ - 389, + 390, "DestroyViewFieldGroupInput!" ] } @@ -7516,7 +7516,7 @@ export default { 65, { "input": [ - 390, + 391, "UpsertFieldsWidgetInput!" ] } @@ -7525,7 +7525,7 @@ export default { 2, { "input": [ - 393, + 394, "CreateApiKeyInput!" ] } @@ -7534,7 +7534,7 @@ export default { 2, { "input": [ - 394, + 395, "UpdateApiKeyInput!" ] } @@ -7543,7 +7543,7 @@ export default { 2, { "input": [ - 395, + 396, "RevokeApiKeyInput!" ] } @@ -7698,7 +7698,7 @@ export default { 125, { "input": [ - 396, + 397, "CreateApprovedAccessDomainInput!" ] } @@ -7707,7 +7707,7 @@ export default { 6, { "input": [ - 397, + 398, "DeleteApprovedAccessDomainInput!" ] } @@ -7716,7 +7716,7 @@ export default { 125, { "input": [ - 398, + 399, "ValidateApprovedAccessDomainInput!" ] } @@ -7725,7 +7725,7 @@ export default { 118, { "input": [ - 399, + 400, "CreatePageLayoutTabInput!" ] } @@ -7738,7 +7738,7 @@ export default { "String!" ], "input": [ - 400, + 401, "UpdatePageLayoutTabInput!" ] } @@ -7756,7 +7756,7 @@ export default { 119, { "input": [ - 401, + 402, "CreatePageLayoutInput!" ] } @@ -7769,7 +7769,7 @@ export default { "String!" ], "input": [ - 402, + 403, "UpdatePageLayoutInput!" ] } @@ -7791,7 +7791,7 @@ export default { "String!" ], "input": [ - 403, + 404, "UpdatePageLayoutWithTabsInput!" ] } @@ -7827,7 +7827,7 @@ export default { 80, { "input": [ - 407, + 408, "CreatePageLayoutWidgetInput!" ] } @@ -7840,7 +7840,7 @@ export default { "String!" ], "input": [ - 408, + 409, "UpdatePageLayoutWidgetInput!" ] } @@ -7858,7 +7858,7 @@ export default { 25, { "input": [ - 409, + 410, "CreateAgentInput!" ] } @@ -7867,7 +7867,7 @@ export default { 25, { "input": [ - 410, + 411, "UpdateAgentInput!" ] } @@ -7885,7 +7885,7 @@ export default { 47, { "input": [ - 411, + 412, "CreateOneObjectInput!" ] } @@ -7894,7 +7894,7 @@ export default { 47, { "input": [ - 413, + 414, "DeleteOneObjectInput!" ] } @@ -7903,7 +7903,7 @@ export default { 47, { "input": [ - 414, + 415, "UpdateOneObjectInput!" ] } @@ -7912,7 +7912,7 @@ export default { 45, { "input": [ - 416, + 417, "CreateOneIndexInput!" ] } @@ -7921,7 +7921,7 @@ export default { 45, { "input": [ - 419, + 420, "DeleteOneIndexInput!" ] } @@ -7939,7 +7939,7 @@ export default { 41, { "input": [ - 420, + 421, "CreateLogicFunctionFromSourceInput!" ] } @@ -7948,7 +7948,7 @@ export default { 165, { "input": [ - 421, + 422, "ExecuteOneLogicFunctionInput!" ] } @@ -7957,7 +7957,7 @@ export default { 6, { "input": [ - 422, + 423, "UpdateLogicFunctionFromSourceInput!" ] } @@ -7966,7 +7966,7 @@ export default { 35, { "input": [ - 424, + 425, "CreateCommandMenuItemInput!" ] } @@ -7975,7 +7975,7 @@ export default { 35, { "input": [ - 425, + 426, "UpdateCommandMenuItemInput!" ] } @@ -8002,7 +8002,7 @@ export default { 34, { "input": [ - 426, + 427, "CreateFrontComponentInput!" ] } @@ -8011,7 +8011,7 @@ export default { 34, { "input": [ - 427, + 428, "UpdateFrontComponentInput!" ] } @@ -8029,7 +8029,7 @@ export default { 71, { "data": [ - 429, + 430, "ActivateWorkspaceInput!" ] } @@ -8038,7 +8038,7 @@ export default { 71, { "data": [ - 430, + 431, "UpdateWorkspaceInput!" ] } @@ -8066,7 +8066,7 @@ export default { 197, { "input": [ - 431, + 432, "CreateApplicationRegistrationInput!" ] } @@ -8075,7 +8075,7 @@ export default { 8, { "input": [ - 432, + 433, "UpdateApplicationRegistrationInput!" ] } @@ -8102,7 +8102,7 @@ export default { 5, { "input": [ - 434, + 435, "CreateApplicationRegistrationVariableInput!" ] } @@ -8111,7 +8111,7 @@ export default { 5, { "input": [ - 435, + 436, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8191,7 +8191,7 @@ export default { "UUID!" ], "input": [ - 437, + 438, "UpdateApplicationInput!" ] } @@ -8212,7 +8212,7 @@ export default { 43, { "input": [ - 438, + 439, "CreateOneFieldMetadataInput!" ] } @@ -8221,7 +8221,7 @@ export default { 43, { "input": [ - 440, + 441, "UpdateOneFieldMetadataInput!" ] } @@ -8230,7 +8230,7 @@ export default { 43, { "input": [ - 442, + 443, "DeleteOneFieldInput!" ] } @@ -8239,7 +8239,7 @@ export default { 61, { "input": [ - 443, + 444, "CreateViewGroupInput!" ] } @@ -8248,7 +8248,7 @@ export default { 61, { "inputs": [ - 443, + 444, "[CreateViewGroupInput!]!" ] } @@ -8257,7 +8257,7 @@ export default { 61, { "input": [ - 444, + 445, "UpdateViewGroupInput!" ] } @@ -8266,7 +8266,7 @@ export default { 61, { "inputs": [ - 444, + 445, "[UpdateViewGroupInput!]!" ] } @@ -8275,7 +8275,7 @@ export default { 61, { "input": [ - 446, + 447, "DeleteViewGroupInput!" ] } @@ -8284,7 +8284,7 @@ export default { 61, { "input": [ - 447, + 448, "DestroyViewGroupInput!" ] } @@ -8306,7 +8306,7 @@ export default { 29, { "createRoleInput": [ - 448, + 449, "CreateRoleInput!" ] } @@ -8315,7 +8315,7 @@ export default { 29, { "updateRoleInput": [ - 449, + 450, "UpdateRoleInput!" ] } @@ -8333,7 +8333,7 @@ export default { 16, { "upsertObjectPermissionsInput": [ - 451, + 452, "UpsertObjectPermissionsInput!" ] } @@ -8342,7 +8342,7 @@ export default { 27, { "upsertPermissionFlagsInput": [ - 453, + 454, "UpsertPermissionFlagsInput!" ] } @@ -8351,7 +8351,7 @@ export default { 26, { "upsertFieldPermissionsInput": [ - 454, + 455, "UpsertFieldPermissionsInput!" ] } @@ -8360,7 +8360,7 @@ export default { 234, { "input": [ - 456, + 457, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -8391,7 +8391,7 @@ export default { 282, { "input": [ - 459, + 460, "SendEmailViaDomainInput!" ] } @@ -8400,7 +8400,7 @@ export default { 284, { "input": [ - 460, + 461, "SendMessageCampaignInput!" ] } @@ -8409,7 +8409,7 @@ export default { 285, { "input": [ - 461, + 462, "CreateUnsubscribeTopicInput!" ] } @@ -8418,7 +8418,7 @@ export default { 285, { "input": [ - 462, + 463, "UpdateUnsubscribeTopicInput!" ] } @@ -8436,7 +8436,7 @@ export default { 272, { "input": [ - 463, + 464, "UpdateMessageChannelInput!" ] } @@ -8445,7 +8445,7 @@ export default { 280, { "input": [ - 465, + 466, "CreateEmailGroupChannelInput!" ] } @@ -8463,7 +8463,7 @@ export default { 270, { "input": [ - 466, + 467, "CreateEmailingDomainInput!" ] } @@ -8499,7 +8499,7 @@ export default { 297, { "input": [ - 467, + 468, "RunAgentInput!" ] } @@ -8508,7 +8508,7 @@ export default { 294, { "input": [ - 468, + 469, "CreateWebhookInput!" ] } @@ -8517,7 +8517,7 @@ export default { 294, { "input": [ - 469, + 470, "UpdateWebhookInput!" ] } @@ -8535,7 +8535,7 @@ export default { 330, { "input": [ - 471, + 472, "UpdateMessageFolderInput!" ] } @@ -8544,7 +8544,7 @@ export default { 330, { "input": [ - 473, + 474, "UpdateMessageFoldersInput!" ] } @@ -8553,7 +8553,7 @@ export default { 325, { "input": [ - 474, + 475, "UpdateCalendarChannelInput!" ] } @@ -8583,7 +8583,7 @@ export default { 1 ], "fileAttachments": [ - 476, + 477, "[FileAttachmentInput!]" ] } @@ -8612,7 +8612,7 @@ export default { "UUID!" ], "answers": [ - 477, + 478, "[AgentChatQuestionAnswerInput!]!" ], "modelId": [ @@ -8682,7 +8682,7 @@ export default { 313, { "input": [ - 478, + 479, "CreateSkillInput!" ] } @@ -8691,7 +8691,7 @@ export default { 313, { "input": [ - 479, + 480, "UpdateSkillInput!" ] } @@ -8749,7 +8749,7 @@ export default { 247, { "input": [ - 480, + 481, "GetAuthorizationUrlForSSOInput!" ] } @@ -8915,7 +8915,7 @@ export default { 250, { "input": [ - 481 + 482 ] } ], @@ -9070,7 +9070,7 @@ export default { 6, { "input": [ - 482, + 483, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -9104,7 +9104,7 @@ export default { 207, { "input": [ - 483, + 484, "SetupOIDCSsoInput!" ] } @@ -9113,7 +9113,7 @@ export default { 207, { "input": [ - 484, + 485, "SetupSAMLSsoInput!" ] } @@ -9122,7 +9122,7 @@ export default { 203, { "input": [ - 485, + 486, "DeleteSsoInput!" ] } @@ -9131,7 +9131,7 @@ export default { 204, { "input": [ - 486, + 487, "EditSsoInput!" ] } @@ -9160,7 +9160,7 @@ export default { 309, { "type": [ - 487, + 488, "AnalyticsType!" ], "name": [ @@ -9200,7 +9200,7 @@ export default { 299, { "input": [ - 488, + 489, "CreateCalendarEventInput!" ] } @@ -9209,7 +9209,7 @@ export default { 308, { "input": [ - 489, + 490, "SendEmailInput!" ] } @@ -9231,7 +9231,7 @@ export default { "String!" ], "connectionParameters": [ - 491, + 492, "EmailAccountConnectionParameters!" ], "id": [ @@ -9243,7 +9243,7 @@ export default { 167, { "input": [ - 493, + 494, "UpdateLabPublicFeatureFlagInput!" ] } @@ -9702,18 +9702,56 @@ export default { "widgetId": [ 3 ], - "viewFields": [ + "view": [ 371 ], - "viewFilters": [ + "viewFields": [ 372 ], - "viewFilterGroups": [ + "viewFilters": [ 373 ], - "viewSorts": [ + "viewFilterGroups": [ 374 ], + "viewSorts": [ + 375 + ], + "__typename": [ + 1 + ] + }, + "UpsertViewWidgetViewSettingsInput": { + "type": [ + 66 + ], + "mainGroupByFieldMetadataId": [ + 3 + ], + "shouldHideEmptyGroups": [ + 6 + ], + "openRecordIn": [ + 68 + ], + "kanbanAggregateOperation": [ + 56 + ], + "kanbanAggregateOperationFieldMetadataId": [ + 3 + ], + "kanbanColumnWidth": [ + 21 + ], + "calendarLayout": [ + 69 + ], + "calendarFieldMetadataId": [ + 3 + ], + "calendarEndFieldMetadataId": [ + 3 + ], "__typename": [ 1 ] @@ -9823,7 +9861,7 @@ export default { 3 ], "update": [ - 377 + 378 ], "__typename": [ 1 @@ -9861,7 +9899,7 @@ export default { 3 ], "update": [ - 381 + 382 ], "__typename": [ 1 @@ -9937,7 +9975,7 @@ export default { 3 ], "update": [ - 386 + 387 ], "__typename": [ 1 @@ -10001,10 +10039,10 @@ export default { 3 ], "groups": [ - 391 + 392 ], "fields": [ - 392 + 393 ], "__typename": [ 1 @@ -10024,7 +10062,7 @@ export default { 6 ], "fields": [ - 392 + 393 ], "__typename": [ 1 @@ -10192,7 +10230,7 @@ export default { 3 ], "tabs": [ - 404 + 405 ], "__typename": [ 1 @@ -10215,7 +10253,7 @@ export default { 84 ], "widgets": [ - 405 + 406 ], "__typename": [ 1 @@ -10238,7 +10276,7 @@ export default { 3 ], "gridPosition": [ - 406 + 407 ], "position": [ 7 @@ -10287,7 +10325,7 @@ export default { 3 ], "gridPosition": [ - 406 + 407 ], "position": [ 7 @@ -10313,7 +10351,7 @@ export default { 3 ], "gridPosition": [ - 406 + 407 ], "position": [ 7 @@ -10406,7 +10444,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 412 + 413 ], "__typename": [ 1 @@ -10466,7 +10504,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 415 + 416 ], "id": [ 3 @@ -10521,7 +10559,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 417 + 418 ], "__typename": [ 1 @@ -10532,7 +10570,7 @@ export default { 3 ], "fields": [ - 418 + 419 ], "indexType": [ 46 @@ -10617,7 +10655,7 @@ export default { 3 ], "update": [ - 423 + 424 ], "__typename": [ 1 @@ -10777,7 +10815,7 @@ export default { 3 ], "update": [ - 428 + 429 ], "__typename": [ 1 @@ -10904,7 +10942,7 @@ export default { 1 ], "update": [ - 433 + 434 ], "__typename": [ 1 @@ -10958,7 +10996,7 @@ export default { 1 ], "update": [ - 436 + 437 ], "__typename": [ 1 @@ -10988,7 +11026,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 439 + 440 ], "__typename": [ 1 @@ -11061,7 +11099,7 @@ export default { 3 ], "update": [ - 441 + 442 ], "__typename": [ 1 @@ -11156,7 +11194,7 @@ export default { 3 ], "update": [ - 445 + 446 ], "__typename": [ 1 @@ -11241,7 +11279,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 450 + 451 ], "id": [ 3 @@ -11296,7 +11334,7 @@ export default { 3 ], "objectPermissions": [ - 452 + 453 ], "__typename": [ 1 @@ -11338,7 +11376,7 @@ export default { 3 ], "fieldPermissions": [ - 455 + 456 ], "__typename": [ 1 @@ -11369,10 +11407,10 @@ export default { 3 ], "predicates": [ - 457 + 458 ], "predicateGroups": [ - 458 + 459 ], "__typename": [ 1 @@ -11518,7 +11556,7 @@ export default { 3 ], "update": [ - 464 + 465 ], "__typename": [ 1 @@ -11602,7 +11640,7 @@ export default { 3 ], "update": [ - 470 + 471 ], "__typename": [ 1 @@ -11630,7 +11668,7 @@ export default { 3 ], "update": [ - 472 + 473 ], "__typename": [ 1 @@ -11649,7 +11687,7 @@ export default { 3 ], "update": [ - 472 + 473 ], "__typename": [ 1 @@ -11660,7 +11698,7 @@ export default { 3 ], "update": [ - 475 + 476 ], "__typename": [ 1 @@ -11914,7 +11952,7 @@ export default { 1 ], "files": [ - 490 + 491 ], "__typename": [ 1 @@ -11936,13 +11974,13 @@ export default { 1 ], "IMAP": [ - 492 + 493 ], "SMTP": [ - 492 + 493 ], "CALDAV": [ - 492 + 493 ], "__typename": [ 1 @@ -11993,7 +12031,7 @@ export default { 235, { "input": [ - 495, + 496, "LogicFunctionLogsInput!" ] } diff --git a/packages/twenty-docs/developers/extend/apps/layout/views.mdx b/packages/twenty-docs/developers/extend/apps/layout/views.mdx index ee444d84dc..45a22255e5 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/views.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/views.mdx @@ -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. | diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 5ea53486e8..32a4a99cb2 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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; /** The view fields to upsert. */ viewFields?: InputMaybe>; /** The view filter groups to upsert. */ @@ -5868,6 +5870,20 @@ export type UpsertViewWidgetViewFilterInput = { viewFilterGroupId?: InputMaybe; }; +export type UpsertViewWidgetViewSettingsInput = { + calendarEndFieldMetadataId?: InputMaybe; + calendarFieldMetadataId?: InputMaybe; + calendarLayout?: InputMaybe; + kanbanAggregateOperation?: InputMaybe; + kanbanAggregateOperationFieldMetadataId?: InputMaybe; + kanbanColumnWidth?: InputMaybe; + mainGroupByFieldMetadataId?: InputMaybe; + openRecordIn?: InputMaybe; + shouldHideEmptyGroups?: InputMaybe; + /** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */ + type?: InputMaybe; +}; + export type UpsertViewWidgetViewSortInput = { direction?: InputMaybe; 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' } diff --git a/packages/twenty-front/src/modules/context-store/types/ContextStoreViewType.ts b/packages/twenty-front/src/modules/context-store/types/ContextStoreViewType.ts index 91c2afcb9a..d2d275e99e 100644 --- a/packages/twenty-front/src/modules/context-store/types/ContextStoreViewType.ts +++ b/packages/twenty-front/src/modules/context-store/types/ContextStoreViewType.ts @@ -1,4 +1,5 @@ export enum ContextStoreViewType { Table = 'table', Kanban = 'kanban', + Calendar = 'calendar', } diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts index ca7c4765d5..ad41cff66a 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts @@ -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( diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu.ts b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu.ts index 7dbbe028fc..1ee1c32648 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/side-panel/utils/isViewDisplayableInNavigationMenu.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent.tsx index 1f9cb4181b..1329ff05e5 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent.tsx @@ -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 = diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent.tsx index 500d4cdc55..65c6439242 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent.tsx @@ -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 diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx index 6e14cf8f65..7b05116626 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx index 6ea2b089ee..5fffbc3dfe 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx index e5fcc91fd6..dc5e39bdcf 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx index 57c6f8b179..4ad7155754 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx @@ -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); }); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts index f8c1b69ffc..12987c1c62 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidget.tsx b/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidget.tsx new file mode 100644 index 0000000000..be27449cbe --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidget.tsx @@ -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 ( + <> + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect.tsx new file mode 100644 index 0000000000..4680fb69b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 2a496b711b..79434f30c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -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(null); + const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue( + isRecordBoardViewSettingsReadOnlyComponentState, + ); + return ( <> { - + {!isRecordBoardViewSettingsReadOnly && ( + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx index 564904143f..85f6cc53e2 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx index bb0034b37d..faf10ef262 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx index 8234d7005f..f2f10328ee 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx @@ -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 ( - + diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx index ebfd43351c..0b522b3721 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx @@ -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 <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx index e6bdabde6e..d0f1c308a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx @@ -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) => ( - - - {isRecordBoardCardFocused && } - - - - - )} + {(draggableProvided, draggableSnapshot) => { + const draggableContent = ( + + + {isRecordBoardCardFocused && } + + + + + ); + + // 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; + }} ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index f7d71abd2d..fcaff02694 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -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 = () => { - - - - } - dropdownComponents={} - /> + {isRecordBoardViewSettingsReadOnly ? ( + + + + ) : ( + + + + } + dropdownComponents={} + /> + )} - + + + - - { - toggleDropdown({ - dropdownComponentInstanceIdFromProps: dropdownId, - }); - }} - /> - {canCreateRecords && !hasAnySoftDeleteFilterOnView && ( + {!isRecordBoardViewSettingsReadOnly && ( + { + toggleDropdown({ + dropdownComponentInstanceIdFromProps: dropdownId, + }); + }} /> - )} - + {canCreateRecords && !hasAnySoftDeleteFilterOnView && ( + + )} + + )} - + {!isRecordBoardViewSettingsReadOnly && } ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx index e20012a51c..bf0f50d0b4 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx @@ -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 ( <> - sensors={DND_KIT_SENSORS} + sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState.ts new file mode 100644 index 0000000000..2dc4cae3bb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState.ts @@ -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({ + key: 'isRecordBoardViewSettingsReadOnlyComponentState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/getRecordBoardHeaderHtmlId.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/getRecordBoardHeaderHtmlId.ts new file mode 100644 index 0000000000..2153cf2dca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/getRecordBoardHeaderHtmlId.ts @@ -0,0 +1,2 @@ +export const getRecordBoardHeaderHtmlId = (recordBoardId: string) => + `record-board-header-${recordBoardId}`; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidget.tsx b/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidget.tsx new file mode 100644 index 0000000000..3a1375603e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidget.tsx @@ -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 ( + <> + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect.tsx new file mode 100644 index 0000000000..23a33f59c8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx index 60cdbdc3a2..dfde069fb6 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx index 09556f2e86..560f3b9df6 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx index 2014764288..dd6956d926 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx @@ -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 ( - {isCalendarWeekViewEnabled && ( - + )} ({ 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(); @@ -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(); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/__tests__/RecordCalendarTopBar.test.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/__tests__/RecordCalendarTopBar.test.tsx index 30d6cf89d8..d5f1070873 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/__tests__/RecordCalendarTopBar.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/__tests__/RecordCalendarTopBar.test.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts index b7e94c0fc0..c6f4456d39 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts @@ -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({ diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx index 0c9dc5cbf9..4201284dc5 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx @@ -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( diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled.ts b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled.ts index 46b39f4685..6c3a9afa17 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled.ts @@ -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 ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState.ts new file mode 100644 index 0000000000..01be85efb4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState.ts @@ -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({ + key: 'isRecordCalendarReadOnlyComponentState', + defaultValue: false, + componentInstanceContext: RecordCalendarComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts index 153cdec1c9..1477e584f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts @@ -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); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx index b5743a5dc6..6aa40a8602 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx @@ -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(null); const gridRef = useRef(null); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField.ts b/packages/twenty-front/src/modules/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField.ts new file mode 100644 index 0000000000..9712b44d98 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField.ts @@ -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, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts index 1cea763379..f613842d84 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts @@ -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(); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts index f2a2563a4a..1a6e867701 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState.ts new file mode 100644 index 0000000000..e133b54ca8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState.ts @@ -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({ + key: 'recordIndexCalendarEndFieldMetadataIdComponentState', + defaultValue: null, + componentInstanceContext: RecordCalendarComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState.ts deleted file mode 100644 index b0f1c1cf4c..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const recordIndexCalendarEndFieldMetadataIdState = createAtomState< - string | null ->({ - key: 'recordIndexCalendarEndFieldMetadataIdState', - defaultValue: null, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState.ts new file mode 100644 index 0000000000..ff3e065cfb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState.ts @@ -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({ + key: 'recordIndexCalendarFieldMetadataIdComponentState', + defaultValue: null, + componentInstanceContext: RecordCalendarComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState.ts deleted file mode 100644 index e2cca98588..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const recordIndexCalendarFieldMetadataIdState = createAtomState< - string | null ->({ - key: 'recordIndexCalendarFieldMetadataIdState', - defaultValue: null, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutComponentState.ts new file mode 100644 index 0000000000..c33fb3e518 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutComponentState.ts @@ -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({ + key: 'recordIndexCalendarLayoutComponentState', + defaultValue: ViewCalendarLayout.MONTH, + componentInstanceContext: RecordCalendarComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutState.ts deleted file mode 100644 index defc80d425..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexCalendarLayoutState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; -import { ViewCalendarLayout } from '~/generated-metadata/graphql'; - -export const recordIndexCalendarLayoutState = - createAtomState({ - key: 'recordIndexCalendarLayoutState', - defaultValue: ViewCalendarLayout.MONTH, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect.tsx index 15f54305d7..10a06b8190 100644 --- a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx index 412618b58b..b5d5a78a2a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table-widget/components/RecordTableWidgetProvider.tsx @@ -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 = ({ groupA.position - groupB.position) + .map((group) => ({ + id: group.id, + fieldValue: group.fieldValue, + isVisible: group.isVisible, + position: group.position, + })), }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx index 2e8cc48192..c245321869 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx @@ -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) ) { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx index 23e77c56ca..5a605bdfea 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx @@ -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" /> - + + + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew.tsx index 19d3a0e9cd..6786c8e12f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSectionAddNew.tsx @@ -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 ( { diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePendingRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePendingRecordTableWidgetViews.ts index b052d92dd4..f1c0008dbf 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePendingRecordTableWidgetViews.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePendingRecordTableWidgetViews.ts @@ -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, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts index d692de90c4..f8042d82fd 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts @@ -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( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx index 9df5e8e57a..c69b75b29a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent.tsx @@ -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 ( <> - + {isKanbanLayout ? ( + + ) : isCalendarLayout ? ( + + ) : ( + + )} ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks.ts new file mode 100644 index 0000000000..969dd13f05 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot.ts index 7f37685ccb..74e4ac0db0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot.ts @@ -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 & { @@ -15,4 +16,5 @@ export type RecordTableWidgetViewSnapshot = { viewFilters: FlatViewFilter[]; viewFilterGroups: FlatViewFilterGroup[]; viewSorts: FlatViewSort[]; + viewGroups: FlatViewGroup[]; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts new file mode 100644 index 0000000000..7cf21ca61e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildDraftViewGroupsForFieldMetadataItem.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildUpsertViewWidgetViewSettingsInput.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildUpsertViewWidgetViewSettingsInput.test.ts new file mode 100644 index 0000000000..58175d738f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildUpsertViewWidgetViewSettingsInput.test.ts @@ -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 => + ({ + 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'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts index 1488861228..21d9ba89c6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/cloneRecordTableWidgetViewSnapshot.test.ts @@ -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); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts new file mode 100644 index 0000000000..3fe5d23873 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts index e438b33318..3af8c81b1c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts @@ -67,5 +67,6 @@ export const buildRecordTableWidgetViewSnapshot = ( viewFilters: [], viewFilterGroups: [], viewSorts: [], + viewGroups: [], }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts index 7c2b25609b..851d226545 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshotFromView.ts @@ -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, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput.ts new file mode 100644 index 0000000000..cd23d9354b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput.ts @@ -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, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts index 732cf42a26..48aa506dae 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/cloneRecordTableWidgetViewSnapshot.ts @@ -47,5 +47,10 @@ export const cloneRecordTableWidgetViewSnapshot = ( id: uuidv4(), viewId: newViewId, })), + viewGroups: sourceSnapshot.viewGroups.map((viewGroup) => ({ + ...viewGroup, + id: uuidv4(), + viewId: newViewId, + })), }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot.ts index 77e6313e2e..a484229e6c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot.ts @@ -18,5 +18,5 @@ export const constructViewFromRecordTableWidgetViewSnapshot = ( viewFilters: snapshot.viewFilters, viewFilterGroups: snapshot.viewFilterGroups, viewSorts: snapshot.viewSorts, - viewGroups: [], + viewGroups: snapshot.viewGroups, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField.ts new file mode 100644 index 0000000000..34aa1cfe60 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField.ts @@ -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); diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx index a034fb01d2..2abd2d9be7 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dashboard/SidePanelDashboardRecordTableSettings.tsx @@ -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 = () => { - - } - dropdownPlacement="bottom-end" - description={t`Table`} - disabled={true} - contextualTextPosition="right" - /> - { contextualTextPosition="right" /> + + + + + ) : ( + <> + ) + } + dropdownPlacement="bottom-end" + hasSubMenu={hasViewId} + description={ + isKanbanLayout + ? t`Kanban` + : isCalendarLayout + ? t`Calendar` + : t`Table` + } + disabled={!hasViewId} + contextualTextPosition="right" + /> + {hasViewId && ( <> @@ -210,16 +326,109 @@ export const SidePanelDashboardRecordTableSettings = () => { contextualTextPosition="right" /> - - - + {isCalendarLayout && ( + + + + + } + dropdownPlacement="bottom-end" + hasSubMenu + description={calendarFieldLabel} + contextualTextPosition="right" + /> + + )} + {isCalendarLayout && isCalendarWeekViewEnabled && ( + + + + + } + dropdownPlacement="bottom-end" + hasSubMenu + description={calendarLayoutLabel} + contextualTextPosition="right" + /> + + )} + {!isCalendarLayout && ( + + + + + } + dropdownPlacement="bottom-end" + hasSubMenu + description={mainGroupByFieldLabel} + contextualTextPosition="right" + /> + + )} + {!isCalendarLayout && hasGroupBy && ( + + + + )} + {!isCalendarLayout && !hasGroupBy && ( + + + + )} )} diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent.tsx new file mode 100644 index 0000000000..4045fed4c8 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent.tsx @@ -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 ( + + fieldMetadataItem.id, + )} + focusId={dropdownId} + > + {dateFields.map((fieldMetadataItem) => ( + { + handleCalendarFieldChange(fieldMetadataItem); + closeDropdown(); + }} + > + { + handleCalendarFieldChange(fieldMetadataItem); + closeDropdown(); + }} + /> + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent.tsx new file mode 100644 index 0000000000..b0b23a0268 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent.tsx @@ -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 ( + + value)} + focusId={dropdownId} + > + {calendarLayoutOptions.map(({ value, label, Icon }) => ( + handleSelect(value)} + > + handleSelect(value)} + /> + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent.tsx new file mode 100644 index 0000000000..fa58f0ec28 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent.tsx @@ -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 ( + <> + setSearchQuery(event.target.value)} + /> + + + + {isClearable && ( + { + handleGroupByFieldChange(null); + closeDropdown(); + }} + > + { + handleGroupByFieldChange(null); + closeDropdown(); + }} + /> + + )} + {filteredFields.map((fieldMetadataItem) => ( + { + handleGroupByFieldChange(fieldMetadataItem); + closeDropdown(); + }} + > + { + handleGroupByFieldChange(fieldMetadataItem); + closeDropdown(); + }} + /> + + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent.tsx new file mode 100644 index 0000000000..1ca533c3e4 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent.tsx @@ -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 ( + + + handleSelectLayout(ViewType.TABLE_WIDGET)} + > + handleSelectLayout(ViewType.TABLE_WIDGET)} + /> + + handleSelectLayout(ViewType.KANBAN_WIDGET)} + > + handleSelectLayout(ViewType.KANBAN_WIDGET)} + /> + + handleSelectLayout(ViewType.CALENDAR_WIDGET)} + > + handleSelectLayout(ViewType.CALENDAR_WIDGET)} + /> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewsFromObjectMetadataItemFamilySelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewsFromObjectMetadataItemFamilySelector.ts index 160db26f62..9f604bbd11 100644 --- a/packages/twenty-front/src/modules/views/states/selectors/viewsFromObjectMetadataItemFamilySelector.ts +++ b/packages/twenty-front/src/modules/views/states/selectors/viewsFromObjectMetadataItemFamilySelector.ts @@ -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({ @@ -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); }, diff --git a/packages/twenty-front/src/modules/views/types/ViewType.ts b/packages/twenty-front/src/modules/views/types/ViewType.ts index 1b094ad79f..bf14615d1a 100644 --- a/packages/twenty-front/src/modules/views/types/ViewType.ts +++ b/packages/twenty-front/src/modules/views/types/ViewType.ts @@ -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; export const getViewTypeLabel = ( diff --git a/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsForCalendar.ts b/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsForCalendar.ts index 9faf22e559..cb59869692 100644 --- a/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsForCalendar.ts +++ b/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsForCalendar.ts @@ -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(); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626405-add-kanban-and-calendar-widget-view-types.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626405-add-kanban-and-calendar-widget-view-types.ts new file mode 100644 index 0000000000..3e3124bb56 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626405-add-kanban-and-calendar-widget-view-types.ts @@ -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 { + 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 { + 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"', + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626406-widen-view-calendar-integrity-constraint.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626406-widen-view-calendar-integrity-constraint.ts new file mode 100644 index 0000000000..56746de452 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-23/2-23-instance-command-fast-1784620626406-widen-view-calendar-integrity-constraint.ts @@ -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 { + 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 { + 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))`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 032850c558..21d53a2752 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -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, ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input.ts b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input.ts new file mode 100644 index 0000000000..e0e310f206 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input.ts b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input.ts index 3dd8464391..838707e8f9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input.ts @@ -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) diff --git a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts index aaa43887ed..fea98ac738 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts @@ -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 diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts index 2309be9799..9020006e84 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts @@ -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: [], diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts index 3437695b85..a5b551cf8d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts @@ -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() diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts index c6094451cc..31fae3fc84 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts @@ -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], }), ); diff --git a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts index 2e8cc0c626..d884a1cd67 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts @@ -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: "", configuration: { configurationType: "RECORD_TABLE", viewId: "" } } AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`, diff --git a/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget-view-settings.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget-view-settings.integration-spec.ts new file mode 100644 index 0000000000..2094cac2c7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget-view-settings.integration-spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-shared/src/types/ViewType.ts b/packages/twenty-shared/src/types/ViewType.ts index e96d7e98c6..df6d1b19e3 100644 --- a/packages/twenty-shared/src/types/ViewType.ts +++ b/packages/twenty-shared/src/types/ViewType.ts @@ -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', } diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 6e50e913ce..db70aa1f0b 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts b/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts new file mode 100644 index 0000000000..419e3e0a7d --- /dev/null +++ b/packages/twenty-shared/src/utils/views/getViewLayoutFromViewType.ts @@ -0,0 +1,19 @@ +import { ViewType } from '@/types'; + +type ViewTypeValue = `${ViewType}`; + +const VIEW_TYPE_TO_LAYOUT_MAPPING: Record = { + [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]; +}; diff --git a/packages/twenty-shared/src/utils/views/isWidgetViewType.ts b/packages/twenty-shared/src/utils/views/isWidgetViewType.ts new file mode 100644 index 0000000000..ce20fdeb8c --- /dev/null +++ b/packages/twenty-shared/src/utils/views/isWidgetViewType.ts @@ -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); +};