Skip to content

Commit fbc16e8

Browse files
authored
feat(template-builder): wire lockMode through field insertion paths (#2721)
* feat(template-builder): wire lockMode through field insertion paths SuperDoc core already enforces SDT lock modes (SD-1616), but Template Builder never passed lockMode to the editor commands. This adds: - LockMode type and lockMode prop on FieldDefinition / TemplateField - defaultLockMode prop on SuperDocTemplateBuilderProps - lockMode threading through insertFieldInternal, handleSelectExisting, and handleMenuSelect - lockMode read-back in getTemplateFieldsFromEditor Closes SD-1866 * fix(template-builder): include lockMode in field equality check areTemplateFieldsEqual omitted lockMode, so lock-only changes were silently dropped by discoverFields. Also tightens the lockMode spread guard from truthiness to nullish check for consistency with the ?? resolution above. * docs(template-builder): add lockMode and defaultLockMode Documents field locking in configuration guide and API reference. * feat(template-builder): add lock mode selector to field creation form Adds a dropdown to the default FieldMenu create form so users can set a lock mode when creating new fields. Options: No lock, Unlocked, Container locked, Content locked, Fully locked. * fix(template-builder): respect lock mode in delete and show lock badge - deleteField no longer force-removes locked fields from state when the editor command is rejected by the lock plugin - FieldList sidebar shows a lock icon for fields with active lock modes * refactor(template-builder): simplify lock UI to a checkbox - Revert lock guard in deleteField — template authors can always delete fields regardless of lock mode - Replace 5-option lock dropdown with a simple "Locked" checkbox that maps to contentLocked - Full LockMode type still available for programmatic use * docs(template-builder): simplify lock mode documentation Focus on contentLocked as the default, remove the 4-mode table, clarify that locking only affects end users not template authors.
1 parent 45687a4 commit fbc16e8

8 files changed

Lines changed: 107 additions & 31 deletions

File tree

apps/docs/solutions/template-builder/api-reference.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ None - the component works with zero configuration.
7575
- `object` — full toolbar configuration (see ToolbarConfig)
7676
</ParamField>
7777

78+
<ParamField path="defaultLockMode" type="'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'">
79+
Lock mode for all inserted fields. Per-field `lockMode` overrides this. See [lock modes](/extensions/structured-content#lock-modes).
80+
</ParamField>
81+
7882
<ParamField path="cspNonce" type="string">
7983
Content Security Policy nonce for dynamically injected styles
8084
</ParamField>
@@ -174,6 +178,7 @@ interface FieldDefinition {
174178
mode?: "inline" | "block"; // Insertion mode (default: "inline")
175179
group?: string; // Group ID for linked fields
176180
fieldType?: string; // Field type, e.g. "owner" or "signer" (default: "owner")
181+
lockMode?: LockMode; // Lock mode for this field (overrides defaultLockMode)
177182
}
178183
```
179184

@@ -190,6 +195,7 @@ interface TemplateField {
190195
mode?: "inline" | "block"; // Rendering mode
191196
group?: string; // Group ID for linked fields
192197
fieldType?: string; // Field type, e.g. "owner" or "signer"
198+
lockMode?: LockMode; // Current lock mode of this field
193199
}
194200
```
195201

@@ -426,6 +432,7 @@ import type {
426432
SuperDocTemplateBuilderHandle,
427433
FieldDefinition,
428434
TemplateField,
435+
LockMode,
429436
TriggerEvent,
430437
ExportEvent,
431438
ExportConfig,

apps/docs/solutions/template-builder/configuration.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ Allow users to create new fields while building templates:
110110

111111
When enabled, the field menu shows a "Create New Field" option with inputs for name, mode (inline/block), and field type (owner/signer).
112112

113+
### Field locking
114+
115+
Make fields read-only so end users can't edit their content:
116+
117+
```tsx
118+
<SuperDocTemplateBuilder
119+
defaultLockMode="contentLocked"
120+
fields={{
121+
available: [
122+
{ id: "1", label: "Customer Name" },
123+
{ id: "2", label: "Notes", lockMode: "unlocked" }, // this one stays editable
124+
],
125+
}}
126+
/>
127+
```
128+
129+
Per-field `lockMode` overrides `defaultLockMode`. The default field creation form includes a "Locked" checkbox that sets `contentLocked`.
130+
131+
Template authors can always delete and manage fields regardless of lock mode — locking only affects end users interacting with the document. For advanced use cases, see [lock modes](/extensions/structured-content#lock-modes).
132+
113133
### Linked fields
114134

115135
When a user selects an existing field from the "Existing Fields" section in the menu, a linked copy is inserted. Both instances share a group ID and stay in sync.

packages/template-builder/src/defaults/FieldList.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,21 @@ const FieldItem: FC<{
124124
{field.fieldType}
125125
</span>
126126
)}
127+
{field.lockMode && field.lockMode !== 'unlocked' && (
128+
<span
129+
style={{
130+
fontSize: '9px',
131+
padding: '2px 5px',
132+
borderRadius: '3px',
133+
background: '#fef2f2',
134+
color: '#991b1b',
135+
fontWeight: '500',
136+
}}
137+
title={field.lockMode}
138+
>
139+
🔒
140+
</span>
141+
)}
127142
</div>
128143
</div>
129144
</div>

packages/template-builder/src/defaults/FieldMenu.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
2020
const [newFieldName, setNewFieldName] = useState('');
2121
const [fieldMode, setFieldMode] = useState<'inline' | 'block'>('inline');
2222
const [fieldType, setFieldType] = useState<string>('owner');
23+
const [fieldLocked, setFieldLocked] = useState(false);
2324
const [existingExpanded, setExistingExpanded] = useState(true);
2425
const [availableExpanded, setAvailableExpanded] = useState(true);
2526

@@ -29,6 +30,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
2930
setNewFieldName('');
3031
setFieldMode('inline');
3132
setFieldType('owner');
33+
setFieldLocked(false);
3234
}
3335
}, [isVisible]);
3436

@@ -69,6 +71,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
6971
label: trimmedName,
7072
mode: fieldMode,
7173
fieldType: fieldType,
74+
...(fieldLocked && { lockMode: 'contentLocked' as const }),
7275
};
7376

7477
try {
@@ -83,6 +86,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
8386
setNewFieldName('');
8487
setFieldMode('inline');
8588
setFieldType('owner');
89+
setFieldLocked(false);
8690
}
8791
};
8892

@@ -131,6 +135,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
131135
setIsCreating(false);
132136
setNewFieldName('');
133137
setFieldMode('inline');
138+
setFieldLocked(false);
134139
}
135140
}}
136141
autoFocus
@@ -227,6 +232,19 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
227232
Signer
228233
</label>
229234
</div>
235+
<div
236+
style={{
237+
marginTop: '8px',
238+
display: 'flex',
239+
gap: '12px',
240+
fontSize: '13px',
241+
}}
242+
>
243+
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
244+
<input type='checkbox' checked={fieldLocked} onChange={(e) => setFieldLocked(e.target.checked)} />
245+
Locked
246+
</label>
247+
</div>
230248
<div
231249
style={{
232250
marginTop: '8px',
@@ -254,6 +272,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
254272
setNewFieldName('');
255273
setFieldMode('inline');
256274
setFieldType('owner');
275+
setFieldLocked(false);
257276
}}
258277
style={{
259278
padding: '4px 12px',

packages/template-builder/src/index.tsx

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const getTemplateFieldsFromEditor = (editor: Editor): Types.TemplateField[] => {
3939
mode,
4040
group: structuredContentHelpers.getGroup?.(attrs.tag) ?? undefined,
4141
fieldType: parsedTag?.fieldType ?? 'owner',
42+
lockMode: attrs.lockMode ?? undefined,
4243
} as Types.TemplateField;
4344
});
4445
};
@@ -51,6 +52,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
5152
menu = {},
5253
list = {},
5354
toolbar,
55+
defaultLockMode,
5456
cspNonce,
5557
telemetry,
5658
licenseKey,
@@ -139,22 +141,18 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
139141
})
140142
: undefined;
141143

144+
const lockMode = field.lockMode ?? defaultLockMode;
145+
146+
const attrs: Record<string, unknown> = {
147+
alias: field.alias,
148+
tag: tagData,
149+
...(lockMode != null && { lockMode }),
150+
};
151+
142152
const success = (
143153
mode === 'inline'
144-
? editor.commands.insertStructuredContentInline?.({
145-
attrs: {
146-
alias: field.alias,
147-
tag: tagData,
148-
},
149-
text: field.defaultValue || field.alias,
150-
})
151-
: editor.commands.insertStructuredContentBlock?.({
152-
attrs: {
153-
alias: field.alias,
154-
tag: tagData,
155-
},
156-
text: field.defaultValue || field.alias,
157-
})
154+
? editor.commands.insertStructuredContentInline?.({ attrs, text: field.defaultValue || field.alias })
155+
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.defaultValue || field.alias })
158156
) as boolean | undefined;
159157

160158
if (success) {
@@ -174,7 +172,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
174172

175173
return success ?? false;
176174
},
177-
[onFieldInsert, onFieldsChange, templateFields],
175+
[onFieldInsert, onFieldsChange, templateFields, defaultLockMode],
178176
);
179177

180178
const updateField = useCallback(
@@ -485,6 +483,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
485483
metadata: createdField.metadata,
486484
defaultValue: createdField.defaultValue,
487485
fieldType: createdField.fieldType,
486+
lockMode: createdField.lockMode,
488487
});
489488
setMenuVisible(false);
490489
return;
@@ -496,6 +495,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
496495
metadata: field.metadata,
497496
defaultValue: field.defaultValue,
498497
fieldType: field.fieldType,
498+
lockMode: field.lockMode,
499499
});
500500
setMenuVisible(false);
501501
},
@@ -526,23 +526,18 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
526526
});
527527

528528
const mode = field.mode || 'inline';
529+
const lockMode = field.lockMode ?? defaultLockMode;
530+
531+
const attrs: Record<string, unknown> = {
532+
alias: field.alias,
533+
tag: tagWithGroup,
534+
...(lockMode != null && { lockMode }),
535+
};
529536

530537
const success =
531538
mode === 'inline'
532-
? editor.commands.insertStructuredContentInline?.({
533-
attrs: {
534-
alias: field.alias,
535-
tag: tagWithGroup,
536-
},
537-
text: field.alias,
538-
})
539-
: editor.commands.insertStructuredContentBlock?.({
540-
attrs: {
541-
alias: field.alias,
542-
tag: tagWithGroup,
543-
},
544-
text: field.alias,
545-
});
539+
? editor.commands.insertStructuredContentInline?.({ attrs, text: field.alias })
540+
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.alias });
546541

547542
if (success) {
548543
if (!field.group) {
@@ -556,7 +551,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
556551
onFieldsChange?.(updatedFields);
557552
}
558553
},
559-
[updateField, resetMenuFilter, onFieldsChange],
554+
[updateField, resetMenuFilter, onFieldsChange, defaultLockMode],
560555
);
561556

562557
const handleMenuClose = useCallback(() => {

packages/template-builder/src/tests/utils.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ describe('areTemplateFieldsEqual', () => {
7272
const b: TemplateField[] = [{ id: '1', alias: 'Name', fieldType: 'signer' }];
7373
expect(areTemplateFieldsEqual(a, b)).toBe(false);
7474
});
75+
76+
it('returns false when lockMode differs', () => {
77+
const a: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'unlocked' }];
78+
const b: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'sdtContentLocked' }];
79+
expect(areTemplateFieldsEqual(a, b)).toBe(false);
80+
});
81+
82+
it('returns true when lockMode is the same', () => {
83+
const a: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'contentLocked' }];
84+
const b: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'contentLocked' }];
85+
expect(areTemplateFieldsEqual(a, b)).toBe(true);
86+
});
7587
});
7688

7789
describe('resolveToolbar', () => {

packages/template-builder/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { SuperDoc } from 'superdoc';
22

3+
export type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
4+
35
/** Field definition for template builder */
46
export interface FieldDefinition {
57
id: string;
@@ -9,6 +11,7 @@ export interface FieldDefinition {
911
mode?: 'inline' | 'block';
1012
group?: string;
1113
fieldType?: string;
14+
lockMode?: LockMode;
1215
}
1316

1417
/** Field instance in a template document */
@@ -20,6 +23,7 @@ export interface TemplateField {
2023
mode?: 'inline' | 'block';
2124
group?: string;
2225
fieldType?: string;
26+
lockMode?: LockMode;
2327
}
2428

2529
export interface TriggerEvent {
@@ -114,6 +118,9 @@ export interface SuperDocTemplateBuilderProps {
114118
list?: ListConfig;
115119
toolbar?: boolean | string | ToolbarConfig;
116120

121+
/** Lock mode applied to all inserted fields unless overridden per-field */
122+
defaultLockMode?: LockMode;
123+
117124
/** Content Security Policy nonce for dynamically injected styles */
118125
cspNonce?: string;
119126

packages/template-builder/src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export const areTemplateFieldsEqual = (a: TemplateField[], b: TemplateField[]):
1818
left.position !== right.position ||
1919
left.mode !== right.mode ||
2020
left.group !== right.group ||
21-
left.fieldType !== right.fieldType
21+
left.fieldType !== right.fieldType ||
22+
left.lockMode !== right.lockMode
2223
) {
2324
return false;
2425
}

0 commit comments

Comments
 (0)