From 938debd83e90d76b468c6ad32f3938c058925de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 10 Oct 2025 16:26:17 +0200 Subject: [PATCH] Refactor record layouts for backend-driven configuration (#15021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../{Calendar.tsx => CalendarEventsCard.tsx} | 33 +- .../__stories__/Calendar.stories.tsx | 33 +- .../{EmailThreads.tsx => EmailsCard.tsx} | 17 +- .../{Attachments.tsx => FilesCard.tsx} | 17 +- .../activities/notes/components/NoteList.tsx | 4 +- .../components/{NoteCard.tsx => NoteTile.tsx} | 2 +- .../components/{Notes.tsx => NotesCard.tsx} | 17 +- .../{ObjectTasks.tsx => TasksCard.tsx} | 10 +- ...imelineActivities.tsx => TimelineCard.tsx} | 17 +- .../TimelineActivities.stories.tsx | 38 +- .../hooks/useCommandMenuSearchRecords.tsx | 3 +- ...ashboardRenderer.tsx => DashboardCard.tsx} | 9 +- ...ionDrawerSectionForObjectMetadataItems.tsx | 13 +- .../components/MergePreviewTab.tsx | 41 +- .../components/MergeRecordTab.tsx | 41 +- .../record-show/components/CardComponents.tsx | 220 ----------- .../record-show/components/FieldsCard.tsx | 25 +- .../hooks/useRecordShowContainerTabs.ts | 350 +++--------------- .../base-record-layout.ts} | 21 +- .../layouts/company-record-layout.ts | 35 ++ .../layouts/dashboard-record-layout.ts | 27 ++ .../record-show/layouts/note-record-layout.ts | 23 ++ .../layouts/opportunity-record-layout.ts | 35 ++ .../layouts/person-record-layout.ts | 35 ++ .../record-show/layouts/task-record-layout.ts | 23 ++ .../layouts/workflow-record-layout.ts | 27 ++ .../layouts/workflow-run-record-layout.ts | 25 ++ .../layouts/workflow-version-record-layout.ts | 25 ++ .../record-show/types/CardConfiguration.ts | 29 ++ .../utils/evaluateTabVisibility.ts | 109 ++++++ .../record-show/utils/getCardComponent.tsx | 94 +++++ .../components/PageLayoutRenderer.tsx | 32 +- .../hooks/useCompanyDefaultChartConfig.ts | 3 +- ...DataModelFieldRelationSettingsFormCard.tsx | 4 +- .../SettingsDataModelRelationFieldPreview.tsx | 3 +- .../contexts/LayoutRenderingContext.tsx | 20 + .../ui/layout/contexts/useTargetRecord.ts | 13 + ...ActivityContainer.tsx => RichTextCard.tsx} | 27 +- .../components/ShowPageSubContainer.tsx | 110 +++--- .../ui/layout/tab-list/types/LayoutCard.ts | 2 + .../layout/tab-list/types/RecordLayoutTab.ts | 4 +- .../tab-list/types/TabVisibilityConfig.ts | 1 + .../components/WorkflowCard.tsx | 24 ++ .../components/WorkflowRunCard.tsx | 70 ++++ .../components/WorkflowVersionCard.tsx | 56 +++ .../utils/getDefaultFormFieldSettings.ts | 3 +- 46 files changed, 993 insertions(+), 777 deletions(-) rename packages/twenty-front/src/modules/activities/calendar/components/{Calendar.tsx => CalendarEventsCard.tsx} (92%) rename packages/twenty-front/src/modules/activities/emails/components/{EmailThreads.tsx => EmailsCard.tsx} (91%) rename packages/twenty-front/src/modules/activities/files/components/{Attachments.tsx => FilesCard.tsx} (91%) rename packages/twenty-front/src/modules/activities/notes/components/{NoteCard.tsx => NoteTile.tsx} (99%) rename packages/twenty-front/src/modules/activities/notes/components/{Notes.tsx => NotesCard.tsx} (87%) rename packages/twenty-front/src/modules/activities/tasks/components/{ObjectTasks.tsx => TasksCard.tsx} (68%) rename packages/twenty-front/src/modules/activities/timeline-activities/components/{TimelineActivities.tsx => TimelineCard.tsx} (88%) rename packages/twenty-front/src/modules/dashboards/components/{DashboardRenderer.tsx => DashboardCard.tsx} (76%) delete mode 100644 packages/twenty-front/src/modules/object-record/record-show/components/CardComponents.tsx rename packages/twenty-front/src/modules/object-record/record-show/{constants/BaseRecordLayout.ts => layouts/base-record-layout.ts} (85%) create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/company-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/dashboard-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/note-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/opportunity-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/person-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/task-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-run-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-version-record-layout.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/types/CardConfiguration.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/utils/evaluateTabVisibility.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-show/utils/getCardComponent.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/contexts/LayoutRenderingContext.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/contexts/useTargetRecord.ts rename packages/twenty-front/src/modules/ui/layout/show-page/components/{ShowPageActivityContainer.tsx => RichTextCard.tsx} (78%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowCard.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunCard.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionCard.tsx diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx similarity index 92% rename from packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx rename to packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx index f0f7cc46a0..e2108387e1 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx @@ -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 ; } @@ -116,7 +113,7 @@ export const Calendar = ({ No events have been scheduled with this{' '} - {targetableObject.targetObjectNameSingular} yet. + {targetRecord.targetObjectNameSingular} yet. diff --git a/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx index d5ec752c23..ea0502a876 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx @@ -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 = { - title: 'Modules/Activities/Calendar/Calendar', - component: Calendar, +const meta: Meta = { + title: 'Modules/Activities/Calendar/CalendarEventsCard', + component: CalendarEventsCard, decorators: [ I18nFrontDecorator, ComponentDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, + (Story) => ( + + + + ), ], parameters: { container: { width: 728 }, @@ -53,15 +70,9 @@ const meta: Meta = { ], }, }, - args: { - targetableObject: { - id: '1', - targetObjectNameSingular: 'Company', - }, - }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx similarity index 91% rename from packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx rename to packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx index 792865f25e..047a3fe63d 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx @@ -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, ); diff --git a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx b/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx similarity index 91% rename from packages/twenty-front/src/modules/activities/files/components/Attachments.tsx rename to packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx index 12a86447f5..421d9842df 100644 --- a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx @@ -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(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 /> ( {notes.map((note) => ( - theme.spacing(4)}); `; -export const NoteCard = ({ +export const NoteTile = ({ note, isSingleNote, }: { diff --git a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx b/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx similarity index 87% rename from packages/twenty-front/src/modules/activities/notes/components/Notes.tsx rename to packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx index 4b86ee9994..6baf041e89 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NotesCard.tsx @@ -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], }) } /> diff --git a/packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TasksCard.tsx similarity index 68% rename from packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx rename to packages/twenty-front/src/modules/activities/tasks/components/TasksCard.tsx index b985e7ee69..54bd8f5f73 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/ObjectTasks.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TasksCard.tsx @@ -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 ( - + ); diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCard.tsx similarity index 88% rename from packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCard.tsx index 5b4e3fbb51..5f687e4476 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCard.tsx @@ -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 ( diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx index 9a06151ff4..cbd38d610c 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx @@ -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 = { - title: 'Modules/TimelineActivities/TimelineActivities', - component: TimelineActivities, +const meta: Meta = { + title: 'Modules/TimelineActivities/TimelineCard', + component: TimelineCard, decorators: [ ComponentDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, (Story) => { return ( - - - + + + + + ); }, ], - args: { - targetableObject: { - id: '1', - targetObjectNameSingular: 'company', - }, - }, parameters: { msw: { handlers: [ @@ -71,6 +79,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx index bbd3f312e6..459584d5f8 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx @@ -65,7 +65,8 @@ export const useCommandMenuSearchRecords = () => { Icon: () => ( { + const targetRecord = useTargetRecord(); -export const DashboardRenderer = ({ recordId }: DashboardRendererProps) => { const { record: dashboard } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.Dashboard, - objectRecordId: recordId, + objectRecordId: targetRecord.id, }); if (!isDefined(dashboard)) { diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx index 3f2dee9505..d387dc218e 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx @@ -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 = { diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx index 7c9771f8f4..635ff43e7a 100644 --- a/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx @@ -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 ( -
- - - -
+ targetObjectNameSingular: objectNameSingular, + }, + layoutType: PageLayoutType.RECORD_PAGE, + isInRightDrawer: true, + }} + > +
+ + + {getCardComponent(CardType.FieldCard, { + showDuplicatesSection: false, + })} +
+ ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx index a30f3b794c..761d450db0 100644 --- a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx @@ -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 ( -
- - - -
+ targetObjectNameSingular: objectNameSingular, + }, + layoutType: PageLayoutType.RECORD_PAGE, + isInRightDrawer: true, + }} + > +
+ + + {getCardComponent(CardType.FieldCard, { + showDuplicatesSection: false, + })} +
+ ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/CardComponents.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/CardComponents.tsx deleted file mode 100644 index bdcac6d7b4..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-show/components/CardComponents.tsx +++ /dev/null @@ -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 ( - - - - - - - - ); -}; - -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.TimelineCard]: ({ targetableObject, isInRightDrawer }) => ( - - ), - - [CardType.FieldCard]: ({ - targetableObject, - isInRightDrawer, - showDuplicatesSection, - }: FieldsCardComponentProps) => ( - - - - ), - - [CardType.RichTextCard]: ({ targetableObject }) => ( - - ), - - [CardType.TaskCard]: ({ targetableObject }) => ( - - ), - - [CardType.NoteCard]: ({ targetableObject }) => ( - - ), - - [CardType.FileCard]: ({ targetableObject }) => ( - - ), - - [CardType.EmailCard]: ({ targetableObject }) => ( - - ), - - [CardType.CalendarCard]: ({ targetableObject }) => ( - - ), - - [CardType.WorkflowCard]: ({ targetableObject }) => { - return ( - - - }> - - - - ); - }, - - [CardType.WorkflowVersionCard]: ({ targetableObject }) => { - return ( - - - }> - - - - ); - }, - - [CardType.WorkflowRunCard]: ({ targetableObject }) => { - const componentId = useId(); - - return ( - - - - - }> - - - - - ); - }, - - [CardType.DashboardCard]: ({ targetableObject }) => { - return ; - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx index f360bf2723..61e66b3498 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx @@ -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 ( ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts index c610a5920e..9848032b88 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts @@ -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.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 - > = 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 diff --git a/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/base-record-layout.ts similarity index 85% rename from packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts rename to packages/twenty-front/src/modules/object-record/record-show/layouts/base-record-layout.ts index fb95cb3017..a0beb6ad42 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/base-record-layout.ts @@ -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: { diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/company-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/company-record-layout.ts new file mode 100644 index 0000000000..ca11130b6e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/company-record-layout.ts @@ -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: [], + }, + }, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/dashboard-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/dashboard-record-layout.ts new file mode 100644 index 0000000000..de8d330549 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/dashboard-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/note-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/note-record-layout.ts new file mode 100644 index 0000000000..fde98f3a9d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/note-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/opportunity-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/opportunity-record-layout.ts new file mode 100644 index 0000000000..ab6157c172 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/opportunity-record-layout.ts @@ -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: [], + }, + }, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/person-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/person-record-layout.ts new file mode 100644 index 0000000000..fc18e172d0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/person-record-layout.ts @@ -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: [], + }, + }, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/task-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/task-record-layout.ts new file mode 100644 index 0000000000..3c0579b934 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/task-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-record-layout.ts new file mode 100644 index 0000000000..d730896e78 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-run-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-run-record-layout.ts new file mode 100644 index 0000000000..d4ae735dc9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-run-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-version-record-layout.ts b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-version-record-layout.ts new file mode 100644 index 0000000000..241d1ca732 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/layouts/workflow-version-record-layout.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/types/CardConfiguration.ts b/packages/twenty-front/src/modules/object-record/record-show/types/CardConfiguration.ts new file mode 100644 index 0000000000..a61fde86ee --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/types/CardConfiguration.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-show/utils/evaluateTabVisibility.ts b/packages/twenty-front/src/modules/object-record/record-show/utils/evaluateTabVisibility.ts new file mode 100644 index 0000000000..f4a6365f35 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/utils/evaluateTabVisibility.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/utils/getCardComponent.tsx b/packages/twenty-front/src/modules/object-record/record-show/utils/getCardComponent.tsx new file mode 100644 index 0000000000..ef629da36d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/utils/getCardComponent.tsx @@ -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 = ({ + 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 ; +}; + +// 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 = ( + cardType: T, + configuration?: CardTypeToConfiguration[T], +): JSX.Element | null => { + switch (cardType) { + case CardType.TimelineCard: + return ; + + case CardType.FieldCard: + return ( + + ); + + case CardType.RichTextCard: + return ; + + case CardType.TaskCard: + return ; + + case CardType.NoteCard: + return ; + + case CardType.FileCard: + return ; + + case CardType.EmailCard: + return ; + + case CardType.CalendarCard: + return ; + + case CardType.WorkflowCard: + return ; + + case CardType.WorkflowVersionCard: + return ; + + case CardType.WorkflowRunCard: + return ; + + case CardType.DashboardCard: + return ; + default: + assertUnreachable(cardType); + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx index aed9146826..ed56896b04 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx @@ -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 ( - - - - - - + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCompanyDefaultChartConfig.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCompanyDefaultChartConfig.ts index 7aa22e51fd..058a351aeb 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCompanyDefaultChartConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCompanyDefaultChartConfig.ts @@ -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', }), ); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard.tsx index 6ab65abecf..604f95e3d1 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard.tsx @@ -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} diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreview.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreview.tsx index ee4cfe1d25..90616b2169 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreview.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreview.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/ui/layout/contexts/LayoutRenderingContext.tsx b/packages/twenty-front/src/modules/ui/layout/contexts/LayoutRenderingContext.tsx new file mode 100644 index 0000000000..104c404e97 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/contexts/LayoutRenderingContext.tsx @@ -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('LayoutRenderingContext'); diff --git a/packages/twenty-front/src/modules/ui/layout/contexts/useTargetRecord.ts b/packages/twenty-front/src/modules/ui/layout/contexts/useTargetRecord.ts new file mode 100644 index 0000000000..7c6c8ace34 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/contexts/useTargetRecord.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/RichTextCard.tsx similarity index 78% rename from packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx rename to packages/twenty-front/src/modules/ui/layout/show-page/components/RichTextCard.tsx index eaa15528c1..ecb51787de 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/RichTextCard.tsx @@ -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 ; } return ( }> diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx index 4b10005a21..d5b913f533 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx @@ -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 = ( - - ); + const 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 ? ( - - ) : null; - }); + return ( + <> + {activeTab.cards.map((card, index) => ( + + {getCardComponent(card.type, card.configuration)} + + ))} + + ); }; const visibleTabs = tabs.filter((tab) => !tab.hide); @@ -121,41 +118,52 @@ export const ShowPageSubContainer = ({ layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer; return ( - - {displaySummaryAndFields && ( - - {summaryCard} - {fieldsCard} - - )} - - 1}> - - - {(isMobile || isInRightDrawer) && summaryCard} - - {renderActiveTabContent()} - - {isInRightDrawer && ( - , - , - ]} - /> + + {displaySummaryAndFields && ( + + {summaryCard} + {fieldsCard} + )} - - + + 1}> + + + {(isMobile || isInRightDrawer) && summaryCard} + + {renderActiveTabContent()} + + {isInRightDrawer && ( + , + , + ]} + /> + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/LayoutCard.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/LayoutCard.ts index 1778677b19..ffda093ce9 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/LayoutCard.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/LayoutCard.ts @@ -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; }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts index 45256f358c..9a061cdf90 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts @@ -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; }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts index 9d9d57e428..3b553174f0 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts @@ -9,4 +9,5 @@ export type TabVisibilityConfig = { ifRequiredObjectsInactive: CoreObjectNameSingular[]; ifRelationsMissing: string[]; ifNoReadPermission?: boolean; + ifNoReadPermissionObject?: CoreObjectNameSingular; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowCard.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowCard.tsx new file mode 100644 index 0000000000..d7c6646b52 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowCard.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunCard.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunCard.tsx new file mode 100644 index 0000000000..cd2653a833 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunCard.tsx @@ -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 ( + + + + + + + + ); +}; + +export const WorkflowRunCard = () => { + const targetRecord = useTargetRecord(); + const componentId = useId(); + + return ( + + + + + }> + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionCard.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionCard.tsx new file mode 100644 index 0000000000..523321be3e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionCard.tsx @@ -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 ( + + + + + + + + ); +}; + +export const WorkflowVersionCard = () => { + const targetRecord = useTargetRecord(); + + return ( + + + }> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts index 4ac5f9dedf..ad1c5d54d4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings.ts @@ -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: