Make solo tabs first-class: derived presentation, native editing, unified widget header (#23109)

## Why

Full-page record tabs (Timeline, Tasks, Notes, Files, Emails, Calendar,
Flow) were encoded by storing a `CANVAS` layout mode. That made them a
separate species: editing one didn't feel native (no drag handles, no
way to add a second widget, the tab couldn't adapt), and the widget
pipeline was full of `layoutMode === CANVAS` branches.

This PR replaces the stored mode with two derived rules and one unified
header grammar:

> **Presentation is derived from content, never stored.**
> A list tab with exactly **one widget** renders it **solo**
(full-bleed, it owns the tab). Anything else is a **stack** of boxed
cards. **Edit mode always shows the stack structure.**

No widget taxonomy, no per-type branches: any lone widget owns its tab.

## What

**Presentation model**
- `getTabPresentation({ widgets, layoutMode, isInEditMode })`: solo iff
a list tab has exactly one widget in view mode; grid tabs (dashboards)
and edit mode are always stacks. The pinned left panel is always a
column (a surface rule, not a widget rule).
- Solo view rendering is identical to the old CANVAS rendering
(container height, internal scroll).
- Stacked widgets in the main tab area get one bounded slot rule
(`max-height` + own scroll) so no widget swallows the tab;
pinned/side-column stacks keep their flowing behavior. This only binds
on user-composed mixed tabs, which could not exist before.

**Native editing (the point of the PR)**
- Every record-page tab is edited through the same vertical-list editor:
drag handle, reorder, remove, add widget. Add a second widget to a
Timeline tab and it becomes a stack; remove back down to one and it's
solo again. Nothing is stored, nothing to migrate.
- Fixes the stuck-drag bug found while testing the preview: widgets
publishing header info republished a fresh object on every render
(activity cards build their action from non-memoized hook returns), and
since the widget chrome reads that state above the widget content, any
tab with an activity card sat in an infinite render loop. The loop
starved React's transition lane, which dnd-kit's drop teardown waits on,
so the drag clone and drop outlines froze on screen after a drop. The
header hook now republishes only on real value changes and routes
onClick through a stable wrapper, so callers need no memoization. The
page-layout drag provider also disables the Feedback drop animation so
clone cleanup is synchronous at drop time.

**Unified widget header API**
- A widget's content can publish header info to its chrome via
`usePublishWidgetHeaderInfo({ count, primaryAction })`: a count rendered
in grey next to the title, and a primary action (icon button with
accessible name) on the right in view mode. Instance-scoped state keyed
by widget id, so third-party widgets (front components) can use the same
seam later; the hook no-ops outside a page layout (stories, previews)
and is safe to call with inline, non-memoized values.
- A solo widget's header only appears when the widget published
something: the tab label already names it, so a bare title row adds
nothing. Timeline/Flow tabs stay exactly as today.
- Emails, Tasks, Notes, Files, Calendar publish their count (query
totals, not loaded-page lengths) and action (Compose, New task, New
note, Add file) and stop rendering internal title rows ("Inbox 12", "All
5"): exactly one header per widget everywhere, same grammar.
`ComposeEmailButton`, `AddTaskButton` and the title/button plumbing in
`NoteList`/`AttachmentList`/`TaskList` are deleted.

**Object-aware tabs**
- The hardcoded `SYSTEM_OBJECT_TABS` title allowlist is gone. A tab
renders based on whether the target object supports its widgets: widgets
that read through a relation (Tasks, Notes, Files, Timeline) require the
relation field to exist and be active, while Emails and Calendar
aggregate through the messaging timeline, so a missing participants
relation is fine (Company) and a deactivated one is an explicit opt-out.
System objects on the shared default layout keep exactly Home +
Timeline, now by derivation instead of hardcoded titles.

**Data cleanup**
- Seeds (frontend defaults, server standard template, `twenty app`
scaffolder, docs) write `VERTICAL_LIST`;
`PageLayoutTabLayoutMode.CANVAS` is `@deprecated`, kept read-only for
layouts persisted before this change (they render correctly through the
derivation; no data migration, by design: an in-place flip can't pass
the widget-position/tab-layoutMode validator atomically, and it isn't
needed).
- Locale catalogs are intentionally untouched: the i18n pipeline
extracts and translates the new header labels on main; they fall back to
their English source until then.

## Deliberate view-mode changes (approved)

- A lone widget of any type now owns its tab full-bleed: lone Fields tab
(mobile/side panel), lone rich-text Note tab, lone chart, and the
message-thread page lose their card box.
- Activity tabs show the unified header (title, grey count, + action)
instead of their internal "Inbox 12"-style rows.

Everything else is pixel-parity, including solo scroll behavior and
dashboards.

## Test plan
- `nx typecheck twenty-front` / `twenty-server`: clean; oxlint/oxfmt on
the changeset: clean
- 239 suites / 1474 tests across page-layout, activities, side-panel
pass, including new tests for `getTabPresentation` (count-based,
edit-mode override) and `usePublishWidgetHeaderInfo` (publish, cleanup
on unmount, no-op outside a widget, referential stability across
re-renders with inline actions, latest-onClick wrapper)
- `getTabsRenderableForTargetObject` tests covering missing vs
deactivated relations, Emails/Calendar without a participants relation,
and non-relation widgets
- Stuck-drag repro verified fixed end to end against a local stack with
an instrumented dnd-kit: before the fix the affected tab committed ~65
renders/second at idle and drops never tore down; after it, idle commits
are flat and every drop cleans up
This commit is contained in:
Félix Malfait
2026-07-24 17:02:02 +02:00
committed by GitHub
parent 0bba75dd18
commit 6cc7ed7570
67 changed files with 1128 additions and 565 deletions
@@ -31,7 +31,7 @@ export default definePageLayout({
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
@@ -53,7 +53,7 @@ export default definePageLayout({
- `type` is typically `'RECORD_PAGE'` to customize the detail view of a specific object.
- `objectUniversalIdentifier` specifies which object this layout applies to.
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`CANVAS` for free-form layout).
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`VERTICAL_LIST` for record pages, `GRID` for dashboards). A tab holding a single widget renders it full-bleed automatically; with several widgets they stack as cards.
- Each `widget` inside a tab can render a [front component](/developers/extend/apps/layout/front-components), a relation list, or other built-in widget types.
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
@@ -77,7 +77,7 @@ export default definePageLayoutTab({
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
@@ -220,8 +220,8 @@ export default defineFrontComponent({
```
Mount it with a [page layout](/developers/extend/apps/layout/page-layouts). A
`RECORD_PAGE` layout adds tabs to an object's record view; a `FRONT_COMPONENT`
widget in a `CANVAS` tab hosts the component:
`RECORD_PAGE` layout adds tabs to an object's record view; a tab with a single
`FRONT_COMPONENT` widget hosts the component full-bleed:
```ts filename="src/page-layouts/document-record.page-layout.ts"
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
@@ -236,7 +236,7 @@ export default definePageLayout({
title: 'Preview',
icon: 'IconEye',
position: 50,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [{
universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Document preview',
@@ -12,6 +12,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { useSubscribeTimelineToParticipantChanges } from '@/activities/hooks/useSubscribeTimelineToParticipantChanges';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { H3Title } from 'twenty-ui/typography';
import {
@@ -69,6 +70,8 @@ export const CalendarEventsCard = () => {
const { timelineCalendarEvents, totalNumberOfCalendarEvents } =
data?.getTimelineCalendarEventsFromObjectRecord ?? {};
usePublishWidgetHeaderInfo({ count: totalNumberOfCalendarEvents });
const {
calendarEventsByDayTime,
daysByMonthTime,
@@ -1,17 +0,0 @@
import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
import { IconPlus } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
export const ComposeEmailButton = () => {
const { openComposer, loading } = useComposeEmailForTargetRecord();
return (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
size="small"
onClick={openComposer}
disabled={loading}
/>
);
};
@@ -4,16 +4,18 @@ import { CoreObjectNameSingular } from 'twenty-shared/types';
import { ActivityList } from '@/activities/components/ActivityList';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton';
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { EmptyInboxPlaceholder } from '@/activities/emails/components/EmptyInboxPlaceholder';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
import { getTimelineThreadsFromObjectRecord } from '@/activities/emails/graphql/queries/getTimelineThreadsFromObjectRecord';
import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { useSubscribeTimelineToParticipantChanges } from '@/activities/hooks/useSubscribeTimelineToParticipantChanges';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/typography';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { IconPlus } from 'twenty-ui/icon';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
@@ -31,25 +33,10 @@ const StyledContainer = styled.div`
${themeCssVariables.spacing[2]};
`;
const StyledHeaderRow = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledH1Title = styled(H1Title)`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: 0;
`;
const StyledEmailCount = styled.span`
color: ${themeCssVariables.font.color.light};
`;
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
const { openComposer, loading: composerLoading } =
useComposeEmailForTargetRecord();
const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords, refetch } =
useCustomResolver<TimelineThreadsWithTotal>(
@@ -70,6 +57,22 @@ export const EmailsCard = () => {
const { totalNumberOfThreads, timelineThreads } =
data?.getTimelineThreadsFromObjectRecord ?? {};
const composeAction = useMemo(
() => ({
Icon: IconPlus,
label: t`Compose`,
onClick: openComposer,
disabled: composerLoading,
}),
[openComposer, composerLoading],
);
usePublishWidgetHeaderInfo({
count: totalNumberOfThreads,
primaryAction: composeAction,
});
const hasMoreTimelineThreads =
timelineThreads && totalNumberOfThreads
? timelineThreads?.length < totalNumberOfThreads
@@ -96,18 +99,6 @@ export const EmailsCard = () => {
return (
<StyledContainer>
<Section>
<StyledHeaderRow>
<StyledH1Title
title={
<>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
<ComposeEmailButton />
</StyledHeaderRow>
{!firstQueryLoading && (
<ActivityList>
{timelineThreads?.map((thread: TimelineThread) => (
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { lazy, type ReactElement, Suspense, useState } from 'react';
import { lazy, Suspense, useState } from 'react';
import { createPortal } from 'react-dom';
import { DropZone } from '@/activities/files/components/DropZone';
@@ -37,9 +37,7 @@ const DocumentViewer = lazy(() =>
type AttachmentListProps = {
targetableObject: ActivityTargetableObject;
title: string;
attachments: Attachment[];
button?: ReactElement | false | null;
};
const StyledContainer = styled.div`
@@ -49,30 +47,11 @@ const StyledContainer = styled.div`
flex-direction: column;
height: 100%;
justify-content: center;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[6]}
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]}
${themeCssVariables.spacing[6]};
width: calc(100% - ${themeCssVariables.spacing[12]});
`;
const StyledTitleBar = styled.h3`
display: flex;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[4]};
place-items: center;
width: 100%;
`;
const StyledTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledCount = styled.span`
color: ${themeCssVariables.font.color.light};
margin-left: ${themeCssVariables.spacing[2]};
`;
const StyledDropZoneContainer = styled.div`
height: 100%;
overflow: auto;
@@ -116,9 +95,7 @@ export const PREVIEW_MODAL_ID = 'preview-modal';
export const AttachmentList = ({
targetableObject,
title,
attachments,
button,
}: AttachmentListProps) => {
const { uploadAttachmentFile } = useUploadAttachmentFile();
const [isDraggingFile, setIsDraggingFile] = useState(false);
@@ -172,12 +149,6 @@ export const AttachmentList = ({
<>
{attachmentsWithFile.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{attachmentsWithFile.length}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledDropZoneContainer
onDragEnter={() => hasUploadPermission && setIsDraggingFile(true)}
>
@@ -1,5 +1,5 @@
import { styled } from '@linaria/react';
import { type ChangeEvent, useRef, useState } from 'react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { AttachmentList } from '@/activities/files/components/AttachmentList';
@@ -8,6 +8,7 @@ import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -42,7 +43,8 @@ const StyledDropZoneContainer = styled.div`
export const FilesCard = () => {
const targetRecord = useTargetRecord();
const inputFileRef = useRef<HTMLInputElement>(null);
const { attachments, loading } = useAttachments(targetRecord);
const { attachments, loading, totalCountAttachments } =
useAttachments(targetRecord);
const { uploadAttachmentFile } = useUploadAttachmentFile();
const [isDraggingFile, setIsDraggingFile] = useState(false);
@@ -87,6 +89,23 @@ export const FilesCard = () => {
const canUploadFiles = hasObjectUpdatePermissions && hasUploadPermission;
const addFileAction = useMemo(
() =>
canUploadFiles
? {
Icon: IconPlus,
label: t`Add file`,
onClick: () => inputFileRef?.current?.click?.(),
}
: undefined,
[canUploadFiles, t],
);
usePublishWidgetHeaderInfo({
count: totalCountAttachments,
primaryAction: addFileAction,
});
if (loading && isAttachmentsEmpty) {
return <SkeletonLoader />;
}
@@ -142,19 +161,7 @@ export const FilesCard = () => {
/>
<AttachmentList
targetableObject={targetRecord}
title={t`All`}
attachments={attachments ?? []}
button={
canUploadFiles && (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title={t`Add file`}
onClick={handleUploadFileClick}
></Button>
)
}
/>
</StyledAttachmentsContainer>
);
@@ -8,7 +8,11 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => {
nameSingular: targetableObject.targetObjectNameSingular,
});
const { records: attachments, loading } = useFindManyRecords<Attachment>({
const {
records: attachments,
loading,
totalCount,
} = useFindManyRecords<Attachment>({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: {
[targetableObjectFieldIdName]: {
@@ -25,5 +29,6 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => {
return {
attachments,
loading,
totalCountAttachments: totalCount,
};
};
@@ -1,17 +1,12 @@
import { styled } from '@linaria/react';
import { type ReactElement } from 'react';
import { type Note } from '@/activities/types/Note';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NoteTile } from './NoteTile';
type NoteListProps = {
title: string;
notes: Note[];
button?: ReactElement | false | null;
totalCount: number;
};
const StyledContainer = styled.div`
@@ -20,26 +15,7 @@ const StyledContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
padding: 8px 24px;
`;
const StyledTitleBar = styled.h3`
display: flex;
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[4]};
place-items: center;
width: 100%;
`;
const StyledTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledCount = styled.span`
color: ${themeCssVariables.font.color.light};
margin-left: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]};
`;
const StyledNoteContainer = styled.div`
@@ -50,24 +26,11 @@ const StyledNoteContainer = styled.div`
width: 100%;
`;
export const NoteList = ({
title,
notes,
totalCount,
button,
}: NoteListProps) => {
const { formatNumber } = useNumberFormat();
export const NoteList = ({ notes }: NoteListProps) => {
return (
<>
{notes.length > 0 && (
<StyledContainer>
<StyledTitleBar>
<StyledTitle>
{title} <StyledCount>{formatNumber(totalCount)}</StyledCount>
</StyledTitle>
{button}
</StyledTitleBar>
<StyledNoteContainer>
{notes.map((note) => (
<NoteTile
@@ -6,9 +6,11 @@ import { useNotes } from '@/activities/notes/hooks/useNotes';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { IconPlus } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import {
@@ -54,6 +56,24 @@ export const NotesCard = () => {
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const newNoteAction = useMemo(
() =>
hasObjectUpdatePermissions
? {
Icon: IconPlus,
label: t`New note`,
onClick: () =>
openCreateActivity({ targetableObjects: [targetRecord] }),
}
: undefined,
[hasObjectUpdatePermissions, openCreateActivity, targetRecord],
);
usePublishWidgetHeaderInfo({
count: totalCountNotes,
primaryAction: newNoteAction,
});
if (loading && isNotesEmpty) {
return <SkeletonLoader />;
}
@@ -88,26 +108,7 @@ export const NotesCard = () => {
return (
<StyledNotesContainer>
<NoteList
title={t`All`}
notes={notes}
totalCount={totalCountNotes}
button={
hasObjectUpdatePermissions && (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title={t`Add note`}
onClick={() =>
openCreateActivity({
targetableObjects: [targetRecord],
})
}
/>
)
}
/>
<NoteList notes={notes} />
<CustomResolverFetchMoreLoader
loading={loading}
onLastRowVisible={handleLastRowVisible}
@@ -1,46 +0,0 @@
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { t } from '@lingui/core/macro';
import { IconPlus } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
export const AddTaskButton = ({
activityTargetableObject,
}: {
activityTargetableObject: ActivityTargetableObject;
}) => {
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: activityTargetableObject.targetObjectNameSingular,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
if (!hasObjectUpdatePermissions) {
return null;
}
return (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title={t`Add task`}
onClick={() =>
openCreateActivity({
targetableObjects: [activityTargetableObject],
})
}
/>
);
};
@@ -7,10 +7,12 @@ import { type ActivityTargetableObject } from '@/activities/types/ActivityTarget
import { type Task } from '@/activities/types/Task';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import groupBy from 'lodash.groupby';
import { useMemo } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { IconPlus } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
@@ -21,7 +23,6 @@ import {
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
} from 'twenty-ui/feedback';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
const StyledContainer = styled.div`
@@ -36,7 +37,7 @@ type TaskGroupsProps = {
};
export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
const { tasks, tasksLoading } = useTasks({
const { tasks, tasksLoading, totalCountTasks } = useTasks({
targetableObjects: [targetableObject],
});
@@ -54,6 +55,24 @@ export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const newTaskAction = useMemo(
() =>
hasObjectUpdatePermissions
? {
Icon: IconPlus,
label: t`New task`,
onClick: () =>
openCreateActivity({ targetableObjects: [targetableObject] }),
}
: undefined,
[hasObjectUpdatePermissions, openCreateActivity, targetableObject],
);
usePublishWidgetHeaderInfo({
count: totalCountTasks,
primaryAction: newTaskAction,
});
const activeTabId = useAtomComponentStateValue(activeTabIdComponentState);
const isLoading =
@@ -100,23 +119,10 @@ export const TaskGroups = ({ targetableObject }: TaskGroupsProps) => {
groupBy(tasks, ({ status }) => status),
).sort(([statusA], [statusB]) => statusB.localeCompare(statusA));
const hasTodoStatus = sortedTasksByStatus.some(
([status]) => status === 'TODO',
);
return (
<StyledContainer>
{sortedTasksByStatus.map(([status, tasksByStatus]: [string, Task[]]) => (
<TaskList
key={status}
title={status}
tasks={tasksByStatus}
button={
(status === 'TODO' || !hasTodoStatus) && (
<AddTaskButton activityTargetableObject={targetableObject} />
)
}
/>
<TaskList key={status} title={status} tasks={tasksByStatus} />
))}
</StyledContainer>
);
@@ -1,5 +1,4 @@
import { styled } from '@linaria/react';
import { type ReactElement } from 'react';
import { ActivityList } from '@/activities/components/ActivityList';
import { type Task } from '@/activities/types/Task';
@@ -9,7 +8,6 @@ import { TaskRow } from './TaskRow';
type TaskListProps = {
title: string;
tasks: Task[];
button?: ReactElement | false;
};
const StyledContainer = styled.div`
@@ -42,7 +40,7 @@ const StyledCount = styled.span`
margin-left: ${themeCssVariables.spacing[2]};
`;
export const TaskList = ({ title, tasks, button }: TaskListProps) => (
export const TaskList = ({ title, tasks }: TaskListProps) => (
<>
{tasks.length > 0 && (
<StyledContainer>
@@ -52,7 +50,6 @@ export const TaskList = ({ title, tasks, button }: TaskListProps) => (
{title} <StyledCount>{tasks.length}</StyledCount>
</StyledTitle>
)}
{button}
</StyledTitleBar>
<ActivityList>
{tasks.map((task) => (
@@ -1,8 +1,10 @@
import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
type PageLayoutMainContentProps = {
tabId: string;
@@ -13,17 +15,25 @@ export const PageLayoutMainContent = ({
}: PageLayoutMainContentProps) => {
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId);
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const layoutMode = getTabLayoutMode({
tab: activeTab,
pageLayoutType: currentPageLayout.type,
});
const presentation = getTabPresentation({
widgets: activeTab.widgets,
layoutMode,
isInEditMode: isPageLayoutInEditMode,
});
return (
<PageLayoutContentProvider
value={{
tabId,
layoutMode,
presentation,
}}
>
<PageLayoutContent />
@@ -1,5 +1,5 @@
import { PageLayoutCanvasViewer } from '@/page-layout/components/PageLayoutCanvasViewer';
import { PageLayoutGridLayout } from '@/page-layout/components/PageLayoutGridLayout';
import { PageLayoutSoloViewer } from '@/page-layout/components/PageLayoutSoloViewer';
import { PageLayoutVerticalListEditor } from '@/page-layout/components/PageLayoutVerticalListEditor';
import { PageLayoutVerticalListViewer } from '@/page-layout/components/PageLayoutVerticalListViewer';
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
@@ -26,15 +26,14 @@ export const PageLayoutContent = () => {
const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId);
const { layoutMode } = usePageLayoutContentContext();
const { layoutMode, presentation } = usePageLayoutContentContext();
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isRecordPageLayout =
currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isCanvasLayout = layoutMode === PageLayoutTabLayoutMode.CANVAS;
const isVerticalList = layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
const isGridLayout = layoutMode === PageLayoutTabLayoutMode.GRID;
const isEmptyStandalonePage =
currentPageLayout.type === PageLayoutType.STANDALONE_PAGE &&
@@ -48,22 +47,24 @@ export const PageLayoutContent = () => {
);
}
if (isCanvasLayout) {
return <PageLayoutCanvasViewer widgets={activeTab.widgets} />;
if (isGridLayout) {
return <PageLayoutGridLayout tabId={tabId} />;
}
if (isVerticalList) {
if (isPageLayoutInEditMode && isRecordPageLayout) {
return (
<PageLayoutVerticalListEditor
widgets={activeTab.widgets}
trailingElement={<RecordPageAddWidgetSection />}
/>
);
}
return <PageLayoutVerticalListViewer widgets={activeTab.widgets} />;
// Edit mode always shows the stack structure, whatever the view-mode
// presentation is: every tab is edited through the same vertical-list editor.
if (isPageLayoutInEditMode && isRecordPageLayout) {
return (
<PageLayoutVerticalListEditor
widgets={activeTab.widgets}
trailingElement={<RecordPageAddWidgetSection />}
/>
);
}
return <PageLayoutGridLayout tabId={tabId} />;
if (presentation === 'solo') {
return <PageLayoutSoloViewer widgets={activeTab.widgets} />;
}
return <PageLayoutVerticalListViewer widgets={activeTab.widgets} />;
};
@@ -52,10 +52,13 @@ export const PageLayoutLeftPanel = ({
isInSidePanel={isInSidePanel}
/>
{/* The pinned left panel is always a column of cards, even with a single
widget: solo presentation is a main-tab-area concept. */}
<PageLayoutContentProvider
value={{
tabId: pinnedLeftTabId,
layoutMode,
presentation: 'stack',
}}
>
<ScrollWrapper
@@ -6,11 +6,13 @@ import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/Pa
import { PageLayoutRecordPageCustomizationSessionRegistrationEffect } from '@/page-layout/components/PageLayoutRecordPageCustomizationSessionRegistrationEffect';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/pageLayoutIsInitializedComponentState';
import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
@@ -37,6 +39,7 @@ const PageLayoutSingleTabRendererInner = () => {
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const targetRecordIdentifier = useTargetRecord();
const { isInSidePanel } = useLayoutRenderingContext();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const sortedActiveTabs = sortTabsByPosition(
currentPageLayout.tabs.filter((tab) => tab.isActive),
@@ -52,6 +55,12 @@ const PageLayoutSingleTabRendererInner = () => {
pageLayoutType: currentPageLayout.type,
});
const presentation = getTabPresentation({
widgets: firstTabWithVisibleWidgets.widgets,
layoutMode,
isInEditMode: isPageLayoutInEditMode,
});
return (
<>
<SummaryCard
@@ -64,6 +73,7 @@ const PageLayoutSingleTabRendererInner = () => {
value={{
tabId: firstTab.id,
layoutMode,
presentation,
}}
>
<PageLayoutWidgetDndProvider>
@@ -3,18 +3,18 @@ import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
const StyledCanvasContainer = styled.div`
const StyledSoloContainer = styled.div`
display: grid;
height: 100%;
`;
type PageLayoutCanvasViewerProps = {
type PageLayoutSoloViewerProps = {
widgets: PageLayoutWidget[];
};
export const PageLayoutCanvasViewer = ({
export const PageLayoutSoloViewer = ({
widgets,
}: PageLayoutCanvasViewerProps) => {
}: PageLayoutSoloViewerProps) => {
const widget = widgets.at(0);
if (!isDefined(widget)) {
@@ -22,8 +22,8 @@ export const PageLayoutCanvasViewer = ({
}
return (
<StyledCanvasContainer>
<StyledSoloContainer>
<WidgetRenderer widget={widget} />
</StyledCanvasContainer>
</StyledSoloContainer>
);
};
@@ -1,33 +1,23 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { PageLayoutWidgetDndProvider } from '@/page-layout/components/dnd/PageLayoutWidgetDndProvider';
import { PageLayoutLeftPanel } from '@/page-layout/components/PageLayoutLeftPanel';
import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList';
import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect';
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
import { PAGE_LAYOUT_LEFT_PANEL_CONTAINER_WIDTH } from '@/page-layout/constants/PageLayoutLeftPanelContainerWidth';
import { WIDGET_TYPE_TO_RELATION_FIELD_NAME } from '@/page-layout/constants/WidgetTypeToRelationFieldName';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutAddTabStrategy } from '@/page-layout/hooks/usePageLayoutAddTabStrategy';
import { usePageLayoutRenderableTabs } from '@/page-layout/hooks/usePageLayoutRenderableTabs';
import { PageLayoutMainContent } from '@/page-layout/PageLayoutMainContent';
import { getScrollWrapperInstanceIdFromPageLayoutId } from '@/page-layout/utils/getScrollWrapperInstanceIdFromPageLayoutId';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets';
import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnableTabEditingFeatures';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { styled } from '@linaria/react';
import { useMemo } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledContainer = styled.div<{ hasPinnedTab: boolean }>`
display: grid;
@@ -94,95 +84,15 @@ export const PageLayoutTabsRenderer = () => {
tabListInstanceId,
});
const { objectMetadataItems } = useObjectMetadataItems();
const inactiveRelationFieldNames = useMemo(() => {
if (!isDefined(targetRecordIdentifier)) {
return new Set<string>();
}
const objectMetadataItem = objectMetadataItems.find(
(item) =>
item.nameSingular === targetRecordIdentifier.targetObjectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
return new Set<string>();
}
return new Set(
objectMetadataItem.fields
.filter(
(field) =>
!field.isActive &&
(field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION),
)
.map((field) => field.name),
);
}, [objectMetadataItems, targetRecordIdentifier]);
const isMobile = useIsMobile();
const metadataStore = useAtomFamilyStateValue(
metadataStoreState,
'objectMetadataItems',
);
const isSystemObject =
(metadataStore.current as FlatObjectMetadataItem[]).find(
(item) =>
item.nameSingular === targetRecordIdentifier?.targetObjectNameSingular,
)?.isSystem ?? false;
const canEnableTabEditing =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(currentPageLayout.type);
const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({
tabs: currentPageLayout.tabs,
isMobile,
isInSidePanel,
isEditMode: isPageLayoutInEditMode,
});
const SYSTEM_OBJECT_TABS = ['Home', 'Timeline', 'Overview', 'Flow'];
const isUsingDefaultRecordPageLayout =
currentPageLayout.id === DEFAULT_RECORD_PAGE_LAYOUT_ID;
const tabsForCurrentObject =
isSystemObject && isUsingDefaultRecordPageLayout
? tabsWithVisibleWidgets.filter((tab) =>
SYSTEM_OBJECT_TABS.includes(tab.title),
)
: tabsWithVisibleWidgets;
const { tabsToRenderInTabList, pinnedLeftTab } = getTabsByDisplayMode({
tabs: tabsForCurrentObject,
pageLayoutType: currentPageLayout.type,
isMobile,
isInSidePanel,
});
const { tabsToRenderInTabList, pinnedLeftTab } =
usePageLayoutRenderableTabs();
const sortedTabs = sortTabsByPosition(tabsToRenderInTabList);
const sortedActiveTabs = useMemo(
() =>
sortedTabs.filter((tab) => {
const widgetTypes = tab.widgets.map((widget) => widget.type);
return !widgetTypes.some((widgetType) => {
const relationFieldName =
WIDGET_TYPE_TO_RELATION_FIELD_NAME[widgetType];
return (
isDefined(relationFieldName) &&
inactiveRelationFieldNames.has(relationFieldName)
);
});
}),
[sortedTabs, inactiveRelationFieldNames],
);
const activeTabExistsInCurrentPageLayout = currentPageLayout.tabs.some(
(tab) => tab.id === activeTabId,
);
@@ -196,17 +106,17 @@ export const PageLayoutTabsRenderer = () => {
<StyledTabsAndDashboardContainer>
<PageLayoutTabListEffect
tabs={sortedActiveTabs}
tabs={sortedTabs}
componentInstanceId={tabListInstanceId}
defaultTabToFocusOnMobileAndSidePanelId={
currentPageLayout.defaultTabToFocusOnMobileAndSidePanelId ??
undefined
}
/>
{(sortedActiveTabs.length > 1 || isPageLayoutInEditMode) && (
{(sortedTabs.length > 1 || isPageLayoutInEditMode) && (
<PageLayoutTabList
className="page-layout-tab-list-print-hidden"
tabs={sortedActiveTabs}
tabs={sortedTabs}
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
isInSidePanel={isInSidePanel}
componentInstanceId={tabListInstanceId}
@@ -72,7 +72,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -95,8 +95,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -117,7 +118,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Tasks',
icon: 'IconCheckbox',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -140,8 +141,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -162,7 +164,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Notes',
icon: 'IconNotes',
position: 400,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -185,8 +187,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -207,7 +210,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 500,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -230,8 +233,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -252,7 +256,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Emails',
icon: 'IconMail',
position: 600,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -275,8 +279,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -297,7 +302,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Calendar',
icon: 'IconCalendarEvent',
position: 700,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -320,8 +325,9 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -165,7 +165,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -188,8 +188,9 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -210,7 +211,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -233,8 +234,9 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -72,7 +72,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -95,8 +95,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -117,7 +118,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Tasks',
icon: 'IconCheckbox',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -140,8 +141,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -162,7 +164,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Notes',
icon: 'IconNotes',
position: 400,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -185,8 +187,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -207,7 +210,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 500,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -230,8 +233,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -252,7 +256,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Emails',
icon: 'IconMail',
position: 600,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -275,8 +279,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -297,7 +302,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Calendar',
icon: 'IconCalendarEvent',
position: 700,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -320,8 +325,9 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -72,7 +72,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -95,8 +95,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -117,7 +118,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Tasks',
icon: 'IconCheckbox',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -140,8 +141,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -162,7 +164,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Notes',
icon: 'IconNotes',
position: 400,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -185,8 +187,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -207,7 +210,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 500,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -230,8 +233,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -252,7 +256,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Emails',
icon: 'IconMail',
position: 600,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -275,8 +279,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -297,7 +302,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Calendar',
icon: 'IconCalendarEvent',
position: 700,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -320,8 +325,9 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -72,7 +72,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -95,8 +95,9 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -117,7 +118,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Tasks',
icon: 'IconCheckbox',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -140,8 +141,9 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -162,7 +164,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Notes',
icon: 'IconNotes',
position: 400,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -185,8 +187,9 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -207,7 +210,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 500,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -230,8 +233,9 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -162,7 +162,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Timeline',
icon: 'IconTimelineEvent',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -185,8 +185,9 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -207,7 +208,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
title: 'Files',
icon: 'IconPaperclip',
position: 300,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -230,8 +231,9 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -30,7 +30,7 @@ export const DEFAULT_WORKFLOW_PAGE_LAYOUT: PageLayout = {
isActive: true,
title: 'Flow',
position: 100,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
icon: 'IconSettings',
pageLayoutId: DEFAULT_WORKFLOW_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
@@ -54,8 +54,9 @@ export const DEFAULT_WORKFLOW_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -78,7 +78,7 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
isActive: true,
title: 'Flow',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
icon: 'IconSettings',
pageLayoutId: DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
@@ -102,8 +102,9 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -79,7 +79,7 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
title: 'Flow',
icon: 'IconSettings',
position: 200,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -102,8 +102,9 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetCanvasPosition',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: 0,
},
configuration: {
__typename: 'FieldsConfiguration',
@@ -0,0 +1,12 @@
import { WidgetType } from '~/generated-metadata/graphql';
// These widgets read their records through the relation itself, so the target
// object must expose the relation field. Emails and Calendar are absent on
// purpose: they aggregate through the messaging timeline and work on objects
// without a direct participants relation (e.g. Company through its people).
export const WIDGET_TYPES_REQUIRING_RELATION_FIELD: WidgetType[] = [
WidgetType.TASKS,
WidgetType.NOTES,
WidgetType.FILES,
WidgetType.TIMELINE,
];
@@ -1,9 +1,11 @@
import { type TabPresentation } from '@/page-layout/types/TabPresentation';
import { type PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
import { createRequiredContext } from '~/utils/createRequiredContext';
export type PageLayoutContentContextType = {
tabId: string;
layoutMode: PageLayoutTabLayoutMode;
presentation: TabPresentation;
};
export const [PageLayoutContentProvider, usePageLayoutContentContext] =
@@ -0,0 +1,54 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
import { getTabsRenderableForTargetObject } from '@/page-layout/utils/getTabsRenderableForTargetObject';
import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
// Single source of truth for which tabs render and which one is pinned, so
// every consumer derives them from the same filtered tab set.
export const usePageLayoutRenderableTabs = () => {
const isMobile = useIsMobile();
const { isInSidePanel, targetRecordIdentifier } = useLayoutRenderingContext();
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { objectMetadataItems } = useObjectMetadataItems();
const targetObjectMetadataItem = isDefined(targetRecordIdentifier)
? objectMetadataItems.find(
(item) =>
item.nameSingular === targetRecordIdentifier.targetObjectNameSingular,
)
: undefined;
const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({
tabs: currentPageLayout.tabs,
isMobile,
isInSidePanel,
isEditMode: isPageLayoutInEditMode,
});
// Edit mode keeps every tab visible (like getTabsWithVisibleWidgets) so a
// widget the object does not support can still be reached and removed.
const renderableTabs = isPageLayoutInEditMode
? tabsWithVisibleWidgets
: getTabsRenderableForTargetObject({
tabs: tabsWithVisibleWidgets,
targetObjectFields: targetObjectMetadataItem?.fields,
});
const { tabsToRenderInTabList, pinnedLeftTab } = getTabsByDisplayMode({
tabs: renderableTabs,
pageLayoutType: currentPageLayout.type,
isMobile,
isInSidePanel,
});
return {
tabsToRenderInTabList,
pinnedLeftTab,
};
};
@@ -0,0 +1,3 @@
// How a list-mode tab (non-dashboard) presents its widgets. A solo tab hosts a
// single module widget full-bleed; a stack tab boxes its widgets vertically.
export type TabPresentation = 'solo' | 'stack';
@@ -0,0 +1,75 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
import {
PageLayoutTabLayoutMode,
WidgetType,
} from '~/generated-metadata/graphql';
const widgetOfType = (type: WidgetType) => ({ type }) as PageLayoutWidget;
describe('getTabPresentation', () => {
it('returns solo for any single widget on a list tab', () => {
expect(
getTabPresentation({
widgets: [widgetOfType(WidgetType.TASKS)],
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
}),
).toBe('solo');
expect(
getTabPresentation({
widgets: [widgetOfType(WidgetType.FIELDS)],
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
}),
).toBe('solo');
});
it('treats a legacy CANVAS tab with a single widget as solo', () => {
expect(
getTabPresentation({
widgets: [widgetOfType(WidgetType.CALENDAR)],
layoutMode: PageLayoutTabLayoutMode.CANVAS,
}),
).toBe('solo');
});
it('returns stack when more than one widget is present', () => {
expect(
getTabPresentation({
widgets: [
widgetOfType(WidgetType.TASKS),
widgetOfType(WidgetType.GRAPH),
],
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
}),
).toBe('stack');
});
it('returns stack for an empty tab', () => {
expect(
getTabPresentation({
widgets: [],
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
}),
).toBe('stack');
});
it('always returns stack in edit mode', () => {
expect(
getTabPresentation({
widgets: [widgetOfType(WidgetType.TASKS)],
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
isInEditMode: true,
}),
).toBe('stack');
});
it('never returns solo for grid (dashboard) tabs', () => {
expect(
getTabPresentation({
widgets: [widgetOfType(WidgetType.RECORD_TABLE)],
layoutMode: PageLayoutTabLayoutMode.GRID,
}),
).toBe('stack');
});
});
@@ -0,0 +1,167 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { getTabsRenderableForTargetObject } from '@/page-layout/utils/getTabsRenderableForTargetObject';
import { FieldMetadataType } from 'twenty-shared/types';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('getTabsRenderableForTargetObject', () => {
const createMockWidget = (
id: string,
type: WidgetType,
): PageLayoutTab['widgets'][0] => ({
__typename: 'PageLayoutWidget',
id,
applicationId: '',
isActive: true,
pageLayoutTabId: 'tab-1',
title: `Widget ${id}`,
type,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
conditionalDisplay: null,
});
const createMockTab = (
id: string,
widgets: PageLayoutTab['widgets'],
): PageLayoutTab => ({
__typename: 'PageLayoutTab',
applicationId: '',
id,
isActive: true,
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
position: 0,
widgets,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
});
const createRelationField = (
name: string,
isActive = true,
): Pick<FieldMetadataItem, 'name' | 'isActive' | 'type'> => ({
name,
isActive,
type: FieldMetadataType.RELATION,
});
it('should return tabs unchanged when there is no target object', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.TASKS)]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: undefined,
});
expect(result).toEqual(tabs);
});
it('should keep tabs whose widgets are not relation-scoped', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.FIELDS)]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [],
});
expect(result).toHaveLength(1);
});
it('should drop tabs reading through a relation the object does not have', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.TASKS)]),
createMockTab('tab-2', [createMockWidget('widget-2', WidgetType.NOTES)]),
createMockTab('tab-3', [createMockWidget('widget-3', WidgetType.FILES)]),
createMockTab('tab-4', [
createMockWidget('widget-4', WidgetType.TIMELINE),
]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [createRelationField('timelineActivities')],
});
expect(result.map((tab) => tab.id)).toEqual(['tab-4']);
});
it('should drop tabs whose relation field exists but is deactivated', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.TASKS)]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [createRelationField('taskTargets', false)],
});
expect(result).toHaveLength(0);
});
it('should keep emails and calendar tabs without a participants relation', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.EMAILS)]),
createMockTab('tab-2', [
createMockWidget('widget-2', WidgetType.CALENDAR),
]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [],
});
expect(result).toHaveLength(2);
});
it('should drop emails tabs when the participants relation is deactivated', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.EMAILS)]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [createRelationField('messageParticipants', false)],
});
expect(result).toHaveLength(0);
});
it('should ignore non-relation fields sharing the relation field name', () => {
const tabs = [
createMockTab('tab-1', [createMockWidget('widget-1', WidgetType.TASKS)]),
];
const result = getTabsRenderableForTargetObject({
tabs,
targetObjectFields: [
{ name: 'taskTargets', isActive: true, type: FieldMetadataType.TEXT },
],
});
expect(result).toHaveLength(0);
});
});
@@ -0,0 +1,26 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { type TabPresentation } from '@/page-layout/types/TabPresentation';
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
type GetTabPresentationParams = {
widgets: PageLayoutWidget[];
layoutMode: PageLayoutTabLayoutMode;
isInEditMode?: boolean;
};
// Presentation is derived from content, never stored. In view mode, a list tab
// hosting a single widget renders it solo: full-bleed, owning the tab. Any
// other tab is a stack of boxed widgets. Edit mode always shows the stack
// structure so every tab is edited through the same vertical-list editor.
// Grid tabs (dashboards) are always stacks.
export const getTabPresentation = ({
widgets,
layoutMode,
isInEditMode = false,
}: GetTabPresentationParams): TabPresentation => {
if (isInEditMode || layoutMode === PageLayoutTabLayoutMode.GRID) {
return 'stack';
}
return widgets.length === 1 ? 'solo' : 'stack';
};
@@ -0,0 +1,53 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { WIDGET_TYPES_REQUIRING_RELATION_FIELD } from '@/page-layout/constants/WidgetTypesRequiringRelationField';
import { WIDGET_TYPE_TO_RELATION_FIELD_NAME } from '@/page-layout/constants/WidgetTypeToRelationFieldName';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type GetTabsRenderableForTargetObjectParams = {
tabs: PageLayoutTab[];
targetObjectFields:
| Pick<FieldMetadataItem, 'name' | 'isActive' | 'type'>[]
| undefined;
};
// A relation-scoped widget renders only if the target object supports it: a
// deactivated relation always hides the widget, while a missing relation hides
// it only for widgets that read their records through the relation itself.
export const getTabsRenderableForTargetObject = ({
tabs,
targetObjectFields,
}: GetTabsRenderableForTargetObjectParams): PageLayoutTab[] => {
if (!isDefined(targetObjectFields)) {
return tabs;
}
const relationFieldsByName = new Map(
targetObjectFields
.filter(
(field) =>
field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION,
)
.map((field) => [field.name, field]),
);
return tabs.filter((tab) =>
tab.widgets.every((widget) => {
const relationFieldName = WIDGET_TYPE_TO_RELATION_FIELD_NAME[widget.type];
if (!isDefined(relationFieldName)) {
return true;
}
const relationField = relationFieldsByName.get(relationFieldName);
if (isDefined(relationField)) {
return relationField.isActive === true;
}
return !WIDGET_TYPES_REQUIRING_RELATION_FIELD.includes(widget.type);
}),
);
};
@@ -11,7 +11,7 @@ export const NonRecordPageWidgetRenderer = ({
}: NonRecordPageWidgetRendererProps) => {
const state = useWidgetRendererState(widget);
const isCanvasVariant = state.variant === 'canvas';
const isSoloVariant = state.variant === 'solo';
return (
<WidgetCardShell
@@ -32,8 +32,8 @@ export const NonRecordPageWidgetRenderer = ({
isDeletingWidgetEnabled={true}
onClick={state.isPageLayoutInEditMode ? state.handleClick : undefined}
onRemove={state.handleRemove}
onMouseEnter={isCanvasVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : state.handleMouseLeave}
onMouseEnter={isSoloVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isSoloVariant ? undefined : state.handleMouseLeave}
/>
);
};
@@ -16,7 +16,7 @@ export const RecordPageWidgetRenderer = ({
const actions = useWidgetActions({ widget });
const isCanvasVariant = state.variant === 'canvas';
const isSoloVariant = state.variant === 'solo';
return (
<WidgetCardShell
@@ -37,8 +37,8 @@ export const RecordPageWidgetRenderer = ({
isDeletingWidgetEnabled={true}
onClick={isWidgetEditable ? state.handleClick : undefined}
onRemove={state.handleRemove}
onMouseEnter={isCanvasVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : state.handleMouseLeave}
onMouseEnter={isSoloVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isSoloVariant ? undefined : state.handleMouseLeave}
/>
);
};
@@ -70,6 +70,11 @@ export const WidgetCardShell = ({
const dataTestId =
widget.type === WidgetType.FIELDS ? 'record-fields-widget' : widget.id;
// A widget stacked in the main tab area gets a bounded slot with its own
// scroll, so no single widget swallows the tab. Solo widgets own the tab,
// and pinned/side-column stacks keep their flowing column behavior.
const hasBoundedHeight = variant === 'record-page' && isInVerticalListTab;
return (
<WidgetComponentInstanceContext.Provider value={{ instanceId: widget.id }}>
<WidgetCard
@@ -116,6 +121,7 @@ export const WidgetCardShell = ({
isInVerticalListTab={isInVerticalListTab}
isMobile={isMobile}
hasInteractiveContent={widget.type === WidgetType.RECORD_TABLE}
hasBoundedHeight={hasBoundedHeight}
>
{hasAccess ? (
<ErrorBoundary
@@ -59,6 +59,7 @@ const meta: Meta<typeof DashboardWidgetPlaceholder> = {
value={{
tabId: 'tab-1',
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
}}
>
<Story />
@@ -317,6 +317,7 @@ export const WithNumberChart: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -409,6 +410,7 @@ export const WithBarChart: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -504,6 +506,7 @@ export const SmallWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -603,6 +606,7 @@ export const MediumWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -702,6 +706,7 @@ export const LargeWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -797,6 +802,7 @@ export const WideWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -896,6 +902,7 @@ export const TallWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1016,6 +1023,7 @@ export const WithManyToOneRelationFieldWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1128,6 +1136,7 @@ export const WithOneToManyRelationFieldWidget: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1248,6 +1257,7 @@ export const OneToManyRelationFieldWidgetWithSeeAllButton: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1365,6 +1375,7 @@ export const OnMobile: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1464,6 +1475,7 @@ export const InSidePanel: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -1550,10 +1562,12 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
: PageLayoutType.RECORD_PAGE;
const layoutMode =
variant === 'canvas'
variant === 'solo'
? PageLayoutTabLayoutMode.CANVAS
: PageLayoutTabLayoutMode.GRID;
const presentation = variant === 'solo' ? 'solo' : 'stack';
const widget: PageLayoutWidget = {
__typename: 'PageLayoutWidget',
applicationId: '',
@@ -1753,6 +1767,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
<PageLayoutContentProvider
value={{
layoutMode,
presentation,
tabId:
variant === 'side-column'
? 'pinned-tab'
@@ -0,0 +1,3 @@
// A widget stacked alongside others in the main tab area gets a bounded slot
// with its own scroll, so no single widget swallows the whole tab.
export const STACKED_WIDGET_MAX_HEIGHT = 640;
@@ -400,6 +400,7 @@ const renderFieldWidgetStory = ({
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: 'fields',
}}
>
@@ -345,6 +345,7 @@ export const WithViewFieldGroups: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -425,6 +426,7 @@ export const WithDefaultGroups: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -507,6 +509,7 @@ export const Empty: Story = {
<PageLayoutContentProvider
value={{
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
presentation: 'stack',
tabId: TAB_ID_OVERVIEW,
}}
>
@@ -10,14 +10,13 @@ import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
import { isWidgetConfigurationOfType } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfType';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
const StyledContainer = styled.div<{
isCanvasLayout: boolean;
isSoloLayout: boolean;
isInEditMode: boolean;
}>`
height: 100%;
overflow: ${({ isCanvasLayout }) => (isCanvasLayout ? 'visible' : 'auto')};
overflow: ${({ isSoloLayout }) => (isSoloLayout ? 'visible' : 'auto')};
pointer-events: ${({ isInEditMode }) => (isInEditMode ? 'none' : 'auto')};
width: 100%;
`;
@@ -36,7 +35,7 @@ export const FrontComponentWidgetRenderer = ({
widget,
}: FrontComponentWidgetRendererProps) => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { layoutMode } = usePageLayoutContentContext();
const { presentation } = usePageLayoutContentContext();
const { targetRecordIdentifier } = useLayoutRenderingContext();
const configuration = widget.configuration;
@@ -55,7 +54,7 @@ export const FrontComponentWidgetRenderer = ({
return (
<StyledContainer
isCanvasLayout={layoutMode === PageLayoutTabLayoutMode.CANVAS}
isSoloLayout={presentation === 'solo'}
isInEditMode={isPageLayoutInEditMode}
>
<Suspense fallback={<FrontComponentSkeletonLoader />}>
@@ -0,0 +1,125 @@
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper';
import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { widgetHeaderInfoComponentFamilyState } from '@/page-layout/widgets/states/widgetHeaderInfoComponentFamilyState';
import { renderHook } from '@testing-library/react';
import { createStore } from 'jotai';
import { type ReactNode } from 'react';
import { IconPlus } from 'twenty-ui/icon';
const WIDGET_ID = 'widget-under-test';
const getWrapper =
(store: ReturnType<typeof createStore>) =>
({ children }: { children: ReactNode }) => (
<PageLayoutTestWrapper
store={store}
instanceId={PAGE_LAYOUT_TEST_INSTANCE_ID}
>
<WidgetComponentInstanceContext.Provider
value={{ instanceId: WIDGET_ID }}
>
{children}
</WidgetComponentInstanceContext.Provider>
</PageLayoutTestWrapper>
);
const getPublishedHeaderInfo = (store: ReturnType<typeof createStore>) =>
store.get(
widgetHeaderInfoComponentFamilyState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
familyKey: WIDGET_ID,
}),
);
describe('usePublishWidgetHeaderInfo', () => {
it('publishes count and primary action to the widget header info state', () => {
const store = createStore();
const onClick = jest.fn();
renderHook(
() =>
usePublishWidgetHeaderInfo({
count: 12,
primaryAction: { Icon: IconPlus, label: 'Compose', onClick },
}),
{ wrapper: getWrapper(store) },
);
const publishedHeaderInfo = getPublishedHeaderInfo(store);
expect(publishedHeaderInfo?.count).toBe(12);
expect(publishedHeaderInfo?.primaryAction?.label).toBe('Compose');
});
it('clears the published header info on unmount', () => {
const store = createStore();
const { unmount } = renderHook(
() => usePublishWidgetHeaderInfo({ count: 3 }),
{ wrapper: getWrapper(store) },
);
expect(getPublishedHeaderInfo(store)?.count).toBe(3);
unmount();
expect(getPublishedHeaderInfo(store)).toBeNull();
});
it('no-ops without throwing when rendered outside a widget', () => {
expect(() =>
renderHook(() => usePublishWidgetHeaderInfo({ count: 7 })),
).not.toThrow();
});
it('keeps the published state referentially stable across re-renders with inline actions', () => {
const store = createStore();
const { rerender } = renderHook(
// a fresh object and a fresh closure on every render, like a
// non-memoizing caller would pass
() =>
usePublishWidgetHeaderInfo({
count: 5,
primaryAction: {
Icon: IconPlus,
label: 'New task',
onClick: () => undefined,
},
}),
{ wrapper: getWrapper(store) },
);
const firstPublished = getPublishedHeaderInfo(store);
rerender();
rerender();
const secondPublished = getPublishedHeaderInfo(store);
expect(secondPublished).toBe(firstPublished);
});
it('calls the latest onClick through the stable wrapper', () => {
const store = createStore();
const firstOnClick = jest.fn();
const secondOnClick = jest.fn();
const { rerender } = renderHook(
({ onClick }: { onClick: () => void }) =>
usePublishWidgetHeaderInfo({
primaryAction: { Icon: IconPlus, label: 'Compose', onClick },
}),
{ wrapper: getWrapper(store), initialProps: { onClick: firstOnClick } },
);
rerender({ onClick: secondOnClick });
getPublishedHeaderInfo(store)?.primaryAction?.onClick();
expect(firstOnClick).not.toHaveBeenCalled();
expect(secondOnClick).toHaveBeenCalledTimes(1);
});
});
@@ -1,34 +1,11 @@
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode';
import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { usePageLayoutRenderableTabs } from '@/page-layout/hooks/usePageLayoutRenderableTabs';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
export const useIsInPinnedTab = () => {
const isMobile = useIsMobile();
const { tabId } = usePageLayoutContentContext();
const { isInSidePanel } = useLayoutRenderingContext();
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({
tabs: currentPageLayout.tabs,
isMobile,
isInSidePanel,
isEditMode: isPageLayoutInEditMode,
});
const { pinnedLeftTab } = getTabsByDisplayMode({
tabs: tabsWithVisibleWidgets,
pageLayoutType: currentPageLayout.type,
isMobile,
isInSidePanel,
});
const { pinnedLeftTab } = usePageLayoutRenderableTabs();
return {
isInPinnedTab: isDefined(pinnedLeftTab) && pinnedLeftTab.id === tabId,
@@ -0,0 +1,97 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { widgetHeaderInfoComponentFamilyState } from '@/page-layout/widgets/states/widgetHeaderInfoComponentFamilyState';
import { type WidgetHeaderInfo } from '@/page-layout/widgets/types/WidgetHeaderInfo';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { isDefined } from 'twenty-shared/utils';
const OUTSIDE_WIDGET_INSTANCE_ID = 'widget-header-info-outside-widget';
// Publish header information (grey count next to the title, primary action on
// the right) from a widget's content to the widget chrome. Safe to call from
// components that also render outside a page layout: it no-ops there.
// Republishes only when a rendered value actually changes: the widget chrome
// reads this state above the widget content, so publishing a fresh object on
// every render (an inline primaryAction, an unstable onClick) would loop the
// widget subtree forever. onClick goes through a stable wrapper for the same
// reason, so callers do not have to memoize anything.
export const usePublishWidgetHeaderInfo = ({
count,
primaryAction,
}: WidgetHeaderInfo) => {
const pageLayoutInstanceId = useAvailableComponentInstanceId(
PageLayoutComponentInstanceContext,
);
const widgetInstanceId = useAvailableComponentInstanceId(
WidgetComponentInstanceContext,
);
const isInsideWidget =
isDefined(pageLayoutInstanceId) && isDefined(widgetInstanceId);
const setWidgetHeaderInfo = useSetAtomComponentFamilyState(
widgetHeaderInfoComponentFamilyState,
widgetInstanceId ?? OUTSIDE_WIDGET_INSTANCE_ID,
pageLayoutInstanceId ?? OUTSIDE_WIDGET_INSTANCE_ID,
);
// oxlint-disable-next-line twenty/no-state-useref
const onClickRef = useRef(primaryAction?.onClick);
const primaryActionOnClick = primaryAction?.onClick;
// Assigned after commit so the stable wrapper never exposes a callback from
// a render React abandoned.
useLayoutEffect(() => {
onClickRef.current = primaryActionOnClick;
}, [primaryActionOnClick]);
const handlePrimaryActionClick = useCallback(() => {
onClickRef.current?.();
}, []);
const hasPrimaryAction = isDefined(primaryAction);
const primaryActionIcon = primaryAction?.Icon;
const primaryActionLabel = primaryAction?.label;
const primaryActionDisabled = primaryAction?.disabled;
useEffect(() => {
if (!isInsideWidget) {
return;
}
setWidgetHeaderInfo({
count,
primaryAction:
hasPrimaryAction && isDefined(primaryActionIcon)
? {
Icon: primaryActionIcon,
label: primaryActionLabel ?? '',
onClick: handlePrimaryActionClick,
disabled: primaryActionDisabled,
}
: undefined,
});
}, [
count,
hasPrimaryAction,
primaryActionIcon,
primaryActionLabel,
primaryActionDisabled,
handlePrimaryActionClick,
isInsideWidget,
setWidgetHeaderInfo,
]);
useEffect(() => {
if (!isInsideWidget) {
return;
}
return () => {
setWidgetHeaderInfo(null);
};
}, [isInsideWidget, setWidgetHeaderInfo]);
};
@@ -10,13 +10,16 @@ import { useIsCurrentWidgetLastOfTab } from '@/page-layout/widgets/hooks/useIsCu
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions';
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { widgetHeaderInfoComponentFamilyState } from '@/page-layout/widgets/states/widgetHeaderInfoComponentFamilyState';
import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardVariant';
import { useOpenWidgetSettingsInSidePanel } from '@/side-panel/hooks/useOpenWidgetSettingsInSidePanel';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { type MouseEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
PageLayoutTabLayoutMode,
WidgetType,
@@ -46,7 +49,7 @@ export const useWidgetRendererState = (widget: PageLayoutWidget) => {
const { hasAccess, restriction } = useWidgetPermissions(widget);
const { layoutMode } = usePageLayoutContentContext();
const { layoutMode, presentation } = usePageLayoutContentContext();
const { isInPinnedTab } = useIsInPinnedTab();
const { isInSidePanel } = useLayoutRenderingContext();
const isMobile = useIsMobile();
@@ -55,14 +58,25 @@ export const useWidgetRendererState = (widget: PageLayoutWidget) => {
const isLastWidget = useIsCurrentWidgetLastOfTab(widget.id);
const widgetHeaderInfo = useAtomComponentFamilyStateValue(
widgetHeaderInfoComponentFamilyState,
widget.id,
);
const hasWidgetHeaderInfo =
isDefined(widgetHeaderInfo?.count) ||
isDefined(widgetHeaderInfo?.primaryAction);
const isHeaderHiddenInViewMode =
widget.type === WidgetType.STANDALONE_RICH_TEXT ||
widget.type === WidgetType.EMAIL_THREAD;
const hideHeaderInViewMode =
isHeaderHiddenInViewMode && !isPageLayoutInEditMode;
// A solo widget owns its tab, and the tab label already names it: the
// header only appears when the widget published something to say in it.
const showHeader =
layoutMode !== PageLayoutTabLayoutMode.CANVAS && !hideHeaderInViewMode;
presentation === 'solo' ? hasWidgetHeaderInfo : !hideHeaderInViewMode;
const handleClick = () => {
openWidgetSettingsInSidePanel({
@@ -90,7 +104,7 @@ export const useWidgetRendererState = (widget: PageLayoutWidget) => {
};
const variant = getWidgetCardVariant({
layoutMode,
presentation,
isInPinnedTab,
pageLayoutType: currentPageLayout.type,
isMobile,
@@ -0,0 +1,10 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { type WidgetHeaderInfo } from '@/page-layout/widgets/types/WidgetHeaderInfo';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const widgetHeaderInfoComponentFamilyState =
createAtomComponentFamilyState<WidgetHeaderInfo | null, string>({
key: 'widgetHeaderInfoComponentFamilyState',
defaultValue: null,
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -1,5 +1,5 @@
export type WidgetCardVariant =
| 'canvas'
| 'solo'
| 'side-column'
| 'dashboard'
| 'standalone'
@@ -0,0 +1,17 @@
import { type IconComponent } from 'twenty-ui/icon';
export type WidgetHeaderPrimaryAction = {
Icon: IconComponent;
label: string;
onClick: () => void;
disabled?: boolean;
};
// Header information a widget's content publishes to the widget chrome: a
// count rendered in grey next to the title and an optional primary action
// rendered on the right. Published at runtime so any widget, including
// third-party ones, can contribute without new widget-type branches.
export type WidgetHeaderInfo = {
count?: number;
primaryAction?: WidgetHeaderPrimaryAction;
};
@@ -1,8 +1,5 @@
import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardVariant';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
import { PageLayoutType } from '~/generated-metadata/graphql';
const baseParams = {
isInPinnedTab: false,
@@ -11,32 +8,29 @@ const baseParams = {
};
describe('getWidgetCardVariant', () => {
describe('when layoutMode is CANVAS', () => {
describe('when presentation is solo', () => {
it.each([
PageLayoutType.RECORD_PAGE,
PageLayoutType.STANDALONE_PAGE,
PageLayoutType.DASHBOARD,
PageLayoutType.RECORD_INDEX,
])(
"returns 'canvas' regardless of pageLayoutType (%s)",
(pageLayoutType) => {
expect(
getWidgetCardVariant({
...baseParams,
layoutMode: PageLayoutTabLayoutMode.CANVAS,
pageLayoutType,
}),
).toBe('canvas');
},
);
])("returns 'solo' regardless of pageLayoutType (%s)", (pageLayoutType) => {
expect(
getWidgetCardVariant({
...baseParams,
presentation: 'solo',
pageLayoutType,
}),
).toBe('solo');
});
});
describe('when layoutMode is GRID', () => {
describe('when presentation is stack', () => {
it("returns 'dashboard' for DASHBOARD page", () => {
expect(
getWidgetCardVariant({
...baseParams,
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
pageLayoutType: PageLayoutType.DASHBOARD,
}),
).toBe('dashboard');
@@ -46,7 +40,7 @@ describe('getWidgetCardVariant', () => {
expect(
getWidgetCardVariant({
...baseParams,
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
pageLayoutType: PageLayoutType.STANDALONE_PAGE,
}),
).toBe('standalone');
@@ -56,7 +50,7 @@ describe('getWidgetCardVariant', () => {
expect(
getWidgetCardVariant({
...baseParams,
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
pageLayoutType: PageLayoutType.RECORD_PAGE,
}),
).toBe('record-page');
@@ -73,7 +67,7 @@ describe('getWidgetCardVariant', () => {
getWidgetCardVariant({
...baseParams,
...override,
layoutMode: PageLayoutTabLayoutMode.GRID,
presentation: 'stack',
pageLayoutType: PageLayoutType.RECORD_PAGE,
}),
).toBe('side-column');
@@ -1,11 +1,9 @@
import {
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
import { type TabPresentation } from '@/page-layout/types/TabPresentation';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { type WidgetCardVariant } from '~/modules/page-layout/widgets/types/WidgetCardVariant';
type GetWidgetCardVariantParams = {
layoutMode: PageLayoutTabLayoutMode;
presentation: TabPresentation;
isInPinnedTab: boolean;
pageLayoutType: PageLayoutType | null;
isMobile: boolean;
@@ -13,14 +11,14 @@ type GetWidgetCardVariantParams = {
};
export const getWidgetCardVariant = ({
layoutMode,
presentation,
isInPinnedTab,
pageLayoutType,
isMobile,
isInSidePanel,
}: GetWidgetCardVariantParams): WidgetCardVariant => {
if (layoutMode === PageLayoutTabLayoutMode.CANVAS) {
return 'canvas';
if (presentation === 'solo') {
return 'solo';
}
const isSideColumnContext = isInPinnedTab || isMobile || isInSidePanel;
@@ -116,7 +116,7 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
&:hover {
// border-color shorthand must precede border-bottom-color longhand for correct CSS cascade
border-color: ${(props) => {
if (props.variant === 'canvas') {
if (props.variant === 'solo') {
return computeBorderColor(props);
}
if (
@@ -131,7 +131,7 @@ const StyledWidgetCard = styled.div<WidgetCardStyledProps>`
}};
border-bottom-color: ${(props) => {
if (props.variant === 'canvas') {
if (props.variant === 'solo') {
return computeBorderColor(props);
}
@@ -1,5 +1,6 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { STACKED_WIDGET_MAX_HEIGHT } from '~/modules/page-layout/widgets/constants/StackedWidgetMaxHeight';
import { type WidgetCardVariant } from '~/modules/page-layout/widgets/types/WidgetCardVariant';
type WidgetCardContentStyledProps = {
@@ -8,6 +9,7 @@ type WidgetCardContentStyledProps = {
isEditable: boolean;
isInVerticalListTab: boolean;
isMobile: boolean;
hasBoundedHeight: boolean;
};
const StyledWidgetCardContent = styled.div<WidgetCardContentStyledProps>`
@@ -27,12 +29,15 @@ const StyledWidgetCardContent = styled.div<WidgetCardContentStyledProps>`
display: grid;
grid-template-columns: minmax(0, 1fr);
height: 100%;
height: ${({ hasBoundedHeight }) => (hasBoundedHeight ? 'auto' : '100%')};
margin-top: ${({ hasHeader }) =>
hasHeader ? themeCssVariables.spacing[2] : '0'};
overflow: hidden;
max-height: ${({ hasBoundedHeight }) =>
hasBoundedHeight ? `${STACKED_WIDGET_MAX_HEIGHT}px` : 'none'};
overflow: ${({ hasBoundedHeight }) => (hasBoundedHeight ? 'auto' : 'hidden')};
padding: ${({ variant, isEditable }) => {
if (variant === 'dashboard' || variant === 'standalone')
@@ -61,6 +66,7 @@ type WidgetCardContentProps = {
isInVerticalListTab: boolean;
isMobile: boolean;
hasInteractiveContent?: boolean;
hasBoundedHeight?: boolean;
className?: string;
children?: React.ReactNode;
};
@@ -72,6 +78,7 @@ export const WidgetCardContent = ({
isInVerticalListTab,
isMobile,
hasInteractiveContent = false,
hasBoundedHeight = false,
className,
children,
}: WidgetCardContentProps) => {
@@ -90,6 +97,7 @@ export const WidgetCardContent = ({
isEditable={isEditable}
isInVerticalListTab={isInVerticalListTab}
isMobile={isMobile}
hasBoundedHeight={hasBoundedHeight}
className={className}
onClick={handleContentClick}
>
@@ -1,5 +1,7 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { WidgetActionRenderer } from '@/page-layout/widgets/components/WidgetActionRenderer';
import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState';
import { widgetHeaderInfoComponentFamilyState } from '@/page-layout/widgets/states/widgetHeaderInfoComponentFamilyState';
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { styled } from '@linaria/react';
@@ -7,7 +9,7 @@ import { t } from '@lingui/core/macro';
import { type ReactNode, useContext } from 'react';
import { IconTrash } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { IconButton } from 'twenty-ui/input';
import { IconButton, LightIconButton } from 'twenty-ui/input';
import { type WidgetCardVariant } from '@/page-layout/widgets/types/WidgetCardVariant';
import { WidgetGrip } from '@/page-layout/widgets/widget-card/components/WidgetGrip';
@@ -29,25 +31,40 @@ export type WidgetCardHeaderProps = {
isDeletingWidgetEnabled?: boolean;
};
const StyledWidgetCardHeader = styled.div`
const StyledWidgetCardHeader = styled.div<{ variant: WidgetCardVariant }>`
align-items: center;
display: flex;
flex-shrink: 0;
height: ${themeCssVariables.spacing[6]};
padding: ${({ variant }) =>
variant === 'solo'
? `${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]} 0`
: '0'};
`;
const StyledTitleContainer = styled.div<{ variant: WidgetCardVariant }>`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
flex: 1;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
overflow: hidden;
padding-inline: ${({ variant }) =>
variant === 'side-column' ? '0' : themeCssVariables.spacing[1]};
variant === 'side-column' || variant === 'solo'
? '0'
: themeCssVariables.spacing[1]};
user-select: none;
`;
const StyledCount = styled.span`
color: ${themeCssVariables.font.color.light};
flex-shrink: 0;
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledRightContainer = styled.div`
align-items: center;
display: flex;
@@ -82,13 +99,22 @@ export const WidgetCardHeader = ({
className,
}: WidgetCardHeaderProps) => {
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
const widgetCardHovered = useAtomComponentFamilyStateValue(
widgetCardHoveredComponentFamilyState,
widgetId,
);
const widgetHeaderInfo = useAtomComponentFamilyStateValue(
widgetHeaderInfoComponentFamilyState,
widgetId,
);
const count = widgetHeaderInfo?.count;
const primaryAction = widgetHeaderInfo?.primaryAction;
return (
<StyledWidgetCardHeader className={className}>
<StyledWidgetCardHeader variant={variant} className={className}>
<AnimatePresence initial={false}>
{!isEmpty && isInEditMode && isReorderEnabled && (
<WidgetGrip
@@ -99,8 +125,20 @@ export const WidgetCardHeader = ({
</AnimatePresence>
<StyledTitleContainer variant={variant}>
<OverflowingTextWithTooltip text={isEmpty ? t`Add Widget` : title} />
{isDefined(count) && <StyledCount>{formatNumber(count)}</StyledCount>}
</StyledTitleContainer>
<StyledRightContainer>
{!isInEditMode && isDefined(primaryAction) && (
<LightIconButton
Icon={primaryAction.Icon}
aria-label={primaryAction.label}
title={primaryAction.label}
accent="tertiary"
size="small"
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
/>
)}
{isNonEmptyArray(actions) && (
<StyledActionsContainer>
{actions.map((action) => (
@@ -1,5 +1,6 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
import { GRAPH_TYPE_INFORMATION } from '@/side-panel/pages/page-layout/constants/GraphTypeInformation';
import { getCurrentGraphTypeFromConfig } from '@/side-panel/pages/page-layout/utils/getCurrentGraphTypeFromConfig';
import { isWidgetConfigurationOfTypeGraph } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfTypeGraph';
@@ -70,7 +71,11 @@ export const usePageLayoutHeaderInfo = ({
? tab.title
: '';
const isCanvasTab = tab.layoutMode === PageLayoutTabLayoutMode.CANVAS;
const isSoloTab =
getTabPresentation({
widgets: tab.widgets.filter((widget) => widget.isActive),
layoutMode: tab.layoutMode ?? PageLayoutTabLayoutMode.VERTICAL_LIST,
}) === 'solo';
const resolvedTabIcon = isDefined(tab.icon)
? getIcon(tab.icon)
@@ -79,7 +84,7 @@ export const usePageLayoutHeaderInfo = ({
return {
headerIcon: resolvedTabIcon ?? IconAppWindow,
headerIconColor: iconColor,
headerType: isCanvasTab ? t`Full tab widget` : t`Tab`,
headerType: isSoloTab ? t`Full tab widget` : t`Tab`,
title,
isReadonly: false,
tab,
@@ -4,6 +4,7 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/context
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useIsDashboardPageLayout } from '@/side-panel/pages/page-layout/hooks/useIsDashboardPageLayout';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
@@ -14,6 +15,7 @@ import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSe
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { t } from '@lingui/core/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
PageLayoutTabLayoutMode,
WidgetType,
@@ -132,7 +134,15 @@ export const useOpenWidgetSettingsInSidePanel = (
tab.widgets.some((w) => w.id === widgetId),
);
if (containingTab?.layoutMode === PageLayoutTabLayoutMode.CANVAS) {
const isContainingTabSolo =
isDefined(containingTab) &&
getTabPresentation({
widgets: containingTab.widgets.filter((w) => w.isActive),
layoutMode:
containingTab.layoutMode ?? PageLayoutTabLayoutMode.VERTICAL_LIST,
}) === 'solo';
if (isContainingTabSolo) {
setPageLayoutTabSettingsOpenTabId(containingTab.id);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutTabSettings,
@@ -7,10 +7,11 @@ import { useSetAsPinnedTab } from '@/page-layout/hooks/useSetAsPinnedTab';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { getTabPresentation } from '@/page-layout/utils/getTabPresentation';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { CanvasTabSettingsContent } from '@/side-panel/pages/page-layout/components/CanvasTabSettingsContent';
import { RegularTabSettingsContent } from '@/side-panel/pages/page-layout/components/RegularTabSettingsContent';
import { SoloTabSettingsContent } from '@/side-panel/pages/page-layout/components/SoloTabSettingsContent';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -100,13 +101,19 @@ export const SidePanelPageLayoutTabSettingsContent = ({
resetPageLayoutTabToDefault(tab.id);
};
const isCanvasTab = tab.layoutMode === PageLayoutTabLayoutMode.CANVAS;
const activeWidgets = tab.widgets.filter((widget) => widget.isActive);
if (isCanvasTab) {
const isSoloTab =
getTabPresentation({
widgets: activeWidgets,
layoutMode: tab.layoutMode ?? PageLayoutTabLayoutMode.VERTICAL_LIST,
}) === 'solo';
if (isSoloTab) {
return (
<CanvasTabSettingsContent
<SoloTabSettingsContent
pageLayoutId={pageLayoutId}
canvasWidget={tab.widgets.at(0)}
soloWidget={activeWidgets.at(0)}
canSetAsPinned={canSetAsPinned}
canMoveLeft={canMoveLeft}
canMoveRight={canMoveRight}
@@ -3,7 +3,7 @@ import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuIt
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { CanvasTabWidgetVisibilityDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/CanvasTabWidgetVisibilityDropdownContent';
import { SoloTabWidgetVisibilityDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/SoloTabWidgetVisibilityDropdownContent';
import { TAB_SETTINGS_SELECTABLE_ITEM_IDS } from '@/side-panel/pages/page-layout/constants/settings/TabSettingsSelectableItemIds';
import { useTranslatedVisibilityLabel } from '@/side-panel/pages/page-layout/hooks/useTranslatedVisibilityLabel';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -22,13 +22,12 @@ import {
} from 'twenty-ui/icon';
import { AppTooltip } from 'twenty-ui/surfaces';
const RESET_TAB_TO_DEFAULT_MODAL_ID = 'reset-canvas-tab-to-default-modal';
const RESET_TAB_TO_DEFAULT_MENU_ITEM_ID =
'reset-canvas-tab-to-default-menu-item';
const RESET_TAB_TO_DEFAULT_MODAL_ID = 'reset-solo-tab-to-default-modal';
const RESET_TAB_TO_DEFAULT_MENU_ITEM_ID = 'reset-solo-tab-to-default-menu-item';
type CanvasTabSettingsContentProps = {
type SoloTabSettingsContentProps = {
pageLayoutId: string;
canvasWidget: PageLayoutWidget | undefined;
soloWidget: PageLayoutWidget | undefined;
canSetAsPinned: boolean;
canMoveLeft: boolean;
canMoveRight: boolean;
@@ -41,9 +40,9 @@ type CanvasTabSettingsContentProps = {
onDelete: () => void;
};
export const CanvasTabSettingsContent = ({
export const SoloTabSettingsContent = ({
pageLayoutId,
canvasWidget,
soloWidget,
canSetAsPinned,
canMoveLeft,
canMoveRight,
@@ -54,12 +53,12 @@ export const CanvasTabSettingsContent = ({
onSetAsPinned,
onResetToDefault,
onDelete,
}: CanvasTabSettingsContentProps) => {
}: SoloTabSettingsContentProps) => {
const { t } = useLingui();
const { openModal } = useModal();
const visibilityLabel = useTranslatedVisibilityLabel(
canvasWidget?.conditionalAvailabilityExpression,
soloWidget?.conditionalAvailabilityExpression,
);
const handleResetToDefault = () => {
@@ -73,7 +72,7 @@ export const CanvasTabSettingsContent = ({
...(canSetAsPinned ? [TAB_SETTINGS_SELECTABLE_ITEM_IDS.SET_AS_PINNED] : []),
...(canMoveLeft ? [TAB_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_LEFT] : []),
...(canMoveRight ? [TAB_SETTINGS_SELECTABLE_ITEM_IDS.MOVE_RIGHT] : []),
...(isDefined(canvasWidget)
...(isDefined(soloWidget)
? [TAB_SETTINGS_SELECTABLE_ITEM_IDS.VISIBILITY_RESTRICTION]
: []),
TAB_SETTINGS_SELECTABLE_ITEM_IDS.RESET_TO_DEFAULT,
@@ -125,7 +124,7 @@ export const CanvasTabSettingsContent = ({
)}
</SidePanelGroup>
<SidePanelGroup heading={t`Manage`}>
{isDefined(canvasWidget) && (
{isDefined(soloWidget) && (
<SelectableListItem
itemId={TAB_SETTINGS_SELECTABLE_ITEM_IDS.VISIBILITY_RESTRICTION}
>
@@ -138,10 +137,10 @@ export const CanvasTabSettingsContent = ({
}
dropdownComponents={
<DropdownContent>
<CanvasTabWidgetVisibilityDropdownContent
widgetId={canvasWidget.id}
<SoloTabWidgetVisibilityDropdownContent
widgetId={soloWidget.id}
currentExpression={
canvasWidget.conditionalAvailabilityExpression
soloWidget.conditionalAvailabilityExpression
}
pageLayoutId={pageLayoutId}
/>
@@ -13,17 +13,17 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { MenuItemSelect } from 'twenty-ui/navigation';
type CanvasTabWidgetVisibilityDropdownContentProps = {
type SoloTabWidgetVisibilityDropdownContentProps = {
widgetId: string;
currentExpression: string | null | undefined;
pageLayoutId: string;
};
export const CanvasTabWidgetVisibilityDropdownContent = ({
export const SoloTabWidgetVisibilityDropdownContent = ({
widgetId,
currentExpression,
pageLayoutId,
}: CanvasTabWidgetVisibilityDropdownContentProps) => {
}: SoloTabWidgetVisibilityDropdownContentProps) => {
const currentOptionId = expressionToOptionId(currentExpression);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
@@ -13,7 +13,9 @@ describe('getPageLayoutTabBaseFile', () => {
expect(result).toContain("title: 'My Custom Tab'");
expect(result).toContain('pageLayoutUniversalIdentifier:');
expect(result).toContain('widgets: []');
expect(result).toContain('layoutMode: PageLayoutTabLayoutMode.CANVAS');
expect(result).toContain(
'layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST',
);
});
it('should generate a valid UUID for the tab', () => {
@@ -9,7 +9,7 @@ export default definePageLayoutTab({
title: '${name}',
position: 1000,
icon: 'IconLayout',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [],
});
`;
@@ -40,7 +40,7 @@ export default definePageLayout({
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '${uuidv4()}',
@@ -1,7 +1,6 @@
import {
PageLayoutTabLayoutMode,
type GridPosition,
type PageLayoutWidgetCanvasPosition,
type PageLayoutWidgetConditionalDisplay,
type PageLayoutWidgetGridPosition,
type PageLayoutWidgetVerticalListPosition,
@@ -91,12 +90,6 @@ export const VERTICAL_LIST_LAYOUT_POSITIONS = {
},
} as const satisfies Record<string, PageLayoutWidgetVerticalListPosition>;
export const CANVAS_LAYOUT_POSITIONS = {
DEFAULT: {
layoutMode: PageLayoutTabLayoutMode.CANVAS,
},
} as const satisfies Record<string, PageLayoutWidgetCanvasPosition>;
export const TAB_PROPS = {
home: {
title: 'Home',
@@ -108,37 +101,37 @@ export const TAB_PROPS = {
title: 'Timeline',
position: 20,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
tasks: {
title: 'Tasks',
position: 30,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
notes: {
title: 'Notes',
position: 40,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
files: {
title: 'Files',
position: 50,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
emails: {
title: 'Emails',
position: 60,
icon: 'IconMail',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
calendar: {
title: 'Calendar',
position: 70,
icon: 'IconCalendarEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
note: {
title: 'Note',
@@ -150,13 +143,13 @@ export const TAB_PROPS = {
title: 'Flow',
position: 10,
icon: 'IconSettings',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
flowSecondary: {
title: 'Flow',
position: 20,
icon: 'IconSettings',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
} as const;
@@ -171,37 +164,37 @@ export const WIDGET_PROPS = {
title: 'Timeline',
type: WidgetType.TIMELINE,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
tasks: {
title: 'Tasks',
type: WidgetType.TASKS,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
notes: {
title: 'Notes',
type: WidgetType.NOTES,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
files: {
title: 'Files',
type: WidgetType.FILES,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
emails: {
title: 'Emails',
type: WidgetType.EMAILS,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
calendar: {
title: 'Calendar',
type: WidgetType.CALENDAR,
gridPosition: GRID_POSITIONS.HALF_HEIGHT,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
noteRichText: {
title: 'Note',
@@ -219,19 +212,19 @@ export const WIDGET_PROPS = {
title: 'Flow',
type: WidgetType.WORKFLOW,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
workflowVersion: {
title: 'Flow',
type: WidgetType.WORKFLOW_VERSION,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
workflowRun: {
title: 'Flow',
type: WidgetType.WORKFLOW_RUN,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
position: VERTICAL_LIST_LAYOUT_POSITIONS.FIRST,
},
emailThread: {
title: 'Thread',
@@ -1,5 +1,10 @@
export enum PageLayoutTabLayoutMode {
GRID = 'GRID',
VERTICAL_LIST = 'VERTICAL_LIST',
/**
* @deprecated Solo full-page tabs are no longer stored as a layout mode.
* Presentation (solo vs stack) is derived from the tab's widgets. Kept only to
* read layouts persisted before this change; nothing new should write it.
*/
CANVAS = 'CANVAS',
}