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:
+15
-18
@@ -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>
|
||||
+22
-11
@@ -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 = {};
|
||||
|
||||
+7
-10
@@ -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,
|
||||
);
|
||||
|
||||
+7
-10
@@ -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}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const StyledFooter = styled.div`
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(4)});
|
||||
`;
|
||||
|
||||
export const NoteCard = ({
|
||||
export const NoteTile = ({
|
||||
note,
|
||||
isSingleNote,
|
||||
}: {
|
||||
+7
-10
@@ -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],
|
||||
})
|
||||
}
|
||||
/>
|
||||
+4
-6
@@ -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>
|
||||
);
|
||||
+7
-10
@@ -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 ?? []}
|
||||
/>
|
||||
+23
-15
@@ -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 = {};
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ export const useCommandMenuSearchRecords = () => {
|
||||
Icon: () => (
|
||||
<Avatar
|
||||
type={
|
||||
searchRecord.objectNameSingular === 'company'
|
||||
searchRecord.objectNameSingular ===
|
||||
CoreObjectNameSingular.Company
|
||||
? 'squared'
|
||||
: 'rounded'
|
||||
}
|
||||
|
||||
+4
-5
@@ -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)) {
|
||||
+7
-6
@@ -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 = {
|
||||
|
||||
+25
-16
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+25
-16
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-220
@@ -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} />;
|
||||
},
|
||||
};
|
||||
+11
-14
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+44
-306
@@ -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
|
||||
|
||||
+7
-14
@@ -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: {
|
||||
+35
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+27
@@ -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,
|
||||
},
|
||||
};
|
||||
+23
@@ -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,
|
||||
},
|
||||
};
|
||||
+35
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+35
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+23
@@ -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,
|
||||
},
|
||||
};
|
||||
+27
@@ -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,
|
||||
},
|
||||
};
|
||||
+25
@@ -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,
|
||||
},
|
||||
};
|
||||
+25
@@ -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,
|
||||
},
|
||||
};
|
||||
+29
@@ -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;
|
||||
+109
@@ -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;
|
||||
};
|
||||
+94
@@ -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',
|
||||
}),
|
||||
);
|
||||
|
||||
+3
-1
@@ -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
-1
@@ -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;
|
||||
};
|
||||
+10
-17
@@ -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>
|
||||
+59
-51
@@ -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;
|
||||
};
|
||||
|
||||
+24
@@ -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>
|
||||
);
|
||||
};
|
||||
+70
@@ -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>
|
||||
);
|
||||
};
|
||||
+56
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user