Refactor record layouts for backend-driven configuration (#15021)

## Refactor: Prepare frontend record layouts for backend-driven
configuration

This PR simplifies and prepares the frontend record layout system for
eventual migration to backend-driven layouts, aligning the architecture
with the existing PageLayout system used for dashboards.

### Key Changes

**Architecture Improvements:**
- Created unified LayoutRenderingContext that works for both record
pages (with targetRecord) and dashboards (standalone)
- Replaced prop drilling with context-based data flow - cards access
targetRecord and isInRightDrawer via context hooks
- Introduced useTargetRecord() helper hook that provides type-safe
access to the current record
- Created generic CardRenderer component that handles configuration
injection and context guards uniformly

**Configuration System:**
- Converted all tab icons from React components to JSON-serializable
strings (e.g., Icon: IconCheckbox → icon: 'IconCheckbox')
- Added configuration field to cards, matching the widget configuration
pattern on the backend
- Created CardConfiguration types system similar to WidgetConfiguration
on the backend
- Moved widget-specific props (like showDuplicatesSection) into
configuration objects

**Code Organization:**
- Extracted visibility evaluation logic into reusable
evaluateTabVisibility() utility
- Organized layouts into dedicated files (one per object:
base-record-layout.ts, company-record-layout.ts, etc.)
- Renamed components to match their purpose: Notes → NotesCard,
Attachments → FilesCard, etc.
- Consolidated card rendering from registry object to direct
getCardComponent() function

**API Alignment:**
- Made ifNoReadPermissionObject an explicit part of TabVisibilityConfig
(follows if* naming convention)
- Removed redundant targetObjectNameSingular from tab-level (now derived
from visibility config)
- Card API now mirrors Widget API (both use type, configuration,
accessed via context)
This commit is contained in:
Félix Malfait
2025-10-10 16:26:17 +02:00
committed by GitHub
parent 5013222db4
commit 938debd83e
46 changed files with 993 additions and 777 deletions
@@ -12,8 +12,8 @@ import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { H3Title } from 'twenty-ui/display';
import {
AnimatedPlaceholder,
@@ -45,21 +45,17 @@ const StyledTitleContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const Calendar = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
export const CalendarEventsCard = () => {
const targetRecord = useTargetRecord();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const [query, queryName] =
targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person
? [
getTimelineCalendarEventsFromPersonId,
'getTimelineCalendarEventsFromPersonId',
]
: targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Company
: targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company
? [
getTimelineCalendarEventsFromCompanyId,
'getTimelineCalendarEventsFromCompanyId',
@@ -74,12 +70,20 @@ export const Calendar = ({
query,
queryName,
'timelineCalendarEvents',
targetableObject,
targetRecord,
TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
);
const { timelineCalendarEvents, totalNumberOfCalendarEvents } =
data?.[queryName] ?? {};
const {
calendarEventsByDayTime,
daysByMonthTime,
monthTimes,
monthTimesByYear,
} = useCalendarEvents(timelineCalendarEvents || []);
const hasMoreCalendarEvents =
timelineCalendarEvents && totalNumberOfCalendarEvents
? timelineCalendarEvents?.length < totalNumberOfCalendarEvents
@@ -91,13 +95,6 @@ export const Calendar = ({
}
};
const {
calendarEventsByDayTime,
daysByMonthTime,
monthTimes,
monthTimesByYear,
} = useCalendarEvents(timelineCalendarEvents || []);
if (firstQueryLoading) {
return <SkeletonLoader />;
}
@@ -116,7 +113,7 @@ export const Calendar = ({
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
No events have been scheduled with this{' '}
{targetableObject.targetObjectNameSingular} yet.
{targetRecord.targetObjectNameSingular} yet.
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
@@ -2,23 +2,40 @@ import { getOperationName } from '@apollo/client/utilities';
import { type Meta, type StoryObj } from '@storybook/react';
import { HttpResponse, graphql } from 'msw';
import { Calendar } from '@/activities/calendar/components/Calendar';
import { CalendarEventsCard } from '@/activities/calendar/components/CalendarEventsCard';
import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { ComponentDecorator } from 'twenty-ui/testing';
import { PageLayoutType } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedTimelineCalendarEvents } from '~/testing/mock-data/timeline-calendar-events';
const meta: Meta<typeof Calendar> = {
title: 'Modules/Activities/Calendar/Calendar',
component: Calendar,
const meta: Meta<typeof CalendarEventsCard> = {
title: 'Modules/Activities/Calendar/CalendarEventsCard',
component: CalendarEventsCard,
decorators: [
I18nFrontDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
(Story) => (
<LayoutRenderingProvider
value={{
targetRecord: {
id: '1',
targetObjectNameSingular: CoreObjectNameSingular.Company,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer: false,
}}
>
<Story />
</LayoutRenderingProvider>
),
],
parameters: {
container: { width: 728 },
@@ -53,15 +70,9 @@ const meta: Meta<typeof Calendar> = {
],
},
},
args: {
targetableObject: {
id: '1',
targetObjectNameSingular: 'Company',
},
},
};
export default meta;
type Story = StoryObj<typeof Calendar>;
type Story = StoryObj<typeof CalendarEventsCard>;
export const Default: Story = {};
@@ -9,8 +9,8 @@ import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/que
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import {
@@ -45,16 +45,13 @@ const StyledEmailCount = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
export const EmailThreads = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
const [query, queryName] =
targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person
? [getTimelineThreadsFromPersonId, 'getTimelineThreadsFromPersonId']
: targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Company
: targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company
? [getTimelineThreadsFromCompanyId, 'getTimelineThreadsFromCompanyId']
: [
getTimelineThreadsFromOpportunityId,
@@ -66,7 +63,7 @@ export const EmailThreads = ({
query,
queryName,
'timelineThreads',
targetableObject,
targetRecord,
TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
);
@@ -6,9 +6,9 @@ import { AttachmentList } from '@/activities/files/components/AttachmentList';
import { DropZone } from '@/activities/files/components/DropZone';
import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
@@ -38,13 +38,10 @@ const StyledDropZoneContainer = styled.div`
height: 100%;
`;
export const Attachments = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
export const FilesCard = () => {
const targetRecord = useTargetRecord();
const inputFileRef = useRef<HTMLInputElement>(null);
const { attachments, loading } = useAttachments(targetableObject);
const { attachments, loading } = useAttachments(targetRecord);
const { uploadAttachmentFile } = useUploadAttachmentFile();
const [isDraggingFile, setIsDraggingFile] = useState(false);
@@ -52,7 +49,7 @@ export const Attachments = ({
const { t } = useLingui();
const onUploadFile = async (file: File) => {
await uploadAttachmentFile(file, targetableObject);
await uploadAttachmentFile(file, targetRecord);
};
const onUploadFiles = async (files: File[]) => {
@@ -74,7 +71,7 @@ export const Attachments = ({
const isAttachmentsEmpty = !attachments || attachments.length === 0;
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetableObject.targetObjectNameSingular,
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const objectPermissions = useObjectPermissionsForObject(
@@ -138,7 +135,7 @@ export const Attachments = ({
multiple
/>
<AttachmentList
targetableObject={targetableObject}
targetableObject={targetRecord}
title={t`All`}
attachments={attachments ?? []}
button={
@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { type Note } from '@/activities/types/Note';
import { NoteCard } from './NoteCard';
import { NoteTile } from './NoteTile';
type NoteListProps = {
title: string;
@@ -59,7 +59,7 @@ export const NoteList = ({ title, notes, button }: NoteListProps) => (
</StyledTitleBar>
<StyledNoteContainer>
{notes.map((note) => (
<NoteCard
<NoteTile
key={note.id}
note={note}
isSingleNote={notes.length === 1}
@@ -62,7 +62,7 @@ const StyledFooter = styled.div`
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
export const NoteCard = ({
export const NoteTile = ({
note,
isSingleNote,
}: {
@@ -2,10 +2,10 @@ import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { NoteList } from '@/activities/notes/components/NoteList';
import { useNotes } from '@/activities/notes/hooks/useNotes';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
@@ -26,12 +26,9 @@ const StyledNotesContainer = styled.div`
overflow: auto;
`;
export const Notes = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const { notes, loading } = useNotes(targetableObject);
export const NotesCard = () => {
const targetRecord = useTargetRecord();
const { notes, loading } = useNotes(targetRecord);
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
@@ -40,7 +37,7 @@ export const Notes = ({
const isNotesEmpty = !notes || notes.length === 0;
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetableObject.targetObjectNameSingular,
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const objectPermissions = useObjectPermissionsForObject(
@@ -75,7 +72,7 @@ export const Notes = ({
variant="secondary"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
targetableObjects: [targetRecord],
})
}
/>
@@ -98,7 +95,7 @@ export const Notes = ({
title="Add note"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
targetableObjects: [targetRecord],
})
}
/>
@@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
const StyledContainer = styled.div`
display: flex;
@@ -12,17 +12,15 @@ const StyledContainer = styled.div`
overflow: auto;
`;
type ObjectTasksProps = {
targetableObject: ActivityTargetableObject;
};
export const TasksCard = () => {
const targetRecord = useTargetRecord();
export const ObjectTasks = ({ targetableObject }: ObjectTasksProps) => {
return (
<StyledContainer>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{ instanceId: 'entity-tasks-filter-instance' }}
>
<TaskGroups targetableObject={targetableObject} />
<TaskGroups targetableObject={targetRecord} />
</ObjectFilterDropdownComponentInstanceContext.Provider>
</StyledContainer>
);
@@ -4,7 +4,8 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { EventList } from '@/activities/timeline-activities/components/EventList';
import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
AnimatedPlaceholder,
@@ -44,15 +45,11 @@ const StyledRightDrawerAnimatedPlaceholderEmptyContainer = styled(
padding-top: ${({ theme }) => theme.spacing(8)};
`;
export const TimelineActivities = ({
targetableObject,
isInRightDrawer,
}: {
targetableObject: ActivityTargetableObject;
isInRightDrawer?: boolean;
}) => {
export const TimelineCard = () => {
const targetRecord = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
const { timelineActivities, loading, fetchMoreRecords } =
useTimelineActivities(targetableObject);
useTimelineActivities(targetRecord);
const isTimelineActivitiesEmpty =
!timelineActivities || timelineActivities.length === 0;
@@ -87,7 +84,7 @@ export const TimelineActivities = ({
return (
<StyledMainContainer>
<EventList
targetableObject={targetableObject}
targetableObject={targetRecord}
title="All"
events={timelineActivities ?? []}
/>
@@ -1,34 +1,42 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { HttpResponse, graphql } from 'msw';
import { TimelineActivities } from '@/activities/timeline-activities/components/TimelineActivities';
import { TimelineCard } from '@/activities/timeline-activities/components/TimelineCard';
import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { ComponentDecorator } from 'twenty-ui/testing';
import { PageLayoutType } from '~/generated/graphql';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof TimelineActivities> = {
title: 'Modules/TimelineActivities/TimelineActivities',
component: TimelineActivities,
const meta: Meta<typeof TimelineCard> = {
title: 'Modules/TimelineActivities/TimelineCard',
component: TimelineCard,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
(Story) => {
return (
<TimelineActivityContext.Provider value={{ recordId: 'mock-id' }}>
<Story />
</TimelineActivityContext.Provider>
<LayoutRenderingProvider
value={{
targetRecord: {
id: '1',
targetObjectNameSingular: CoreObjectNameSingular.Company,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer: false,
}}
>
<TimelineActivityContext.Provider value={{ recordId: 'mock-id' }}>
<Story />
</TimelineActivityContext.Provider>
</LayoutRenderingProvider>
);
},
],
args: {
targetableObject: {
id: '1',
targetObjectNameSingular: 'company',
},
},
parameters: {
msw: {
handlers: [
@@ -71,6 +79,6 @@ const meta: Meta<typeof TimelineActivities> = {
};
export default meta;
type Story = StoryObj<typeof TimelineActivities>;
type Story = StoryObj<typeof TimelineCard>;
export const Default: Story = {};
@@ -65,7 +65,8 @@ export const useCommandMenuSearchRecords = () => {
Icon: () => (
<Avatar
type={
searchRecord.objectNameSingular === 'company'
searchRecord.objectNameSingular ===
CoreObjectNameSingular.Company
? 'squared'
: 'rounded'
}
@@ -2,16 +2,15 @@ import { DashboardContentRenderer } from '@/dashboards/components/DashboardConte
import { type Dashboard } from '@/dashboards/components/types/Dashboard';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { isDefined } from 'twenty-shared/utils';
type DashboardRendererProps = {
recordId: string;
};
export const DashboardCard = () => {
const targetRecord = useTargetRecord();
export const DashboardRenderer = ({ recordId }: DashboardRendererProps) => {
const { record: dashboard } = useFindOneRecord<Dashboard>({
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: recordId,
objectRecordId: targetRecord.id,
});
if (!isDefined(dashboard)) {
@@ -1,4 +1,5 @@
import { NavigationDrawerItemForObjectMetadataItem } from '@/object-metadata/components/NavigationDrawerItemForObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
@@ -8,12 +9,12 @@ import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { useRecoilValue } from 'recoil';
const ORDERED_STANDARD_OBJECTS = [
'person',
'company',
'opportunity',
'task',
'note',
const ORDERED_STANDARD_OBJECTS: string[] = [
CoreObjectNameSingular.Person,
CoreObjectNameSingular.Company,
CoreObjectNameSingular.Opportunity,
CoreObjectNameSingular.Task,
CoreObjectNameSingular.Note,
];
type NavigationDrawerSectionForObjectMetadataItemsProps = {
@@ -1,8 +1,11 @@
import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { getCardComponent } from '@/object-record/record-show/utils/getCardComponent';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { isDefined } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { PageLayoutType } from '~/generated/graphql';
type MergePreviewTabProps = {
objectNameSingular: string;
@@ -22,21 +25,27 @@ export const MergePreviewTab = ({
const recordId = mergePreviewRecord?.id ?? 'merge-preview-loading';
return (
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInRightDrawer={true}
/>
<CardComponents.FieldCard
targetableObject={{
targetObjectNameSingular: objectNameSingular,
<LayoutRenderingProvider
value={{
targetRecord: {
id: recordId,
}}
showDuplicatesSection={false}
isInRightDrawer={true}
/>
</Section>
targetObjectNameSingular: objectNameSingular,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer: true,
}}
>
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInRightDrawer={true}
/>
{getCardComponent(CardType.FieldCard, {
showDuplicatesSection: false,
})}
</Section>
</LayoutRenderingProvider>
);
};
@@ -1,6 +1,9 @@
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { getCardComponent } from '@/object-record/record-show/utils/getCardComponent';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { Section } from 'twenty-ui/layout';
import { PageLayoutType } from '~/generated/graphql';
type MergeRecordTabProps = {
isInRightDrawer?: boolean;
@@ -13,21 +16,27 @@ export const MergeRecordTab = ({
recordId,
}: MergeRecordTabProps) => {
return (
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInRightDrawer={true}
/>
<CardComponents.FieldCard
targetableObject={{
targetObjectNameSingular: objectNameSingular,
<LayoutRenderingProvider
value={{
targetRecord: {
id: recordId,
}}
isInRightDrawer={true}
showDuplicatesSection={false}
/>
</Section>
targetObjectNameSingular: objectNameSingular,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer: true,
}}
>
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInRightDrawer={true}
/>
{getCardComponent(CardType.FieldCard, {
showDuplicatesSection: false,
})}
</Section>
</LayoutRenderingProvider>
);
};
@@ -1,220 +0,0 @@
import { Calendar } from '@/activities/calendar/components/Calendar';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { EmailThreads } from '@/activities/emails/components/EmailThreads';
import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes';
import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
import { TimelineActivities } from '@/activities/timeline-activities/components/TimelineActivities';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { DashboardRenderer } from '@/dashboards/components/DashboardRenderer';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import { CardType } from '@/object-record/record-show/types/CardType';
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenRecordUpdatesEffect';
import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
import { WorkflowRunVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowRunVisualizerComponentInstanceContext';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { lazy, Suspense, useId } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
background: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? theme.background.secondary : ''};
border: ${({ isInRightDrawer, theme }) =>
isInRightDrawer ? `1px solid ${theme.border.color.medium}` : ''};
border-radius: ${({ isInRightDrawer, theme }) =>
isInRightDrawer ? theme.border.radius.md : ''};
height: ${({ isInRightDrawer }) => (isInRightDrawer ? 'auto' : '100%')};
margin: ${({ isInRightDrawer, theme }) =>
isInRightDrawer ? theme.spacing(4) : ''};
`;
const StyledLoadingSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
type CardComponentProps = {
targetableObject: Pick<
ActivityTargetableObject,
'targetObjectNameSingular' | 'id'
>;
isInRightDrawer?: boolean;
};
type CardComponentType = (
props: CardComponentProps | FieldsCardComponentProps,
) => JSX.Element | null;
type FieldsCardComponentProps = CardComponentProps & {
showDuplicatesSection?: boolean;
};
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<StyledLoadingSkeletonContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
</SkeletonTheme>
</StyledLoadingSkeletonContainer>
);
};
const WorkflowVisualizer = lazy(() =>
import('@/workflow/workflow-diagram/components/WorkflowVisualizer').then(
(module) => ({
default: module.WorkflowVisualizer,
}),
),
);
const WorkflowVersionVisualizer = lazy(() =>
import(
'@/workflow/workflow-diagram/components/WorkflowVersionVisualizer'
).then((module) => ({
default: module.WorkflowVersionVisualizer,
})),
);
const WorkflowRunVisualizer = lazy(() =>
import('@/workflow/workflow-diagram/components/WorkflowRunVisualizer').then(
(module) => ({
default: module.WorkflowRunVisualizer,
}),
),
);
export const CardComponents: Record<CardType, CardComponentType> = {
[CardType.TimelineCard]: ({ targetableObject, isInRightDrawer }) => (
<TimelineActivities
targetableObject={targetableObject}
isInRightDrawer={isInRightDrawer}
/>
),
[CardType.FieldCard]: ({
targetableObject,
isInRightDrawer,
showDuplicatesSection,
}: FieldsCardComponentProps) => (
<StyledGreyBox isInRightDrawer={isInRightDrawer}>
<FieldsCard
objectNameSingular={targetableObject.targetObjectNameSingular}
objectRecordId={targetableObject.id}
showDuplicatesSection={showDuplicatesSection}
/>
</StyledGreyBox>
),
[CardType.RichTextCard]: ({ targetableObject }) => (
<ShowPageActivityContainer targetableObject={targetableObject} />
),
[CardType.TaskCard]: ({ targetableObject }) => (
<ObjectTasks targetableObject={targetableObject} />
),
[CardType.NoteCard]: ({ targetableObject }) => (
<Notes targetableObject={targetableObject} />
),
[CardType.FileCard]: ({ targetableObject }) => (
<Attachments targetableObject={targetableObject} />
),
[CardType.EmailCard]: ({ targetableObject }) => (
<EmailThreads targetableObject={targetableObject} />
),
[CardType.CalendarCard]: ({ targetableObject }) => (
<Calendar targetableObject={targetableObject} />
),
[CardType.WorkflowCard]: ({ targetableObject }) => {
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetableObject.id,
}),
}}
>
<WorkflowVisualizerEffect workflowId={targetableObject.id} />
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowVisualizer />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
[CardType.WorkflowVersionCard]: ({ targetableObject }) => {
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetableObject.id,
}),
}}
>
<WorkflowVersionVisualizerEffect
workflowVersionId={targetableObject.id}
/>
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowVersionVisualizer workflowVersionId={targetableObject.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
[CardType.WorkflowRunCard]: ({ targetableObject }) => {
const componentId = useId();
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetableObject.id,
}),
}}
>
<WorkflowRunVisualizerComponentInstanceContext.Provider
value={{
instanceId: componentId,
}}
>
<WorkflowRunVisualizerEffect workflowRunId={targetableObject.id} />
<ListenRecordUpdatesEffect
objectNameSingular={targetableObject.targetObjectNameSingular}
recordId={targetableObject.id}
listenedFields={['status', 'state']}
/>
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
</Suspense>
</WorkflowRunVisualizerComponentInstanceContext.Provider>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
[CardType.DashboardCard]: ({ targetableObject }) => {
return <DashboardRenderer recordId={targetableObject.id} />;
},
};
@@ -1,25 +1,22 @@
import { RecordFieldList } from '@/object-record/record-field-list/components/RecordFieldList';
import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
import { type FieldCardConfiguration } from '@/object-record/record-show/types/CardConfiguration';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
type FieldsCardProps = {
objectNameSingular: string;
objectRecordId: string;
showDuplicatesSection?: boolean;
configuration?: FieldCardConfiguration;
};
export const FieldsCard = ({
objectNameSingular,
objectRecordId,
showDuplicatesSection = true,
}: FieldsCardProps) => {
const { isInRightDrawer } = useIsInRightDrawerOrThrow();
export const FieldsCard = ({ configuration }: FieldsCardProps) => {
const targetRecord = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
return (
<RecordFieldList
instanceId={`fields-card-${objectRecordId}-${isInRightDrawer ? 'right-drawer' : ''}`}
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
showDuplicatesSection={showDuplicatesSection}
instanceId={`fields-card-${targetRecord.id}-${isInRightDrawer ? 'right-drawer' : ''}`}
objectNameSingular={targetRecord.targetObjectNameSingular}
objectRecordId={targetRecord.id}
showDuplicatesSection={configuration?.showDuplicatesSection ?? true}
/>
);
};
@@ -3,25 +3,39 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { BASE_RECORD_LAYOUT } from '@/object-record/record-show/constants/BaseRecordLayout';
import { CardType } from '@/object-record/record-show/types/CardType';
import { BASE_RECORD_LAYOUT } from '@/object-record/record-show/layouts/base-record-layout';
import { COMPANY_RECORD_LAYOUT } from '@/object-record/record-show/layouts/company-record-layout';
import { DASHBOARD_RECORD_LAYOUT } from '@/object-record/record-show/layouts/dashboard-record-layout';
import { NOTE_RECORD_LAYOUT } from '@/object-record/record-show/layouts/note-record-layout';
import { OPPORTUNITY_RECORD_LAYOUT } from '@/object-record/record-show/layouts/opportunity-record-layout';
import { PERSON_RECORD_LAYOUT } from '@/object-record/record-show/layouts/person-record-layout';
import { TASK_RECORD_LAYOUT } from '@/object-record/record-show/layouts/task-record-layout';
import { WORKFLOW_RECORD_LAYOUT } from '@/object-record/record-show/layouts/workflow-record-layout';
import { WORKFLOW_RUN_RECORD_LAYOUT } from '@/object-record/record-show/layouts/workflow-run-record-layout';
import { WORKFLOW_VERSION_RECORD_LAYOUT } from '@/object-record/record-show/layouts/workflow-version-record-layout';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { evaluateTabVisibility } from '@/object-record/record-show/utils/evaluateTabVisibility';
import { type RecordLayoutTab } from '@/ui/layout/tab-list/types/RecordLayoutTab';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
IconCalendarEvent,
IconHome,
IconLayoutDashboard,
IconMail,
IconNotes,
IconSettings,
} from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { IconHome, useIcons } from 'twenty-ui/display';
// Object-specific layouts that override or extend the base layout
const OBJECT_SPECIFIC_LAYOUTS: Partial<
Record<CoreObjectNameSingular, RecordLayout>
> = {
[CoreObjectNameSingular.Note]: NOTE_RECORD_LAYOUT,
[CoreObjectNameSingular.Task]: TASK_RECORD_LAYOUT,
[CoreObjectNameSingular.Company]: COMPANY_RECORD_LAYOUT,
[CoreObjectNameSingular.Person]: PERSON_RECORD_LAYOUT,
[CoreObjectNameSingular.Opportunity]: OPPORTUNITY_RECORD_LAYOUT,
[CoreObjectNameSingular.Workflow]: WORKFLOW_RECORD_LAYOUT,
[CoreObjectNameSingular.WorkflowVersion]: WORKFLOW_VERSION_RECORD_LAYOUT,
[CoreObjectNameSingular.WorkflowRun]: WORKFLOW_RUN_RECORD_LAYOUT,
[CoreObjectNameSingular.Dashboard]: DASHBOARD_RECORD_LAYOUT,
};
export const useRecordShowContainerTabs = (
loading: boolean,
@@ -34,257 +48,19 @@ export const useRecordShowContainerTabs = (
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
// Object-specific layouts that override or extend the base layout
const OBJECT_SPECIFIC_LAYOUTS: Partial<
Record<CoreObjectNameSingular, RecordLayout>
> = useMemo(
() => ({
[CoreObjectNameSingular.Note]: {
tabs: {
richText: {
title: 'Note',
position: 101,
Icon: IconNotes,
cards: [{ type: CardType.RichTextCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
tasks: null,
notes: null,
},
},
[CoreObjectNameSingular.Task]: {
tabs: {
richText: {
title: 'Note',
position: 101,
Icon: IconNotes,
cards: [{ type: CardType.RichTextCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
tasks: null,
notes: null,
},
},
[CoreObjectNameSingular.Company]: {
tabs: {
emails: {
title: 'Emails',
position: 600,
Icon: IconMail,
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
Icon: IconCalendarEvent,
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
},
[CoreObjectNameSingular.Person]: {
tabs: {
emails: {
title: 'Emails',
position: 600,
Icon: IconMail,
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
Icon: IconCalendarEvent,
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
},
[CoreObjectNameSingular.Opportunity]: {
tabs: {
emails: {
title: 'Emails',
position: 600,
Icon: IconMail,
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
Icon: IconCalendarEvent,
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
},
[CoreObjectNameSingular.Workflow]: {
hideSummaryAndFields: true,
tabs: {
workflow: {
title: 'Flow',
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
fields: null,
tasks: null,
notes: null,
files: null,
},
},
[CoreObjectNameSingular.WorkflowVersion]: {
tabs: {
workflowVersion: {
title: 'Flow',
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowVersionCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
},
[CoreObjectNameSingular.WorkflowRun]: {
tabs: {
workflowRun: {
title: 'Flow',
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowRunCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
},
[CoreObjectNameSingular.Dashboard]: {
hideSummaryAndFields: true,
hideFieldsInSidePanel: true,
tabs: {
dashboard: {
title: 'Dashboard',
position: 101,
Icon: IconLayoutDashboard,
cards: [{ type: CardType.DashboardCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
},
}),
[],
);
const baseRecordLayout = BASE_RECORD_LAYOUT;
const { getIcon } = useIcons();
// Merge base layout with object-specific layout
const recordLayout: RecordLayout = useMemo(() => {
return {
...baseRecordLayout,
...BASE_RECORD_LAYOUT,
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular] || {}),
tabs: {
...baseRecordLayout.tabs,
...BASE_RECORD_LAYOUT.tabs,
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular]?.tabs || {}),
},
};
}, [OBJECT_SPECIFIC_LAYOUTS, baseRecordLayout, targetObjectNameSingular]);
}, [targetObjectNameSingular]);
return {
layout: recordLayout,
@@ -294,7 +70,9 @@ export const useRecordShowContainerTabs = (
entry[1] !== null && entry[1] !== undefined,
)
.sort(([, a], [, b]) => a.position - b.position)
.map(([key, { title, Icon, hide, cards, targetObjectNameSingular }]) => {
.map(([key, { title, icon, hide, cards }]) => {
const Icon = getIcon(icon);
// Special handling for fields tab
if (key === 'fields') {
return {
@@ -308,62 +86,22 @@ export const useRecordShowContainerTabs = (
};
}
const baseHide =
(hide.ifMobile && isMobile) ||
(hide.ifDesktop && !isMobile) ||
(hide.ifInRightDrawer && isInRightDrawer);
const featureNotEnabled =
hide.ifFeaturesDisabled.length > 0 &&
!hide.ifFeaturesDisabled.every((flagKey) => {
return !!currentWorkspace?.featureFlags?.find(
(flag) => flag.key === flagKey && flag.value,
);
});
const targetObjectMetadataId = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
)?.id;
const permissionHide =
hide.ifNoReadPermission &&
isDefined(targetObjectNameSingular) &&
!getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: targetObjectMetadataId ?? '',
})?.canReadObjectRecords;
const requiredObjectsInactive =
hide.ifRequiredObjectsInactive.length > 0 &&
!hide.ifRequiredObjectsInactive.every((obj) =>
objectMetadataItems.some(
(item) => item.nameSingular === obj && item.isActive,
),
);
const relationsDontExist =
hide.ifRelationsMissing.length > 0 &&
!hide.ifRelationsMissing.every((rel) =>
objectMetadataItem.fields.some(
(field) =>
field.type === FieldMetadataType.RELATION &&
field.name === rel &&
field.isActive,
),
);
// Use extracted visibility evaluation logic
const shouldHide = evaluateTabVisibility(hide, {
isMobile,
isInRightDrawer,
currentWorkspace,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
targetObjectMetadataItem: objectMetadataItem,
});
return {
id: key,
title,
Icon,
cards,
hide:
loading ||
baseHide ||
featureNotEnabled ||
requiredObjectsInactive ||
relationsDontExist ||
permissionHide,
hide: loading || shouldHide,
};
})
// When isInRightDrawer === true, we merge first and second tab into first tab
@@ -1,19 +1,12 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import {
IconCheckbox,
IconList,
IconNotes,
IconPaperclip,
IconTimelineEvent,
} from 'twenty-ui/display';
export const BASE_RECORD_LAYOUT: RecordLayout = {
tabs: {
fields: {
title: 'Fields',
Icon: IconList,
icon: 'IconList',
position: 100,
cards: [{ type: CardType.FieldCard }],
hide: {
@@ -27,7 +20,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
},
timeline: {
title: 'Timeline',
Icon: IconTimelineEvent,
icon: 'IconTimelineEvent',
position: 200,
cards: [{ type: CardType.TimelineCard }],
hide: {
@@ -41,10 +34,9 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
},
tasks: {
title: 'Tasks',
Icon: IconCheckbox,
icon: 'IconCheckbox',
position: 300,
cards: [{ type: CardType.TaskCard }],
targetObjectNameSingular: CoreObjectNameSingular.Task,
hide: {
ifMobile: false,
ifDesktop: false,
@@ -53,14 +45,14 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
ifRequiredObjectsInactive: [CoreObjectNameSingular.Task],
ifRelationsMissing: ['taskTargets'],
ifNoReadPermission: true,
ifNoReadPermissionObject: CoreObjectNameSingular.Task,
},
},
notes: {
title: 'Notes',
Icon: IconNotes,
icon: 'IconNotes',
position: 400,
cards: [{ type: CardType.NoteCard }],
targetObjectNameSingular: CoreObjectNameSingular.Note,
hide: {
ifMobile: false,
ifDesktop: false,
@@ -69,11 +61,12 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
ifRequiredObjectsInactive: [CoreObjectNameSingular.Note],
ifRelationsMissing: ['noteTargets'],
ifNoReadPermission: true,
ifNoReadPermissionObject: CoreObjectNameSingular.Note,
},
},
files: {
title: 'Files',
Icon: IconPaperclip,
icon: 'IconPaperclip',
position: 500,
cards: [{ type: CardType.FileCard }],
hide: {
@@ -0,0 +1,35 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const COMPANY_RECORD_LAYOUT: RecordLayout = {
tabs: {
emails: {
title: 'Emails',
position: 600,
icon: 'IconMail',
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
icon: 'IconCalendarEvent',
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
};
@@ -0,0 +1,27 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const DASHBOARD_RECORD_LAYOUT: RecordLayout = {
hideSummaryAndFields: true,
hideFieldsInSidePanel: true,
tabs: {
dashboard: {
title: 'Dashboard',
position: 101,
icon: 'IconLayoutDashboard',
cards: [{ type: CardType.DashboardCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
};
@@ -0,0 +1,23 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const NOTE_RECORD_LAYOUT: RecordLayout = {
tabs: {
richText: {
title: 'Note',
position: 101,
icon: 'IconNotes',
cards: [{ type: CardType.RichTextCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
tasks: null,
notes: null,
},
};
@@ -0,0 +1,35 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const OPPORTUNITY_RECORD_LAYOUT: RecordLayout = {
tabs: {
emails: {
title: 'Emails',
position: 600,
icon: 'IconMail',
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
icon: 'IconCalendarEvent',
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
};
@@ -0,0 +1,35 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const PERSON_RECORD_LAYOUT: RecordLayout = {
tabs: {
emails: {
title: 'Emails',
position: 600,
icon: 'IconMail',
cards: [{ type: CardType.EmailCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
calendar: {
title: 'Calendar',
position: 700,
icon: 'IconCalendarEvent',
cards: [{ type: CardType.CalendarCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
},
};
@@ -0,0 +1,23 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const TASK_RECORD_LAYOUT: RecordLayout = {
tabs: {
richText: {
title: 'Note',
position: 101,
icon: 'IconNotes',
cards: [{ type: CardType.RichTextCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
tasks: null,
notes: null,
},
};
@@ -0,0 +1,27 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const WORKFLOW_RECORD_LAYOUT: RecordLayout = {
hideSummaryAndFields: true,
tabs: {
workflow: {
title: 'Flow',
position: 101,
icon: 'IconSettings',
cards: [{ type: CardType.WorkflowCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
fields: null,
tasks: null,
notes: null,
files: null,
},
};
@@ -0,0 +1,25 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const WORKFLOW_RUN_RECORD_LAYOUT: RecordLayout = {
tabs: {
workflowRun: {
title: 'Flow',
position: 101,
icon: 'IconSettings',
cards: [{ type: CardType.WorkflowRunCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
};
@@ -0,0 +1,25 @@
import { CardType } from '@/object-record/record-show/types/CardType';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
export const WORKFLOW_VERSION_RECORD_LAYOUT: RecordLayout = {
tabs: {
workflowVersion: {
title: 'Flow',
position: 101,
icon: 'IconSettings',
cards: [{ type: CardType.WorkflowVersionCard }],
hide: {
ifMobile: false,
ifDesktop: false,
ifInRightDrawer: false,
ifFeaturesDisabled: [],
ifRequiredObjectsInactive: [],
ifRelationsMissing: [],
},
},
timeline: null,
tasks: null,
notes: null,
files: null,
},
};
@@ -0,0 +1,29 @@
import { type CardType } from '@/object-record/record-show/types/CardType';
// Card configuration types - each card type can define its own configuration
export type FieldCardConfiguration = {
showDuplicatesSection?: boolean;
};
// For cards that don't need configuration, use undefined
export type EmptyCardConfiguration = undefined;
// Type mapping from CardType to its specific configuration type
// This creates precise typing: each CardType is linked to exactly one configuration type
export type CardTypeToConfiguration = {
[CardType.FieldCard]: FieldCardConfiguration;
[CardType.TimelineCard]: EmptyCardConfiguration;
[CardType.TaskCard]: EmptyCardConfiguration;
[CardType.NoteCard]: EmptyCardConfiguration;
[CardType.FileCard]: EmptyCardConfiguration;
[CardType.EmailCard]: EmptyCardConfiguration;
[CardType.CalendarCard]: EmptyCardConfiguration;
[CardType.RichTextCard]: EmptyCardConfiguration;
[CardType.WorkflowCard]: EmptyCardConfiguration;
[CardType.WorkflowVersionCard]: EmptyCardConfiguration;
[CardType.WorkflowRunCard]: EmptyCardConfiguration;
[CardType.DashboardCard]: EmptyCardConfiguration;
};
// Union type for all card configurations (for general use)
export type CardConfiguration = FieldCardConfiguration | EmptyCardConfiguration;
@@ -0,0 +1,109 @@
import { isDefined } from 'twenty-shared/utils';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { type TabVisibilityConfig } from '@/ui/layout/tab-list/types/TabVisibilityConfig';
import { type ObjectPermissions } from 'twenty-shared/types';
import { FieldMetadataType, type FeatureFlagDto } from '~/generated/graphql';
export type TabVisibilityContext = {
isMobile: boolean;
isInRightDrawer: boolean;
currentWorkspace: {
featureFlags?: FeatureFlagDto[] | null;
} | null;
objectMetadataItems: ObjectMetadataItem[];
objectPermissionsByObjectMetadataId: Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
targetObjectMetadataItem: ObjectMetadataItem;
};
export const evaluateTabVisibility = (
hide: TabVisibilityConfig,
context: TabVisibilityContext,
): boolean => {
const {
isMobile,
isInRightDrawer,
currentWorkspace,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
} = context;
const baseHide =
(hide.ifMobile && isMobile) ||
(hide.ifDesktop && !isMobile) ||
(hide.ifInRightDrawer && isInRightDrawer);
if (baseHide) {
return true;
}
const featureNotEnabled =
hide.ifFeaturesDisabled.length > 0 &&
!hide.ifFeaturesDisabled.every((flagKey) => {
const featureFlags = currentWorkspace?.featureFlags;
if (!featureFlags) {
return false;
}
return !!featureFlags.find((flag) => flag.key === flagKey && flag.value);
});
if (featureNotEnabled) {
return true;
}
const requiredObjectInactive = hide.ifRequiredObjectsInactive.some(
(requiredObjectName) => {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === requiredObjectName,
);
return !objectMetadataItem?.isActive;
},
);
if (requiredObjectInactive) {
return true;
}
const relationMissing = hide.ifRelationsMissing.some((relationName) => {
return !context.targetObjectMetadataItem.fields.some(
(field) =>
field.name === relationName &&
field.type === FieldMetadataType.RELATION &&
field.isActive === true,
);
});
if (relationMissing) {
return true;
}
const noReadPermission =
hide.ifNoReadPermission === true &&
isDefined(hide.ifNoReadPermissionObject) &&
(() => {
const targetObjectMetadataId = objectMetadataItems.find(
(item) => item.nameSingular === hide.ifNoReadPermissionObject,
)?.id;
if (!isDefined(targetObjectMetadataId)) {
return false;
}
const objectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
targetObjectMetadataId,
);
return objectPermissions.canReadObjectRecords === false;
})();
if (noReadPermission) {
return true;
}
return false;
};
@@ -0,0 +1,94 @@
import { CalendarEventsCard } from '@/activities/calendar/components/CalendarEventsCard';
import { EmailsCard } from '@/activities/emails/components/EmailsCard';
import { FilesCard } from '@/activities/files/components/FilesCard';
import { NotesCard } from '@/activities/notes/components/NotesCard';
import { TasksCard } from '@/activities/tasks/components/TasksCard';
import { TimelineCard } from '@/activities/timeline-activities/components/TimelineCard';
import { DashboardCard } from '@/dashboards/components/DashboardCard';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import {
type CardConfiguration,
type CardTypeToConfiguration,
type FieldCardConfiguration,
} from '@/object-record/record-show/types/CardConfiguration';
import { CardType } from '@/object-record/record-show/types/CardType';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { RichTextCard } from '@/ui/layout/show-page/components/RichTextCard';
import { WorkflowCard } from '@/workflow/workflow-diagram/components/WorkflowCard';
import { WorkflowRunCard } from '@/workflow/workflow-diagram/components/WorkflowRunCard';
import { WorkflowVersionCard } from '@/workflow/workflow-diagram/components/WorkflowVersionCard';
import { assertUnreachable } from 'twenty-shared/utils';
const CardRenderer = <T extends CardConfiguration>({
Component,
configuration,
}: {
Component: React.ComponentType<{ configuration?: T }> | React.ComponentType;
configuration?: T;
}) => {
const { targetRecord } = useLayoutRenderingContext();
if (!targetRecord) {
return null;
}
// TypeScript can't infer if Component accepts configuration prop or not
// So we cast to the more permissive type and let the component ignore unused props
const ComponentWithConfig = Component as React.ComponentType<{
configuration?: T;
}>;
return <ComponentWithConfig configuration={configuration} />;
};
// Generic function with precise type mapping from CardType to Configuration
// TypeScript will enforce that the correct configuration type is passed for each card type
export const getCardComponent = <T extends CardType>(
cardType: T,
configuration?: CardTypeToConfiguration[T],
): JSX.Element | null => {
switch (cardType) {
case CardType.TimelineCard:
return <CardRenderer Component={TimelineCard} />;
case CardType.FieldCard:
return (
<CardRenderer
Component={FieldsCard}
configuration={configuration as FieldCardConfiguration | undefined}
/>
);
case CardType.RichTextCard:
return <CardRenderer Component={RichTextCard} />;
case CardType.TaskCard:
return <CardRenderer Component={TasksCard} />;
case CardType.NoteCard:
return <CardRenderer Component={NotesCard} />;
case CardType.FileCard:
return <CardRenderer Component={FilesCard} />;
case CardType.EmailCard:
return <CardRenderer Component={EmailsCard} />;
case CardType.CalendarCard:
return <CardRenderer Component={CalendarEventsCard} />;
case CardType.WorkflowCard:
return <CardRenderer Component={WorkflowCard} />;
case CardType.WorkflowVersionCard:
return <CardRenderer Component={WorkflowVersionCard} />;
case CardType.WorkflowRunCard:
return <CardRenderer Component={WorkflowRunCard} />;
case CardType.DashboardCard:
return <CardRenderer Component={DashboardCard} />;
default:
assertUnreachable(cardType);
}
};
@@ -4,9 +4,11 @@ import { PageLayoutRendererContent } from '@/page-layout/components/PageLayoutRe
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { PageLayoutType } from '~/generated/graphql';
type PageLayoutRendererProps = {
pageLayoutId: string;
@@ -18,22 +20,30 @@ export const PageLayoutRenderer = ({
onInitialized,
}: PageLayoutRendererProps) => {
return (
<PageLayoutComponentInstanceContext.Provider
<LayoutRenderingProvider
value={{
instanceId: pageLayoutId,
targetRecord: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInRightDrawer: false,
}}
>
<TabListComponentInstanceContext.Provider
<PageLayoutComponentInstanceContext.Provider
value={{
instanceId: getTabListInstanceIdFromPageLayoutId(pageLayoutId),
instanceId: pageLayoutId,
}}
>
<PageLayoutInitializationQueryEffect
pageLayoutId={pageLayoutId}
onInitialized={onInitialized}
/>
<PageLayoutRendererContent />
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
<TabListComponentInstanceContext.Provider
value={{
instanceId: getTabListInstanceIdFromPageLayoutId(pageLayoutId),
}}
>
<PageLayoutInitializationQueryEffect
pageLayoutId={pageLayoutId}
onInitialized={onInitialized}
/>
<PageLayoutRendererContent />
</TabListComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
</LayoutRenderingProvider>
);
};
@@ -2,12 +2,13 @@ import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objec
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type GraphWidgetFieldSelection } from '@/page-layout/types/GraphWidgetFieldSelection';
export const useCompanyDefaultChartConfig = () => {
const companyObjectMetadata = useRecoilValue(
objectMetadataItemFamilySelector({
objectName: 'company',
objectName: CoreObjectNameSingular.Company,
objectNameType: 'singular',
}),
);
@@ -1,6 +1,7 @@
import { useFormContext } from 'react-hook-form';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
@@ -82,7 +83,8 @@ export const SettingsDataModelFieldRelationSettingsFormCard = ({
}}
shrink
objectNameSingulars={[
relationObjectMetadataItem?.nameSingular ?? 'company',
relationObjectMetadataItem?.nameSingular ??
CoreObjectNameSingular.Company,
]}
fieldPreviewTargetObjectNameSingular={objectNameSingular}
pluralizeLabel={oppositeRelationType === RelationType.ONE_TO_MANY}
@@ -2,6 +2,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
@@ -75,7 +76,7 @@ export const SettingsDataModelRelationFieldPreview = ({
const metadata = {
fieldName,
objectMetadataNameSingular: 'company',
objectMetadataNameSingular: CoreObjectNameSingular.Company,
relationObjectMetadataNameSingular: relationTargetObjectNameSingular,
options: [],
settings: fieldMetadataItem.settings,
@@ -0,0 +1,20 @@
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { type PageLayoutType } from '~/generated/graphql';
import { createRequiredContext } from '~/utils/createRequiredContext';
export type LayoutRenderingContextType = {
// Optional target record - only present for record pages that display data about a specific record
// Undefined for dashboards which are standalone
// Uses ActivityTargetableObject shape for compatibility with existing components
targetRecord?: Pick<
ActivityTargetableObject,
'id' | 'targetObjectNameSingular'
>;
layoutType: PageLayoutType;
isInRightDrawer: boolean;
};
export const [LayoutRenderingProvider, useLayoutRenderingContext] =
createRequiredContext<LayoutRenderingContextType>('LayoutRenderingContext');
@@ -0,0 +1,13 @@
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
export const useTargetRecord = () => {
const { targetRecord } = useLayoutRenderingContext();
if (!targetRecord) {
throw new Error(
'useTargetRecord must be used within a record page context (targetRecord is required)',
);
}
return targetRecord;
};
@@ -1,7 +1,7 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@@ -46,38 +46,31 @@ const LoadingSkeleton = () => {
);
};
export const ShowPageActivityContainer = ({
targetableObject,
}: {
targetableObject: Pick<
ActivityTargetableObject,
'targetObjectNameSingular' | 'id'
>;
}) => {
const activityObjectNameSingular =
targetableObject.targetObjectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
export const RichTextCard = () => {
const targetRecord = useTargetRecord();
const activityBodyV2 = useRecoilValue(
recordStoreFamilySelector({
recordId: targetableObject.id,
recordId: targetRecord.id,
fieldName: 'bodyV2',
}),
);
const activityObjectNameSingular = targetRecord.targetObjectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
if (!isDefined(activityBodyV2)) {
return <LoadingSkeleton />;
}
return (
<ScrollWrapper
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
componentInstanceId={`scroll-wrapper-tab-list-${targetRecord.id}`}
>
<StyledShowPageActivityContainer>
<Suspense fallback={<LoadingSkeleton />}>
<ActivityRichTextEditor
activityId={targetableObject.id}
activityId={targetRecord.id}
activityObjectNameSingular={activityObjectNameSingular}
/>
</Suspense>
@@ -2,14 +2,16 @@ import { RecordShowRightDrawerActionMenu } from '@/action-menu/components/Record
import { RecordShowRightDrawerOpenRecordButton } from '@/action-menu/components/RecordShowRightDrawerOpenRecordButton';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { type RecordLayout } from '@/object-record/record-show/types/RecordLayout';
import { getCardComponent } from '@/object-record/record-show/utils/getCardComponent';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { PageLayoutType } from '~/generated/graphql';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
@@ -18,6 +20,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import React from 'react';
const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
display: flex;
@@ -92,27 +95,21 @@ export const ShowPageSubContainer = ({
/>
);
const fieldsCard = (
<FieldsCard
objectNameSingular={targetableObject.targetObjectNameSingular}
objectRecordId={targetableObject.id}
/>
);
const fieldsCard = <FieldsCard />;
const renderActiveTabContent = () => {
const activeTab = tabs.find((tab) => tab.id === activeTabId);
if (!activeTab?.cards?.length) return null;
return activeTab.cards.map((card, index) => {
const CardComponent = CardComponents[card.type];
return CardComponent ? (
<CardComponent
key={`${activeTab.id}-card-${index}`}
targetableObject={targetableObject}
isInRightDrawer={isInRightDrawer}
/>
) : null;
});
return (
<>
{activeTab.cards.map((card, index) => (
<React.Fragment key={`${activeTab.id}-card-${index}`}>
{getCardComponent(card.type, card.configuration)}
</React.Fragment>
))}
</>
);
};
const visibleTabs = tabs.filter((tab) => !tab.hide);
@@ -121,41 +118,52 @@ export const ShowPageSubContainer = ({
layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer;
return (
<TabListComponentInstanceContext.Provider
value={{ instanceId: tabListComponentId }}
<LayoutRenderingProvider
value={{
targetRecord: {
id: targetableObject.id,
targetObjectNameSingular: targetableObject.targetObjectNameSingular,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer,
}}
>
{displaySummaryAndFields && (
<ShowPageLeftContainer forceMobile={isMobile}>
{summaryCard}
{fieldsCard}
</ShowPageLeftContainer>
)}
<StyledShowPageRightContainer isMobile={isMobile}>
<StyledTabListContainer shouldDisplay={visibleTabs.length > 1}>
<StyledTabList
behaveAsLinks={!isInRightDrawer}
loading={loading}
tabs={tabs}
isInRightDrawer={isInRightDrawer}
componentInstanceId={tabListComponentId}
/>
</StyledTabListContainer>
{(isMobile || isInRightDrawer) && summaryCard}
<StyledContentContainer isInRightDrawer={isInRightDrawer}>
{renderActiveTabContent()}
</StyledContentContainer>
{isInRightDrawer && (
<RightDrawerFooter
actions={[
<RecordShowRightDrawerActionMenu />,
<RecordShowRightDrawerOpenRecordButton
objectNameSingular={targetableObject.targetObjectNameSingular}
recordId={targetableObject.id}
/>,
]}
/>
<TabListComponentInstanceContext.Provider
value={{ instanceId: tabListComponentId }}
>
{displaySummaryAndFields && (
<ShowPageLeftContainer forceMobile={isMobile}>
{summaryCard}
{fieldsCard}
</ShowPageLeftContainer>
)}
</StyledShowPageRightContainer>
</TabListComponentInstanceContext.Provider>
<StyledShowPageRightContainer isMobile={isMobile}>
<StyledTabListContainer shouldDisplay={visibleTabs.length > 1}>
<StyledTabList
behaveAsLinks={!isInRightDrawer}
loading={loading}
tabs={tabs}
isInRightDrawer={isInRightDrawer}
componentInstanceId={tabListComponentId}
/>
</StyledTabListContainer>
{(isMobile || isInRightDrawer) && summaryCard}
<StyledContentContainer isInRightDrawer={isInRightDrawer}>
{renderActiveTabContent()}
</StyledContentContainer>
{isInRightDrawer && (
<RightDrawerFooter
actions={[
<RecordShowRightDrawerActionMenu />,
<RecordShowRightDrawerOpenRecordButton
objectNameSingular={targetableObject.targetObjectNameSingular}
recordId={targetableObject.id}
/>,
]}
/>
)}
</StyledShowPageRightContainer>
</TabListComponentInstanceContext.Provider>
</LayoutRenderingProvider>
);
};
@@ -1,5 +1,7 @@
import { type CardConfiguration } from '@/object-record/record-show/types/CardConfiguration';
import { type CardType } from '@/object-record/record-show/types/CardType';
export type LayoutCard = {
type: CardType;
configuration?: CardConfiguration;
};
@@ -1,12 +1,10 @@
import { type LayoutCard } from '@/ui/layout/tab-list/types/LayoutCard';
import { type TabVisibilityConfig } from '@/ui/layout/tab-list/types/TabVisibilityConfig';
import { type IconComponent } from 'twenty-ui/display';
export type RecordLayoutTab = {
title: string;
position: number;
Icon: IconComponent;
icon: string;
hide: TabVisibilityConfig;
cards: LayoutCard[];
targetObjectNameSingular?: string;
};
@@ -9,4 +9,5 @@ export type TabVisibilityConfig = {
ifRequiredObjectsInactive: CoreObjectNameSingular[];
ifRelationsMissing: string[];
ifNoReadPermission?: boolean;
ifNoReadPermissionObject?: CoreObjectNameSingular;
};
@@ -0,0 +1,24 @@
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowDiagramCanvasEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditable';
import { WorkflowDiagramEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramEffect';
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
export const WorkflowCard = () => {
const targetRecord = useTargetRecord();
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetRecord.id,
}),
}}
>
<WorkflowVisualizerEffect workflowId={targetRecord.id} />
<WorkflowDiagramEffect />
<WorkflowDiagramCanvasEditable />
</WorkflowVisualizerComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,70 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenRecordUpdatesEffect';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowRunVisualizer } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizer';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { WorkflowRunVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowRunVisualizerComponentInstanceContext';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Suspense, useId } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledLoadingSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<StyledLoadingSkeletonContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
</SkeletonTheme>
</StyledLoadingSkeletonContainer>
);
};
export const WorkflowRunCard = () => {
const targetRecord = useTargetRecord();
const componentId = useId();
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetRecord.id,
}),
}}
>
<WorkflowRunVisualizerComponentInstanceContext.Provider
value={{
instanceId: componentId,
}}
>
<WorkflowRunVisualizerEffect workflowRunId={targetRecord.id} />
<ListenRecordUpdatesEffect
objectNameSingular={targetRecord.targetObjectNameSingular}
recordId={targetRecord.id}
listenedFields={['status', 'state']}
/>
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowRunVisualizer workflowRunId={targetRecord.id} />
</Suspense>
</WorkflowRunVisualizerComponentInstanceContext.Provider>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,56 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledLoadingSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<StyledLoadingSkeletonContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
</SkeletonTheme>
</StyledLoadingSkeletonContainer>
);
};
export const WorkflowVersionCard = () => {
const targetRecord = useTargetRecord();
return (
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: getWorkflowVisualizerComponentInstanceId({
recordId: targetRecord.id,
}),
}}
>
<WorkflowVersionVisualizerEffect workflowVersionId={targetRecord.id} />
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowVersionVisualizer workflowVersionId={targetRecord.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
};
@@ -1,3 +1,4 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type WorkflowFormFieldType } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
@@ -33,7 +34,7 @@ export const getDefaultFormFieldSettings = (type: WorkflowFormFieldType) => {
label: 'Record',
placeholder: `Select a Company`,
settings: {
objectName: 'company',
objectName: CoreObjectNameSingular.Company,
},
};
case FieldMetadataType.SELECT: