From 6cc7ed757066c4a7d6524e8c2082a9970ebb1843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 24 Jul 2026 17:02:02 +0200 Subject: [PATCH] Make solo tabs first-class: derived presentation, native editing, unified widget header (#23109) ## Why Full-page record tabs (Timeline, Tasks, Notes, Files, Emails, Calendar, Flow) were encoded by storing a `CANVAS` layout mode. That made them a separate species: editing one didn't feel native (no drag handles, no way to add a second widget, the tab couldn't adapt), and the widget pipeline was full of `layoutMode === CANVAS` branches. This PR replaces the stored mode with two derived rules and one unified header grammar: > **Presentation is derived from content, never stored.** > A list tab with exactly **one widget** renders it **solo** (full-bleed, it owns the tab). Anything else is a **stack** of boxed cards. **Edit mode always shows the stack structure.** No widget taxonomy, no per-type branches: any lone widget owns its tab. ## What **Presentation model** - `getTabPresentation({ widgets, layoutMode, isInEditMode })`: solo iff a list tab has exactly one widget in view mode; grid tabs (dashboards) and edit mode are always stacks. The pinned left panel is always a column (a surface rule, not a widget rule). - Solo view rendering is identical to the old CANVAS rendering (container height, internal scroll). - Stacked widgets in the main tab area get one bounded slot rule (`max-height` + own scroll) so no widget swallows the tab; pinned/side-column stacks keep their flowing behavior. This only binds on user-composed mixed tabs, which could not exist before. **Native editing (the point of the PR)** - Every record-page tab is edited through the same vertical-list editor: drag handle, reorder, remove, add widget. Add a second widget to a Timeline tab and it becomes a stack; remove back down to one and it's solo again. Nothing is stored, nothing to migrate. - Fixes the stuck-drag bug found while testing the preview: widgets publishing header info republished a fresh object on every render (activity cards build their action from non-memoized hook returns), and since the widget chrome reads that state above the widget content, any tab with an activity card sat in an infinite render loop. The loop starved React's transition lane, which dnd-kit's drop teardown waits on, so the drag clone and drop outlines froze on screen after a drop. The header hook now republishes only on real value changes and routes onClick through a stable wrapper, so callers need no memoization. The page-layout drag provider also disables the Feedback drop animation so clone cleanup is synchronous at drop time. **Unified widget header API** - A widget's content can publish header info to its chrome via `usePublishWidgetHeaderInfo({ count, primaryAction })`: a count rendered in grey next to the title, and a primary action (icon button with accessible name) on the right in view mode. Instance-scoped state keyed by widget id, so third-party widgets (front components) can use the same seam later; the hook no-ops outside a page layout (stories, previews) and is safe to call with inline, non-memoized values. - A solo widget's header only appears when the widget published something: the tab label already names it, so a bare title row adds nothing. Timeline/Flow tabs stay exactly as today. - Emails, Tasks, Notes, Files, Calendar publish their count (query totals, not loaded-page lengths) and action (Compose, New task, New note, Add file) and stop rendering internal title rows ("Inbox 12", "All 5"): exactly one header per widget everywhere, same grammar. `ComposeEmailButton`, `AddTaskButton` and the title/button plumbing in `NoteList`/`AttachmentList`/`TaskList` are deleted. **Object-aware tabs** - The hardcoded `SYSTEM_OBJECT_TABS` title allowlist is gone. A tab renders based on whether the target object supports its widgets: widgets that read through a relation (Tasks, Notes, Files, Timeline) require the relation field to exist and be active, while Emails and Calendar aggregate through the messaging timeline, so a missing participants relation is fine (Company) and a deactivated one is an explicit opt-out. System objects on the shared default layout keep exactly Home + Timeline, now by derivation instead of hardcoded titles. **Data cleanup** - Seeds (frontend defaults, server standard template, `twenty app` scaffolder, docs) write `VERTICAL_LIST`; `PageLayoutTabLayoutMode.CANVAS` is `@deprecated`, kept read-only for layouts persisted before this change (they render correctly through the derivation; no data migration, by design: an in-place flip can't pass the widget-position/tab-layoutMode validator atomically, and it isn't needed). - Locale catalogs are intentionally untouched: the i18n pipeline extracts and translates the new header labels on main; they fall back to their English source until then. ## Deliberate view-mode changes (approved) - A lone widget of any type now owns its tab full-bleed: lone Fields tab (mobile/side panel), lone rich-text Note tab, lone chart, and the message-thread page lose their card box. - Activity tabs show the unified header (title, grey count, + action) instead of their internal "Inbox 12"-style rows. Everything else is pixel-parity, including solo scroll behavior and dashboards. ## Test plan - `nx typecheck twenty-front` / `twenty-server`: clean; oxlint/oxfmt on the changeset: clean - 239 suites / 1474 tests across page-layout, activities, side-panel pass, including new tests for `getTabPresentation` (count-based, edit-mode override) and `usePublishWidgetHeaderInfo` (publish, cleanup on unmount, no-op outside a widget, referential stability across re-renders with inline actions, latest-onClick wrapper) - `getTabsRenderableForTargetObject` tests covering missing vs deactivated relations, Emails/Calendar without a participants relation, and non-relation widgets - Stuck-drag repro verified fixed end to end against a local stack with an instrumented dnd-kit: before the fix the affected tab committed ~65 renders/second at idle and drops never tore down; after it, idle commits are flat and every drop cleans up --- .../extend/apps/layout/page-layouts.mdx | 6 +- .../document-generator/building-the-ui.mdx | 6 +- .../components/CalendarEventsCard.tsx | 3 + .../emails/components/ComposeEmailButton.tsx | 17 -- .../emails/components/EmailsCard.tsx | 55 +++--- .../files/components/AttachmentList.tsx | 33 +--- .../activities/files/components/FilesCard.tsx | 35 ++-- .../activities/files/hooks/useAttachments.tsx | 7 +- .../activities/notes/components/NoteList.tsx | 41 +---- .../activities/notes/components/NotesCard.tsx | 41 ++--- .../tasks/components/AddTaskButton.tsx | 46 ----- .../tasks/components/TaskGroups.tsx | 38 ++-- .../activities/tasks/components/TaskList.tsx | 5 +- .../page-layout/PageLayoutMainContent.tsx | 10 ++ .../components/PageLayoutContent.tsx | 37 ++-- .../components/PageLayoutLeftPanel.tsx | 3 + .../PageLayoutSingleTabRenderer.tsx | 10 ++ ...vasViewer.tsx => PageLayoutSoloViewer.tsx} | 12 +- .../components/PageLayoutTabsRenderer.tsx | 102 +---------- .../DefaultCompanyRecordPageLayout.ts | 42 +++-- .../constants/DefaultNoteRecordPageLayout.ts | 14 +- .../DefaultOpportunityRecordPageLayout.ts | 42 +++-- .../DefaultPersonRecordPageLayout.ts | 42 +++-- .../constants/DefaultRecordPageLayout.ts | 28 +-- .../constants/DefaultTaskRecordPageLayout.ts | 14 +- .../constants/DefaultWorkflowPageLayout.ts | 7 +- .../constants/DefaultWorkflowRunPageLayout.ts | 7 +- .../DefaultWorkflowVersionPageLayout.ts | 7 +- .../WidgetTypesRequiringRelationField.ts | 12 ++ .../contexts/PageLayoutContentContext.ts | 2 + .../hooks/usePageLayoutRenderableTabs.ts | 54 ++++++ .../page-layout/types/TabPresentation.ts | 3 + .../__tests__/getTabPresentation.test.ts | 75 ++++++++ .../getTabsRenderableForTargetObject.test.ts | 167 ++++++++++++++++++ .../page-layout/utils/getTabPresentation.ts | 26 +++ .../utils/getTabsRenderableForTargetObject.ts | 53 ++++++ .../NonRecordPageWidgetRenderer.tsx | 6 +- .../components/RecordPageWidgetRenderer.tsx | 6 +- .../widgets/components/WidgetCardShell.tsx | 6 + .../DashboardWidgetPlaceholder.stories.tsx | 1 + .../__stories__/WidgetRenderer.stories.tsx | 17 +- .../constants/StackedWidgetMaxHeight.ts | 3 + .../__stories__/FieldWidget.stories.tsx | 1 + .../__stories__/FieldsWidget.stories.tsx | 3 + .../FrontComponentWidgetRenderer.tsx | 9 +- .../usePublishWidgetHeaderInfo.test.tsx | 125 +++++++++++++ .../widgets/hooks/useIsInPinnedTab.ts | 27 +-- .../hooks/usePublishWidgetHeaderInfo.ts | 97 ++++++++++ .../widgets/hooks/useWidgetRendererState.ts | 20 ++- .../widgetHeaderInfoComponentFamilyState.ts | 10 ++ .../widgets/types/WidgetCardVariant.ts | 2 +- .../widgets/types/WidgetHeaderInfo.ts | 17 ++ .../__tests__/getWidgetCardVariant.test.ts | 38 ++-- .../widgets/utils/getWidgetCardVariant.ts | 14 +- .../widget-card/components/WidgetCard.tsx | 4 +- .../components/WidgetCardContent.tsx | 12 +- .../components/WidgetCardHeader.tsx | 46 ++++- .../hooks/usePageLayoutHeaderInfo.ts | 9 +- .../hooks/useOpenWidgetSettingsInSidePanel.ts | 12 +- .../SidePanelPageLayoutTabSettingsContent.tsx | 17 +- ...Content.tsx => SoloTabSettingsContent.tsx} | 29 ++- ...oloTabWidgetVisibilityDropdownContent.tsx} | 6 +- .../get-page-layout-tab-base-file.spec.ts | 4 +- .../entity/entity-page-layout-tab-template.ts | 2 +- .../entity-record-page-layout-template.ts | 2 +- .../standard-page-layout-tabs.template.ts | 41 ++--- .../page-layout/PageLayoutTabLayoutMode.ts | 5 + 67 files changed, 1128 insertions(+), 565 deletions(-) delete mode 100644 packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx delete mode 100644 packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx rename packages/twenty-front/src/modules/page-layout/components/{PageLayoutCanvasViewer.tsx => PageLayoutSoloViewer.tsx} (69%) create mode 100644 packages/twenty-front/src/modules/page-layout/constants/WidgetTypesRequiringRelationField.ts create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutRenderableTabs.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/TabPresentation.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabPresentation.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsRenderableForTargetObject.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/getTabPresentation.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/getTabsRenderableForTargetObject.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/constants/StackedWidgetMaxHeight.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/hooks/__tests__/usePublishWidgetHeaderInfo.test.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/hooks/usePublishWidgetHeaderInfo.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/states/widgetHeaderInfoComponentFamilyState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/types/WidgetHeaderInfo.ts rename packages/twenty-front/src/modules/side-panel/pages/page-layout/components/{CanvasTabSettingsContent.tsx => SoloTabSettingsContent.tsx} (89%) rename packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/{CanvasTabWidgetVisibilityDropdownContent.tsx => SoloTabWidgetVisibilityDropdownContent.tsx} (94%) diff --git a/packages/twenty-docs/developers/extend/apps/layout/page-layouts.mdx b/packages/twenty-docs/developers/extend/apps/layout/page-layouts.mdx index 8e11f88afc..aa04200c98 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/page-layouts.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/page-layouts.mdx @@ -31,7 +31,7 @@ export default definePageLayout({ title: 'Hello World', position: 50, icon: 'IconWorld', - layoutMode: PageLayoutTabLayoutMode.CANVAS, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, widgets: [ { universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d', @@ -53,7 +53,7 @@ export default definePageLayout({ - `type` is typically `'RECORD_PAGE'` to customize the detail view of a specific object. - `objectUniversalIdentifier` specifies which object this layout applies to. -- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`CANVAS` for free-form layout). +- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`VERTICAL_LIST` for record pages, `GRID` for dashboards). A tab holding a single widget renders it full-bleed automatically; with several widgets they stack as cards. - Each `widget` inside a tab can render a [front component](/developers/extend/apps/layout/front-components), a relation list, or other built-in widget types. - `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones. @@ -77,7 +77,7 @@ export default definePageLayoutTab({ title: 'Hello World', position: 1000, icon: 'IconWorld', - layoutMode: PageLayoutTabLayoutMode.CANVAS, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, widgets: [ { universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002', diff --git a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx index b2309049ef..340e0690ab 100644 --- a/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx +++ b/packages/twenty-docs/developers/extend/apps/tutorials/document-generator/building-the-ui.mdx @@ -220,8 +220,8 @@ export default defineFrontComponent({ ``` Mount it with a [page layout](/developers/extend/apps/layout/page-layouts). A -`RECORD_PAGE` layout adds tabs to an object's record view; a `FRONT_COMPONENT` -widget in a `CANVAS` tab hosts the component: +`RECORD_PAGE` layout adds tabs to an object's record view; a tab with a single +`FRONT_COMPONENT` widget hosts the component full-bleed: ```ts filename="src/page-layouts/document-record.page-layout.ts" import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; @@ -236,7 +236,7 @@ export default definePageLayout({ title: 'Preview', icon: 'IconEye', position: 50, - layoutMode: PageLayoutTabLayoutMode.CANVAS, + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, widgets: [{ universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, title: 'Document preview', diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx index dba5317f2b..dda1ccabab 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventsCard.tsx @@ -12,6 +12,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { useSubscribeTimelineToParticipantChanges } from '@/activities/hooks/useSubscribeTimelineToParticipantChanges'; +import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { H3Title } from 'twenty-ui/typography'; import { @@ -69,6 +70,8 @@ export const CalendarEventsCard = () => { const { timelineCalendarEvents, totalNumberOfCalendarEvents } = data?.getTimelineCalendarEventsFromObjectRecord ?? {}; + usePublishWidgetHeaderInfo({ count: totalNumberOfCalendarEvents }); + const { calendarEventsByDayTime, daysByMonthTime, diff --git a/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx b/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx deleted file mode 100644 index 4369f9a3a1..0000000000 --- a/packages/twenty-front/src/modules/activities/emails/components/ComposeEmailButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord'; -import { IconPlus } from 'twenty-ui/icon'; -import { LightIconButton } from 'twenty-ui/input'; - -export const ComposeEmailButton = () => { - const { openComposer, loading } = useComposeEmailForTargetRecord(); - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx index f310a2c6f3..2712dd38a7 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailsCard.tsx @@ -4,16 +4,18 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { ActivityList } from '@/activities/components/ActivityList'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; -import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailButton'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { EmptyInboxPlaceholder } from '@/activities/emails/components/EmptyInboxPlaceholder'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging'; import { getTimelineThreadsFromObjectRecord } from '@/activities/emails/graphql/queries/getTimelineThreadsFromObjectRecord'; +import { useComposeEmailForTargetRecord } from '@/activities/emails/hooks/useComposeEmailForTargetRecord'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { useSubscribeTimelineToParticipantChanges } from '@/activities/hooks/useSubscribeTimelineToParticipantChanges'; +import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; -import { Trans } from '@lingui/react/macro'; -import { H1Title, H1TitleFontColor } from 'twenty-ui/typography'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { IconPlus } from 'twenty-ui/icon'; import { Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { @@ -31,25 +33,10 @@ const StyledContainer = styled.div` ${themeCssVariables.spacing[2]}; `; -const StyledHeaderRow = styled.div` - align-items: center; - display: flex; - justify-content: space-between; - margin-bottom: ${themeCssVariables.spacing[4]}; -`; - -const StyledH1Title = styled(H1Title)` - display: flex; - gap: ${themeCssVariables.spacing[2]}; - margin-bottom: 0; -`; - -const StyledEmailCount = styled.span` - color: ${themeCssVariables.font.color.light}; -`; - export const EmailsCard = () => { const targetRecord = useTargetRecord(); + const { openComposer, loading: composerLoading } = + useComposeEmailForTargetRecord(); const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords, refetch } = useCustomResolver( @@ -70,6 +57,22 @@ export const EmailsCard = () => { const { totalNumberOfThreads, timelineThreads } = data?.getTimelineThreadsFromObjectRecord ?? {}; + + const composeAction = useMemo( + () => ({ + Icon: IconPlus, + label: t`Compose`, + onClick: openComposer, + disabled: composerLoading, + }), + [openComposer, composerLoading], + ); + + usePublishWidgetHeaderInfo({ + count: totalNumberOfThreads, + primaryAction: composeAction, + }); + const hasMoreTimelineThreads = timelineThreads && totalNumberOfThreads ? timelineThreads?.length < totalNumberOfThreads @@ -96,18 +99,6 @@ export const EmailsCard = () => { return (
- - - Inbox{' '} - {totalNumberOfThreads} - - } - fontColor={H1TitleFontColor.Primary} - /> - - {!firstQueryLoading && ( {timelineThreads?.map((thread: TimelineThread) => ( diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentList.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentList.tsx index 8919828c8d..0af9f98267 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentList.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentList.tsx @@ -1,6 +1,6 @@ import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; -import { lazy, type ReactElement, Suspense, useState } from 'react'; +import { lazy, Suspense, useState } from 'react'; import { createPortal } from 'react-dom'; import { DropZone } from '@/activities/files/components/DropZone'; @@ -37,9 +37,7 @@ const DocumentViewer = lazy(() => type AttachmentListProps = { targetableObject: ActivityTargetableObject; - title: string; attachments: Attachment[]; - button?: ReactElement | false | null; }; const StyledContainer = styled.div` @@ -49,30 +47,11 @@ const StyledContainer = styled.div` flex-direction: column; height: 100%; justify-content: center; - padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[6]} + padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[6]}; width: calc(100% - ${themeCssVariables.spacing[12]}); `; -const StyledTitleBar = styled.h3` - display: flex; - justify-content: space-between; - margin-bottom: ${themeCssVariables.spacing[4]}; - margin-top: ${themeCssVariables.spacing[4]}; - place-items: center; - width: 100%; -`; - -const StyledTitle = styled.span` - color: ${themeCssVariables.font.color.primary}; - font-weight: ${themeCssVariables.font.weight.semiBold}; -`; - -const StyledCount = styled.span` - color: ${themeCssVariables.font.color.light}; - margin-left: ${themeCssVariables.spacing[2]}; -`; - const StyledDropZoneContainer = styled.div` height: 100%; overflow: auto; @@ -116,9 +95,7 @@ export const PREVIEW_MODAL_ID = 'preview-modal'; export const AttachmentList = ({ targetableObject, - title, attachments, - button, }: AttachmentListProps) => { const { uploadAttachmentFile } = useUploadAttachmentFile(); const [isDraggingFile, setIsDraggingFile] = useState(false); @@ -172,12 +149,6 @@ export const AttachmentList = ({ <> {attachmentsWithFile.length > 0 && ( - - - {title} {attachmentsWithFile.length} - - {button} - hasUploadPermission && setIsDraggingFile(true)} > diff --git a/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx b/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx index 4ec552daf8..acbf2bef11 100644 --- a/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/FilesCard.tsx @@ -1,5 +1,5 @@ import { styled } from '@linaria/react'; -import { type ChangeEvent, useRef, useState } from 'react'; +import { type ChangeEvent, useMemo, useRef, useState } from 'react'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { AttachmentList } from '@/activities/files/components/AttachmentList'; @@ -8,6 +8,7 @@ import { useAttachments } from '@/activities/files/hooks/useAttachments'; import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { usePublishWidgetHeaderInfo } from '@/page-layout/widgets/hooks/usePublishWidgetHeaderInfo'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { Trans, useLingui } from '@lingui/react/macro'; @@ -42,7 +43,8 @@ const StyledDropZoneContainer = styled.div` export const FilesCard = () => { const targetRecord = useTargetRecord(); const inputFileRef = useRef(null); - const { attachments, loading } = useAttachments(targetRecord); + const { attachments, loading, totalCountAttachments } = + useAttachments(targetRecord); const { uploadAttachmentFile } = useUploadAttachmentFile(); const [isDraggingFile, setIsDraggingFile] = useState(false); @@ -87,6 +89,23 @@ export const FilesCard = () => { const canUploadFiles = hasObjectUpdatePermissions && hasUploadPermission; + const addFileAction = useMemo( + () => + canUploadFiles + ? { + Icon: IconPlus, + label: t`Add file`, + onClick: () => inputFileRef?.current?.click?.(), + } + : undefined, + [canUploadFiles, t], + ); + + usePublishWidgetHeaderInfo({ + count: totalCountAttachments, + primaryAction: addFileAction, + }); + if (loading && isAttachmentsEmpty) { return ; } @@ -142,19 +161,7 @@ export const FilesCard = () => { /> - ) - } /> ); diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx index caa3889dad..e89d7f039a 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx @@ -8,7 +8,11 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => { nameSingular: targetableObject.targetObjectNameSingular, }); - const { records: attachments, loading } = useFindManyRecords({ + const { + records: attachments, + loading, + totalCount, + } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Attachment, filter: { [targetableObjectFieldIdName]: { @@ -25,5 +29,6 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => { return { attachments, loading, + totalCountAttachments: totalCount, }; }; diff --git a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx index 7e9bdccdd4..05f06e6d2f 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NoteList.tsx @@ -1,17 +1,12 @@ import { styled } from '@linaria/react'; -import { type ReactElement } from 'react'; import { type Note } from '@/activities/types/Note'; -import { useNumberFormat } from '@/localization/hooks/useNumberFormat'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { NoteTile } from './NoteTile'; type NoteListProps = { - title: string; notes: Note[]; - button?: ReactElement | false | null; - totalCount: number; }; const StyledContainer = styled.div` @@ -20,26 +15,7 @@ const StyledContainer = styled.div` display: flex; flex-direction: column; justify-content: center; - padding: 8px 24px; -`; - -const StyledTitleBar = styled.h3` - display: flex; - justify-content: space-between; - margin-bottom: ${themeCssVariables.spacing[4]}; - margin-top: ${themeCssVariables.spacing[4]}; - place-items: center; - width: 100%; -`; - -const StyledTitle = styled.span` - color: ${themeCssVariables.font.color.primary}; - font-weight: ${themeCssVariables.font.weight.semiBold}; -`; - -const StyledCount = styled.span` - color: ${themeCssVariables.font.color.light}; - margin-left: ${themeCssVariables.spacing[2]}; + padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[6]}; `; const StyledNoteContainer = styled.div` @@ -50,24 +26,11 @@ const StyledNoteContainer = styled.div` width: 100%; `; -export const NoteList = ({ - title, - notes, - totalCount, - button, -}: NoteListProps) => { - const { formatNumber } = useNumberFormat(); - +export const NoteList = ({ notes }: NoteListProps) => { return ( <> {notes.length > 0 && ( - - - {title} {formatNumber(totalCount)} - - {button} - {notes.map((note) => ( { const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; + const newNoteAction = useMemo( + () => + hasObjectUpdatePermissions + ? { + Icon: IconPlus, + label: t`New note`, + onClick: () => + openCreateActivity({ targetableObjects: [targetRecord] }), + } + : undefined, + [hasObjectUpdatePermissions, openCreateActivity, targetRecord], + ); + + usePublishWidgetHeaderInfo({ + count: totalCountNotes, + primaryAction: newNoteAction, + }); + if (loading && isNotesEmpty) { return ; } @@ -88,26 +108,7 @@ export const NotesCard = () => { return ( - - openCreateActivity({ - targetableObjects: [targetRecord], - }) - } - /> - ) - } - /> + { - const openCreateActivity = useOpenCreateActivityDrawer({ - activityObjectNameSingular: CoreObjectNameSingular.Task, - }); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: activityTargetableObject.targetObjectNameSingular, - }); - - const objectPermissions = useObjectPermissionsForObject( - objectMetadataItem.id, - ); - - const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; - - if (!hasObjectUpdatePermissions) { - return null; - } - - return ( -