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: 500000 }, // renders "500 000,00"
]
}}
/>

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.

Action tags (formule compte) with Modal​

Use onTagSelect to intercept action tags and open a modal. Tags with action: true appear at the bottom of the @ suggestion list. Use actionValues to provide resolved values for inserted formulas.

Try it:

  • Select "Présentation de la société" for a text paragraph with mentions + formule compte
  • Select "Bilan comptable" for a table with editable cells — type @ in the "Commentaire" column, then select "formule compte" to open the modal
  • Présentation de la société
  • Bilan comptable
  • 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

When you pass textEditorProps (including tags) to DocumentComposer, editable text-format cells in table blocks also support @-mention variables: typing @ opens the same variable list, and insertion uses the {{key}} token format. The same displayTags toggle controls whether table cells show variable names (e.g. {raison sociale}) or resolved values (e.g. MyUnisoft SA), consistent with the text editor. Editable text cells with variables use a minimal TipTap editor (one per focused cell) with the same mention extension as the main text editor.

Try it: Select the section on the left, click an editable cell in the "Commentaire" column, type @ to insert a variable, and use the switch above to toggle between variable names and values.

  • Tableau avec variables
  • MUI X Missing license key
Tableau avec variables

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

Variant Text Content​

When sectionContent contains variant-text blocks (type: 'variant-text'), DocumentComposer renders a set of radio options. Each option holds its own TipTap text content; switching options swaps the editor content. Only the selected option's content is included in the saved value.

Try it: Select the section on the left, then choose between the four pre-defined paragraphs.

  • Conclusion du rapport
  • MUI X Missing license key

Sélectionnez une section pour éditer son contenu.

Usage​

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

const sectionContent: SectionContent = [
{
id: 'variant-1',
displayOrder: 0,
type: 'variant-text',
value: {
selectedOptionId: 'option-1',
options: [
{ id: 'option-1', label: 'Conclusion favorable' },
{ id: 'option-2', label: 'Réserve technique' },
{ id: 'option-3', label: 'Réserve significative' },
{ id: 'option-4', label: 'Refus de certification' }
],
contents: {
'option-1': { type: 'doc', content: [/* ... */] },
'option-2': { type: 'doc', content: [/* ... */] },
'option-3': { type: 'doc', content: [/* ... */] },
'option-4': { type: 'doc', content: [/* ... */] }
}
}
}
];

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 et hébergement pour la mission de conseil auprès du client Dupont & Associés titre vraiment vraiment vraiment vraiment long

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 DS translations 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)