@byline/admin 4.1.0 → 4.3.0

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.
Files changed (58) hide show
  1. package/dist/fields/array/array-field.d.ts +12 -2
  2. package/dist/fields/array/array-field.js +46 -20
  3. package/dist/fields/array/array-field.module.js +5 -1
  4. package/dist/fields/array/array-field_module.css +23 -0
  5. package/dist/fields/blocks/blocks-field.js +34 -12
  6. package/dist/fields/blocks/blocks-field.module.js +2 -0
  7. package/dist/fields/blocks/blocks-field_module.css +37 -0
  8. package/dist/fields/code/code-editor.d.ts +20 -0
  9. package/dist/fields/code/code-editor.js +239 -0
  10. package/dist/fields/code/code-field.d.ts +21 -0
  11. package/dist/fields/code/code-field.js +124 -0
  12. package/dist/fields/code/code-field.module.js +7 -0
  13. package/dist/fields/code/code-field_module.css +58 -0
  14. package/dist/fields/draggable-context-menu.js +2 -1
  15. package/dist/fields/field-admin.d.ts +15 -0
  16. package/dist/fields/field-admin.js +11 -0
  17. package/dist/fields/field-admin.test.node.d.ts +8 -0
  18. package/dist/fields/field-helpers.js +1 -0
  19. package/dist/fields/field-renderer.d.ts +11 -2
  20. package/dist/fields/field-renderer.js +21 -4
  21. package/dist/fields/group/group-field.d.ts +23 -2
  22. package/dist/fields/group/group-field.js +7 -3
  23. package/dist/fields/relation/relation-picker.js +8 -2
  24. package/dist/fields/select/select-field.js +4 -2
  25. package/dist/fields/select/select-field.module.js +1 -0
  26. package/dist/fields/select/select-field_module.css +4 -0
  27. package/dist/fields/sortable-item.d.ts +6 -0
  28. package/dist/fields/sortable-item.js +44 -25
  29. package/dist/fields/text/text-field_module.css +1 -0
  30. package/dist/fields/text-area/text-area-field_module.css +1 -0
  31. package/dist/forms/form-context.js +1 -1
  32. package/dist/forms/form-renderer.d.ts +1 -1
  33. package/dist/forms/form-renderer.js +3 -1
  34. package/dist/forms/tree-placement-widget.d.ts +1 -1
  35. package/package.json +23 -10
  36. package/src/fields/array/array-field.module.css +30 -0
  37. package/src/fields/array/array-field.tsx +74 -20
  38. package/src/fields/blocks/blocks-field.module.css +53 -0
  39. package/src/fields/blocks/blocks-field.tsx +38 -1
  40. package/src/fields/code/code-editor.tsx +246 -0
  41. package/src/fields/code/code-field.module.css +84 -0
  42. package/src/fields/code/code-field.tsx +191 -0
  43. package/src/fields/draggable-context-menu.tsx +9 -1
  44. package/src/fields/field-admin.test.node.ts +49 -0
  45. package/src/fields/field-admin.ts +39 -0
  46. package/src/fields/field-helpers.ts +1 -0
  47. package/src/fields/field-renderer.tsx +33 -2
  48. package/src/fields/field-services-types.ts +1 -1
  49. package/src/fields/group/group-field.tsx +29 -2
  50. package/src/fields/relation/relation-picker.tsx +11 -0
  51. package/src/fields/select/select-field.module.css +5 -0
  52. package/src/fields/select/select-field.tsx +9 -2
  53. package/src/fields/sortable-item.tsx +115 -34
  54. package/src/fields/text/text-field.module.css +1 -0
  55. package/src/fields/text-area/text-area-field.module.css +1 -0
  56. package/src/forms/form-context.tsx +9 -1
  57. package/src/forms/form-renderer.tsx +3 -1
  58. package/src/forms/tree-placement-widget.tsx +1 -1
@@ -10,6 +10,7 @@ import type {
10
10
  ArrayField as ArrayFieldType,
11
11
  BlocksField as BlocksFieldType,
12
12
  Field,
13
+ FieldAdminConfig,
13
14
  FieldComponentSlots,
14
15
  GroupField as GroupFieldType,
15
16
  RichTextEditorComponent,
@@ -21,6 +22,7 @@ import { useFormContext } from '../forms/form-context'
21
22
  import { ArrayField } from './array/array-field'
22
23
  import { BlocksField } from './blocks/blocks-field'
23
24
  import { CheckboxField } from './checkbox/checkbox-field'
25
+ import { CodeField } from './code/code-field'
24
26
  import { DateTimeField } from './datetime/datetime-field'
25
27
  import styles from './field-renderer.module.css'
26
28
  import { FileField } from './file/file-field'
@@ -67,6 +69,15 @@ interface FieldRendererProps {
67
69
  * Ignored when `field.type !== 'richText'`.
68
70
  */
69
71
  editor?: RichTextEditorComponent
72
+ /**
73
+ * Admin overrides for this field's *descendants*, keyed by dotted,
74
+ * index-free schema paths relative to this field ('answer',
75
+ * 'filesGroup.publicationFile'). Only meaningful when `field` is a
76
+ * structural `group` / `array` — the widget slices the map per child
77
+ * (see `sliceFieldAdmin`). `components` / `editor` above stay the
78
+ * overrides for this field itself.
79
+ */
80
+ fieldAdmin?: Record<string, FieldAdminConfig>
70
81
  }
71
82
 
72
83
  export const FieldRenderer = ({
@@ -79,6 +90,7 @@ export const FieldRenderer = ({
79
90
  contentLocale,
80
91
  components,
81
92
  editor,
93
+ fieldAdmin,
82
94
  }: FieldRendererProps) => {
83
95
  const path = basePath ? `${basePath}.${field.name}` : field.name
84
96
  const htmlId = path.replace(/[[\].]/g, '-')
@@ -140,6 +152,18 @@ export const FieldRenderer = ({
140
152
  components={components}
141
153
  />
142
154
  )
155
+ case 'code':
156
+ return (
157
+ <CodeField
158
+ field={hideLabel ? { ...field, label: undefined } : field}
159
+ defaultValue={defaultValue}
160
+ onChange={handleChange}
161
+ path={path}
162
+ id={htmlId}
163
+ locale={isLocalised ? contentLocale : undefined}
164
+ components={components}
165
+ />
166
+ )
143
167
  case 'checkbox':
144
168
  return (
145
169
  <CheckboxField
@@ -276,8 +300,10 @@ export const FieldRenderer = ({
276
300
  }
277
301
  defaultValue={defaultValue}
278
302
  path={path}
303
+ disableSorting={disableSorting}
279
304
  collectionPath={collectionPath}
280
305
  contentLocale={contentLocale}
306
+ fieldAdmin={fieldAdmin}
281
307
  />
282
308
  )
283
309
  case 'blocks':
@@ -300,6 +326,7 @@ export const FieldRenderer = ({
300
326
  disableSorting={disableSorting}
301
327
  collectionPath={collectionPath}
302
328
  contentLocale={contentLocale}
329
+ fieldAdmin={fieldAdmin}
303
330
  />
304
331
  )
305
332
  default:
@@ -307,9 +334,13 @@ export const FieldRenderer = ({
307
334
  }
308
335
  }
309
336
 
310
- // text and textArea render the badge inside their own Label row;
337
+ // text, textArea, and code render the badge inside their own Label row;
311
338
  // the outer wrapper is only needed for other field types.
312
- const selfBadge = field.type === 'text' || field.type === 'textArea' || field.type === 'richText'
339
+ const selfBadge =
340
+ field.type === 'text' ||
341
+ field.type === 'textArea' ||
342
+ field.type === 'code' ||
343
+ field.type === 'richText'
313
344
 
314
345
  if (badge && !selfBadge) {
315
346
  return (
@@ -62,7 +62,7 @@ export type UploadFieldFn = (
62
62
  createDocument?: boolean
63
63
  ) => Promise<UploadedFileResult>
64
64
 
65
- // --- Document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md) -----
65
+ // --- Document tree (the `tree: true` primitive — docs/04-collections/04-document-trees.md) -----
66
66
 
67
67
  /** One hydrated ancestor in a document's breadcrumb trail (root-first). */
68
68
  export interface TreeAncestor {
@@ -8,10 +8,11 @@
8
8
 
9
9
  import { useMemo } from 'react'
10
10
 
11
- import type { Field, GroupField as GroupFieldType } from '@byline/core'
11
+ import type { Field, FieldAdminConfig, GroupField as GroupFieldType } from '@byline/core'
12
12
  import { ErrorText } from '@byline/ui/react'
13
13
  import cx from 'classnames'
14
14
 
15
+ import { sliceFieldAdmin } from '../../fields/field-admin'
15
16
  import { placeholderForField } from '../../fields/field-helpers'
16
17
  import { FieldRenderer } from '../../fields/field-renderer'
17
18
  import { useFieldError } from '../../forms/form-context'
@@ -32,6 +33,16 @@ interface GroupFieldProps {
32
33
  field: GroupFieldType
33
34
  defaultValue: any
34
35
  path: string
36
+ /**
37
+ * Threaded to child fields — governs only the *drag* affordance of any
38
+ * `array` children (structural add/remove always renders; see ArrayField).
39
+ * Defaults to `true` (conservative): arrays inside plain schema groups
40
+ * stay drag-free. `BlocksField` passes `false` on its synthesized group so
41
+ * arrays directly inside blocks are fully sortable — safe because each
42
+ * `DraggableSortable` is an independent DndContext with grip-scoped
43
+ * listeners.
44
+ */
45
+ disableSorting?: boolean
35
46
  /**
36
47
  * Collection path forwarded to upload-capable child fields (`file` / `image`),
37
48
  * which need it to reach the `/upload` endpoint. Without it those fields fall
@@ -44,14 +55,27 @@ interface GroupFieldProps {
44
55
  * locale badge.
45
56
  */
46
57
  contentLocale?: string
58
+ /**
59
+ * Per-child-field admin overrides (`components` slots, richtext `editor`),
60
+ * keyed by dotted, index-free schema paths relative to this group
61
+ * ('caption', 'faq.answer'). Threaded by `BlocksField` from the site-wide
62
+ * `ClientConfig.blockAdmin` registry (block children render through a
63
+ * synthesized group) and by `FieldRenderer` for plain schema groups, whose
64
+ * map arrives pre-sliced from the collection admin config. Exact-name
65
+ * entries apply to the child itself; deeper entries are re-sliced and
66
+ * threaded on (see `sliceFieldAdmin`).
67
+ */
68
+ fieldAdmin?: Record<string, FieldAdminConfig>
47
69
  }
48
70
 
49
71
  export const GroupField = ({
50
72
  field,
51
73
  defaultValue,
52
74
  path,
75
+ disableSorting = true,
53
76
  collectionPath,
54
77
  contentLocale,
78
+ fieldAdmin,
55
79
  }: GroupFieldProps) => {
56
80
  const fieldError = useFieldError(field.name)
57
81
  // Default value for a group field is a plain object: { rating: 5, comment: '...' }
@@ -91,9 +115,12 @@ export const GroupField = ({
91
115
  field={innerField}
92
116
  defaultValue={groupData[innerField.name]}
93
117
  basePath={path}
94
- disableSorting={true}
118
+ disableSorting={disableSorting}
95
119
  collectionPath={collectionPath}
96
120
  contentLocale={contentLocale}
121
+ components={fieldAdmin?.[innerField.name]?.components}
122
+ editor={fieldAdmin?.[innerField.name]?.editor}
123
+ fieldAdmin={sliceFieldAdmin(fieldAdmin, innerField.name)}
97
124
  />
98
125
  )
99
126
  })}
@@ -165,6 +165,13 @@ export const RelationPicker = ({
165
165
 
166
166
  setLoading(true)
167
167
  setError(null)
168
+ // Item-view sort: the target collection's `itemViewSort` (boot-validated)
169
+ // orders the picker independently of its list view's `defaultSort`.
170
+ // Passed as explicit params because the list server fn gives an explicit
171
+ // `order` top precedence; when absent the server falls back through
172
+ // `defaultSort` → `created_at desc` (or `order_key asc` for orderable
173
+ // collections) exactly as before.
174
+ const itemViewSort = targetAdminConfig?.itemViewSort
168
175
  getCollectionDocuments({
169
176
  collection: targetCollectionPath,
170
177
  params: {
@@ -172,6 +179,9 @@ export const RelationPicker = ({
172
179
  page_size: PAGE_SIZE,
173
180
  query: query.length > 0 ? query : undefined,
174
181
  fields: selectFields,
182
+ ...(itemViewSort != null
183
+ ? { order: String(itemViewSort.field), desc: itemViewSort.direction === 'desc' }
184
+ : {}),
175
185
  },
176
186
  })
177
187
  .then((response: any) => {
@@ -202,6 +212,7 @@ export const RelationPicker = ({
202
212
  pickerColumns,
203
213
  getCollectionDocuments,
204
214
  t,
215
+ targetAdminConfig?.itemViewSort,
205
216
  ])
206
217
 
207
218
  const resolvedDisplayField =
@@ -7,6 +7,11 @@
7
7
  * field has unsaved local changes
8
8
  */
9
9
 
10
+ .label,
11
+ :global(.byline-field-select-label) {
12
+ margin-bottom: 0.25rem;
13
+ }
14
+
10
15
  .dirty,
11
16
  :global(.byline-field-select-dirty) {
12
17
  border-color: var(--blue-300);
@@ -38,10 +38,17 @@ export const SelectField = ({
38
38
  return (
39
39
  <div className={`byline-field-select ${field.name}`}>
40
40
  {field.label && (
41
- <Label id={htmlId} htmlFor={htmlId} label={field.label} required={!field.optional} />
41
+ <Label
42
+ id={htmlId}
43
+ htmlFor={htmlId}
44
+ label={field.label}
45
+ required={!field.optional}
46
+ className={cx('byline-field-select-label', styles.label)}
47
+ />
42
48
  )}
43
49
  <Select<string>
44
- size="sm"
50
+ size="xs"
51
+ variant="outlined"
45
52
  id={htmlId}
46
53
  name={field.name}
47
54
  placeholder="Select an option"
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { type ReactNode, useState } from 'react'
9
+ import { type CSSProperties, type ReactNode, type Ref, useState } from 'react'
10
10
 
11
11
  import { useTranslation } from '@byline/i18n/react'
12
12
  import { ChevronDownIcon, GripperVerticalIcon, useSortable } from '@byline/ui/react'
@@ -15,44 +15,60 @@ import cx from 'classnames'
15
15
  import { DraggableContextMenu } from './draggable-context-menu'
16
16
  import styles from './sortable-item.module.css'
17
17
 
18
- export const SortableItem = ({
19
- id,
20
- label,
21
- children,
22
- onAddBelow,
23
- onRemove,
24
- }: {
25
- id: string
18
+ // ---------------------------------------------------------------------------
19
+ // Item chrome for repeating-structure entries (array items, block instances):
20
+ // a header carrying the label, the add-below/remove context menu, and a
21
+ // collapse toggle, above the item's rendered fields.
22
+ //
23
+ // Two variants share the frame:
24
+ // - `SortableItem` — adds the dnd-kit grip (drag handle). Must render
25
+ // inside a `DraggableSortable` (it calls `useSortable`).
26
+ // - `StaticItem` — no grip, no dnd hook. For contexts where drag is
27
+ // disabled but structural editing (add/remove/collapse) must remain,
28
+ // e.g. arrays nested inside another array's items.
29
+ //
30
+ // They are separate components (not a boolean prop on one component)
31
+ // because `useSortable` is a hook — it cannot be called conditionally, and
32
+ // it throws outside a DndContext.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface ItemFrameProps {
26
36
  label: ReactNode
27
37
  children: ReactNode
28
38
  onAddBelow?: () => void
29
39
  onRemove?: () => void
30
- }) => {
31
- const { t } = useTranslation('byline-admin')
32
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
33
- id,
34
- transition: {
35
- duration: 250,
36
- easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
37
- },
38
- })
40
+ /** Drag handle slot — rendered leading the header when provided. */
41
+ grip?: ReactNode
42
+ rootRef?: Ref<HTMLDivElement>
43
+ style?: CSSProperties
44
+ dragging?: boolean
45
+ /** Extra root class, e.g. the static variant marker. */
46
+ rootClassName?: string
47
+ }
39
48
 
49
+ const ItemFrame = ({
50
+ label,
51
+ children,
52
+ onAddBelow,
53
+ onRemove,
54
+ grip,
55
+ rootRef,
56
+ style,
57
+ dragging = false,
58
+ rootClassName,
59
+ }: ItemFrameProps) => {
60
+ const { t } = useTranslation('byline-admin')
40
61
  const [collapsed, setCollapsed] = useState(false)
41
62
 
42
- const style = {
43
- transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
44
- transition,
45
- zIndex: isDragging ? 10 : 'auto',
46
- }
47
-
48
63
  return (
49
64
  <div
50
- ref={setNodeRef}
65
+ ref={rootRef}
51
66
  style={style}
52
67
  className={cx(
53
68
  'byline-sortable',
54
69
  styles.root,
55
- isDragging && ['byline-sortable-dragging', styles.dragging],
70
+ rootClassName,
71
+ dragging && ['byline-sortable-dragging', styles.dragging],
56
72
  collapsed && ['byline-sortable-collapsed', styles.collapsed]
57
73
  )}
58
74
  >
@@ -63,14 +79,7 @@ export const SortableItem = ({
63
79
  !collapsed && ['byline-sortable-header-expanded', styles['header-expanded']]
64
80
  )}
65
81
  >
66
- <button
67
- type="button"
68
- className={cx('byline-sortable-grip', styles.grip)}
69
- {...attributes}
70
- {...listeners}
71
- >
72
- <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
73
- </button>
82
+ {grip}
74
83
  <div className={cx('byline-sortable-label', styles.label)}>{label}</div>
75
84
  <DraggableContextMenu onAddBelow={onAddBelow} onRemove={onRemove} />
76
85
  <button
@@ -104,3 +113,75 @@ export const SortableItem = ({
104
113
  </div>
105
114
  )
106
115
  }
116
+
117
+ export const SortableItem = ({
118
+ id,
119
+ label,
120
+ children,
121
+ onAddBelow,
122
+ onRemove,
123
+ }: {
124
+ id: string
125
+ label: ReactNode
126
+ children: ReactNode
127
+ onAddBelow?: () => void
128
+ onRemove?: () => void
129
+ }) => {
130
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
131
+ id,
132
+ transition: {
133
+ duration: 250,
134
+ easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
135
+ },
136
+ })
137
+
138
+ const style = {
139
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
140
+ transition,
141
+ zIndex: isDragging ? 10 : 'auto',
142
+ }
143
+
144
+ return (
145
+ <ItemFrame
146
+ label={label}
147
+ onAddBelow={onAddBelow}
148
+ onRemove={onRemove}
149
+ rootRef={setNodeRef}
150
+ style={style}
151
+ dragging={isDragging}
152
+ grip={
153
+ <button
154
+ type="button"
155
+ className={cx('byline-sortable-grip', styles.grip)}
156
+ {...attributes}
157
+ {...listeners}
158
+ >
159
+ <GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])} />
160
+ </button>
161
+ }
162
+ >
163
+ {children}
164
+ </ItemFrame>
165
+ )
166
+ }
167
+
168
+ export const StaticItem = ({
169
+ label,
170
+ children,
171
+ onAddBelow,
172
+ onRemove,
173
+ }: {
174
+ label: ReactNode
175
+ children: ReactNode
176
+ onAddBelow?: () => void
177
+ onRemove?: () => void
178
+ }) => (
179
+ <ItemFrame
180
+ label={label}
181
+ onAddBelow={onAddBelow}
182
+ onRemove={onRemove}
183
+ rootClassName="byline-sortable-static"
184
+ >
185
+ {children}
186
+ </ItemFrame>
187
+ )
@@ -10,4 +10,5 @@
10
10
  :global(.byline-field-text-label-row) {
11
11
  display: flex;
12
12
  align-items: center;
13
+ margin-bottom: 0.25rem;
13
14
  }
@@ -10,4 +10,5 @@
10
10
  :global(.byline-field-text-area-label-row) {
11
11
  display: flex;
12
12
  align-items: center;
13
+ margin-bottom: 0.25rem;
13
14
  }
@@ -316,7 +316,15 @@ export const FormProvider = ({
316
316
  const getPatches = useCallback(() => patchesRef.current, [])
317
317
  const appendPatch = useCallback(
318
318
  (patch: DocumentPatch) => {
319
- patchesRef.current = [...patchesRef.current, patch]
319
+ // Snapshot the patch at append time. Structural patches (array.insert,
320
+ // block add) carry item objects that are ALSO placed into the form
321
+ // store — and `setNestedValue` mutates store nodes in place, so a
322
+ // later nested write inside the item (e.g. adding an array item to a
323
+ // block added this session) would silently rewrite the queued patch.
324
+ // Serialized at save time, the block insert would then already contain
325
+ // the array items AND the array.insert patches would re-add them —
326
+ // duplicating items server-side (caught by e2e/array-in-block.spec.ts).
327
+ patchesRef.current = [...patchesRef.current, structuredClone(patch)]
320
328
  // Mark a generic dirty flag so hasChanges() becomes true even
321
329
  // for patches that don't correspond to a specific field.set.
322
330
  dirtyFields.current.add('__patch__')
@@ -24,6 +24,7 @@ import { useTranslation } from '@byline/i18n/react'
24
24
  import { Alert, Button, ComboButton } from '@byline/ui/react'
25
25
  import cx from 'classnames'
26
26
 
27
+ import { sliceFieldAdmin } from '../fields/field-admin'
27
28
  import { FieldRenderer } from '../fields/field-renderer'
28
29
  import { useBylineFieldServices } from '../fields/field-services-context'
29
30
  import { AdminGroup } from '../presentation/group'
@@ -135,7 +136,7 @@ export interface FormRendererProps {
135
136
  * Opts the document-tree placement widget into the sidebar (above the
136
137
  * available-locales widget). Sourced from `CollectionDefinition.tree` by the
137
138
  * caller. Renders only in edit mode (placement needs a persisted document)
138
- * and only when the host wires the tree services. See docs/04-collections/03-document-trees.md.
139
+ * and only when the host wires the tree services. See docs/04-collections/04-document-trees.md.
139
140
  */
140
141
  tree?: boolean
141
142
  headingLabel?: string
@@ -488,6 +489,7 @@ const FormContent = ({
488
489
  contentLocale={contentLocale}
489
490
  components={adminConfig?.fields?.[field.name]?.components}
490
491
  editor={adminConfig?.fields?.[field.name]?.editor}
492
+ fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
491
493
  />
492
494
  )
493
495
  }
@@ -30,7 +30,7 @@ export interface TreePlacementWidgetProps {
30
30
 
31
31
  /**
32
32
  * Sidebar widget for placing the current document within its collection's
33
- * single-parent document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md).
33
+ * single-parent document tree (the `tree: true` primitive — docs/04-collections/04-document-trees.md).
34
34
  *
35
35
  * The tree is document-grain and **unversioned**, so changes here write
36
36
  * immediately (independent of the form's content save). The editor picks a