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
isItemEditableandonItemLabelChangeprops) - 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
getSectionOrderChangePermissionto 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
renderSectionIconcallback 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} />;
- Background
- Objectives
- Scope
- Analysis
- Implementation
- Conclusion
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 & dropgetSectionOrderChangePermission: 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
- Analysis
- Implementation
- Conclusion
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}
/>;
- Editable subsection
- Editable section
- Not renamable section
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.
- Deletable subsection
- Deletable section
- Can not delete me
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"
/>;
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'}
/>;
- Existing subsection
- Regular section
- Not section creatable
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.
- Overview
- Main content
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} />
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
- Background (no children - cannot create)
- Hidden section (cannot rename)
- Analysis (no children - cannot create)
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— whentrue, tags show their label (e.g.{raison sociale}); whenfalse(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.
- Informations générales
- Dirigeants
- Événements significatifs
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
rowSpanandcolSpanon 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: 2occupies 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: 2spans 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
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
- Actif
- Passif
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 akey(matching a property invalues), atype('text'or'number'), and alabel. Number values are formatted as currency (EUR).fields: Defines the radio button groups rendered in the last column. Each field has akey,type: 'select', alabel, an optionaldefaultValue, and an array ofoptions({ value, label }).values: Row data. Each row has anidand 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
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.
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 />
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
getSectionTitleChangePermissionreturns false) - Supprimer (Delete): Remove the section (disabled if
getSectionDeletePermissionreturns false) - Créer une sous-section (Create subsection): Add a child section (disabled if
getSectionCreatePermissionreturns false)
API Reference
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
ref | Ref<DocumentComposerHandle | null> | No | undefined | Ref to access requestLeave() for leave guard (e.g. before closing or navigating away). |
sections | DocumentSection[] | No | undefined | Array of document sections (tree structure) |
noSectionsPlaceholder | { message?: string; linkLabel?: string } | No | undefined | Custom texts for the empty state placeholder (message and create link label) |
isLoadingSections | boolean | No | false | Shows a loading skeleton when true |
getSectionTitleChangePermission | (section: DocumentSection) => boolean | No | () => true | Returns whether section title can be edited |
getSectionDeletePermission | (section: DocumentSection) => boolean | No | () => true | Returns whether section can be deleted |
getSectionOrderChangePermission | (section: DocumentSection) => boolean | No | () => true | Returns whether section can be reordered via drag & drop |
getSectionCreatePermission | (section: DocumentSection) => boolean | No | () => true | Returns whether a section can be created under this section |
getSectionVisibilityChangePermission | (section: DocumentSection) => boolean | No | () => true | Returns whether section visibility can be toggled |
canMoveSectionToNewPosition | (params: ItemPositionParams) => boolean | No | () => true | Custom validation for drag & drop positions. Use this to implement drop zone restrictions. |
hiddenSectionTooltip | string | No | undefined | Tooltip text displayed when hovering a hidden section label |
visibleIconTooltip | string | No | undefined | Tooltip text displayed when hovering the visibility icon of a visible section |
hiddenIconTooltip | string | No | undefined | Tooltip text displayed when hovering the visibility icon of a hidden section |
renderSectionIcon | (section: DocumentSection) => ReactNode | No | undefined | Renders a custom icon for a section in the tree. Return null for no icon. |
onSectionOrderChange | (params: ItemPositionParams) => void | No | undefined | Callback when section order is changed via drag & drop |
onSectionTitleChange | (sectionId: string, newTitle: string) => void | No | undefined | Callback when section title is changed via inline editing |
onSectionDelete | (sectionId: string) => void | No | undefined | Callback when a section is deleted (called after the user confirms via onDeleteRequest). |
onSectionVisibilityChange | (sectionId: string, visible: boolean) => void | No | undefined | Callback when section visibility is toggled |
onSectionCreate | (parentId: string | null, title: string) => string | Promise<string> | No | undefined | Callback when a new section is created. Must return the new section's ID. |
onSectionSelect | (sectionId: string | null) => void | No | undefined | Callback 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> | No | undefined | Save 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. |
selectedSectionId | string | null | No | undefined | Controlled selected section ID. |
sectionContent | SectionContent | No | undefined | Content for the currently selected section (array of ContentBlock), displayed in the right panel. |
isLoadingSectionContent | boolean | No | false | Shows a loading skeleton in the right panel when true. |
textEditorProps | TextEditorProps | No | undefined | Props forwarded to the internal RichTextEditor (see TextEditorProps). |
onDeleteRequest | (params: OnDeleteRequestParams) => void | No | undefined | Called when the user triggers delete and confirmation is enabled. Call params.confirm() or params.cancel() from your UI as appropriate. |
onUnsavedChangesRequest | (params: OnUnsavedChangesRequestParams) => void | No | undefined | Called 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 movedoldPosition: Object withparentId(string | null) andindex(number)newPosition: Object withparentId(string | null) andindex(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: nulland receive special visual styling (bold text). - Drag & Drop: By default, all sections can be reordered freely. Use
getSectionOrderChangePermissionto 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)