Skip to main content

DocumentComposer

A structured document editor component with an interactive tree view for composing and organizing document sections. The component provides drag-and-drop reordering, inline editing, contextual menus, and hierarchical section management.

Features

Features from MUI X Tree View Pro

The component leverages the following features from the underlying @mui/x-tree-view-pro library: https://mui.com/x/react-tree-view/quickstart/

  • Hierarchical Tree View: Display and navigate nested document sections
  • Drag & Drop Reordering: Core drag-and-drop functionality
  • Inline Editing: Double-click to edit section titles (via isItemEditable and onItemLabelChange props)
  • Expansion Control: Expand/collapse sections (triggered by icon container click)

DocumentComposer-Specific Features

The following features and business rules are specific to the DocumentComposer component:

  • Drag & Drop: Sections can be reordered via drag & drop. Use getSectionOrderChangePermission to implement custom restrictions if needed.
  • Contextual Menus: Menu button (three dots) with document-specific actions (rename, delete, create subsection)
  • Subsection Creation: Create new subsections via context menu with inline editing (temporary section pattern)
  • Section Highlighting: Visual distinction for root sections (sections with parentId: null)
  • Hover Controls: Custom interactive controls (drag handle, menu button) that appear on section hover
  • Custom Icons: Support for custom icons per section via renderSectionIcon callback prop
  • Two-Column Layout: DocumentComposer-specific layout with tree view (left) and content area (right)
  • Capability Callbacks: Fine-grained control over which actions are available per section

Basic Usage

See DocumentSection for the type definition.

const sectionsData: DocumentSection[] = [
{
id: "section-1",
title: "Introduction",
visible: true,
parentId: null,
sections: [
{
id: "section-1-1",
title: "Overview",
visible: true,
parentId: "section-1",
sections: [],
},
],
},
];

<DocumentComposer sections={sectionsData} />;
  • Introduction with long long long long long longlong long longlong long longlong long longlong long longlong long long label
    • Overview
      • Background
      • Objectives
    • Scope
  • Main Content
    • Analysis
    • Implementation
  • Conclusion
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Note: In this doc, code blocks are reference implementations you can copy. Live demos let you try the component in the browser; they may use a simplified setup (e.g. no confirmation modal) and are not always the exact code above. When they differ, the demo is labeled.

onSectionOrderChange & getSectionOrderChangePermission & canMoveSectionToNewPosition

  • onSectionOrderChange: Callback when a section is moved via drag & drop
  • getSectionOrderChangePermission: Controls whether a section can be dragged (drag icon visibility)
  • canMoveSectionToNewPosition: Custom validation for drag & drop positions. Use this to implement drop zone restrictions.
const [sections, setSections] = useState(sectionsData);

const onSectionOrderChange = (params: ItemPositionParams) => {
console.log("Section moved:", params);
// Update your state with the new position
};

<DocumentComposer
sections={sections}
onSectionOrderChange={onSectionOrderChange}
// "locked-section" cannot be dragged but can receive drops
getSectionOrderChangePermission={(section) => section.id !== "locked-section"}
// "no-drop-section" can be dragged but cannot receive drops
canMoveSectionToNewPosition={(params) =>
params.newPosition.parentId !== "no-drop-section"
}
/>;

Try it:

  • "Cannot be dragged" - no drag icon, but you can drop other sections into it
  • DRAG ME TO SPECIFIC RE ORDER
  • Cannot be dragged
  • Cannot receive drops
  • Main Content
    • Analysis
    • Implementation
  • Conclusion
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

onSectionTitleChange & getSectionTitleChangePermission

Double-click on a section or use the context menu (menu button → "Rename") to edit the title.

const [sections, setSections] = useState(sectionsData);

const handleSectionTitleChange = (sectionId: string, newTitle: string) => {
console.log(`Section ${sectionId} renamed to "${newTitle}"`);
if (sectionId === "section-1") {
// specific rename
}
};

<DocumentComposer
sections={sections}
onSectionTitleChange={handleSectionTitleChange}
/>;
  • SPECIFIC RENAME (custom behavior)
    • Editable subsection
  • Editable section
  • Not renamable section
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

onDeleteRequest, onSectionDelete & getSectionDeletePermission

Click on the menu button (three dots) and select "Supprimer" from the context menu to remove it.

Note: When the user triggers delete and you provide onDeleteRequest, the component calls it with { sectionId, confirm, cancel }. Handle the event as you see fit: call confirm() or cancel() from your UI when the user chooses. The onSectionDelete callback is only called after you call confirm().

Use getSectionDeletePermission to control whether a section can be deleted. When it returns false, the "Supprimer" menu item is disabled.

Example using the provided DeleteConfirmationModal:

import { DeleteConfirmationModal } from '@myunisoft/design-system';

const [sections, setSections] = useState(sectionsData);
const [pendingDelete, setPendingDelete] = useState<{
sectionId: string;
confirm: () => void;
cancel: () => void;
} | null>(null);

const handleSectionDeleteConfirm = (sectionId: string) => {
console.log(`Section ${sectionId} deleted`);
setSections((prev) => removeSectionById(prev, sectionId));
};

<>
<DocumentComposer
sections={sections}
onSectionDelete={handleSectionDeleteConfirm}
getSectionDeletePermission={(section) => section.id !== 'section-3'}
onDeleteRequest={(params) => setPendingDelete(params)}
/>
{pendingDelete && (
<DeleteConfirmationModal
isOpen
onConfirm={() => {
pendingDelete.confirm();
setPendingDelete(null);
}}
onClose={() => {
pendingDelete.cancel();
setPendingDelete(null);
}}
labels={{
title: 'Delete section',
content: 'Are you sure you want to delete this section?',
cancel: 'Cancel',
confirm: 'Delete'
}}
/>
)}
</>

Live demo (no confirmation modal): The demo below lets you try the delete action and getSectionDeletePermission ("Can not delete me" is protected). It does not use onDeleteRequest or a confirmation modal — deletion is immediate. Use the code above to add a confirmation modal in your app.

  • CUSTOM DELETE (deletes all except last)
    • Deletable subsection
  • Deletable section
  • Can not delete me
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Section visibility

Click on the eye icon to toggle the visibility of a section.

  • onSectionVisibilityChange: Callback when the visibility of a section is toggled.
  • getSectionVisibilityChangePermission: Controls whether the visibility toggle is enabled for a given section. Defaults to () => true.
  • hiddenSectionTooltip: Tooltip text displayed when hovering a hidden section label (visible: false).
  • visibleIconTooltip: Tooltip text displayed when hovering the visibility icon of a visible section.
  • hiddenIconTooltip: Tooltip text displayed when hovering the visibility icon of a hidden section.
const handleSectionVisibilityChange = (sectionId: string, visible: boolean) => {
console.log(`Section ${sectionId} visibility changed to ${visible}`);

// Specific behavior for section-1: hide all sections
if (sectionId === 'section-1') {
setSections((prev) => hideAllSections(prev));
return;
}

// Default behavior: update only the target section
setSections((prev) => updateVisibility(prev, sectionId, visible));
};

<DocumentComposer
sections={sections}
onSectionVisibilityChange={handleSectionVisibilityChange}
hiddenSectionTooltip="This section will not appear in the annex"
visibleIconTooltip="Click to hide this section"
hiddenIconTooltip="Click to show this section"
/>;
  • MUI X Missing license key

Search & Filtering

The DocumentComposer includes a built-in search bar above the tree view. No props are needed — the search is always available.

Features:

  • Accent-insensitive: Searching "evenements" matches "Évènements" (diacritics are normalized via NFD decomposition)
  • Case-insensitive: Searching "BILAN" matches "Bilan et compte de résultat"
  • Hierarchical filtering: When a child section matches, its parent chain is preserved in the tree. Direct children of a matching section are also shown for context.
  • Visibility toggle: A "Show only visible sections" switch filters out hidden sections (visible: false)
  • Combined filters: Both the search term and the visibility toggle can be active simultaneously

Try it: Use the search bar in the visibility example above to filter sections by title. Toggle the switch to show only visible sections.

onSectionCreate & getSectionCreatePermission

Click on the menu button (three dots) and select "Créer une sous-section" to create a new subsection. You can also use the "Créer une section" button at the bottom of the tree view to create a root-level section.

  • Validation: Type the new section name and press Enter, or blur the input (e.g. click outside). Non-empty title creates the section; empty title or Escape cancels.
  • Selection: The new section is not auto-selected after creation; the current selection stays unchanged.

Use getSectionCreatePermission to control whether the "Créer une sous-section" menu item is enabled for a given section. Defaults to () => true.

const [sections, setSections] = useState(sectionsData);

const handleSectionCreate = (
parentId: string | null,
title: string
): string => {
console.log(`onSectionCreate called with parentId: ${parentId}, title: ${title}`);

const newId = `section-${Date.now()}`;
setSections((prev) => addSectionToParent(prev, parentId, newSection));
return newId;
};

<DocumentComposer
sections={sections}
onSectionCreate={handleSectionCreate}
// Disable section creation for section with id 'section-3'
getSectionCreatePermission={(section) => section.id !== 'section-3'}
/>;
  • CUSTOM CREATE SECTION
    • Existing subsection
  • Regular section
  • Not section creatable
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

onSectionSelect, selectedSectionId, sectionContent & isLoadingSectionContent

Called when a section is selected (clicked). The component uses a controlled selection pattern with selectedSectionId.

Important: If there are unsaved changes (dirty state), the component notifies you before allowing navigation. See onUnsavedChangesRequest for details.

const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);

// Fetch section content (e.g., with React Query)
const { data: sectionContent, isLoading: isLoadingSection } = useQuery({
queryKey: ['section', selectedSectionId],
queryFn: () => fetchSectionDetails(selectedSectionId!),
enabled: !!selectedSectionId
});

<DocumentComposer
sections={sections}
selectedSectionId={selectedSectionId}
onSectionSelect={setSelectedSectionId}
sectionContent={sectionContent}
isLoadingSectionContent={isLoadingSection}
/>;

The right panel displays the selected section's content in the section editor (text blocks, structured tables, etc.). Pass sectionContent and onSectionContentSave so the component can load and persist content.

onUnsavedChangesRequest — Unsaved changes

When the user has unsaved changes and tries to navigate to a different section, the component calls onUnsavedChangesRequest with { canSave, saveAndQuit, quitWithoutSaving, cancel }. Handle the event as you see fit: wire the three actions to your UI and call the corresponding param when the user chooses.

  • Save and quit: Call saveAndQuit() (returns a Promise)
  • Cancel: Call cancel()
  • Quit without saving: Call quitWithoutSaving()

You can track a local isSaving state while saveAndQuit() is pending (e.g. to disable a save button or show a spinner).

Minimal complete example using the provided UnsavedChangesModal (all state and handlers defined):

const [sections, setSections] = useState<DocumentSection[]>(sectionsData);
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
const [sectionContent, setSectionContent] = useState<SectionContent>([]);
const [pendingUnsaved, setPendingUnsaved] = useState<{
canSave: boolean;
saveAndQuit: () => Promise<void>;
quitWithoutSaving: () => void;
cancel: () => void;
} | null>(null);
const [isSaving, setIsSaving] = useState(false);

const handleSave = async (
content: SectionContent,
options?: { sectionId?: string | null; editedBlockIds?: Set<string> }
) => {
const sectionId = options?.sectionId ?? selectedSectionId;
await yourSaveApi(sectionId, content, options?.editedBlockIds); // e.g. API call
setSectionContent(content);
};

<>
<DocumentComposer
sections={sections}
selectedSectionId={selectedSectionId}
onSectionSelect={setSelectedSectionId}
onSectionContentSave={handleSave}
sectionContent={sectionContent}
onUnsavedChangesRequest={(params) => setPendingUnsaved(params)}
/>
{pendingUnsaved && (
<UnsavedChangesModal
isOpen
isSaving={isSaving}
showSaveButton={pendingUnsaved.canSave}
onSaveAndQuit={async () => {
setIsSaving(true);
try {
await pendingUnsaved.saveAndQuit();
setPendingUnsaved(null);
} finally {
setIsSaving(false);
}
}}
onQuitWithoutSaving={() => {
pendingUnsaved.quitWithoutSaving();
setPendingUnsaved(null);
}}
onClose={() => {
pendingUnsaved.cancel();
setPendingUnsaved(null);
}}
labels={{
title: 'Unsaved changes',
content: 'You have unsaved changes. What do you want to do?',
saveAndQuit: 'Save and quit',
cancel: 'Cancel',
quitWithoutSaving: 'Quit without saving'
}}
/>
)}
</>

Try it: In the live example below, select a section, make a change in the right panel (e.g. edit text or toggle a variant), then click another section. The unsaved-changes flow appears.

  • Introduction
    • Overview
  • Main content
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Note: Dirty state is derived from the section content in the right panel; when it differs from the saved sectionContent, the component may prompt before navigation via onUnsavedChangesRequest.

Note: This callback is only invoked when the user tries to switch section with unsaved changes. Creating a subsection does not trigger it; the new section is created immediately and the current selection stays unchanged.

ref and requestLeave (leave guard)

DocumentComposer forwards a ref and exposes a handle with requestLeave(): Promise<boolean>. Use it when the user tries to leave the editor (e.g. closing the tab, navigating to another route). Call requestLeave(); if there are unsaved changes, the component will call onUnsavedChangesRequest so you can show your UI (e.g. the same unsaved-changes flow). The promise resolves to true if leave is allowed (no unsaved changes, or user chose save/quit), or false if the user cancelled.

import { useRef } from 'react';
import type { DocumentComposerHandle } from '@myunisoft/design-system';

const composerRef = useRef<DocumentComposerHandle | null>(null);

// Before closing or navigating away (e.g. in your layout or router guard):
const handleLeave = async () => {
const canLeave = await composerRef.current?.requestLeave() ?? true;
if (canLeave) {
navigateAway(); // or close the editor
}
};

<DocumentComposer
ref={composerRef}
sections={sections}
onUnsavedChangesRequest={...}
// ... other props
/>

If you do not provide onUnsavedChangesRequest, requestLeave() always resolves to true.

Loading state

<DocumentComposer sections={sections} isLoadingSections={true} />
  • MUI X Missing license key

Controlling section capabilities

You can control which actions are available for each section using capability callbacks. All callbacks receive the section as parameter and return a boolean. They default to () => true (all actions allowed).

<DocumentComposer
sections={sections}
// Protect root sections from deletion
getSectionDeletePermission={(section) => section.parentId !== null}
// Disable section creation for sections without children
getSectionCreatePermission={(section) => section.sections?.length > 0}
// Prevent renaming hidden sections
getSectionTitleChangePermission={(section) => section.visible}
/>

Try it:

  • Click the menu button on "Introduction (root - cannot delete)" - notice "Supprimer" is disabled
  • Click the menu button on "Analysis (no children - cannot create)" - notice "Créer une sous-section" is disabled
  • Click the menu button on "Hidden section (cannot rename)" - notice "Renommer" is disabled
  • Introduction (root - cannot delete)
    • Overview (has children - can create)
      • Background (no children - cannot create)
    • Hidden section (cannot rename)
  • Main Content (root - cannot delete)
    • Analysis (no children - cannot create)
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

textEditorProps

Use textEditorProps to configure the built-in rich text editor with tag/mention support. Users can type @ to insert variables from a suggestion dropdown, and a toggle switch appears in the toolbar to switch between tag names and resolved values.

  • displayTags — when true, tags show their label (e.g. {raison sociale}); when false (default), tags show their resolved value (e.g. MyUnisoft SA).
<DocumentComposer
sections={sections}
selectedSectionId={selectedSectionId}
onSectionSelect={setSelectedSectionId}
sectionContent={sectionContent}
textEditorProps={{
placeholder: "Commencez à rédiger ou tapez @ pour insérer une variable",
displayTags: false, // set to true to show tag labels instead of values
tags: [
{ key: 'nom_societe', label: 'raison sociale', value: 'MyUnisoft SA' },
{ key: 'nom_dirigeant', label: 'nom dirigeant', value: 'Régis Samuel' },
{ key: 'date_fin', label: 'date clôture exercice', value: '31/12/2025' },
{ key: 'capital_social', label: 'capital social', value: '500.000' },
]
}}
/>

Try it: Select a section on the left, then type @ in the editor to see the tag suggestions. Toggle the displayTags switch above the composer to switch between tag labels and resolved values.

  • Présentation de la société
    • Informations générales
    • Dirigeants
  • Événements significatifs
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Table Content

When sectionContent contains table blocks (type: 'table'), DocumentComposer renders an editable table (StructuredTable) based on MUI X DataGridPro with the following features:

  • Undo/Redo: Built-in history management via toolbar buttons
  • Cell-level editability: Each cell can be independently marked as editable or read-only
  • Cell spanning: Optional rowSpan and colSpan on table cells for merged cells (body and footer each span within their own area; body↔footer merge is not supported). Header rows support multi-level grouped headers via spanning.
  • Pinned rows: Header rows (beyond the first) and footer rows are pinned and non-scrollable
  • Dynamic row height: Rows automatically expand to fit wrapped text content
  • Text wrapping: Long text wraps instead of being truncated with ellipsis

Usage

Pass table content via the sectionContent prop using the SectionContent type. Table blocks use the TableValue shape: headers, rows, and footers are arrays of rows, each row is an array of TableCell objects with value, editable, format, and optional rowSpan / colSpan for merged cells.

import type { SectionContent } from '@myunisoft/design-system';

const sectionContent: SectionContent = [
{
type: 'table',
value: {
id: 'my-table',
headers: [
[
{ value: 'Description', editable: false, format: 'text' },
{ value: 'Amount', editable: false, format: 'amount' }
]
],
rows: [
[
{ value: 'Revenue', editable: false, format: 'text' },
{ value: '100 000', editable: true, format: 'amount' }
]
],
footers: [
[
{ value: 'Total', editable: false, format: 'text' },
{ value: '100 000', editable: false, format: 'amount' }
]
]
}
}
];

<DocumentComposer
sections={sections}
selectedSectionId={selectedSectionId}
onSectionSelect={setSelectedSectionId}
sectionContent={sectionContent}
/>

Cell spanning in header rows

Header cells can use rowSpan and colSpan to build multi-level grouped headers. The last row of headers defines the data columns: each cell in that row (or a cell that reaches it via rowSpan) corresponds to one or more data columns. Use colSpan in upper rows to merge columns under a single label, and rowSpan to let a cell span several header rows (e.g. a "Category" label on the left spanning all header rows).

Rules:

  • Column count: The number of "slots" in the last header row (after resolving colSpan) is the number of data columns. Every data row and footer row must have that many cells.
  • rowSpan: A cell in an upper row with rowSpan: 2 occupies that column in the next row too; the next row then omits that column (fewer cells in the array). The matrix builder fills the gap.
  • colSpan: A cell with colSpan: 2 spans two consecutive columns; the next row can have two separate headers under it.

Example: two header rows — first row has "Category" (rowSpan 2), "Income" (colSpan 2), "Costs" (colSpan 2); second row has four leaf headers (Q1, Q2, Q1, Q2). Total: 5 columns (1 + 2 + 2).

headers: [
[
{ value: 'Category', editable: false, format: 'text', rowSpan: 2 },
{ value: 'Income', editable: false, format: 'text', colSpan: 2 },
{ value: 'Costs', editable: false, format: 'text', colSpan: 2 }
],
[
{ value: 'Q1', editable: false, format: 'text' },
{ value: 'Q2', editable: false, format: 'text' },
{ value: 'Q1', editable: false, format: 'text' },
{ value: 'Q2', editable: false, format: 'text' }
]
]

Demo: Select "Table with grouped headers" in the tree. The table shows:

  • Headers: 2-level grouped headers (Category with rowSpan, Income/Costs with colSpan).
  • Body: "Product A" spans 2 rows (rowSpan); "Product C" row has "—" spanning 2 columns (colSpan).
  • Footer: "Total" spans 2 columns (colSpan).

🔗 Open in full screen

  • Table with grouped headers
  • MUI X Missing license key
Table with grouped headers

Live Example

Click on "Tableau des capitaux propres" to see the table. Edit cells in the "Solde ouverture" column, then use undo/redo in the toolbar.

Other sections show an empty state (no content blocks).

🔗 Open in full screen

  • Tableau des capitaux propres
  • Compte de résultat
  • Bilan
    • Actif
    • Passif
  • MUI X Missing license key
Tableau des capitaux propres

Categorized Amounts Content

When sectionContent contains categorized amounts blocks (type: 'categorized-amounts'), DocumentComposer renders a form-like table where each row displays column values (e.g. a label and an amount) along with radio buttons for categorization.

This block type is useful when each row needs to be classified into a category (e.g. Exploitation / Financier / Exceptionnel).

Usage

Pass categorized amounts content via the sectionContent prop. The block uses CategorizedAmountsValue with three main properties: columns (column definitions), fields (radio/select field definitions), and values (row data).

import type { SectionContent } from '@myunisoft/design-system';

const sectionContent: SectionContent = [
{
id: 'block-1',
displayOrder: 0,
type: 'categorized-amounts',
value: {
id: 'cat-amounts-1',
title: 'Ventilation des charges',
columns: [
{ key: 'label', type: 'text', label: 'Libellé' },
{ key: 'amount', type: 'number', label: 'Montant' }
],
fields: [
{
key: 'resultCategory',
type: 'select',
label: 'Type de charge',
defaultValue: 'exploitation',
options: [
{ value: 'exploitation', label: 'Exploitation' },
{ value: 'financier', label: 'Financier' },
{ value: 'exceptionnel', label: 'Exceptionnel' }
]
}
],
values: [
{ id: 'e-001', label: 'Frais de déplacement', amount: 1250, resultCategory: 'exploitation' },
{ id: 'e-002', label: 'Matériel de bureau', amount: 850, resultCategory: 'exploitation' },
{ id: 'e-003', label: 'Prestations externes', amount: 3200, resultCategory: 'financier' }
]
}
}
];

<DocumentComposer
sections={sections}
selectedSectionId={selectedSectionId}
onSectionSelect={setSelectedSectionId}
sectionContent={sectionContent}
onSectionContentSave={handleSave}
/>

Data Shape

  • columns: Defines the table columns. Each column has a key (matching a property in values), a type ('text' or 'number'), and a label. Number values are formatted as currency (EUR).
  • fields: Defines the radio button groups rendered in the last column. Each field has a key, type: 'select', a label, an optional defaultValue, and an array of options ({ value, label }).
  • values: Row data. Each row has an id and properties matching the column/field keys.

Live Example

Click on "Charges exceptionnelles" to see the categorized amounts. Change the radio buttons to re-categorize each charge, then save.

  • Charges exceptionnelles
  • Produits divers
  • MUI X Missing license key
Charges exceptionnelles

Ventilation des charges

LibelléMontant

Frais de déplacement

1 250,00 €

Matériel de bureau

850,00 €

Prestations externes

3 200,00 €

Formation

1 500,00 €

Abonnements logiciels

450,00 €


Component Structure

The DocumentComposer component uses a two-column layout:

  • Left Column (430px): Interactive tree view for navigating and managing sections
  • Right Column (1fr): Section editor for the selected section (content from sectionContent, edited in place)

Customization

No Sections Placeholder

When there are no sections, a default placeholder is displayed with a message and a clickable link to create the first section. You can customize the displayed texts via the noSectionsPlaceholder prop:

<DocumentComposer
noSectionsPlaceholder={{
message: "No sections available.",
linkLabel: "Create your first section"
}}
/>

Try it: Click the link or the "Créer une section" button to create a section from the empty state.

No sections available.
Create your first section

    MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Both message and linkLabel are optional — if omitted, the default i18n texts are used.

Empty state

<DocumentComposer />

Commencez la création de votre annexe en
créant une section

    MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Interactions

Inline Editing

  • Double-click on any section title to enter edit mode (provided by MUI X Tree View Pro)
  • Or use the context menu "Renommer" (Rename) option (DocumentComposer-specific menu action)
  • Press Enter to confirm or Escape to cancel

Context Menu

DocumentComposer-specific feature: Click the menu button (three dots) on any section to access:

  • Renommer (Rename): Enter edit mode for the section (disabled if getSectionTitleChangePermission returns false)
  • Supprimer (Delete): Remove the section (disabled if getSectionDeletePermission returns false)
  • Créer une sous-section (Create subsection): Add a child section (disabled if getSectionCreatePermission returns false)

API Reference

Props

NameTypeRequiredDefaultDescription
refRef<DocumentComposerHandle | null>NoundefinedRef to access requestLeave() for leave guard (e.g. before closing or navigating away).
sectionsDocumentSection[]NoundefinedArray of document sections (tree structure)
noSectionsPlaceholder{ message?: string; linkLabel?: string }NoundefinedCustom texts for the empty state placeholder (message and create link label)
isLoadingSectionsbooleanNofalseShows a loading skeleton when true
getSectionTitleChangePermission(section: DocumentSection) => booleanNo() => trueReturns whether section title can be edited
getSectionDeletePermission(section: DocumentSection) => booleanNo() => trueReturns whether section can be deleted
getSectionOrderChangePermission(section: DocumentSection) => booleanNo() => trueReturns whether section can be reordered via drag & drop
getSectionCreatePermission(section: DocumentSection) => booleanNo() => trueReturns whether a section can be created under this section
getSectionVisibilityChangePermission(section: DocumentSection) => booleanNo() => trueReturns whether section visibility can be toggled
canMoveSectionToNewPosition(params: ItemPositionParams) => booleanNo() => trueCustom validation for drag & drop positions. Use this to implement drop zone restrictions.
hiddenSectionTooltipstringNoundefinedTooltip text displayed when hovering a hidden section label
visibleIconTooltipstringNoundefinedTooltip text displayed when hovering the visibility icon of a visible section
hiddenIconTooltipstringNoundefinedTooltip text displayed when hovering the visibility icon of a hidden section
renderSectionIcon(section: DocumentSection) => ReactNodeNoundefinedRenders a custom icon for a section in the tree. Return null for no icon.
onSectionOrderChange(params: ItemPositionParams) => voidNoundefinedCallback when section order is changed via drag & drop
onSectionTitleChange(sectionId: string, newTitle: string) => voidNoundefinedCallback when section title is changed via inline editing
onSectionDelete(sectionId: string) => voidNoundefinedCallback when a section is deleted (called after the user confirms via onDeleteRequest).
onSectionVisibilityChange(sectionId: string, visible: boolean) => voidNoundefinedCallback when section visibility is toggled
onSectionCreate(parentId: string | null, title: string) => string | Promise<string>NoundefinedCallback when a new section is created. Must return the new section's ID.
onSectionSelect(sectionId: string | null) => voidNoundefinedCallback when a section is selected (after dirty check or unsaved-changes handling if applicable).
onSectionContentSave(content: SectionContent, options?: { sectionId?: string | null; editedBlockIds?: Set<string> }) => Promise<void>NoundefinedSave callback. Second argument: when saving from the unsaved-changes flow, options.sectionId is set (section being left); when saving from the panel, options.editedBlockIds can be set for partial save. Used by panel Save button and when the user chooses save in the unsaved-changes flow.
selectedSectionIdstring | nullNoundefinedControlled selected section ID.
sectionContentSectionContentNoundefinedContent for the currently selected section (array of ContentBlock), displayed in the right panel.
isLoadingSectionContentbooleanNofalseShows a loading skeleton in the right panel when true.
textEditorPropsTextEditorPropsNoundefinedProps forwarded to the internal RichTextEditor (see TextEditorProps).
onDeleteRequest(params: OnDeleteRequestParams) => voidNoundefinedCalled when the user triggers delete and confirmation is enabled. Call params.confirm() or params.cancel() from your UI as appropriate.
onUnsavedChangesRequest(params: OnUnsavedChangesRequestParams) => voidNoundefinedCalled when the user tries to leave with unsaved changes. Call params.saveAndQuit(), params.quitWithoutSaving(), or params.cancel() from your UI as appropriate.

Types

DocumentComposerHandle

Exposed via the component ref. Use requestLeave() before closing the editor or navigating away.

type DocumentComposerHandle = {
/** Returns true if leave is allowed (no unsaved changes, or user confirmed save/quit). Returns false if user cancelled. */
requestLeave: () => Promise<boolean>;
};

DocumentSection

type DocumentSection = {
id: string;
title: string;
visible: boolean;
parentId: string | null;
sections?: DocumentSection[] | null;
};

Note: The sections property allows for recursive nesting, creating a hierarchical structure. Root sections have parentId: null. The title property is used as the displayed label in the tree view.

OnDeleteRequestParams

type OnDeleteRequestParams = {
sectionId: string;
/** Call this to confirm the deletion. */
confirm: () => void;
/** Call this to cancel the deletion. */
cancel: () => void;
};

OnUnsavedChangesRequestParams

type OnUnsavedChangesRequestParams = {
/** Whether saving is possible (true if onSectionContentSave is provided). */
canSave: boolean;
/** Call this to save and then complete the pending action. Returns a Promise. */
saveAndQuit: () => Promise<void>;
/** Call this to complete the pending action without saving. */
quitWithoutSaving: () => void;
/** Call this to cancel and stay on the current section. */
cancel: () => void;
};

TextEditorProps

Props forwarded to the internal RichTextEditor. See its documentation for details on tags and onTagSelect.

type TextEditorProps = {
placeholder?: string;
tags?: TagDefinition[];
displayTags?: boolean;
onTagSelect?: OnTagSelect;
};
<DocumentComposer
sections={sections}
textEditorProps={{
placeholder: "Commencez à rédiger...",
tags: [
{ key: 'nom_societe', label: 'raison sociale', value: 'MyUnisoft SA' },
{ key: 'formule_compte', label: 'formule compte' },
],
onTagSelect: handleTagSelect
}}
/>

ItemPositionParams

type ItemPositionParams = {
itemId: string;
oldPosition: { parentId: string | null; index: number };
newPosition: { parentId: string | null; index: number };
};

SectionContent

Content for a section, consisting of one or more content blocks:

type SectionContent = ContentBlock[];

type ContentBlock = {
id: string;
displayOrder: number;
} & (TableContentBlock | TextContentBlock | VariantTextContentBlock | CategorizedAmountsContentBlock);

type TableContentBlock = {
type: 'table';
value: TableValue;
};

type CategorizedAmountsContentBlock = {
type: 'categorized-amounts';
value: CategorizedAmountsValue;
};

TableValue

Structure for table data used in table content blocks:

type TableValue = {
id: string;
headers: TableRow[]; // First row becomes column headers, rest are pinned top
rows: TableRow[]; // Editable data rows
footers: TableRow[]; // Pinned bottom rows (e.g., totals)
};

type TableRow = TableCell[];

type TableCell = {
value: string;
editable: boolean;
format: CellFormat;
};

type CellFormat = 'text' | 'amount';

CategorizedAmountsValue

Structure for categorized amounts data used in categorized amounts content blocks:

type CategorizedAmountsValue = {
id: string;
title?: string;
columns: CategorizedAmountsColumn[];
fields: CategorizedAmountsField[];
values: CategorizedAmountsRow[];
};

type CategorizedAmountsColumn = {
key: string;
type: 'text' | 'number';
label: string;
};

type CategorizedAmountsField = {
key: string;
type: 'select';
label: string;
defaultValue?: string;
options: CategorizedAmountsFieldOption[];
};

type CategorizedAmountsFieldOption = {
value: string;
label: string;
};

type CategorizedAmountsRow = {
id: string;
[key: string]: string | number | boolean | null;
};

Callback Props

onSectionOrderChange & getSectionOrderChangePermission

Called when the user reorders sections via drag & drop. Provides:

  • params: Object containing details about the section being moved:
    • itemId: ID of the section being moved
    • oldPosition: Object with parentId (string | null) and index (number)
    • newPosition: Object with parentId (string | null) and index (number)

Example:

const handleSectionOrderChange = (params: ItemPositionParams) => {
console.log("Section moved:", params.itemId);
console.log("From:", params.oldPosition);
console.log("To:", params.newPosition);
// Implement your own logic to update state/backend with the new order
};

<DocumentComposer
sections={sections}
onSectionOrderChange={handleSectionOrderChange}
/>;

onSectionDelete

Called when the user confirms deletion (after you call confirm() from your onDeleteRequest handler). Provides:

  • sectionId: ID of the section being deleted

Note: Provide onDeleteRequest to be notified when the user triggers delete; call params.confirm() or params.cancel() from your UI as appropriate. onSectionDelete is only called after you call confirm().

Example:

const [pendingDelete, setPendingDelete] = useState<OnDeleteRequestParams | null>(null);

<DocumentComposer
sections={sections}
onSectionDelete={handleSectionDeleteConfirm}
onDeleteRequest={setPendingDelete}
/>
{pendingDelete && (
<DeleteConfirmationModal
isOpen
onConfirm={() => { pendingDelete.confirm(); setPendingDelete(null); }}
onClose={() => { pendingDelete.cancel(); setPendingDelete(null); }}
labels={{ title: 'Delete', content: 'Confirm?', cancel: 'No', confirm: 'Yes' }}
/>
)}

Business Rules & Limitations

The following business rules and limitations are enforced by the DocumentComposer component:

  • Root Section Identification: Root sections are identified by parentId: null and receive special visual styling (bold text).
  • Drag & Drop: By default, all sections can be reordered freely. Use getSectionOrderChangePermission to implement custom restrictions (e.g., limiting moves to same parent, depth-based rules, etc.).

Planned Features

The following features are planned for future implementation:

  • Menu Item Labels: Pass menu item labels through props for internationalization and customization
  • Rich Text Editor: Integration of a text editor component for paragraph content in the right column
  • Template Selection Modal / Library Browser: A complementary modal dialog with a multi-select tree view (using checkbox selection) providing a complete view of all available sections and subsections from a pre-defined template/library
  • Menu Action Implementation: Full functionality for remaining context menu action (add from library)