canopycms 0.0.68-int.94 → 0.0.68-int.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/schemas/config.d.ts +4 -4
- package/dist/config/schemas/config.js +4 -4
- package/dist/editor/FormRenderer.js +15 -14
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +3 -2
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/hooks/useCommentSystem.js +20 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +3 -3
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +8 -10
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/rebase.js +7 -9
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +1 -1
|
@@ -237,10 +237,10 @@ export declare const CanopyConfigSchema: z.ZodObject<{
|
|
|
237
237
|
/**
|
|
238
238
|
* Default workspace path for prod mode (used when CANOPYCMS_WORKSPACE_ROOT is not set).
|
|
239
239
|
*
|
|
240
|
-
* WARNING: this fallback assumes a
|
|
241
|
-
*
|
|
242
|
-
* at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
|
|
243
|
-
* if that env were ever unset
|
|
240
|
+
* WARNING: this fallback assumes a ROOT mount of EFS at /mnt/efs. CanopyCmsService
|
|
241
|
+
* mounts EFS in both the Lambda and the worker THROUGH an access point already
|
|
242
|
+
* rooted at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
|
|
243
|
+
* explicitly; if that env were ever unset this default would resolve to
|
|
244
244
|
* /mnt/efs/workspace = EFS:/workspace/workspace (a wrong, nested dir). The CDK
|
|
245
245
|
* always sets the env, so this only bites a hand-rolled misconfiguration.
|
|
246
246
|
*/
|
|
@@ -113,10 +113,10 @@ export const CanopyConfigSchema = z
|
|
|
113
113
|
/**
|
|
114
114
|
* Default workspace path for prod mode (used when CANOPYCMS_WORKSPACE_ROOT is not set).
|
|
115
115
|
*
|
|
116
|
-
* WARNING: this fallback assumes a
|
|
117
|
-
*
|
|
118
|
-
* at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
|
|
119
|
-
* if that env were ever unset
|
|
116
|
+
* WARNING: this fallback assumes a ROOT mount of EFS at /mnt/efs. CanopyCmsService
|
|
117
|
+
* mounts EFS in both the Lambda and the worker THROUGH an access point already
|
|
118
|
+
* rooted at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
|
|
119
|
+
* explicitly; if that env were ever unset this default would resolve to
|
|
120
120
|
* /mnt/efs/workspace = EFS:/workspace/workspace (a wrong, nested dir). The CDK
|
|
121
121
|
* always sets the env, so this only bites a hand-rolled misconfiguration.
|
|
122
122
|
*/
|
|
@@ -15,6 +15,7 @@ import { ReferenceField } from './fields/ReferenceField.js';
|
|
|
15
15
|
import { CodeField } from './fields/CodeField.js';
|
|
16
16
|
import { ImageField } from './fields/ImageField.js';
|
|
17
17
|
import { ObjectField } from './fields/ObjectField.js';
|
|
18
|
+
import { FieldDescription, groupDescriptionProps } from './fields/FieldDescription.js';
|
|
18
19
|
import { InlineGroupField } from './fields/InlineGroupField.js';
|
|
19
20
|
import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
|
|
20
21
|
import { FieldWrapper } from './comments/FieldWrapper.js';
|
|
@@ -87,28 +88,28 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
87
88
|
switch (field.type) {
|
|
88
89
|
case 'string':
|
|
89
90
|
if (field.list) {
|
|
90
|
-
return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
91
|
+
return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
91
92
|
}
|
|
92
|
-
return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
93
|
+
return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
93
94
|
case 'boolean':
|
|
94
|
-
return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
|
|
95
|
+
return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, description: field.description, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
|
|
95
96
|
case 'number':
|
|
96
97
|
if (field.list) {
|
|
97
|
-
return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, value: Array.isArray(currentValue)
|
|
98
|
+
return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
|
|
98
99
|
? currentValue.filter((v) => typeof v === 'number')
|
|
99
100
|
: [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
100
101
|
}
|
|
101
|
-
return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
102
|
+
return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
102
103
|
case 'datetime':
|
|
103
|
-
return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
104
|
+
return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
104
105
|
case 'markdown':
|
|
105
106
|
case 'mdx':
|
|
106
|
-
return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
107
|
+
return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
107
108
|
case 'select': {
|
|
108
109
|
const selectField = field;
|
|
109
110
|
const options = normalizeOptions(selectField.options);
|
|
110
111
|
const isMulti = Boolean(selectField.list);
|
|
111
|
-
return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, options: options, value: isMulti
|
|
112
|
+
return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, description: field.description, options: options, value: isMulti
|
|
112
113
|
? Array.isArray(currentValue)
|
|
113
114
|
? currentValue
|
|
114
115
|
: []
|
|
@@ -120,7 +121,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
120
121
|
? normalizeOptions(referenceField.options)
|
|
121
122
|
: undefined;
|
|
122
123
|
const isMulti = Boolean(referenceField.list);
|
|
123
|
-
return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, options: staticOptions?.map((opt) => ({
|
|
124
|
+
return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions?.map((opt) => ({
|
|
124
125
|
label: opt.label,
|
|
125
126
|
value: opt.value,
|
|
126
127
|
})), collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, value: isMulti
|
|
@@ -131,7 +132,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
131
132
|
}
|
|
132
133
|
case 'image': {
|
|
133
134
|
const imageField = field;
|
|
134
|
-
return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
|
|
135
|
+
return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, description: field.description, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
|
|
135
136
|
src: fieldErrors?.[`${canopyPath}.src`],
|
|
136
137
|
alt: fieldErrors?.[`${canopyPath}.alt`],
|
|
137
138
|
crop: fieldErrors?.[`${canopyPath}.crop`],
|
|
@@ -139,7 +140,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
139
140
|
}
|
|
140
141
|
case 'block': {
|
|
141
142
|
const blockField = field;
|
|
142
|
-
return wrapWithComments(_jsx(BlockField, { label: label, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
143
|
+
return wrapWithComments(_jsx(BlockField, { label: label, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
143
144
|
}
|
|
144
145
|
case 'object': {
|
|
145
146
|
const objectField = field;
|
|
@@ -147,7 +148,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
147
148
|
const items = Array.isArray(currentValue)
|
|
148
149
|
? currentValue
|
|
149
150
|
: [];
|
|
150
|
-
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
|
|
151
|
+
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", ...groupDescriptionProps(fieldId, field.description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsx(FieldDescription, { baseId: fieldId, description: field.description }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
|
|
151
152
|
const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
|
|
152
153
|
return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
|
|
153
154
|
const nextItems = [...items];
|
|
@@ -166,10 +167,10 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
166
167
|
// doesn't read as deleting the field from the schema.
|
|
167
168
|
const hasValue = currentValue !== undefined && currentValue !== null;
|
|
168
169
|
const canClear = objectField.required !== true && hasValue;
|
|
169
|
-
return wrapWithComments(_jsx(ObjectField, { label: label, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
170
|
+
return wrapWithComments(_jsx(ObjectField, { label: label, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
170
171
|
}
|
|
171
172
|
case 'code':
|
|
172
|
-
return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
173
|
+
return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
173
174
|
default:
|
|
174
175
|
return (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Unsupported field: ", field.type] }, fieldKey(path)));
|
|
175
176
|
}
|
|
@@ -7,6 +7,7 @@ export interface BlockInstance {
|
|
|
7
7
|
type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
|
|
8
8
|
export interface BlockFieldProps {
|
|
9
9
|
label?: string;
|
|
10
|
+
description?: string;
|
|
10
11
|
templates: BlockConfig[];
|
|
11
12
|
value: BlockInstance[];
|
|
12
13
|
onChange: (blocks: BlockInstance[]) => void;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import React, { useMemo, useState } from 'react';
|
|
3
|
+
import React, { useId, useMemo, useState } from 'react';
|
|
4
4
|
let blockKeyCounter = 0;
|
|
5
5
|
import { ActionIcon, Button, Group, Paper, Select, Stack, Text } from '@mantine/core';
|
|
6
6
|
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
|
|
7
7
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
|
|
8
8
|
import { CSS } from '@dnd-kit/utilities';
|
|
9
9
|
import { formatCanopyPath } from '../canopy-path.js';
|
|
10
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
10
11
|
const findTemplate = (templates, name) => templates.find((t) => t.name === name);
|
|
11
12
|
const SortableBlock = ({ id, children }) => {
|
|
12
13
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
@@ -19,7 +20,8 @@ const SortableBlock = ({ id, children }) => {
|
|
|
19
20
|
};
|
|
20
21
|
return (_jsx(Paper, { ref: setNodeRef, withBorder: true, radius: "md", p: "sm", shadow: "xs", style: style, children: _jsxs(Group, { align: "flex-start", gap: "sm", children: [_jsx(ActionIcon, { variant: "subtle", "aria-label": "Drag to reorder", ...attributes, ...listeners, style: { cursor: 'grab' }, children: "\u21C5" }, "drag-handle"), _jsx("div", { style: { flex: 1, minWidth: 0, width: '100%' }, children: children }, "content")] }) }));
|
|
21
22
|
};
|
|
22
|
-
export const BlockField = ({ label, templates, value, onChange, renderField, path, dataCanopyField, }) => {
|
|
23
|
+
export const BlockField = ({ label, description, templates, value, onChange, renderField, path, dataCanopyField, }) => {
|
|
24
|
+
const descriptionBaseId = useId();
|
|
23
25
|
const [itemKeys, setItemKeys] = useState(() => value.map(() => `block-${blockKeyCounter++}`));
|
|
24
26
|
const [pendingTemplate, setPendingTemplate] = useState(null);
|
|
25
27
|
// Keeps itemKeys in sync with value's length; setState runs directly in render, not an effect.
|
|
@@ -69,12 +71,12 @@ export const BlockField = ({ label, templates, value, onChange, renderField, pat
|
|
|
69
71
|
moveBlock(oldIndex, newIndex);
|
|
70
72
|
};
|
|
71
73
|
const selectableTemplates = useMemo(() => templates.map((t) => ({ value: t.name, label: t.label ?? t.name })), [templates]);
|
|
72
|
-
return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
|
|
74
|
+
return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
|
|
73
75
|
if (next) {
|
|
74
76
|
addBlock(next);
|
|
75
77
|
}
|
|
76
78
|
setPendingTemplate(null);
|
|
77
|
-
}, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
|
|
79
|
+
}, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
|
|
78
80
|
const template = findTemplate(templates, block.template);
|
|
79
81
|
const currentPath = [...path, idx];
|
|
80
82
|
return (_jsx(SortableBlock, { id: itemKeys[idx], children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "flex-start", children: [_jsx(Text, { size: "sm", fw: 600, children: template?.label ?? block.template ?? 'Unknown block' }), _jsxs(Group, { gap: 4, children: [_jsx(ActionIcon, { variant: "light", "aria-label": "Move block up", disabled: idx === 0, onClick: () => moveBlock(idx, idx - 1), children: "\u2191" }), _jsx(ActionIcon, { variant: "light", "aria-label": "Move block down", disabled: idx === value.length - 1, onClick: () => moveBlock(idx, idx + 1), children: "\u2193" }), _jsx(Button, { variant: "subtle", color: "red", size: "xs", onClick: () => removeBlock(idx), children: "Remove" })] })] }), template ? (_jsx(Stack, { gap: "sm", children: template.fields.map((f) => (_jsx(React.Fragment, { children: renderField(f, block.value?.[f.name], (next) => updateBlockValue(idx, {
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { Textarea } from '@mantine/core';
|
|
4
4
|
// Placeholder for Monaco integration; host app can provide custom renderer for production.
|
|
5
|
-
export const CodeField = ({ id, label, value, onChange, language, dataCanopyField, }) => {
|
|
5
|
+
export const CodeField = ({ id, label, description, value, onChange, language, dataCanopyField, }) => {
|
|
6
6
|
const generatedId = useId();
|
|
7
7
|
const inputId = id ?? generatedId;
|
|
8
|
-
return (_jsx(Textarea, { id: inputId, label: label, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
|
|
8
|
+
return (_jsx(Textarea, { id: inputId, label: label, description: description, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
|
|
9
9
|
};
|
|
@@ -49,8 +49,8 @@ export function datetimeLocalValueToIso(local) {
|
|
|
49
49
|
* round-trip; sub-second precision is not preserved (no milliseconds field),
|
|
50
50
|
* so a value with non-zero milliseconds is zeroed out once resaved.
|
|
51
51
|
*/
|
|
52
|
-
export const DateTimeField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
52
|
+
export const DateTimeField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
53
53
|
const generatedId = useId();
|
|
54
54
|
const inputId = id ?? generatedId;
|
|
55
|
-
return (_jsx(Input.Wrapper, { id: inputId, label: label, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
|
|
55
|
+
return (_jsx(Input.Wrapper, { id: inputId, label: label, description: description, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
|
|
56
56
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** The id Mantine gives a native input's description, so custom fields match it. */
|
|
3
|
+
export declare const fieldDescriptionId: (baseId: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Guidance text for fields that don't use a Mantine input's native `description` prop.
|
|
6
|
+
* Renders nothing for an absent or empty description.
|
|
7
|
+
*/
|
|
8
|
+
export declare const FieldDescription: React.FC<{
|
|
9
|
+
baseId: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
}>;
|
|
12
|
+
/** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
|
|
13
|
+
export declare const groupDescriptionProps: (baseId: string, description: string | undefined) => {
|
|
14
|
+
role?: "group";
|
|
15
|
+
"aria-describedby"?: string;
|
|
16
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Input } from '@mantine/core';
|
|
3
|
+
/** The id Mantine gives a native input's description, so custom fields match it. */
|
|
4
|
+
export const fieldDescriptionId = (baseId) => `${baseId}-description`;
|
|
5
|
+
/**
|
|
6
|
+
* Guidance text for fields that don't use a Mantine input's native `description` prop.
|
|
7
|
+
* Renders nothing for an absent or empty description.
|
|
8
|
+
*/
|
|
9
|
+
export const FieldDescription = ({ baseId, description, }) => description ? (_jsx(Input.Description, { id: fieldDescriptionId(baseId), children: description })) : null;
|
|
10
|
+
/** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
|
|
11
|
+
export const groupDescriptionProps = (baseId, description) => description ? { role: 'group', 'aria-describedby': fieldDescriptionId(baseId) } : {};
|
|
@@ -8,6 +8,7 @@ interface ImageFieldErrors {
|
|
|
8
8
|
export interface ImageFieldProps {
|
|
9
9
|
id?: string;
|
|
10
10
|
label?: string;
|
|
11
|
+
description?: string;
|
|
11
12
|
value: ImageFieldValue | undefined;
|
|
12
13
|
onChange: (value: ImageFieldValue | undefined) => void;
|
|
13
14
|
/** "W:H" aspect ratio - when set, picking/uploading a new image opens the crop step before the value commits, and a "Crop" button appears on the filled state. */
|
|
@@ -11,8 +11,9 @@ import { parseAspectRatio } from '../media/crop-math.js';
|
|
|
11
11
|
import { MediaLibrary } from '../media/MediaLibrary.js';
|
|
12
12
|
import { useAssetUpload } from '../media/useAssetUpload.js';
|
|
13
13
|
import { ACCEPTED_IMAGE_MIME_TYPES, MAX_UPLOAD_BYTES } from '../media/upload-constants.js';
|
|
14
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
14
15
|
const PREVIEW_WIDTH = 320;
|
|
15
|
-
export const ImageField = ({ id, label, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
|
|
16
|
+
export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
|
|
16
17
|
const generatedId = useId();
|
|
17
18
|
const inputId = id ?? generatedId;
|
|
18
19
|
const { baseUrl } = useAssetContext();
|
|
@@ -101,7 +102,7 @@ export const ImageField = ({ id, label, value, onChange, aspect, altOptional, da
|
|
|
101
102
|
? assetUrl({ src: value.src }, { baseUrl })
|
|
102
103
|
: '';
|
|
103
104
|
const cropInitial = cropRequest?.kind === 'existing' ? value?.crop : undefined;
|
|
104
|
-
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
|
|
105
|
+
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
|
|
105
106
|
width: PREVIEW_WIDTH,
|
|
106
107
|
height: 160,
|
|
107
108
|
display: 'flex',
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
2
3
|
import { Paper, Stack, Text } from '@mantine/core';
|
|
3
4
|
import { formatCanopyPath } from '../canopy-path.js';
|
|
5
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
4
6
|
export const InlineGroupField = ({ label, description, fields, value, onChange, renderField, path, }) => {
|
|
5
|
-
|
|
7
|
+
const descriptionBaseId = useId();
|
|
8
|
+
return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [(label || description) && (_jsxs(Stack, { gap: 2, children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label })), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description })] })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
|
|
6
9
|
// Nested inline groups are also transparent — pass the same parent value/onChange
|
|
7
10
|
if (field.type === 'group') {
|
|
8
11
|
const childGroup = field;
|
|
@@ -6,6 +6,7 @@ import { InsertEntryLink } from './entry-link/index.js';
|
|
|
6
6
|
import { MdxImageDialog } from './MdxImageDialog.js';
|
|
7
7
|
import { useApiClient } from '../context/index.js';
|
|
8
8
|
import { uploadAsset } from '../media/upload-asset.js';
|
|
9
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
9
10
|
const MDXEditorLazy = React.lazy(async () => {
|
|
10
11
|
const [{ MDXEditor }, { headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, toolbarPlugin, codeBlockPlugin, codeMirrorPlugin, BoldItalicUnderlineToggles, BlockTypeSelect, ListsToggle, CreateLink, InsertImage, InsertTable, InsertThematicBreak, CodeToggle, InsertCodeBlock, UndoRedo, Separator, insertMarkdown$, usePublisher, useCellValues, saveImage$, closeImageDialog$, imageDialogState$, },] = await Promise.all([import('@mdxeditor/editor'), import('@mdxeditor/editor')]);
|
|
11
12
|
const EntryLinkToolbarButton = () => {
|
|
@@ -94,7 +95,7 @@ const EditorContentStyles = () => (_jsx("style", { children: `
|
|
|
94
95
|
.canopy-mdx-content img { max-width: 100%; height: auto; }
|
|
95
96
|
` }));
|
|
96
97
|
const FallbackTextarea = ({ value, onChange, }) => (_jsx(Textarea, { value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: "Loading markdown editor...", autosize: true, minRows: 6, size: "sm", readOnly: true }));
|
|
97
|
-
export const MarkdownField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
98
|
+
export const MarkdownField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
98
99
|
const generatedId = useId();
|
|
99
100
|
const inputId = id ?? generatedId;
|
|
100
101
|
const editorRef = useRef(null);
|
|
@@ -119,5 +120,5 @@ export const MarkdownField = ({ id, label, value, onChange, dataCanopyField, })
|
|
|
119
120
|
lastExternalValue.current = newValue;
|
|
120
121
|
onChange(newValue);
|
|
121
122
|
}, [onChange]);
|
|
122
|
-
return (_jsxs("div", { id: inputId, "data-canopy-field": dataCanopyField, className: "canopy-markdown-field", children: [label && (_jsx(Text, { size: "sm", fw: 500, mb: 4, children: label })), _jsx(EditorContentStyles, {}), _jsx("div", { style: editorWrapperStyle, children: _jsx(Suspense, { fallback: _jsx(FallbackTextarea, { value: value, onChange: onChange }), children: _jsx(MDXEditorLazy, { markdown: value, onChange: handleChange, editorRef: editorRef, imageUploadHandler: imageUploadHandler }) }) })] }));
|
|
123
|
+
return (_jsxs("div", { id: inputId, "data-canopy-field": dataCanopyField, className: "canopy-markdown-field", ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, mb: 4, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(EditorContentStyles, {}), _jsx("div", { style: editorWrapperStyle, children: _jsx(Suspense, { fallback: _jsx(FallbackTextarea, { value: value, onChange: onChange }), children: _jsx(MDXEditorLazy, { markdown: value, onChange: handleChange, editorRef: editorRef, imageUploadHandler: imageUploadHandler }) }) })] }));
|
|
123
124
|
};
|
|
@@ -27,14 +27,14 @@ const toNumericValue = (raw) => {
|
|
|
27
27
|
* would silently store `0.5` after the second keystroke. See
|
|
28
28
|
* NumberField.test.tsx's keystroke matrix.
|
|
29
29
|
*/
|
|
30
|
-
export const NumberField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
30
|
+
export const NumberField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
31
31
|
const generatedId = useId();
|
|
32
32
|
const inputId = id ?? generatedId;
|
|
33
33
|
const [inputValue, setInputValue] = useState(value ?? '');
|
|
34
34
|
// Shows the in-progress text only while it still matches the parent's
|
|
35
35
|
// value; once the parent's value diverges (load, discard, reload), that value wins.
|
|
36
36
|
const displayValue = toNumericValue(inputValue) === value ? inputValue : (value ?? '');
|
|
37
|
-
return (_jsx(NumberInput, { id: inputId, label: label, value: displayValue, size: "sm", onChange: (next) => {
|
|
37
|
+
return (_jsx(NumberInput, { id: inputId, label: label, description: description, value: displayValue, size: "sm", onChange: (next) => {
|
|
38
38
|
setInputValue(next);
|
|
39
39
|
onChange(toNumericValue(next));
|
|
40
40
|
}, "data-canopy-field": dataCanopyField }));
|
|
@@ -27,11 +27,11 @@ const parseTag = (tag) => {
|
|
|
27
27
|
* on Enter indistinguishably from being accepted, with nothing to tell a
|
|
28
28
|
* typo from a broken field — so rejected text is named in the error slot.
|
|
29
29
|
*/
|
|
30
|
-
export const NumberListField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
30
|
+
export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
31
31
|
const generatedId = useId();
|
|
32
32
|
const inputId = id ?? generatedId;
|
|
33
33
|
const [rejected, setRejected] = useState([]);
|
|
34
|
-
return (_jsx(TagsInput, { id: inputId, label: label, value: value.map(String), size: "sm", error: rejected.length > 0
|
|
34
|
+
return (_jsx(TagsInput, { id: inputId, label: label, description: description, value: value.map(String), size: "sm", error: rejected.length > 0
|
|
35
35
|
? `Not ${rejected.length === 1 ? 'a number' : 'numbers'}: ${rejected.join(', ')}`
|
|
36
36
|
: undefined, onChange: (next) => {
|
|
37
37
|
const parsed = [];
|
|
@@ -3,6 +3,7 @@ import type { FieldConfig } from '../../config/index.js';
|
|
|
3
3
|
export type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
|
|
4
4
|
export interface ObjectFieldProps {
|
|
5
5
|
label?: string;
|
|
6
|
+
description?: string;
|
|
6
7
|
fields: FieldConfig[];
|
|
7
8
|
value: Record<string, unknown> | undefined;
|
|
8
9
|
onChange: (value: Record<string, unknown>) => void;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from 'react';
|
|
2
3
|
import { Button, Group, Paper, Stack, Text } from '@mantine/core';
|
|
3
4
|
import { formatCanopyPath } from '../canopy-path.js';
|
|
4
|
-
|
|
5
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
6
|
+
export const ObjectField = ({ label, description, fields, value, onChange, renderField, path, dataCanopyField, onRemove, }) => {
|
|
5
7
|
const current = value ?? {};
|
|
6
|
-
|
|
8
|
+
const descriptionBaseId = useId();
|
|
9
|
+
return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [(label || onRemove) && (_jsxs(Group, { justify: "space-between", children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "neutral.8", children: label })), onRemove && (_jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: onRemove, children: "Clear" }))] })), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
|
|
7
10
|
const fieldPath = [...path, field.name];
|
|
8
11
|
return (_jsx("div", { children: renderField(field, current[field.name], (next) => onChange({ ...current, [field.name]: next }), fieldPath) }, formatCanopyPath(fieldPath)));
|
|
9
12
|
}) })] }) }));
|
|
@@ -6,7 +6,8 @@ import { IconAlertCircle } from '@tabler/icons-react';
|
|
|
6
6
|
import { createApiClient } from '../../api/client.js';
|
|
7
7
|
import { useOptionalApiClient } from '../context/index.js';
|
|
8
8
|
import { getErrorMessage } from '../../utils/error.js';
|
|
9
|
-
|
|
9
|
+
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
10
|
+
export const ReferenceField = ({ id, label, description, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', value, onChange, multiple, dataCanopyField, }) => {
|
|
10
11
|
// Configured with the deployment's basePath when rendered inside an
|
|
11
12
|
// ApiClientProvider (always true in the real Editor tree); `null` outside one
|
|
12
13
|
// (e.g. FormRenderer.stories.tsx), so the fetch effect falls back to a
|
|
@@ -101,10 +102,10 @@ export const ReferenceField = ({ id, label, options: staticOptions, collections,
|
|
|
101
102
|
setRetryCount((count) => count + 1);
|
|
102
103
|
};
|
|
103
104
|
if (loading) {
|
|
104
|
-
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] }));
|
|
105
|
+
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] }));
|
|
105
106
|
}
|
|
106
107
|
if (error) {
|
|
107
|
-
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { size: "xs", variant: "light", onClick: handleRetry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] }));
|
|
108
|
+
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { size: "xs", variant: "light", onClick: handleRetry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] }));
|
|
108
109
|
}
|
|
109
|
-
return (_jsx(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: multiple ? (_jsx(MultiSelect, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: "Select reference\u2026", size: "sm" })) : (_jsx(Select, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: "Select reference\u2026", size: "sm" })) }));
|
|
110
|
+
return (_jsx(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: multiple ? (_jsx(MultiSelect, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: "Select reference\u2026", size: "sm" })) : (_jsx(Select, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: "Select reference\u2026", size: "sm" })) }));
|
|
110
111
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { MultiSelect, Select, Stack, Text } from '@mantine/core';
|
|
4
|
-
export const SelectField = ({ id, label, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, }) => {
|
|
4
|
+
export const SelectField = ({ id, label, description, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, }) => {
|
|
5
5
|
const normalizedValue = multiple
|
|
6
6
|
? Array.isArray(value)
|
|
7
7
|
? value
|
|
@@ -11,5 +11,5 @@ export const SelectField = ({ id, label, options, value, onChange, multiple, pla
|
|
|
11
11
|
: '';
|
|
12
12
|
const generatedId = useId();
|
|
13
13
|
const inputId = id ?? generatedId;
|
|
14
|
-
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, children: [multiple ? (_jsx(MultiSelect, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: placeholder, size: "sm" })), multiple && (_jsx(Text, { size: "xs", c: "dimmed", children: "Searchable multi-select; start typing to filter." }))] }));
|
|
14
|
+
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, children: [multiple ? (_jsx(MultiSelect, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: placeholder, size: "sm" })), multiple && (_jsx(Text, { size: "xs", c: "dimmed", children: "Searchable multi-select; start typing to filter." }))] }));
|
|
15
15
|
};
|
|
@@ -7,10 +7,10 @@ import { TagsInput } from '@mantine/core';
|
|
|
7
7
|
* Uses Mantine's TagsInput: type + Enter adds an item, each item renders as a
|
|
8
8
|
* removable pill, and Backspace on an empty input removes the last item.
|
|
9
9
|
*/
|
|
10
|
-
export const StringListField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
10
|
+
export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
11
11
|
const generatedId = useId();
|
|
12
12
|
const inputId = id ?? generatedId;
|
|
13
|
-
return (_jsx(TagsInput, { id: inputId, label: label, value: value, size: "sm", onChange: onChange,
|
|
13
|
+
return (_jsx(TagsInput, { id: inputId, label: label, description: description, value: value, size: "sm", onChange: onChange,
|
|
14
14
|
// Faithful generic-list semantics, not tag ergonomics: no comma-splitting
|
|
15
15
|
// ("New York, NY" stays one item) and duplicates are legitimate list
|
|
16
16
|
// data — TagsInput's defaults would break both, so existing file data
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { TextInput } from '@mantine/core';
|
|
4
|
-
export const TextField = ({ id, label, value, onChange, dataCanopyField, }) => {
|
|
4
|
+
export const TextField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
|
|
5
5
|
const generatedId = useId();
|
|
6
6
|
const inputId = id ?? generatedId;
|
|
7
|
-
return (_jsx(TextInput, { id: inputId, label: label, value: value, size: "sm", onChange: (e) => onChange(e.currentTarget.value), "data-canopy-field": dataCanopyField }));
|
|
7
|
+
return (_jsx(TextInput, { id: inputId, label: label, description: description, value: value, size: "sm", onChange: (e) => onChange(e.currentTarget.value), "data-canopy-field": dataCanopyField }));
|
|
8
8
|
};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
|
-
import { Switch } from '@mantine/core';
|
|
4
|
-
|
|
3
|
+
import { Stack, Switch } from '@mantine/core';
|
|
4
|
+
import { FieldDescription, fieldDescriptionId } from './FieldDescription.js';
|
|
5
|
+
export const ToggleField = ({ id, label, description, value, onChange, dataCanopyField, testId, }) => {
|
|
5
6
|
const generatedId = useId();
|
|
6
7
|
const inputId = id ?? generatedId;
|
|
7
|
-
|
|
8
|
+
// A Switch's `description` sits inside its <label> and so joins the accessible name;
|
|
9
|
+
// rendering it as a sibling keeps the name to the label alone.
|
|
10
|
+
return (_jsxs(Stack, { gap: 4, children: [_jsx(Switch, { id: inputId, label: label, "aria-describedby": description ? fieldDescriptionId(inputId) : undefined, checked: value, onChange: (e) => onChange(e.currentTarget.checked), size: "md", "data-canopy-field": dataCanopyField, wrapperProps: testId ? { 'data-testid': testId } : undefined }), _jsx(FieldDescription, { baseId: inputId, description: description })] }));
|
|
8
11
|
};
|
|
@@ -113,6 +113,16 @@ export function useCommentSystem(options) {
|
|
|
113
113
|
}, [activeCommentContext, options.selectedPath, options.branchName]);
|
|
114
114
|
// Listen for field focus messages from preview frame
|
|
115
115
|
useEffect(() => {
|
|
116
|
+
// Delayed undo steps (highlight restore, focus clear). Cleanup runs them early rather than
|
|
117
|
+
// dropping them, so an entry switch never leaves a field highlighted or focused.
|
|
118
|
+
const pending = new Map();
|
|
119
|
+
const later = (undo, ms) => {
|
|
120
|
+
const id = window.setTimeout(() => {
|
|
121
|
+
pending.delete(id);
|
|
122
|
+
undo();
|
|
123
|
+
}, ms);
|
|
124
|
+
pending.set(id, undo);
|
|
125
|
+
};
|
|
116
126
|
const handleFocus = (event) => {
|
|
117
127
|
// Only accept messages from the preview's origin (same-origin when previewSrc is
|
|
118
128
|
// relative). Origin-only by design: this hook has no handle on the preview iframe
|
|
@@ -136,21 +146,26 @@ export function useCommentSystem(options) {
|
|
|
136
146
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
137
147
|
const previous = target.style.boxShadow;
|
|
138
148
|
target.style.boxShadow = '0 0 0 3px rgba(79, 70, 229, 0.35)';
|
|
139
|
-
|
|
149
|
+
later(() => {
|
|
140
150
|
target.style.boxShadow = previous;
|
|
141
151
|
}, 1200);
|
|
142
152
|
// Set focused field path to trigger FieldWrapper auto-focus
|
|
143
153
|
if (normalizedPath) {
|
|
144
154
|
setFocusedFieldPath(normalizedPath);
|
|
145
155
|
// Clear after brief delay to allow FieldWrapper to detect the change
|
|
146
|
-
|
|
147
|
-
setFocusedFieldPath(undefined);
|
|
148
|
-
}, 100);
|
|
156
|
+
later(() => setFocusedFieldPath(undefined), 100);
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
159
|
};
|
|
152
160
|
window.addEventListener('message', handleFocus);
|
|
153
|
-
return () =>
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener('message', handleFocus);
|
|
163
|
+
for (const [id, undo] of pending) {
|
|
164
|
+
window.clearTimeout(id);
|
|
165
|
+
undo();
|
|
166
|
+
}
|
|
167
|
+
pending.clear();
|
|
168
|
+
};
|
|
154
169
|
}, [options.currentEntry]);
|
|
155
170
|
const handleJumpToField = (entryPath, canopyPath, threadId) => {
|
|
156
171
|
if (entryPath !== options.selectedPath) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CANOPYCMS_VERSION = "0.0.68-int.
|
|
1
|
+
export declare const CANOPYCMS_VERSION = "0.0.68-int.96";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
|
|
2
2
|
// tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
|
|
3
|
-
export const CANOPYCMS_VERSION = '0.0.68-int.
|
|
3
|
+
export const CANOPYCMS_VERSION = '0.0.68-int.96';
|
|
@@ -15,11 +15,11 @@ export type AuthCacheRefresher = () => Promise<void>;
|
|
|
15
15
|
* interface for the two shapes and why an App is optional.
|
|
16
16
|
*/
|
|
17
17
|
export interface CmsWorkerConfig extends GitHubAuthConfig {
|
|
18
|
-
/** Path to workspace root on EFS (e.g., /mnt/efs
|
|
18
|
+
/** Path to workspace root on EFS (e.g., /mnt/efs) */
|
|
19
19
|
workspacePath: string;
|
|
20
|
-
/** GitHub owner (e.g., '
|
|
20
|
+
/** GitHub owner (e.g., 'acme') */
|
|
21
21
|
githubOwner: string;
|
|
22
|
-
/** GitHub repo name (e.g., '
|
|
22
|
+
/** GitHub repo name (e.g., 'site') */
|
|
23
23
|
githubRepo: string;
|
|
24
24
|
/** Called periodically to update the auth metadata cache on EFS. */
|
|
25
25
|
refreshAuthCache?: AuthCacheRefresher;
|
|
@@ -15,7 +15,7 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
15
15
|
*
|
|
16
16
|
* One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
|
|
17
17
|
* follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
|
|
18
|
-
* `remote.git
|
|
18
|
+
* `remote.git`, and `reconcileTrackedBranches` is what advances
|
|
19
19
|
* `remote.git`'s `refs/heads/*` toward what the fetch above put in the tracking
|
|
20
20
|
* namespace; reorder them and every branch rebases onto the PREVIOUS cycle's
|
|
21
21
|
* base tip -- not corrupting, but silently a cycle behind.
|
|
@@ -51,7 +51,7 @@ export declare function syncGit(ctx: GitSyncContext): Promise<void>;
|
|
|
51
51
|
export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'contentBranchesPath'>): Promise<number>;
|
|
52
52
|
/**
|
|
53
53
|
* Fast-forward the base branch's own working-tree clone
|
|
54
|
-
* (content-branches/<baseBranch>) to match
|
|
54
|
+
* (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
|
|
55
55
|
* cycle, so the drift window is bounded by gitSyncInterval.
|
|
56
56
|
*
|
|
57
57
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
@@ -60,8 +60,8 @@ export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'cont
|
|
|
60
60
|
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
61
61
|
* goes to worker-status.json for the same reason.
|
|
62
62
|
*
|
|
63
|
-
* ff-only on purpose: this clone must stay a linear mirror of
|
|
64
|
-
*
|
|
63
|
+
* ff-only on purpose: this clone must stay a linear mirror of remote.git's
|
|
64
|
+
* <baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
65
65
|
* history) is left untouched rather than force-resolved.
|
|
66
66
|
*
|
|
67
67
|
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -409,7 +409,7 @@ export async function cleanupTrashedBranchDirs(ctx) {
|
|
|
409
409
|
const warnedTrackedCanopyState = new Set();
|
|
410
410
|
/**
|
|
411
411
|
* Fast-forward the base branch's own working-tree clone
|
|
412
|
-
* (content-branches/<baseBranch>) to match
|
|
412
|
+
* (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
|
|
413
413
|
* cycle, so the drift window is bounded by gitSyncInterval.
|
|
414
414
|
*
|
|
415
415
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
@@ -418,8 +418,8 @@ const warnedTrackedCanopyState = new Set();
|
|
|
418
418
|
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
419
419
|
* goes to worker-status.json for the same reason.
|
|
420
420
|
*
|
|
421
|
-
* ff-only on purpose: this clone must stay a linear mirror of
|
|
422
|
-
*
|
|
421
|
+
* ff-only on purpose: this clone must stay a linear mirror of remote.git's
|
|
422
|
+
* <baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
423
423
|
* history) is left untouched rather than force-resolved.
|
|
424
424
|
*
|
|
425
425
|
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
|
@@ -523,15 +523,13 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
523
523
|
};
|
|
524
524
|
}
|
|
525
525
|
// Raw (unsanitized) name from here on: these are git ref operations
|
|
526
|
-
// against
|
|
526
|
+
// against remote.git's <baseBranch>, not filesystem paths, so they must use
|
|
527
527
|
// the same name GitHub knows the branch by.
|
|
528
|
-
await baseGit.fetch(
|
|
528
|
+
await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
529
529
|
// rev-list, not status.behind, which needs an upstream tracking branch that
|
|
530
|
-
// is not guaranteed here. Against the just-fetched tip
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// with "ambiguous argument". Pin FETCH_HEAD to a SHA immediately -- it is
|
|
534
|
-
// one shared mutable file per repo, silently repointed by any other fetch.
|
|
530
|
+
// is not guaranteed here. Against the just-fetched tip: a fetch by path
|
|
531
|
+
// updates no remote-tracking ref. Pin FETCH_HEAD to a SHA immediately -- it
|
|
532
|
+
// is one shared mutable file per repo, silently repointed by any other fetch.
|
|
535
533
|
const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
|
|
536
534
|
const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
|
|
537
535
|
if (behindCount > 0) {
|
|
@@ -72,7 +72,7 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
|
|
|
72
72
|
* Returns whether the push landed. A refused lease means a concurrent Lambda
|
|
73
73
|
* push moved the ref; logged and retried by the self-heal pass, never thrown.
|
|
74
74
|
*/
|
|
75
|
-
export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
75
|
+
export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
76
76
|
/**
|
|
77
77
|
* Queue the GitHub hop for a branch whose rewritten history now sits in
|
|
78
78
|
* `remote.git`, so an open PR's head follows the rebase within a cycle instead
|
|
@@ -108,7 +108,7 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
|
|
|
108
108
|
// Real flags must precede --end-of-options; everything after it is
|
|
109
109
|
// positional (see GitManager.push() for the same guard).
|
|
110
110
|
'--end-of-options',
|
|
111
|
-
|
|
111
|
+
ctx.remoteGitPath,
|
|
112
112
|
`${branchRef}:${branchRef}`,
|
|
113
113
|
]);
|
|
114
114
|
workerLog(` Published rebased ${branchRef} into remote.git`);
|
package/dist/worker/rebase.js
CHANGED
|
@@ -162,8 +162,8 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
|
|
|
162
162
|
break;
|
|
163
163
|
try {
|
|
164
164
|
if (nextAction === 'start') {
|
|
165
|
-
// The pinned base tip fetched above
|
|
166
|
-
//
|
|
165
|
+
// The pinned base tip fetched above; a fetch by path updates no
|
|
166
|
+
// remote-tracking ref.
|
|
167
167
|
await branchGit.rebase([fetchedBaseTip]);
|
|
168
168
|
}
|
|
169
169
|
else if (nextAction === 'continue') {
|
|
@@ -638,7 +638,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
638
638
|
// the bytes stay untouched, and the wedge is recorded for an operator.
|
|
639
639
|
const trackedState = trackedCanopyStateChanges(dirtyCheck);
|
|
640
640
|
if (trackedState.length > 0) {
|
|
641
|
-
await branchGit.fetch(
|
|
641
|
+
await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
642
642
|
const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
643
643
|
const { stillTracked } = await splitByUpstreamTracking(branchGit, trackedState, baseTip);
|
|
644
644
|
const reason = stillTracked.length > 0
|
|
@@ -672,14 +672,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
672
672
|
marker: metaFile.branch.historyRewrittenFrom,
|
|
673
673
|
});
|
|
674
674
|
}
|
|
675
|
-
await branchGit.fetch(
|
|
675
|
+
await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
676
676
|
// rev-list, not status.behind, which needs an upstream tracking branch
|
|
677
677
|
// that checkoutBranch's fallback paths do not always configure. Against
|
|
678
|
-
// the just-fetched tip
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
// — FETCH_HEAD is one shared mutable file per repo, repointed by any
|
|
682
|
-
// concurrent fetch.
|
|
678
|
+
// the just-fetched tip: a fetch by path updates no remote-tracking ref.
|
|
679
|
+
// Pinned to a SHA immediately — FETCH_HEAD is one shared mutable file per
|
|
680
|
+
// repo, repointed by any concurrent fetch.
|
|
683
681
|
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
684
682
|
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
685
683
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
@@ -38,7 +38,12 @@ export interface WorkerContext {
|
|
|
38
38
|
readonly sanitizedBaseBranch: SanitizedBranchName;
|
|
39
39
|
/** `{workspacePath}/.tasks` — the task queue and worker-status.json. */
|
|
40
40
|
readonly taskDir: string;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* `{workspacePath}/remote.git` — the shared bare repo. The worker fetches
|
|
43
|
+
* into and pushes from a workspace clone against this path, never the
|
|
44
|
+
* clone's `origin`: that holds the path the CLONING process saw (usually
|
|
45
|
+
* the Lambda), and the clone's config is Lambda-writable.
|
|
46
|
+
*/
|
|
42
47
|
readonly remoteGitPath: string;
|
|
43
48
|
/** `{workspacePath}/content-branches` — the branch workspace root. */
|
|
44
49
|
readonly contentBranchesPath: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.0.68-int.
|
|
4
|
+
"version": "0.0.68-int.96",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|