feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)

## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server's
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view's layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module's three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout's transforms can't offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it's enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
This commit is contained in:
Félix Malfait
2026-07-21 15:41:08 +02:00
committed by GitHub
parent 2ef7b7824e
commit 3ad3e8bd1a
94 changed files with 2928 additions and 444 deletions
@@ -844,6 +844,8 @@ enum ViewType {
CALENDAR
FIELDS_WIDGET
TABLE_WIDGET
KANBAN_WIDGET
CALENDAR_WIDGET
}
enum ViewKey {
@@ -3668,6 +3670,11 @@ input UpsertViewWidgetInput {
"""The id of the view widget (page layout widget)."""
widgetId: UUID!
"""
View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view.
"""
view: UpsertViewWidgetViewSettingsInput
"""The view fields to upsert."""
viewFields: [UpsertViewWidgetViewFieldInput!]
@@ -3681,6 +3688,22 @@ input UpsertViewWidgetInput {
viewSorts: [UpsertViewWidgetViewSortInput!]
}
input UpsertViewWidgetViewSettingsInput {
"""
The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed.
"""
type: ViewType
mainGroupByFieldMetadataId: UUID
shouldHideEmptyGroups: Boolean
openRecordIn: ViewOpenRecordIn
kanbanAggregateOperation: AggregateOperations
kanbanAggregateOperationFieldMetadataId: UUID
kanbanColumnWidth: Int
calendarLayout: ViewCalendarLayout
calendarFieldMetadataId: UUID
calendarEndFieldMetadataId: UUID
}
input UpsertViewWidgetViewFieldInput {
"""The id of an existing view field to update."""
viewFieldId: UUID
@@ -601,7 +601,7 @@ export interface View {
__typename: 'View'
}
export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'FIELDS_WIDGET' | 'TABLE_WIDGET'
export type ViewType = 'TABLE' | 'KANBAN' | 'CALENDAR' | 'FIELDS_WIDGET' | 'TABLE_WIDGET' | 'KANBAN_WIDGET' | 'CALENDAR_WIDGET'
export type ViewKey = 'INDEX'
@@ -6328,6 +6328,8 @@ export interface UpdateViewInput {id?: (Scalars['UUID'] | null),name?: (Scalars[
export interface UpsertViewWidgetInput {
/** The id of the view widget (page layout widget). */
widgetId: Scalars['UUID'],
/** View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view. */
view?: (UpsertViewWidgetViewSettingsInput | null),
/** The view fields to upsert. */
viewFields?: (UpsertViewWidgetViewFieldInput[] | null),
/** The view filters to upsert. */
@@ -6337,6 +6339,10 @@ viewFilterGroups?: (UpsertViewWidgetViewFilterGroupInput[] | null),
/** The view sorts to upsert. */
viewSorts?: (UpsertViewWidgetViewSortInput[] | null)}
export interface UpsertViewWidgetViewSettingsInput {
/** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */
type?: (ViewType | null),mainGroupByFieldMetadataId?: (Scalars['UUID'] | null),shouldHideEmptyGroups?: (Scalars['Boolean'] | null),openRecordIn?: (ViewOpenRecordIn | null),kanbanAggregateOperation?: (AggregateOperations | null),kanbanAggregateOperationFieldMetadataId?: (Scalars['UUID'] | null),kanbanColumnWidth?: (Scalars['Int'] | null),calendarLayout?: (ViewCalendarLayout | null),calendarFieldMetadataId?: (Scalars['UUID'] | null),calendarEndFieldMetadataId?: (Scalars['UUID'] | null)}
export interface UpsertViewWidgetViewFieldInput {
/** The id of an existing view field to update. */
viewFieldId?: (Scalars['UUID'] | null),
@@ -8979,7 +8985,9 @@ export const enumViewType = {
KANBAN: 'KANBAN' as const,
CALENDAR: 'CALENDAR' as const,
FIELDS_WIDGET: 'FIELDS_WIDGET' as const,
TABLE_WIDGET: 'TABLE_WIDGET' as const
TABLE_WIDGET: 'TABLE_WIDGET' as const,
KANBAN_WIDGET: 'KANBAN_WIDGET' as const,
CALENDAR_WIDGET: 'CALENDAR_WIDGET' as const
}
export const enumViewKey = {
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,7 @@ export default defineView({
| Property | Values | Description |
|----------|--------|-------------|
| `type` | `ViewType.TABLE` (default), `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET` / `TABLE_WIDGET` also exist but are used internally by page-layout widgets.) |
| `type` | `ViewType.TABLE` (default), `ViewType.KANBAN`, `ViewType.CALENDAR` | How records are laid out. (`FIELDS_WIDGET`, `TABLE_WIDGET`, `KANBAN_WIDGET`, and `CALENDAR_WIDGET` also exist but are used internally by page-layout widgets.) |
| `visibility` | `ViewVisibility.WORKSPACE` (default), `ViewVisibility.UNLISTED` | Whether the view is listed for the whole workspace or hidden from pickers. |
| `openRecordIn` | `ViewOpenRecordIn.SIDE_PANEL` (default), `ViewOpenRecordIn.RECORD_PAGE` | Where clicking a record opens it. |
| `sorts` | `{ fieldMetadataUniversalIdentifier, direction: ViewSortDirection.ASC \| DESC }[]` | Default sort order. |
@@ -5828,6 +5828,8 @@ export type UpsertRowLevelPermissionPredicatesResult = {
};
export type UpsertViewWidgetInput = {
/** View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view. */
view?: InputMaybe<UpsertViewWidgetViewSettingsInput>;
/** The view fields to upsert. */
viewFields?: InputMaybe<Array<UpsertViewWidgetViewFieldInput>>;
/** The view filter groups to upsert. */
@@ -5868,6 +5870,20 @@ export type UpsertViewWidgetViewFilterInput = {
viewFilterGroupId?: InputMaybe<Scalars['UUID']['input']>;
};
export type UpsertViewWidgetViewSettingsInput = {
calendarEndFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
calendarFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
calendarLayout?: InputMaybe<ViewCalendarLayout>;
kanbanAggregateOperation?: InputMaybe<AggregateOperations>;
kanbanAggregateOperationFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
kanbanColumnWidth?: InputMaybe<Scalars['Int']['input']>;
mainGroupByFieldMetadataId?: InputMaybe<Scalars['UUID']['input']>;
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']['input']>;
/** The layout type of the widget view. Only widget view types (TABLE_WIDGET, KANBAN_WIDGET, CALENDAR_WIDGET) are allowed. */
type?: InputMaybe<ViewType>;
};
export type UpsertViewWidgetViewSortInput = {
direction?: InputMaybe<ViewSortDirection>;
fieldMetadataId: Scalars['UUID']['input'];
@@ -6182,8 +6198,10 @@ export enum ViewSortDirection {
export enum ViewType {
CALENDAR = 'CALENDAR',
CALENDAR_WIDGET = 'CALENDAR_WIDGET',
FIELDS_WIDGET = 'FIELDS_WIDGET',
KANBAN = 'KANBAN',
KANBAN_WIDGET = 'KANBAN_WIDGET',
TABLE = 'TABLE',
TABLE_WIDGET = 'TABLE_WIDGET'
}
@@ -1,4 +1,5 @@
export enum ContextStoreViewType {
Table = 'table',
Kanban = 'kanban',
Calendar = 'calendar',
}
@@ -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(
@@ -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;
@@ -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 =
@@ -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
@@ -1,7 +1,8 @@
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
@@ -10,8 +11,6 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
@@ -33,8 +32,8 @@ const RECORD_CALENDAR_TIMELINE_VIEW_ID = 'record-calendar-timeline-view';
export const ObjectOptionsDropdownCalendarViewContent = () => {
const { resetContent } = useObjectOptionsDropdown();
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
const recordIndexCalendarLayout = useAtomComponentStateValue(
recordIndexCalendarLayoutComponentState,
);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
@@ -43,8 +42,8 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
const setRecordIndexCalendarLayout = useSetAtomState(
recordIndexCalendarLayoutState,
const setRecordIndexCalendarLayout = useSetAtomComponentState(
recordIndexCalendarLayoutComponentState,
);
const { updateCurrentView } = useUpdateCurrentView();
@@ -3,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,
@@ -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,
@@ -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);
});
@@ -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;
}
@@ -0,0 +1,42 @@
import { RecordBoardContainer } from '@/object-record/record-board/components/RecordBoardContainer';
import { RecordBoardWidgetViewSettingsReadOnlyEffect } from '@/object-record/record-board-widget/components/RecordBoardWidgetViewSettingsReadOnlyEffect';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledBoardContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
`;
type RecordBoardWidgetProps = {
isReadOnly?: boolean;
};
export const RecordBoardWidget = ({
isReadOnly = true,
}: RecordBoardWidgetProps) => {
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
useRecordIndexContextOrThrow();
return (
<>
<RecordBoardWidgetViewSettingsReadOnlyEffect
recordBoardId={recordIndexId}
isViewSettingsReadOnly={isReadOnly}
/>
<StyledBoardContainer>
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={viewBarInstanceId}
objectNameSingular={objectNameSingular}
/>
</StyledBoardContainer>
</>
);
};
@@ -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;
};
@@ -11,6 +11,8 @@ import { RecordBoardEffects } from '@/object-record/record-board/components/Reco
import { RecordBoardFetchMoreInViewTriggerComponent } from '@/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent';
import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { getRecordBoardHtmlId } from '@/object-record/record-board/utils/getRecordBoardHtmlId';
@@ -40,6 +42,10 @@ export const RecordBoard = () => {
const { recordBoardId } = useContext(RecordBoardContext);
const boardRef = useRef<HTMLDivElement>(null);
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
isRecordBoardViewSettingsReadOnlyComponentState,
);
return (
<>
<ScrollWrapper
@@ -54,7 +60,9 @@ export const RecordBoard = () => {
<RecordBoardDragDropContext>
<RecordBoardColumns />
</RecordBoardDragDropContext>
<RecordBoardDragSelect boardRef={boardRef} />
{!isRecordBoardViewSettingsReadOnly && (
<RecordBoardDragSelect boardRef={boardRef} />
)}
<RecordBoardFetchMoreInViewTriggerComponent />
</StyledContainer>
</StyledBoardContentContainer>
@@ -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;
}
@@ -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();
@@ -1,6 +1,9 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnDnd } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDnd';
import { getRecordBoardHeaderHtmlId } from '@/object-record/record-board/utils/getRecordBoardHeaderHtmlId';
import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
const StyledHeaderContainer = styled.div`
display: flex;
@@ -18,8 +21,10 @@ const StyledHeaderContainer = styled.div`
`;
export const RecordBoardHeader = () => {
const { recordBoardId } = useContext(RecordBoardContext);
return (
<StyledHeaderContainer id="record-board-header">
<StyledHeaderContainer id={getRecordBoardHeaderHtmlId(recordBoardId)}>
<RecordBoardColumnDnd />
<RecordIndexGroupAggregatesDataLoader />
</StyledHeaderContainer>
@@ -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 <></>;
};
@@ -1,6 +1,7 @@
import { Draggable } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { createPortal } from 'react-dom';
import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
@@ -64,27 +65,40 @@ export const RecordBoardCardDraggableContainer = ({
index={rowIndex}
isDragDisabled={isRecordBoardDropProcessing}
>
{(draggableProvided) => (
<StyledDraggableContainer
isDragDisabled={isRecordBoardDropProcessing}
id={`record-board-card-${columnIndex}-${rowIndex}`}
ref={draggableProvided?.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.dragHandleProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...getCssCompatibleDraggableProps(
draggableProvided.draggableProps,
)}
data-selectable-id={recordId}
data-select-disable
>
<DragAndDropLibraryLegacyReRenderBreaker memoizationId={recordId}>
{isRecordBoardCardFocused && <RecordBoardCardHotkeysEffect />}
<RecordBoardCard />
<RecordBoardCardMultiDragPreview />
</DragAndDropLibraryLegacyReRenderBreaker>
</StyledDraggableContainer>
)}
{(draggableProvided, draggableSnapshot) => {
const draggableContent = (
<StyledDraggableContainer
isDragDisabled={isRecordBoardDropProcessing}
id={`record-board-card-${columnIndex}-${rowIndex}`}
ref={draggableProvided?.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.dragHandleProps}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...getCssCompatibleDraggableProps(
draggableProvided.draggableProps,
)}
data-selectable-id={recordId}
data-select-disable
>
<DragAndDropLibraryLegacyReRenderBreaker memoizationId={recordId}>
{isRecordBoardCardFocused && <RecordBoardCardHotkeysEffect />}
<RecordBoardCard />
<RecordBoardCardMultiDragPreview />
</DragAndDropLibraryLegacyReRenderBreaker>
</StyledDraggableContainer>
);
// The drag clone is position: fixed, which resolves against the
// nearest transformed ancestor — dashboard widgets are translated
// by react-grid-layout, offsetting the clone from the pointer.
// Portal it to the body while dragging so the viewport stays its
// containing block.
if (draggableSnapshot.isDragging) {
return createPortal(draggableContent, document.body);
}
return draggableContent;
}}
</Draggable>
</RecordBoardCardContext.Provider>
);
@@ -18,6 +18,7 @@ import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFie
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown';
@@ -108,6 +109,14 @@ const StyledTagContainer = styled.div`
overflow: hidden;
`;
const StyledAggregateDropdownContainer = styled.div<{
isNonInteractive: boolean;
}>`
display: flex;
pointer-events: ${({ isNonInteractive }) =>
isNonInteractive ? 'none' : 'auto'};
`;
const StyledDropdownContainer = styled.div`
min-width: 0;
overflow: hidden;
@@ -127,6 +136,10 @@ export const RecordBoardColumnHeader = () => {
const { objectMetadataItem, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
isRecordBoardViewSettingsReadOnlyComponentState,
);
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
@@ -182,58 +195,74 @@ export const RecordBoardColumnHeader = () => {
<StyledHeaderContainer>
<StyledLeftContainer>
<StyledDropdownContainer>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{
x: 0,
y: 10,
}}
clickableComponent={
<StyledTagContainer>
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
}
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/>
{isRecordBoardViewSettingsReadOnly ? (
<StyledTagContainer>
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
) : (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{
x: 0,
y: 10,
}}
clickableComponent={
<StyledTagContainer>
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
}
dropdownComponents={<RecordBoardColumnDropdownMenu />}
/>
)}
</StyledDropdownContainer>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
<StyledAggregateDropdownContainer
isNonInteractive={isRecordBoardViewSettingsReadOnly}
inert={isRecordBoardViewSettingsReadOnly || undefined}
>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-board-column-aggregate-dropdown-${columnDefinition.id}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledAggregateDropdownContainer>
</StyledLeftContainer>
<StyledHeaderActions
data-dropdown-open={isDropdownOpen ? 'true' : undefined}
>
<LightIconButton
accent="tertiary"
aria-label={t`More options`}
Icon={IconDotsVertical}
onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
{!isRecordBoardViewSettingsReadOnly && (
<StyledHeaderActions
data-dropdown-open={isDropdownOpen ? 'true' : undefined}
>
<LightIconButton
accent="tertiary"
aria-label={t`Add new`}
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
aria-label={t`More options`}
Icon={IconDotsVertical}
onClick={() => {
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
)}
</StyledHeaderActions>
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
<LightIconButton
accent="tertiary"
aria-label={t`Add new`}
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
/>
)}
</StyledHeaderActions>
)}
</StyledHeaderContainer>
</StyledHeader>
</DragDropColumnSortableHandle>
<RecordBoardColumnResizeHandler />
{!isRecordBoardViewSettingsReadOnly && <RecordBoardColumnResizeHandler />}
</StyledColumn>
);
};
@@ -1,7 +1,9 @@
import { DragDropProvider } from '@dnd-kit/react';
import type { ReactNode } from 'react';
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal';
import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext';
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
@@ -17,11 +19,15 @@ export const RecordBoardColumnDndKitProvider = ({
const { contextValues, handlers, handlePendingReorderConfirmClick } =
useRecordBoardColumnDndKit();
const isRecordBoardViewSettingsReadOnly = useAtomComponentStateValue(
isRecordBoardViewSettingsReadOnlyComponentState,
);
return (
<>
<DragDropColumnDndContext.Provider value={contextValues}>
<DragDropProvider<DragDropColumnData>
sensors={DND_KIT_SENSORS}
sensors={isRecordBoardViewSettingsReadOnly ? [] : DND_KIT_SENSORS}
onDragStart={handlers.onDragStart}
onDragMove={handlers.onDragMove}
onDragEnd={handlers.onDragEnd}
@@ -0,0 +1,14 @@
import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
// Locks the board chrome that edits the backing view's settings (add
// group, column reorder/resize/menu, aggregate pickers) plus the bulk
// selection and per-column record creation affordances that dashboard
// widgets don't wire up. Record data itself stays editable: card drag
// still updates records, gated by object permissions as usual.
export const isRecordBoardViewSettingsReadOnlyComponentState =
createAtomComponentState<boolean>({
key: 'isRecordBoardViewSettingsReadOnlyComponentState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -0,0 +1,2 @@
export const getRecordBoardHeaderHtmlId = (recordBoardId: string) =>
`record-board-header-${recordBoardId}`;
@@ -0,0 +1,79 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
import { RecordCalendarSSESubscribeEffect } from '@/object-record/record-calendar/components/RecordCalendarSSESubscribeEffect';
import { RecordIndexCalendarDataLoaderEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect';
import { RecordIndexCalendarSelectedDateInitEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect';
import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarWidgetReadOnlyEffect } from '@/object-record/record-calendar-widget/components/RecordCalendarWidgetReadOnlyEffect';
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCalendarContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
`;
type RecordCalendarWidgetProps = {
isReadOnly?: boolean;
};
export const RecordCalendarWidget = ({
isReadOnly = true,
}: RecordCalendarWidgetProps) => {
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
useRecordIndexContextOrThrow();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
// Hydrated per widget instance from the backing view (draft or persisted)
// by the widget view load effect, so edit-mode previews work before save.
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
recordIndexCalendarFieldMetadataIdComponentState,
recordIndexId,
);
if (!isDefined(recordIndexCalendarFieldMetadataId)) {
return null;
}
return (
<>
<RecordCalendarWidgetReadOnlyEffect
recordCalendarId={recordIndexId}
isReadOnly={isReadOnly}
/>
<StyledCalendarContainer>
<RecordCalendarContextProvider
value={{
viewBarInstanceId,
objectNameSingular,
visibleRecordFields: [],
objectMetadataItem,
objectPermissions,
}}
>
<RecordCalendar />
<RecordCalendarSSESubscribeEffect />
<RecordIndexCalendarDataLoaderEffect />
<RecordIndexCalendarSelectedDateInitEffect />
</RecordCalendarContextProvider>
</StyledCalendarContainer>
</>
);
};
@@ -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;
};
@@ -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,
@@ -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,
@@ -1,10 +1,12 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { isRecordCalendarReadOnlyComponentState } from '@/object-record/record-calendar/states/isRecordCalendarReadOnlyComponentState';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { recordIndexCalendarLayoutComponentState } from '@/object-record/record-index/states/recordIndexCalendarLayoutComponentState';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { Select } from '@/ui/input/components/Select';
@@ -13,9 +15,10 @@ import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@@ -70,8 +73,20 @@ export const RecordCalendarTopBar = () => {
const [recordCalendarSelectedDate, setRecordCalendarSelectedDate] =
useAtomComponentState(recordCalendarSelectedDateComponentState);
const isRecordCalendarReadOnly = useAtomComponentStateValue(
isRecordCalendarReadOnlyComponentState,
);
// The layout switcher persists via updateCurrentView (an index-page write),
// so it must never render inside a dashboard widget; widget calendars drive
// their layout from the side-panel settings instead.
const widgetInstanceId = useAvailableComponentInstanceId(
WidgetComponentInstanceContext,
);
const isInWidget = isDefined(widgetInstanceId);
const [recordIndexCalendarLayout, setRecordIndexCalendarLayout] =
useAtomState(recordIndexCalendarLayoutState);
useAtomComponentState(recordIndexCalendarLayoutComponentState);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
@@ -161,20 +176,22 @@ export const RecordCalendarTopBar = () => {
return (
<StyledContainer>
<StyledLeftSection>
{isCalendarWeekViewEnabled && (
<Select
dropdownId={`record-calendar-layout-${recordCalendarId}`}
value={supportedCalendarLayout}
options={[
{ label: t`Day`, value: ViewCalendarLayout.DAY },
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
]}
selectSizeVariant="small"
dropdownWidth={120}
onChange={handleCalendarLayoutChange}
/>
)}
{isCalendarWeekViewEnabled &&
!isRecordCalendarReadOnly &&
!isInWidget && (
<Select
dropdownId={`record-calendar-layout-${recordCalendarId}`}
value={supportedCalendarLayout}
options={[
{ label: t`Day`, value: ViewCalendarLayout.DAY },
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
]}
selectSizeVariant="small"
dropdownWidth={120}
onChange={handleCalendarLayoutChange}
/>
)}
<Dropdown
dropdownId={datePickerDropdownId}
clickableComponent={
@@ -50,16 +50,19 @@ jest.mock(
jest.mock('@/ui/utilities/pointer-event/hooks/useListenClickOutside', () => ({
useListenClickOutside: jest.fn(),
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: jest.fn(),
}));
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
() => ({
useAtomComponentStateValue: jest.fn(),
}),
);
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(),
}));
const useAtomStateValueMock = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomStateValue',
).useAtomStateValue;
const useAtomComponentStateValueMock = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
).useAtomComponentStateValue;
const useIsFeatureEnabledMock = jest.requireMock(
'@/workspace/hooks/useIsFeatureEnabled',
).useIsFeatureEnabled;
@@ -67,13 +70,13 @@ const useIsFeatureEnabledMock = jest.requireMock(
describe('RecordCalendar', () => {
beforeEach(() => {
jest.clearAllMocks();
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
useAtomComponentStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
});
it.each([ViewCalendarLayout.DAY, ViewCalendarLayout.WEEK])(
'renders month when a persisted %s layout is disabled',
(calendarLayout) => {
useAtomStateValueMock.mockReturnValue(calendarLayout);
useAtomComponentStateValueMock.mockReturnValue(calendarLayout);
useIsFeatureEnabledMock.mockReturnValue(false);
render(<RecordCalendar />);
@@ -88,7 +91,7 @@ describe('RecordCalendar', () => {
);
it('renders day when the day layout is enabled', () => {
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
useAtomComponentStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
useIsFeatureEnabledMock.mockReturnValue(true);
render(<RecordCalendar />);
@@ -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,
@@ -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({
@@ -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(
@@ -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
);
};
@@ -0,0 +1,15 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
// A fully read-only calendar: no card drag, no per-day record creation,
// no layout switching — dashboard widgets render calendars as pure
// projections. Unlike the board's view-settings flag
// (isRecordBoardViewSettingsReadOnlyComponentState, which keeps record
// data editable), this one disables data interactions too, which is why
// it keeps the plain "read-only" name.
export const isRecordCalendarReadOnlyComponentState =
createAtomComponentState<boolean>({
key: 'isRecordCalendarReadOnlyComponentState',
defaultValue: false,
componentInstanceContext: RecordCalendarComponentInstanceContext,
});
@@ -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);
@@ -1,3 +1,4 @@
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
@@ -14,12 +15,11 @@ import {
type RecordCalendarWeekSlotInteractionMode,
updateRecordCalendarWeekActiveSlot,
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { format } from 'date-fns';
@@ -441,11 +441,11 @@ export const RecordCalendarTimeGrid = ({
}: RecordCalendarTimeGridProps) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { timeFormat, timeZone } = useDateTimeFormat();
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
const recordIndexCalendarFieldMetadataId = useAtomComponentStateValue(
recordIndexCalendarFieldMetadataIdComponentState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
const recordIndexCalendarEndFieldMetadataId = useAtomComponentStateValue(
recordIndexCalendarEndFieldMetadataIdComponentState,
);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
@@ -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,
});
@@ -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();
@@ -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,
@@ -0,0 +1,9 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const recordIndexCalendarEndFieldMetadataIdComponentState =
createAtomComponentState<string | null>({
key: 'recordIndexCalendarEndFieldMetadataIdComponentState',
defaultValue: null,
componentInstanceContext: RecordCalendarComponentInstanceContext,
});
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const recordIndexCalendarEndFieldMetadataIdState = createAtomState<
string | null
>({
key: 'recordIndexCalendarEndFieldMetadataIdState',
defaultValue: null,
});
@@ -0,0 +1,9 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const recordIndexCalendarFieldMetadataIdComponentState =
createAtomComponentState<string | null>({
key: 'recordIndexCalendarFieldMetadataIdComponentState',
defaultValue: null,
componentInstanceContext: RecordCalendarComponentInstanceContext,
});
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const recordIndexCalendarFieldMetadataIdState = createAtomState<
string | null
>({
key: 'recordIndexCalendarFieldMetadataIdState',
defaultValue: null,
});
@@ -0,0 +1,10 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
export const recordIndexCalendarLayoutComponentState =
createAtomComponentState<ViewCalendarLayout>({
key: 'recordIndexCalendarLayoutComponentState',
defaultValue: ViewCalendarLayout.MONTH,
componentInstanceContext: RecordCalendarComponentInstanceContext,
});
@@ -1,8 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
export const recordIndexCalendarLayoutState =
createAtomState<ViewCalendarLayout>({
key: 'recordIndexCalendarLayoutState',
defaultValue: ViewCalendarLayout.MONTH,
});
@@ -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,
@@ -1,4 +1,5 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
@@ -20,6 +21,7 @@ type RecordTableWidgetProviderProps = PropsWithChildren<{
widgetId: string;
recordLimit?: number;
instanceIdSuffix?: string;
contextStoreViewType?: ContextStoreViewType;
}>;
export const RecordTableWidgetProvider = ({
@@ -28,6 +30,7 @@ export const RecordTableWidgetProvider = ({
widgetId,
recordLimit,
instanceIdSuffix,
contextStoreViewType,
children,
}: RecordTableWidgetProviderProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
@@ -87,6 +90,7 @@ export const RecordTableWidgetProvider = ({
<RecordTableWidgetContextStoreInitEffect
objectMetadataItemId={objectMetadataItem.id}
viewId={viewId}
contextStoreViewType={contextStoreViewType}
/>
<RecordIndexContextProvider
value={{
@@ -31,4 +31,23 @@ export const computeRecordTableWidgetViewLoadContentSignature = (
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
})),
layout: {
type: view.type,
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId,
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
kanbanAggregateOperation: view.kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId:
view.kanbanAggregateOperationFieldMetadataId,
calendarLayout: view.calendarLayout,
calendarFieldMetadataId: view.calendarFieldMetadataId,
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId,
},
groups: [...(view.viewGroups ?? [])]
.sort((groupA, groupB) => groupA.position - groupB.position)
.map((group) => ({
id: group.id,
fieldValue: group.fieldValue,
isVisible: group.isVisible,
position: group.position,
})),
});
@@ -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)
) {
@@ -23,6 +23,7 @@ import { RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE } from '@/object-r
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { isRecordGroupTableSectionToggledComponentState } from '@/object-record/record-table/record-table-section/states/isRecordGroupTableSectionToggledComponentState';
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
@@ -91,6 +92,14 @@ const StyledTagContainer = styled.div`
flex-shrink: 0;
`;
const StyledAggregateDropdownContainer = styled.div<{
isNonInteractive: boolean;
}>`
display: flex;
pointer-events: ${({ isNonInteractive }) =>
isNonInteractive ? 'none' : 'auto'};
`;
const StyledFieldPlaceholderCell = styled.div<{ widthOfFields: number }>`
height: ${RECORD_TABLE_ROW_HEIGHT}px;
min-width: ${({ widthOfFields }) => widthOfFields}px;
@@ -164,6 +173,10 @@ export const RecordTableRecordGroupSection = () => {
currentRecordGroupId,
);
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
isRecordTableCellsNonEditableComponentState,
);
const handleDropdownToggle = useCallback(() => {
setIsRecordGroupTableSectionToggled((prevState) => !prevState);
}, [setIsRecordGroupTableSectionToggled]);
@@ -216,12 +229,17 @@ export const RecordTableRecordGroupSection = () => {
valueTagWeight="medium"
/>
</StyledTagContainer>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
<StyledAggregateDropdownContainer
isNonInteractive={isRecordTableCellsNonEditable}
inert={isRecordTableCellsNonEditable || undefined}
>
<RecordBoardColumnHeaderAggregateDropdown
aggregateValue={recordIndexAggregateDisplayValueForGroupValue}
dropdownId={`record-group-section-aggregate-dropdown-${currentRecordGroupId}`}
objectMetadataItem={objectMetadataItem}
aggregateLabel={recordIndexAggregateDisplayLabel}
/>
</StyledAggregateDropdownContainer>
</StyledRecordGroupSection>
<StyledFieldPlaceholderCell widthOfFields={fieldsPlaceholderWidth} />
<RecordTableAddButtonPlaceholderCell />
@@ -6,6 +6,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
@@ -15,6 +16,10 @@ import { IconPlus } from 'twenty-ui/icon';
export const RecordTableRecordGroupSectionAddNew = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
isRecordTableCellsNonEditableComponentState,
);
const currentRecordGroupId = useCurrentRecordGroupId();
const recordGroupDefinition = useAtomFamilyStateValue(
@@ -47,6 +52,10 @@ export const RecordTableRecordGroupSectionAddNew = () => {
return null;
}
if (isRecordTableCellsNonEditable) {
return null;
}
return (
<RecordTableActionRow
LeftIcon={IconPlus}
@@ -132,6 +132,7 @@ const sourceRecordTableViewSnapshot: RecordTableWidgetViewSnapshot = {
viewFilterGroups: [],
viewFilters: [],
viewSorts: [],
viewGroups: [],
};
describe('useDuplicatePageLayoutTab', () => {
@@ -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,
@@ -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(
@@ -1,7 +1,22 @@
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { RecordBoardWidget } from '@/object-record/record-board-widget/components/RecordBoardWidget';
import { RecordCalendarWidget } from '@/object-record/record-calendar-widget/components/RecordCalendarWidget';
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useViewById } from '@/views/hooks/useViewById';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { getViewLayoutFromViewType, isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
ViewCalendarLayout,
ViewType,
} from '~/generated-metadata/graphql';
type RecordTableWidgetRendererContentProps = {
objectMetadataId: string;
@@ -26,6 +41,47 @@ export const RecordTableWidgetRendererContent = ({
objectId: objectMetadataId,
});
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const draftSnapshot = useAtomComponentFamilySelectorValue(
recordTableWidgetViewDraftByWidgetIdComponentFamilySelector,
{ widgetId },
);
const { view: persistedView } = useViewById(viewId);
const widgetView =
isPageLayoutInEditMode && isDefined(draftSnapshot)
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
: persistedView;
const widgetViewLayout = getViewLayoutFromViewType(
widgetView?.type ?? ViewType.TABLE_WIDGET,
);
const isKanbanLayout = widgetViewLayout === ViewType.KANBAN;
const isCalendarLayout = widgetViewLayout === ViewType.CALENDAR;
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
// Widget calendars are read-only month projections, except live (non
// edit-mode) day/week calendars, which allow drag-to-reschedule and
// record creation under the usual object permissions.
const isCalendarDayOrWeek =
widgetView?.calendarLayout === ViewCalendarLayout.DAY ||
widgetView?.calendarLayout === ViewCalendarLayout.WEEK;
const canEditCalendar =
isCalendarLayout &&
!isPageLayoutInEditMode &&
isCalendarWeekViewEnabled &&
isCalendarDayOrWeek;
// Read-only unless this is the explicitly allowed live day/week calendar;
// a caller passing isReadOnly={false} must not make month (or flag-off)
// widget calendars editable. Object permissions still gate the drag.
const calendarIsReadOnly = !canEditCalendar;
return (
<>
<RecordTableWidgetViewDraftInitEffect
@@ -38,11 +94,24 @@ export const RecordTableWidgetRendererContent = ({
widgetId={widgetId}
recordLimit={recordLimit}
instanceIdSuffix={instanceIdSuffix}
contextStoreViewType={
isKanbanLayout
? ContextStoreViewType.Kanban
: isCalendarLayout
? ContextStoreViewType.Calendar
: ContextStoreViewType.Table
}
>
<RecordTableWidget
isReadOnly={isReadOnly}
isEmptyStateHidden={isEmptyStateHidden}
/>
{isKanbanLayout ? (
<RecordBoardWidget isReadOnly={isReadOnly} />
) : isCalendarLayout ? (
<RecordCalendarWidget isReadOnly={calendarIsReadOnly} />
) : (
<RecordTableWidget
isReadOnly={isReadOnly}
isEmptyStateHidden={isEmptyStateHidden}
/>
)}
</RecordTableWidgetProvider>
</>
);
@@ -0,0 +1,212 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { buildDraftViewGroupsForFieldMetadataItem } from '@/page-layout/widgets/record-table/utils/buildDraftViewGroupsForFieldMetadataItem';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
type ViewCalendarLayout,
ViewType,
} from '~/generated-metadata/graphql';
export type RecordTableWidgetLayoutViewType =
| ViewType.TABLE_WIDGET
| ViewType.KANBAN_WIDGET
| ViewType.CALENDAR_WIDGET;
type UseRecordTableWidgetLayoutCallbacksParams = {
pageLayoutId: string;
widgetId: string;
};
export const useRecordTableWidgetLayoutCallbacks = ({
pageLayoutId,
widgetId,
}: UseRecordTableWidgetLayoutCallbacksParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
// Clamp a widget calendar's layout to what the workspace supports (MONTH
// while the day/week feature is off), reusing the shared resolver so widget
// and index calendars can't diverge and no stale day/week value is sent back
// to — and rejected by — the server.
const resolveCalendarLayout = (
calendarLayout: ViewCalendarLayout | null | undefined,
) =>
getSupportedRecordCalendarLayout({
calendarLayout,
isCalendarWeekViewEnabled,
});
// Returning the received snapshot unchanged from the updater leaves the
// whole draft map untouched (no state update is published).
const setWidgetViewDraft = (
updater: (
widgetViewDraft: RecordTableWidgetViewSnapshot,
) => RecordTableWidgetViewSnapshot,
) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
const updatedWidgetViewDraft = updater(widgetViewDraft);
if (updatedWidgetViewDraft === widgetViewDraft) {
return prev;
}
return {
...prev,
[widgetId]: updatedWidgetViewDraft,
};
});
};
const handleGroupByFieldChange = (
fieldMetadataItem: FieldMetadataItem | null,
) => {
setWidgetViewDraft((widgetViewDraft) => ({
...widgetViewDraft,
view: {
...widgetViewDraft.view,
mainGroupByFieldMetadataId: fieldMetadataItem?.id ?? null,
},
viewGroups: isDefined(fieldMetadataItem)
? buildDraftViewGroupsForFieldMetadataItem({
viewId: widgetViewDraft.view.id,
fieldMetadataItem,
})
: [],
}));
};
const handleLayoutChange = ({
targetViewType,
defaultGroupByFieldMetadataItem,
defaultCalendarFieldMetadataItem,
}: {
targetViewType: RecordTableWidgetLayoutViewType;
defaultGroupByFieldMetadataItem: FieldMetadataItem | null;
defaultCalendarFieldMetadataItem?: FieldMetadataItem | null;
}) => {
setWidgetViewDraft((widgetViewDraft) => {
if (targetViewType === ViewType.KANBAN_WIDGET) {
const hasGroupBy = isDefined(
widgetViewDraft.view.mainGroupByFieldMetadataId,
);
if (!hasGroupBy && !isDefined(defaultGroupByFieldMetadataItem)) {
return widgetViewDraft;
}
return {
...widgetViewDraft,
view: {
...widgetViewDraft.view,
type: targetViewType,
mainGroupByFieldMetadataId: hasGroupBy
? widgetViewDraft.view.mainGroupByFieldMetadataId
: defaultGroupByFieldMetadataItem?.id,
},
viewGroups:
hasGroupBy || !isDefined(defaultGroupByFieldMetadataItem)
? widgetViewDraft.viewGroups
: buildDraftViewGroupsForFieldMetadataItem({
viewId: widgetViewDraft.view.id,
fieldMetadataItem: defaultGroupByFieldMetadataItem,
}),
};
}
if (targetViewType === ViewType.CALENDAR_WIDGET) {
const hasCalendarField = isDefined(
widgetViewDraft.view.calendarFieldMetadataId,
);
if (!hasCalendarField && !isDefined(defaultCalendarFieldMetadataItem)) {
return widgetViewDraft;
}
return {
...widgetViewDraft,
view: {
...widgetViewDraft.view,
type: targetViewType,
calendarLayout: resolveCalendarLayout(
widgetViewDraft.view.calendarLayout,
),
calendarFieldMetadataId: hasCalendarField
? widgetViewDraft.view.calendarFieldMetadataId
: defaultCalendarFieldMetadataItem?.id,
},
};
}
return {
...widgetViewDraft,
view: {
...widgetViewDraft.view,
type: targetViewType,
},
};
});
};
const handleCalendarFieldChange = (fieldMetadataItem: FieldMetadataItem) => {
setWidgetViewDraft((widgetViewDraft) => ({
...widgetViewDraft,
view: {
...widgetViewDraft.view,
calendarFieldMetadataId: fieldMetadataItem.id,
calendarLayout: resolveCalendarLayout(
widgetViewDraft.view.calendarLayout,
),
},
}));
};
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
setWidgetViewDraft((widgetViewDraft) => ({
...widgetViewDraft,
view: {
...widgetViewDraft.view,
calendarLayout: resolveCalendarLayout(calendarLayout),
},
}));
};
const handleShouldHideEmptyGroupsChange = (
shouldHideEmptyGroups: boolean,
) => {
setWidgetViewDraft((widgetViewDraft) => ({
...widgetViewDraft,
view: {
...widgetViewDraft.view,
shouldHideEmptyGroups,
},
}));
};
return {
handleCalendarFieldChange,
handleCalendarLayoutChange,
handleGroupByFieldChange,
handleLayoutChange,
handleShouldHideEmptyGroupsChange,
};
};
@@ -2,6 +2,7 @@ import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFilter } from '@/metadata-store/types/FlatViewFilter';
import { type FlatViewFilterGroup } from '@/metadata-store/types/FlatViewFilterGroup';
import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
export type RecordTableWidgetDraftViewField = Omit<FlatViewField, 'id'> & {
@@ -15,4 +16,5 @@ export type RecordTableWidgetViewSnapshot = {
viewFilters: FlatViewFilter[];
viewFilterGroups: FlatViewFilterGroup[];
viewSorts: FlatViewSort[];
viewGroups: FlatViewGroup[];
};
@@ -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);
});
});
@@ -0,0 +1,52 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { buildUpsertViewWidgetViewSettingsInput } from '@/page-layout/widgets/record-table/utils/buildUpsertViewWidgetViewSettingsInput';
import {
ViewCalendarLayout,
ViewOpenRecordIn,
ViewType,
} from '~/generated-metadata/graphql';
const buildFlatView = (overrides: Partial<FlatView>): FlatView =>
({
type: ViewType.TABLE_WIDGET,
shouldHideEmptyGroups: false,
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
...overrides,
}) as FlatView;
describe('buildUpsertViewWidgetViewSettingsInput', () => {
it('normalizes every nullable setting to null when unset', () => {
const input = buildUpsertViewWidgetViewSettingsInput(buildFlatView({}));
expect(input).toEqual({
type: ViewType.TABLE_WIDGET,
mainGroupByFieldMetadataId: null,
shouldHideEmptyGroups: false,
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataId: null,
kanbanColumnWidth: null,
calendarLayout: null,
calendarFieldMetadataId: null,
calendarEndFieldMetadataId: null,
});
});
it('passes through defined settings', () => {
const input = buildUpsertViewWidgetViewSettingsInput(
buildFlatView({
type: ViewType.CALENDAR_WIDGET,
shouldHideEmptyGroups: true,
calendarLayout: ViewCalendarLayout.MONTH,
calendarFieldMetadataId: 'calendar-field-id',
mainGroupByFieldMetadataId: 'group-by-id',
}),
);
expect(input.type).toBe(ViewType.CALENDAR_WIDGET);
expect(input.shouldHideEmptyGroups).toBe(true);
expect(input.calendarLayout).toBe(ViewCalendarLayout.MONTH);
expect(input.calendarFieldMetadataId).toBe('calendar-field-id');
expect(input.mainGroupByFieldMetadataId).toBe('group-by-id');
});
});
@@ -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);
@@ -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;
};
@@ -67,5 +67,6 @@ export const buildRecordTableWidgetViewSnapshot = (
viewFilters: [],
viewFilterGroups: [],
viewSorts: [],
viewGroups: [],
};
};
@@ -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,
};
};
@@ -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,
});
@@ -47,5 +47,10 @@ export const cloneRecordTableWidgetViewSnapshot = (
id: uuidv4(),
viewId: newViewId,
})),
viewGroups: sourceSnapshot.viewGroups.map((viewGroup) => ({
...viewGroup,
id: uuidv4(),
viewId: newViewId,
})),
};
};
@@ -18,5 +18,5 @@ export const constructViewFromRecordTableWidgetViewSnapshot = (
viewFilters: snapshot.viewFilters,
viewFilterGroups: snapshot.viewFilterGroups,
viewSorts: snapshot.viewSorts,
viewGroups: [],
viewGroups: snapshot.viewGroups,
});
@@ -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);
@@ -1,13 +1,21 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { CommandMenuItemNumberInput } from '@/command-menu/components/CommandMenuItemNumberInput';
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useRecordTableWidgetFieldCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetFieldCallbacks';
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelSubPageHistory } from '@/side-panel/hooks/useSidePanelSubPageHistory';
import { RecordTableDataSourceDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableDataSourceDropdownContent';
import { RecordTableFieldsDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableFieldsDropdownContent';
import { RecordTableGroupByDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableGroupByDropdownContent';
import { RecordTableCalendarFieldDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarFieldDropdownContent';
import { RecordTableCalendarLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableCalendarLayoutDropdownContent';
import { RecordTableLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableLayoutDropdownContent';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useRecordTableSettingsDescriptions } from '@/side-panel/pages/page-layout/hooks/useRecordTableSettingsDescriptions';
@@ -16,6 +24,7 @@ import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWid
import { SidePanelSubPages } from '@/side-panel/types/SidePanelSubPages';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -23,11 +32,21 @@ import {
IconArrowBarToDownDashed,
IconArrowsSort,
IconBox,
IconCalendar,
IconCalendarEvent,
IconEyeOff,
IconFilter,
IconLayoutKanban,
IconLayoutList,
IconListDetails,
IconTable,
} from 'twenty-ui/icon';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
ViewCalendarLayout,
ViewType,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
display: flex;
@@ -96,11 +115,71 @@ export const SidePanelDashboardRecordTableSettings = () => {
viewId: viewId ?? '',
});
const { handleShouldHideEmptyGroupsChange } =
useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId: widgetInEditMode?.id ?? '',
});
const { view: widgetView } = useRecordTableWidgetViewForDisplay({
viewId: viewId ?? '',
widgetId: widgetInEditMode?.id ?? '',
pageLayoutId,
});
const mainGroupByFieldMetadataId =
widgetView?.mainGroupByFieldMetadataId ?? null;
const shouldHideEmptyGroups = widgetView?.shouldHideEmptyGroups ?? false;
const isKanbanLayout = widgetView?.type === ViewType.KANBAN_WIDGET;
const isCalendarLayout = widgetView?.type === ViewType.CALENDAR_WIDGET;
const currentLayoutViewType = isKanbanLayout
? ViewType.KANBAN_WIDGET
: isCalendarLayout
? ViewType.CALENDAR_WIDGET
: ViewType.TABLE_WIDGET;
const calendarFieldMetadataId = widgetView?.calendarFieldMetadataId ?? null;
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const currentCalendarLayout =
widgetView?.calendarLayout ?? ViewCalendarLayout.MONTH;
const calendarLayoutLabel =
currentCalendarLayout === ViewCalendarLayout.DAY
? t`Day`
: currentCalendarLayout === ViewCalendarLayout.WEEK
? t`Week`
: t`Month`;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === widgetInEditMode?.objectMetadataId,
);
const mainGroupByFieldLabel = isDefined(mainGroupByFieldMetadataId)
? (objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === mainGroupByFieldMetadataId,
)?.label ?? t`None`)
: t`None`;
const calendarFieldLabel = isDefined(calendarFieldMetadataId)
? (objectMetadataItem?.fields.find(
(fieldMetadataItem) => fieldMetadataItem.id === calendarFieldMetadataId,
)?.label ?? t`None`)
: t`None`;
if (!isDefined(widgetInEditMode)) {
return null;
}
const hasViewId = isDefined(viewId);
const hasGroupBy = isDefined(mainGroupByFieldMetadataId);
const selectableItemIds = [
'record-table-source',
@@ -109,7 +188,18 @@ export const SidePanelDashboardRecordTableSettings = () => {
'record-table-fields',
'record-table-filter',
'record-table-sort',
'record-table-limit',
...(isCalendarLayout
? [
'record-table-calendar-field',
...(isCalendarWeekViewEnabled
? ['record-table-calendar-layout']
: []),
]
: ['record-table-group-by']),
...(!isCalendarLayout && hasGroupBy
? ['record-table-hide-empty-groups']
: []),
...(!isCalendarLayout && !hasGroupBy ? ['record-table-limit'] : []),
]
: []),
];
@@ -130,19 +220,6 @@ export const SidePanelDashboardRecordTableSettings = () => {
<StyledSettingsContainer>
<SidePanelList selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Settings`}>
<SelectableListItem itemId="object-view-layout">
<CommandMenuItemDropdown
Icon={IconTable}
label={t`Layout`}
id="object-view-layout"
dropdownId="object-view-layout"
dropdownComponents={<></>}
dropdownPlacement="bottom-end"
description={t`Table`}
disabled={true}
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem itemId="record-table-source">
<CommandMenuItemDropdown
Icon={IconBox}
@@ -160,6 +237,45 @@ export const SidePanelDashboardRecordTableSettings = () => {
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem itemId="object-view-layout">
<CommandMenuItemDropdown
Icon={
isKanbanLayout
? IconLayoutKanban
: isCalendarLayout
? IconCalendar
: IconTable
}
label={t`Layout`}
id="object-view-layout"
dropdownId="object-view-layout"
dropdownComponents={
hasViewId ? (
<DropdownContent>
<RecordTableLayoutDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
objectMetadataId={widgetInEditMode.objectMetadataId!}
currentLayoutViewType={currentLayoutViewType}
/>
</DropdownContent>
) : (
<></>
)
}
dropdownPlacement="bottom-end"
hasSubMenu={hasViewId}
description={
isKanbanLayout
? t`Kanban`
: isCalendarLayout
? t`Calendar`
: t`Table`
}
disabled={!hasViewId}
contextualTextPosition="right"
/>
</SelectableListItem>
{hasViewId && (
<>
<SelectableListItem itemId="record-table-fields">
@@ -210,16 +326,109 @@ export const SidePanelDashboardRecordTableSettings = () => {
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem itemId="record-table-limit">
<CommandMenuItemNumberInput
id="record-table-limit"
label={t`Limit`}
Icon={IconArrowBarToDownDashed}
value={isDefined(limit) ? `${limit}` : ''}
onChange={handleLimitChange}
placeholder={t`No limit`}
/>
</SelectableListItem>
{isCalendarLayout && (
<SelectableListItem itemId="record-table-calendar-field">
<CommandMenuItemDropdown
Icon={IconCalendarEvent}
label={t`Date field`}
id="record-table-calendar-field"
dropdownId="record-table-calendar-field"
dropdownComponents={
<DropdownContent>
<RecordTableCalendarFieldDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
objectMetadataId={
widgetInEditMode.objectMetadataId!
}
currentCalendarFieldMetadataId={
calendarFieldMetadataId
}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={calendarFieldLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{isCalendarLayout && isCalendarWeekViewEnabled && (
<SelectableListItem itemId="record-table-calendar-layout">
<CommandMenuItemDropdown
Icon={IconCalendar}
label={t`Calendar view`}
id="record-table-calendar-layout"
dropdownId="record-table-calendar-layout"
dropdownComponents={
<DropdownContent>
<RecordTableCalendarLayoutDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
currentCalendarLayout={currentCalendarLayout}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={calendarLayoutLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{!isCalendarLayout && (
<SelectableListItem itemId="record-table-group-by">
<CommandMenuItemDropdown
Icon={IconLayoutList}
label={t`Group by`}
id="record-table-group-by"
dropdownId="record-table-group-by"
dropdownComponents={
<DropdownContent>
<RecordTableGroupByDropdownContent
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
objectMetadataId={
widgetInEditMode.objectMetadataId!
}
currentMainGroupByFieldMetadataId={
mainGroupByFieldMetadataId
}
isClearable={!isKanbanLayout}
/>
</DropdownContent>
}
dropdownPlacement="bottom-end"
hasSubMenu
description={mainGroupByFieldLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
)}
{!isCalendarLayout && hasGroupBy && (
<SelectableListItem itemId="record-table-hide-empty-groups">
<CommandMenuItemToggle
LeftIcon={IconEyeOff}
text={t`Hide empty groups`}
id="record-table-hide-empty-groups"
toggled={shouldHideEmptyGroups}
onToggleChange={handleShouldHideEmptyGroupsChange}
/>
</SelectableListItem>
)}
{!isCalendarLayout && !hasGroupBy && (
<SelectableListItem itemId="record-table-limit">
<CommandMenuItemNumberInput
id="record-table-limit"
label={t`Limit`}
Icon={IconArrowBarToDownDashed}
value={isDefined(limit) ? `${limit}` : ''}
onChange={handleLimitChange}
placeholder={t`No limit`}
/>
</SelectableListItem>
)}
</>
)}
</SidePanelGroup>
@@ -0,0 +1,89 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
type RecordTableCalendarFieldDropdownContentProps = {
pageLayoutId: string;
widgetId: string;
objectMetadataId: string;
currentCalendarFieldMetadataId: string | null;
};
export const RecordTableCalendarFieldDropdownContent = ({
pageLayoutId,
widgetId,
objectMetadataId,
currentCalendarFieldMetadataId,
}: RecordTableCalendarFieldDropdownContentProps) => {
const { getIcon } = useIcons();
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const { handleCalendarFieldChange } = useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId,
});
const dateFields = (objectMetadataItem?.readableFields ?? []).filter(
isFieldMetadataItemAvailableAsCalendarField,
);
return (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={dateFields.map(
(fieldMetadataItem) => fieldMetadataItem.id,
)}
focusId={dropdownId}
>
{dateFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleCalendarFieldChange(fieldMetadataItem);
closeDropdown();
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
LeftIcon={getIcon(fieldMetadataItem.icon)}
selected={currentCalendarFieldMetadataId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
onClick={() => {
handleCalendarFieldChange(fieldMetadataItem);
closeDropdown();
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -0,0 +1,86 @@
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import {
IconCalendarEvent,
IconCalendarMonth,
IconCalendarWeek,
} from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
type RecordTableCalendarLayoutDropdownContentProps = {
pageLayoutId: string;
widgetId: string;
currentCalendarLayout: ViewCalendarLayout;
};
export const RecordTableCalendarLayoutDropdownContent = ({
pageLayoutId,
widgetId,
currentCalendarLayout,
}: RecordTableCalendarLayoutDropdownContentProps) => {
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const { handleCalendarLayoutChange } = useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId,
});
const handleSelect = (calendarLayout: ViewCalendarLayout) => {
handleCalendarLayoutChange(calendarLayout);
closeDropdown();
};
const calendarLayoutOptions = [
{ value: ViewCalendarLayout.DAY, label: t`Day`, Icon: IconCalendarEvent },
{ value: ViewCalendarLayout.WEEK, label: t`Week`, Icon: IconCalendarWeek },
{
value: ViewCalendarLayout.MONTH,
label: t`Month`,
Icon: IconCalendarMonth,
},
];
return (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={calendarLayoutOptions.map(({ value }) => value)}
focusId={dropdownId}
>
{calendarLayoutOptions.map(({ value, label, Icon }) => (
<SelectableListItem
key={value}
itemId={value}
onEnter={() => handleSelect(value)}
>
<MenuItemSelect
text={label}
LeftIcon={Icon}
selected={currentCalendarLayout === value}
focused={selectedItemId === value}
onClick={() => handleSelect(value)}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -0,0 +1,138 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useRecordTableWidgetLayoutCallbacks } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { isFieldMetadataItemAvailableAsWidgetGroupByField } from '@/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
const NO_GROUP_BY_ITEM_ID = 'no-group-by';
type RecordTableGroupByDropdownContentProps = {
pageLayoutId: string;
widgetId: string;
objectMetadataId: string;
currentMainGroupByFieldMetadataId: string | null;
isClearable?: boolean;
};
export const RecordTableGroupByDropdownContent = ({
pageLayoutId,
widgetId,
objectMetadataId,
currentMainGroupByFieldMetadataId,
isClearable = true,
}: RecordTableGroupByDropdownContentProps) => {
const [searchQuery, setSearchQuery] = useState('');
const { getIcon } = useIcons();
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const { handleGroupByFieldChange } = useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId,
});
const groupableFields = (objectMetadataItem?.readableFields ?? []).filter(
isFieldMetadataItemAvailableAsWidgetGroupByField,
);
const filteredFields = filterBySearchQuery({
items: groupableFields,
searchQuery,
getSearchableValues: (fieldMetadataItem) => [fieldMetadataItem.label],
});
const selectableItemIds = [
...(isClearable ? [NO_GROUP_BY_ITEM_ID] : []),
...filteredFields.map((fieldMetadataItem) => fieldMetadataItem.id),
];
return (
<>
<DropdownMenuSearchInput
autoFocus
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={selectableItemIds}
focusId={dropdownId}
>
{isClearable && (
<SelectableListItem
itemId={NO_GROUP_BY_ITEM_ID}
onEnter={() => {
handleGroupByFieldChange(null);
closeDropdown();
}}
>
<MenuItemSelect
text={t`None`}
selected={!isDefined(currentMainGroupByFieldMetadataId)}
focused={selectedItemId === NO_GROUP_BY_ITEM_ID}
onClick={() => {
handleGroupByFieldChange(null);
closeDropdown();
}}
/>
</SelectableListItem>
)}
{filteredFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleGroupByFieldChange(fieldMetadataItem);
closeDropdown();
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
LeftIcon={getIcon(fieldMetadataItem.icon)}
selected={
currentMainGroupByFieldMetadataId === fieldMetadataItem.id
}
focused={selectedItemId === fieldMetadataItem.id}
onClick={() => {
handleGroupByFieldChange(fieldMetadataItem);
closeDropdown();
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,147 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
import {
type RecordTableWidgetLayoutViewType,
useRecordTableWidgetLayoutCallbacks,
} from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetLayoutCallbacks';
import { isFieldMetadataItemAvailableAsWidgetGroupByField } from '@/page-layout/widgets/record-table/utils/isFieldMetadataItemAvailableAsWidgetGroupByField';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCalendar, IconLayoutKanban, IconTable } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { ViewType } from '~/generated-metadata/graphql';
type RecordTableLayoutDropdownContentProps = {
pageLayoutId: string;
widgetId: string;
objectMetadataId: string;
currentLayoutViewType: RecordTableWidgetLayoutViewType;
};
export const RecordTableLayoutDropdownContent = ({
pageLayoutId,
widgetId,
objectMetadataId,
currentLayoutViewType,
}: RecordTableLayoutDropdownContentProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { closeDropdown } = useCloseDropdown();
const { handleLayoutChange } = useRecordTableWidgetLayoutCallbacks({
pageLayoutId,
widgetId,
});
const defaultGroupByFieldMetadataItem =
(objectMetadataItem?.readableFields ?? []).find(
isFieldMetadataItemAvailableAsWidgetGroupByField,
) ?? null;
const defaultCalendarFieldMetadataItem =
(objectMetadataItem?.readableFields ?? []).find(
isFieldMetadataItemAvailableAsCalendarField,
) ?? null;
const isKanbanAvailable = isDefined(defaultGroupByFieldMetadataItem);
const isCalendarAvailable = isDefined(defaultCalendarFieldMetadataItem);
const handleSelectLayout = (
targetViewType: RecordTableWidgetLayoutViewType,
) => {
if (targetViewType === ViewType.KANBAN_WIDGET && !isKanbanAvailable) {
return;
}
if (targetViewType === ViewType.CALENDAR_WIDGET && !isCalendarAvailable) {
return;
}
handleLayoutChange({
targetViewType,
defaultGroupByFieldMetadataItem,
defaultCalendarFieldMetadataItem,
});
closeDropdown();
};
return (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={[
ViewType.TABLE_WIDGET,
...(isKanbanAvailable ? [ViewType.KANBAN_WIDGET] : []),
...(isCalendarAvailable ? [ViewType.CALENDAR_WIDGET] : []),
]}
focusId={dropdownId}
>
<SelectableListItem
itemId={ViewType.TABLE_WIDGET}
onEnter={() => handleSelectLayout(ViewType.TABLE_WIDGET)}
>
<MenuItemSelect
text={t`Table`}
LeftIcon={IconTable}
selected={currentLayoutViewType === ViewType.TABLE_WIDGET}
focused={selectedItemId === ViewType.TABLE_WIDGET}
onClick={() => handleSelectLayout(ViewType.TABLE_WIDGET)}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewType.KANBAN_WIDGET}
onEnter={() => handleSelectLayout(ViewType.KANBAN_WIDGET)}
>
<MenuItemSelect
text={t`Kanban`}
LeftIcon={IconLayoutKanban}
disabled={!isKanbanAvailable}
contextualText={
!isKanbanAvailable ? t`Needs a Select field` : undefined
}
contextualTextPosition="right"
selected={currentLayoutViewType === ViewType.KANBAN_WIDGET}
focused={selectedItemId === ViewType.KANBAN_WIDGET}
onClick={() => handleSelectLayout(ViewType.KANBAN_WIDGET)}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewType.CALENDAR_WIDGET}
onEnter={() => handleSelectLayout(ViewType.CALENDAR_WIDGET)}
>
<MenuItemSelect
text={t`Calendar`}
LeftIcon={IconCalendar}
disabled={!isCalendarAvailable}
contextualText={
!isCalendarAvailable ? t`Needs a Date field` : undefined
}
contextualTextPosition="right"
selected={currentLayoutViewType === ViewType.CALENDAR_WIDGET}
focused={selectedItemId === ViewType.CALENDAR_WIDGET}
onClick={() => handleSelectLayout(ViewType.CALENDAR_WIDGET)}
/>
</SelectableListItem>
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -1,7 +1,8 @@
import { isWidgetViewType } from 'twenty-shared/utils';
import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { type View } from '@/views/types/View';
import { ViewType } from '@/views/types/ViewType';
export const viewsFromObjectMetadataItemFamilySelector =
createAtomFamilySelector<View[], { objectMetadataItemId: string }>({
@@ -14,8 +15,7 @@ export const viewsFromObjectMetadataItemFamilySelector =
.filter(
(view) =>
view.objectMetadataId === objectMetadataItemId &&
view.type !== ViewType.FIELDS_WIDGET &&
view.type !== ViewType.TABLE_WIDGET,
!isWidgetViewType(view.type),
)
.sort((a, b) => a.position - b.position);
},
@@ -18,6 +18,8 @@ export const VIEW_TYPE_LABELS = {
[ViewType.CALENDAR]: msg`Calendar`,
[ViewType.FIELDS_WIDGET]: msg`Fields widget`,
[ViewType.TABLE_WIDGET]: msg`Table widget`,
[ViewType.KANBAN_WIDGET]: msg`Kanban widget`,
[ViewType.CALENDAR_WIDGET]: msg`Calendar widget`,
} satisfies Record<ViewTypeLabelKey, MessageDescriptor>;
export const getViewTypeLabel = (
@@ -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();
@@ -0,0 +1,37 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.23.0', 1784620626405)
export class AddKanbanAndCalendarWidgetViewTypesFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."view_type_enum" ADD VALUE IF NOT EXISTS 'KANBAN_WIDGET' AFTER 'TABLE_WIDGET'`,
);
await queryRunner.query(
`ALTER TYPE "core"."view_type_enum" ADD VALUE IF NOT EXISTS 'CALENDAR_WIDGET' AFTER 'KANBAN_WIDGET'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"CREATE TYPE \"core\".\"view_type_enum_old\" AS ENUM('TABLE', 'KANBAN', 'CALENDAR', 'FIELDS_WIDGET', 'TABLE_WIDGET')",
);
await queryRunner.query(
'ALTER TABLE "core"."view" ALTER COLUMN "type" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "core"."view" ALTER COLUMN "type" TYPE "core"."view_type_enum_old" USING "type"::"text"::"core"."view_type_enum_old"',
);
await queryRunner.query(
'ALTER TABLE "core"."view" ALTER COLUMN "type" SET DEFAULT \'TABLE\'',
);
await queryRunner.query('DROP TYPE "core"."view_type_enum"');
await queryRunner.query(
'ALTER TYPE "core"."view_type_enum_old" RENAME TO "view_type_enum"',
);
}
}
@@ -0,0 +1,27 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.23.0', 1784620626406)
export class WidenViewCalendarIntegrityConstraintFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."view" DROP CONSTRAINT IF EXISTS "CHK_VIEW_CALENDAR_INTEGRITY"',
);
await queryRunner.query(
`ALTER TABLE "core"."view" ADD CONSTRAINT "CHK_VIEW_CALENDAR_INTEGRITY" CHECK ("type" NOT IN ('CALENDAR', 'CALENDAR_WIDGET') OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."view" DROP CONSTRAINT IF EXISTS "CHK_VIEW_CALENDAR_INTEGRITY"',
);
await queryRunner.query(
`ALTER TABLE "core"."view" ADD CONSTRAINT "CHK_VIEW_CALENDAR_INTEGRITY" CHECK ("type" != 'CALENDAR' OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
);
}
}
@@ -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,
];
@@ -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;
}
@@ -9,6 +9,7 @@ import {
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UpsertViewWidgetViewSettingsInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-settings.input';
import { UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
import { UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
import { UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
@@ -23,6 +24,16 @@ export class UpsertViewWidgetInput {
})
widgetId: string;
@IsOptional()
@ValidateNested()
@Type(() => UpsertViewWidgetViewSettingsInput)
@Field(() => UpsertViewWidgetViewSettingsInput, {
nullable: true,
description:
'View-level settings (layout type, group by, kanban and calendar settings) to apply to the widget view.',
})
view?: UpsertViewWidgetViewSettingsInput;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => UpsertViewWidgetViewFieldInput)
@@ -72,7 +72,7 @@ export type ViewOverrides = {
@Index('IDX_VIEW_CREATED_BY_USER_WORKSPACE', ['createdByUserWorkspaceId'])
@Check(
'CHK_VIEW_CALENDAR_INTEGRITY',
`("type" != 'CALENDAR' OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
`("type" NOT IN ('CALENDAR', 'CALENDAR_WIDGET') OR ("calendarLayout" IS NOT NULL AND "calendarFieldMetadataId" IS NOT NULL))`,
)
export class ViewEntity
extends OverridableEntity<ViewOverrides>
@@ -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: [],
@@ -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()
@@ -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],
}),
);
@@ -98,11 +98,12 @@ CHART FILTERS (AGGREGATE_CHART, BAR_CHART, LINE_CHART, PIE_CHART):
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } }
7. RECORD_TABLE: displays a live, filterable record list directly on the dashboard.
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE view for the object, then pass its ID as viewId. Never reuse an existing index-page view — widget views and record index views must not overlap.
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE_WIDGET view for the object, then pass its ID as viewId. Never reuse an existing index-page view and never use plain TABLE/KANBAN/CALENDAR types for widget-backing views — non-widget view types leak into record index view pickers.
- Requires: objectMetadataId (top-level, UUID of the object to display) AND configuration.viewId (UUID of the dedicated view you just created)
- configuration.configurationType must be "RECORD_TABLE"
- Recommended size: rowSpan 8-10, columnSpan 12 (full width)
- Workflow: (1) call create_view with type TABLE for the object → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
- Workflow: (1) call create_view with the appropriate *_WIDGET type (TABLE_WIDGET for a table, KANBAN_WIDGET for a board, CALENDAR_WIDGET for a calendar — kanban requires mainGroupByFieldName, calendar requires calendarFieldName) → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
- The widget renders according to its view type: TABLE_WIDGET renders a table, KANBAN_WIDGET a board (requires mainGroupByFieldName pointing at a SELECT or many-to-one relation field), CALENDAR_WIDGET a calendar (requires a date calendar field)
- Example: { type: "RECORD_TABLE", objectMetadataId: "<object-uuid>", configuration: { configurationType: "RECORD_TABLE", viewId: "<dedicated-view-uuid>" } }
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
@@ -0,0 +1,407 @@
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util';
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { findViewGroups } from 'test/integration/metadata/suites/view-group/utils/find-view-groups.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util';
import { upsertViewWidget } from 'test/integration/metadata/suites/view/utils/upsert-view-widget.util';
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
import {
FeatureFlagKey,
FieldMetadataType,
ViewCalendarLayout,
ViewType,
} from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
const VIEW_SETTINGS_GQL_FIELDS = `
id
type
mainGroupByFieldMetadataId
shouldHideEmptyGroups
kanbanAggregateOperation
kanbanAggregateOperationFieldMetadataId
calendarLayout
calendarFieldMetadataId
calendarEndFieldMetadataId
`;
describe('upsertViewWidget view settings', () => {
let objectMetadataId: string;
let selectFieldMetadataId: string;
let dateFieldMetadataId: string;
let pageLayoutId: string;
let pageLayoutTabId: string;
let widgetId: string;
let viewId: string;
beforeAll(async () => {
const {
data: {
createOneObject: { id: createdObjectMetadataId },
},
} = await createOneObjectMetadata({
expectToFail: false,
input: {
nameSingular: 'widgetViewSettingsTestObject',
namePlural: 'widgetViewSettingsTestObjects',
labelSingular: 'Widget View Settings Test Object',
labelPlural: 'Widget View Settings Test Objects',
icon: 'IconTestTube',
},
});
objectMetadataId = createdObjectMetadataId;
const {
data: {
createOneField: { id: createdSelectFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'stage',
type: FieldMetadataType.SELECT,
label: 'Stage',
objectMetadataId,
options: [
{ label: 'New', value: 'NEW', color: 'blue', position: 0 },
{ label: 'Ongoing', value: 'ONGOING', color: 'red', position: 1 },
{ label: 'Done', value: 'DONE', color: 'green', position: 2 },
],
},
gqlFields: 'id',
});
selectFieldMetadataId = createdSelectFieldMetadataId;
const {
data: {
createOneField: { id: createdDateFieldMetadataId },
},
} = await createOneFieldMetadata({
expectToFail: false,
input: {
name: 'eventDate',
type: FieldMetadataType.DATE,
label: 'Event Date',
objectMetadataId,
},
gqlFields: 'id',
});
dateFieldMetadataId = createdDateFieldMetadataId;
const { data: layoutData } = await createOnePageLayout({
expectToFail: false,
input: { name: 'Test Page Layout For View Settings' },
});
pageLayoutId = layoutData.createPageLayout.id;
const { data: tabData } = await createOnePageLayoutTab({
expectToFail: false,
input: {
title: 'Test Tab For View Settings',
pageLayoutId,
},
});
pageLayoutTabId = tabData.createPageLayoutTab.id;
const { data: viewData } = await createOneView({
expectToFail: false,
input: {
name: 'testWidgetViewSettingsView',
objectMetadataId,
icon: 'IconTable',
type: ViewType.TABLE_WIDGET,
},
});
viewId = viewData.createView.id;
const { data: widgetData } = await createOnePageLayoutWidget({
expectToFail: false,
input: {
title: 'Test Record Table Widget For View Settings',
type: WidgetType.RECORD_TABLE,
pageLayoutTabId,
objectMetadataId,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId,
},
},
});
widgetId = widgetData.createPageLayoutWidget.id;
});
afterAll(async () => {
await destroyOnePageLayoutWidget({
expectToFail: false,
input: { id: widgetId },
});
await destroyOneView({
expectToFail: false,
viewId,
});
await destroyOnePageLayoutTab({
expectToFail: false,
input: { id: pageLayoutTabId },
});
await destroyOnePageLayout({
expectToFail: false,
input: { id: pageLayoutId },
});
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: objectMetadataId,
updatePayload: { isActive: false },
},
});
await deleteOneObjectMetadata({
expectToFail: false,
input: { idToDelete: objectMetadataId },
});
});
it('should reject a non-widget view type', async () => {
const { errors } = await upsertViewWidget({
expectToFail: true,
input: {
widgetId,
view: {
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
},
});
expect(errors?.[0]?.message).toContain(
'Widget views must use a widget view type',
);
});
it('should reject switching to KANBAN_WIDGET without a main group by field', async () => {
const { errors } = await upsertViewWidget({
expectToFail: true,
input: {
widgetId,
view: {
type: ViewType.KANBAN_WIDGET,
},
},
});
expect(JSON.stringify(errors)).toContain(
'Kanban view must have a main group by field',
);
});
it('should switch the widget view to KANBAN_WIDGET and auto-create view groups', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId,
view: {
type: ViewType.KANBAN_WIDGET,
mainGroupByFieldMetadataId: selectFieldMetadataId,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.KANBAN_WIDGET);
expect(data.upsertViewWidget.mainGroupByFieldMetadataId).toBe(
selectFieldMetadataId,
);
const { data: viewGroupsData } = await findViewGroups({
expectToFail: false,
viewId,
});
const fieldValues = viewGroupsData.getViewGroups.map(
(viewGroup) => viewGroup.fieldValue,
);
expect(fieldValues).toEqual(
expect.arrayContaining(['NEW', 'ONGOING', 'DONE']),
);
});
it('should reject switching to CALENDAR_WIDGET without a calendar field', async () => {
const { errors } = await upsertViewWidget({
expectToFail: true,
input: {
widgetId,
view: {
type: ViewType.CALENDAR_WIDGET,
mainGroupByFieldMetadataId: null,
},
},
});
expect(JSON.stringify(errors)).toContain(
'Calendar view must have a calendar field',
);
});
it('should reject a non-month calendar layout on a CALENDAR_WIDGET view', async () => {
// This suite shares its workspace with the rest of the shard, and the
// "allow non-month" test below flips IS_CALENDAR_WEEK_VIEW_ENABLED on. Pin
// it off here so the rejection path is exercised deterministically, whatever
// the test/retry ordering leaves behind in the workspace.
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
value: false,
expectToFail: false,
});
const { errors } = await upsertViewWidget({
expectToFail: true,
input: {
widgetId,
view: {
type: ViewType.CALENDAR_WIDGET,
calendarLayout: ViewCalendarLayout.DAY,
calendarFieldMetadataId: dateFieldMetadataId,
mainGroupByFieldMetadataId: null,
},
},
});
expect(JSON.stringify(errors)).toContain(
'Calendar widget views only support the month layout',
);
});
it('should allow a non-month calendar layout on a CALENDAR_WIDGET view when the week/day calendar feature is enabled', async () => {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
value: true,
expectToFail: false,
});
try {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId,
view: {
type: ViewType.CALENDAR_WIDGET,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldMetadataId: dateFieldMetadataId,
mainGroupByFieldMetadataId: null,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.CALENDAR_WIDGET);
expect(data.upsertViewWidget.calendarLayout).toBe(
ViewCalendarLayout.WEEK,
);
} finally {
await updateFeatureFlag({
featureFlag: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
value: false,
expectToFail: false,
});
}
});
it('should switch the widget view to CALENDAR_WIDGET with a date field and layout', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId,
view: {
type: ViewType.CALENDAR_WIDGET,
calendarLayout: ViewCalendarLayout.MONTH,
calendarFieldMetadataId: dateFieldMetadataId,
mainGroupByFieldMetadataId: null,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.CALENDAR_WIDGET);
expect(data.upsertViewWidget.calendarLayout).toBe(ViewCalendarLayout.MONTH);
expect(data.upsertViewWidget.calendarFieldMetadataId).toBe(
dateFieldMetadataId,
);
const { data: viewGroupsData } = await findViewGroups({
expectToFail: false,
viewId,
});
expect(viewGroupsData.getViewGroups).toHaveLength(0);
});
it('should switch the widget view back to TABLE_WIDGET', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId,
view: {
type: ViewType.TABLE_WIDGET,
},
},
gqlFields: VIEW_SETTINGS_GQL_FIELDS,
});
expect(data.upsertViewWidget.type).toBe(ViewType.TABLE_WIDGET);
});
it('should update view settings and view fields in a single call', async () => {
const { data } = await upsertViewWidget({
expectToFail: false,
input: {
widgetId,
view: {
type: ViewType.KANBAN_WIDGET,
mainGroupByFieldMetadataId: selectFieldMetadataId,
shouldHideEmptyGroups: true,
},
viewFields: [
{
fieldMetadataId: selectFieldMetadataId,
isVisible: true,
position: 0,
},
],
},
gqlFields: `
${VIEW_SETTINGS_GQL_FIELDS}
viewFields {
id
fieldMetadataId
isVisible
}
`,
});
expect(data.upsertViewWidget.type).toBe(ViewType.KANBAN_WIDGET);
expect(data.upsertViewWidget.shouldHideEmptyGroups).toBe(true);
expect(
(data.upsertViewWidget.viewFields ?? []).some(
(viewField) => viewField.fieldMetadataId === selectFieldMetadataId,
),
).toBe(true);
});
});
@@ -4,4 +4,6 @@ export enum ViewType {
CALENDAR = 'CALENDAR',
FIELDS_WIDGET = 'FIELDS_WIDGET',
TABLE_WIDGET = 'TABLE_WIDGET',
KANBAN_WIDGET = 'KANBAN_WIDGET',
CALENDAR_WIDGET = 'CALENDAR_WIDGET',
}
@@ -248,3 +248,5 @@ export { normalizeLocale } from './validation/normalizeLocale';
export { getCountryCodesForCallingCode } from './validation/phones-value/getCountryCodesForCallingCode';
export { isValidCountryCode } from './validation/phones-value/isValidCountryCode';
export { resolveInput } from './variable-resolver';
export { getViewLayoutFromViewType } from './views/getViewLayoutFromViewType';
export { isWidgetViewType } from './views/isWidgetViewType';
@@ -0,0 +1,19 @@
import { ViewType } from '@/types';
type ViewTypeValue = `${ViewType}`;
const VIEW_TYPE_TO_LAYOUT_MAPPING: Record<ViewTypeValue, ViewTypeValue> = {
[ViewType.TABLE]: ViewType.TABLE,
[ViewType.KANBAN]: ViewType.KANBAN,
[ViewType.CALENDAR]: ViewType.CALENDAR,
[ViewType.FIELDS_WIDGET]: ViewType.FIELDS_WIDGET,
[ViewType.TABLE_WIDGET]: ViewType.TABLE,
[ViewType.KANBAN_WIDGET]: ViewType.KANBAN,
[ViewType.CALENDAR_WIDGET]: ViewType.CALENDAR,
};
export const getViewLayoutFromViewType = (
viewType: ViewTypeValue,
): ViewTypeValue => {
return VIEW_TYPE_TO_LAYOUT_MAPPING[viewType];
};
@@ -0,0 +1,14 @@
import { ViewType } from '@/types';
type ViewTypeValue = `${ViewType}`;
const WIDGET_VIEW_TYPES: ViewTypeValue[] = [
ViewType.FIELDS_WIDGET,
ViewType.TABLE_WIDGET,
ViewType.KANBAN_WIDGET,
ViewType.CALENDAR_WIDGET,
];
export const isWidgetViewType = (viewType: ViewTypeValue): boolean => {
return WIDGET_VIEW_TYPES.includes(viewType);
};