@byline/admin 3.17.1 → 3.19.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 (31) hide show
  1. package/dist/fields/file/file-field.js +8 -2
  2. package/dist/fields/image/image-field.js +8 -2
  3. package/dist/forms/form-context.d.ts +13 -1
  4. package/dist/forms/form-context.js +2 -1
  5. package/dist/forms/form-renderer.js +18 -1
  6. package/dist/forms/path-widget.d.ts +19 -1
  7. package/dist/forms/path-widget.js +10 -6
  8. package/dist/forms/upload-executor.d.ts +27 -3
  9. package/dist/forms/upload-executor.js +83 -8
  10. package/dist/forms/upload-executor.test.node.d.ts +8 -0
  11. package/dist/modules/admin-account/errors.d.ts +2 -2
  12. package/dist/modules/admin-activity/abilities.d.ts +1 -1
  13. package/dist/modules/admin-permissions/abilities.d.ts +2 -2
  14. package/dist/modules/admin-permissions/errors.d.ts +2 -2
  15. package/dist/modules/admin-permissions/schemas.d.ts +4 -4
  16. package/dist/modules/admin-roles/abilities.d.ts +4 -4
  17. package/dist/modules/admin-roles/errors.d.ts +4 -4
  18. package/dist/modules/admin-users/abilities.d.ts +5 -5
  19. package/dist/modules/admin-users/errors.d.ts +5 -5
  20. package/dist/modules/admin-users/schemas.d.ts +6 -6
  21. package/dist/vendor/noble-argon2/blake2.js +17 -17
  22. package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +4 -18
  23. package/package.json +16 -16
  24. package/src/fields/file/file-field.tsx +16 -2
  25. package/src/fields/image/image-field.tsx +16 -2
  26. package/src/forms/form-context.tsx +14 -0
  27. package/src/forms/form-renderer.tsx +27 -0
  28. package/src/forms/path-widget.test.tsx +33 -0
  29. package/src/forms/path-widget.tsx +33 -5
  30. package/src/forms/upload-executor.test.node.ts +176 -0
  31. package/src/forms/upload-executor.ts +209 -14
@@ -1,7 +1,7 @@
1
1
  import { BSIGMA, G1s, G2s } from "./_blake.js";
2
2
  import { SHA256_IV } from "./_md.js";
3
3
  import { abytes, aexists, anumber, aoutput, clean, createHasher, swap32IfBE, swap8IfBE, u32 } from "./utils.js";
4
- import * as __rspack_external__u64_js_3c1fe104 from "./_u64.js";
4
+ import * as __rspack_external__u64_js_5917676b from "./_u64.js";
5
5
  const B2B_IV = /* @__PURE__ */ Uint32Array.from([
6
6
  0xf3bcc908,
7
7
  0x6a09e667,
@@ -27,25 +27,25 @@ function G1b(a, b, c, d, msg, x) {
27
27
  let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1];
28
28
  let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1];
29
29
  let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1];
30
- let ll = __rspack_external__u64_js_3c1fe104.add3L(Al, Bl, Xl);
31
- Ah = __rspack_external__u64_js_3c1fe104.add3H(ll, Ah, Bh, Xh);
30
+ let ll = __rspack_external__u64_js_5917676b.add3L(Al, Bl, Xl);
31
+ Ah = __rspack_external__u64_js_5917676b.add3H(ll, Ah, Bh, Xh);
32
32
  Al = 0 | ll;
33
33
  ({ Dh, Dl } = {
34
34
  Dh: Dh ^ Ah,
35
35
  Dl: Dl ^ Al
36
36
  });
37
37
  ({ Dh, Dl } = {
38
- Dh: __rspack_external__u64_js_3c1fe104.rotr32H(Dh, Dl),
39
- Dl: __rspack_external__u64_js_3c1fe104.rotr32L(Dh, Dl)
38
+ Dh: __rspack_external__u64_js_5917676b.rotr32H(Dh, Dl),
39
+ Dl: __rspack_external__u64_js_5917676b.rotr32L(Dh, Dl)
40
40
  });
41
- ({ h: Ch, l: Cl } = __rspack_external__u64_js_3c1fe104.add(Ch, Cl, Dh, Dl));
41
+ ({ h: Ch, l: Cl } = __rspack_external__u64_js_5917676b.add(Ch, Cl, Dh, Dl));
42
42
  ({ Bh, Bl } = {
43
43
  Bh: Bh ^ Ch,
44
44
  Bl: Bl ^ Cl
45
45
  });
46
46
  ({ Bh, Bl } = {
47
- Bh: __rspack_external__u64_js_3c1fe104.rotrSH(Bh, Bl, 24),
48
- Bl: __rspack_external__u64_js_3c1fe104.rotrSL(Bh, Bl, 24)
47
+ Bh: __rspack_external__u64_js_5917676b.rotrSH(Bh, Bl, 24),
48
+ Bl: __rspack_external__u64_js_5917676b.rotrSL(Bh, Bl, 24)
49
49
  });
50
50
  BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah;
51
51
  BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh;
@@ -58,25 +58,25 @@ function G2b(a, b, c, d, msg, x) {
58
58
  let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1];
59
59
  let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1];
60
60
  let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1];
61
- let ll = __rspack_external__u64_js_3c1fe104.add3L(Al, Bl, Xl);
62
- Ah = __rspack_external__u64_js_3c1fe104.add3H(ll, Ah, Bh, Xh);
61
+ let ll = __rspack_external__u64_js_5917676b.add3L(Al, Bl, Xl);
62
+ Ah = __rspack_external__u64_js_5917676b.add3H(ll, Ah, Bh, Xh);
63
63
  Al = 0 | ll;
64
64
  ({ Dh, Dl } = {
65
65
  Dh: Dh ^ Ah,
66
66
  Dl: Dl ^ Al
67
67
  });
68
68
  ({ Dh, Dl } = {
69
- Dh: __rspack_external__u64_js_3c1fe104.rotrSH(Dh, Dl, 16),
70
- Dl: __rspack_external__u64_js_3c1fe104.rotrSL(Dh, Dl, 16)
69
+ Dh: __rspack_external__u64_js_5917676b.rotrSH(Dh, Dl, 16),
70
+ Dl: __rspack_external__u64_js_5917676b.rotrSL(Dh, Dl, 16)
71
71
  });
72
- ({ h: Ch, l: Cl } = __rspack_external__u64_js_3c1fe104.add(Ch, Cl, Dh, Dl));
72
+ ({ h: Ch, l: Cl } = __rspack_external__u64_js_5917676b.add(Ch, Cl, Dh, Dl));
73
73
  ({ Bh, Bl } = {
74
74
  Bh: Bh ^ Ch,
75
75
  Bl: Bl ^ Cl
76
76
  });
77
77
  ({ Bh, Bl } = {
78
- Bh: __rspack_external__u64_js_3c1fe104.rotrBH(Bh, Bl, 63),
79
- Bl: __rspack_external__u64_js_3c1fe104.rotrBL(Bh, Bl, 63)
78
+ Bh: __rspack_external__u64_js_5917676b.rotrBH(Bh, Bl, 63),
79
+ Bl: __rspack_external__u64_js_5917676b.rotrBL(Bh, Bl, 63)
80
80
  });
81
81
  BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah;
82
82
  BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh;
@@ -226,7 +226,7 @@ class _BLAKE2b extends _BLAKE2 {
226
226
  compress(msg, offset, isLast) {
227
227
  this.get().forEach((v, i)=>BBUF[i] = v);
228
228
  BBUF.set(B2B_IV, 16);
229
- let { h, l } = __rspack_external__u64_js_3c1fe104.fromBig(BigInt(this.length));
229
+ let { h, l } = __rspack_external__u64_js_5917676b.fromBig(BigInt(this.length));
230
230
  BBUF[24] = B2B_IV[8] ^ l;
231
231
  BBUF[25] = B2B_IV[9] ^ h;
232
232
  if (isLast) {
@@ -376,7 +376,7 @@ class _BLAKE2s extends _BLAKE2 {
376
376
  this.v7 = 0 | v7;
377
377
  }
378
378
  compress(msg, offset, isLast) {
379
- const { h, l } = __rspack_external__u64_js_3c1fe104.fromBig(BigInt(this.length));
379
+ const { h, l } = __rspack_external__u64_js_5917676b.fromBig(BigInt(this.length));
380
380
  const { v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15 } = compress(BSIGMA, offset, msg, 10, this.v0, this.v1, this.v2, this.v3, this.v4, this.v5, this.v6, this.v7, B2S_IV[0], B2S_IV[1], B2S_IV[2], B2S_IV[3], l ^ B2S_IV[4], h ^ B2S_IV[5], isLast ? ~B2S_IV[6] : B2S_IV[6], B2S_IV[7]);
381
381
  this.v0 ^= v0 ^ v8;
382
382
  this.v1 ^= v1 ^ v9;
@@ -10,21 +10,7 @@ export interface SourceLocaleBadgeProps {
10
10
  locale: string;
11
11
  className?: string;
12
12
  }
13
- /**
14
- * Small neutral badge indicating a document's content **source locale** — the
15
- * locale it was authored in (its anchor: fallback floor, path locale,
16
- * completeness yardstick). Rendered next to the document title in the edit and
17
- * list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
18
- * the shared {@link Badge} with the `noeffect` (neutral) intent.
19
- *
20
- * NOTE: currently rendered for *every* document so the anchor is visible during
21
- * development. The intended end state is to show it only when `locale` differs
22
- * from the system's current default content locale (a normal single-default
23
- * install then shows nothing). See docs/07-internationalization/index.md.
24
- *
25
- * Stable override handle: `.byline-source-locale-badge`.
26
- */
27
- export declare const SourceLocaleBadge: {
28
- ({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
29
- displayName: string;
30
- };
13
+ export declare function SourceLocaleBadge({ locale, className }: SourceLocaleBadgeProps): import("react").JSX.Element;
14
+ export declare namespace SourceLocaleBadge {
15
+ var displayName: string;
16
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.17.1",
5
+ "version": "3.19.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -153,31 +153,31 @@
153
153
  "classnames": "^2.5.1",
154
154
  "jose": "^6.2.3",
155
155
  "react-diff-viewer-continued": "^4.2.2",
156
- "uuid": "^14.0.0",
156
+ "uuid": "^14.0.1",
157
157
  "zod": "^4.4.3",
158
- "@byline/auth": "3.17.1",
159
- "@byline/i18n": "3.17.1",
160
- "@byline/ui": "3.17.1",
161
- "@byline/core": "3.17.1"
158
+ "@byline/auth": "3.19.0",
159
+ "@byline/ui": "3.19.0",
160
+ "@byline/core": "3.19.0",
161
+ "@byline/i18n": "3.19.0"
162
162
  },
163
163
  "peerDependencies": {
164
164
  "react": "^19.0.0",
165
165
  "react-dom": "^19.0.0"
166
166
  },
167
167
  "devDependencies": {
168
- "@biomejs/biome": "2.4.15",
169
- "@rsbuild/plugin-react": "^2.0.0",
170
- "@rslib/core": "^0.21.5",
171
- "@types/node": "^25.9.1",
172
- "@types/react": "19.2.15",
168
+ "@biomejs/biome": "2.5.2",
169
+ "@rsbuild/plugin-react": "^2.1.0",
170
+ "@rslib/core": "^0.23.2",
171
+ "@types/node": "^26.1.0",
172
+ "@types/react": "19.2.17",
173
173
  "@types/react-dom": "19.2.3",
174
- "react": "19.2.6",
175
- "react-dom": "19.2.6",
174
+ "react": "^19.2.7",
175
+ "react-dom": "^19.2.7",
176
176
  "rimraf": "^6.1.3",
177
- "tsx": "^4.22.3",
178
- "typescript": "6.0.3",
177
+ "tsx": "^4.23.0",
178
+ "typescript": "^7.0.2",
179
179
  "typescript-plugin-css-modules": "^5.2.0",
180
- "vitest": "^4.1.7"
180
+ "vitest": "^4.1.10"
181
181
  },
182
182
  "publishConfig": {
183
183
  "access": "public",
@@ -79,7 +79,7 @@ export const FileField = ({
79
79
  const isDirty = useIsDirty(fieldPath)
80
80
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
81
81
  const isUploading = useIsFieldUploading(fieldPath)
82
- const { removePendingUpload } = useFormContext()
82
+ const { removePendingUpload, documentId } = useFormContext()
83
83
 
84
84
  const handleChange = useFieldChangeHandler(field, fieldPath)
85
85
 
@@ -102,6 +102,12 @@ export const FileField = ({
102
102
 
103
103
  const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
104
104
 
105
+ // `upload.requireSavedDocument` gate: until the document is persisted,
106
+ // render a "save first" notice in place of the upload zone. Server-side
107
+ // upload hooks that depend on save-time state (counters, document id)
108
+ // rely on this; existing stored values still render normally below.
109
+ const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
110
+
105
111
  const handleRemove = () => {
106
112
  if (isPending) {
107
113
  removePendingUpload(fieldPath)
@@ -131,7 +137,15 @@ export const FileField = ({
131
137
  </div>
132
138
 
133
139
  {showUploadWidget ? (
134
- collectionPath ? (
140
+ uploadGated ? (
141
+ <div
142
+ className={cx('byline-field-file-empty', styles.empty)}
143
+ role="note"
144
+ data-testid="upload-require-saved-document"
145
+ >
146
+ {t('fields.upload.requireSavedDocument')}
147
+ </div>
148
+ ) : collectionPath ? (
135
149
  <FileUploadField
136
150
  field={field}
137
151
  collectionPath={collectionPath}
@@ -61,7 +61,7 @@ export const ImageField = ({
61
61
  const isDirty = useIsDirty(fieldPath)
62
62
  const fieldValue = useFieldValue<StoredFileValue | null | undefined>(fieldPath)
63
63
  const isUploading = useIsFieldUploading(fieldPath)
64
- const { removePendingUpload } = useFormContext()
64
+ const { removePendingUpload, documentId } = useFormContext()
65
65
  const { t } = useTranslation('byline-admin')
66
66
 
67
67
  // Re-use the standard field change handler so patches are emitted correctly.
@@ -87,6 +87,12 @@ export const ImageField = ({
87
87
  // Show upload widget only if no value or old placeholder
88
88
  const showUploadWidget = incomingValue == null || isOldPlaceholder(incomingValue)
89
89
 
90
+ // `upload.requireSavedDocument` gate: until the document is persisted,
91
+ // render a "save first" notice in place of the upload zone. Server-side
92
+ // upload hooks that depend on save-time state (counters, document id)
93
+ // rely on this; existing stored values still render normally below.
94
+ const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
95
+
90
96
  // Prefer the generated thumbnail variant for the preview tile. SVGs and
91
97
  // other bypass types have no variants — fall back to the original.
92
98
  const thumbVariant =
@@ -122,7 +128,15 @@ export const ImageField = ({
122
128
  </div>
123
129
 
124
130
  {showUploadWidget ? (
125
- collectionPath ? (
131
+ uploadGated ? (
132
+ <div
133
+ className={cx('byline-field-image-empty', styles.empty)}
134
+ role="note"
135
+ data-testid="upload-require-saved-document"
136
+ >
137
+ {t('fields.upload.requireSavedDocument')}
138
+ </div>
139
+ ) : collectionPath ? (
126
140
  <ImageUploadField
127
141
  field={field}
128
142
  collectionPath={collectionPath}
@@ -83,6 +83,13 @@ const SYSTEM_PATH_DIRTY_KEY = '__systemPath__'
83
83
  const SYSTEM_AVAILABLE_LOCALES_DIRTY_KEY = '__systemAvailableLocales__'
84
84
 
85
85
  interface FormContextType {
86
+ /**
87
+ * The persisted document id when the form edits an existing document,
88
+ * `null` while the document is unsaved (create mode). Upload widgets use
89
+ * this to honour `upload.requireSavedDocument` (see `UploadConfig` in
90
+ * `@byline/core`).
91
+ */
92
+ documentId: string | null
86
93
  setFieldValue: (name: string, value: any) => void
87
94
  setFieldStore: (name: string, value: any) => void
88
95
  getFieldValue: (name: string) => any
@@ -150,9 +157,15 @@ export const useFormContext = () => {
150
157
  export const FormProvider = ({
151
158
  children,
152
159
  initialData = {},
160
+ documentId = null,
153
161
  }: {
154
162
  children: React.ReactNode
155
163
  initialData?: Record<string, any>
164
+ /**
165
+ * The persisted document id (edit mode); `null` while unsaved. Exposed on
166
+ * the context for upload widgets honouring `upload.requireSavedDocument`.
167
+ */
168
+ documentId?: string | null
156
169
  }) => {
157
170
  const fieldValues = useRef<Record<string, any>>(
158
171
  JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
@@ -640,6 +653,7 @@ export const FormProvider = ({
640
653
  return (
641
654
  <FormContext.Provider
642
655
  value={{
656
+ documentId,
643
657
  setFieldValue,
644
658
  setFieldStore,
645
659
  getFieldValue,
@@ -18,6 +18,7 @@ import type {
18
18
  TabSetDefinition,
19
19
  WorkflowStatus,
20
20
  } from '@byline/core'
21
+ import { getClientConfig } from '@byline/core'
21
22
  import type { DocumentPatch } from '@byline/core/patches'
22
23
  import { useTranslation } from '@byline/i18n/react'
23
24
  import { Alert, Button, ComboButton } from '@byline/ui/react'
@@ -241,6 +242,19 @@ const FormContent = ({
241
242
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
242
243
  const { uploadField } = useBylineFieldServices()
243
244
 
245
+ // Path-widget wiring. The live preview must use the installation's
246
+ // client-side slugifier (same function as `ServerConfig.slugifier`) so it
247
+ // agrees with the persisted path. And when the `useAsPath` source is a
248
+ // server-assigned `counter` or a read-only field, its value can't be
249
+ // reproduced or changed through the form, so the widget suppresses its
250
+ // source-derived preview and "Regenerate" affordance.
251
+ const pathSlugifier = getClientConfig().slugifier
252
+ const pathSourceLocked = useMemo(() => {
253
+ if (!useAsPath) return false
254
+ const source = fields.find((f) => f.name === useAsPath)
255
+ return source != null && (source.type === 'counter' || source.readOnly === true)
256
+ }, [useAsPath, fields])
257
+
244
258
  // Sync contentLocale when the route re-fetches with a different locale.
245
259
  useEffect(() => {
246
260
  if (initialLocale) setContentLocale(initialLocale)
@@ -373,6 +387,16 @@ const FormContent = ({
373
387
  uploadField,
374
388
  ({ fieldPath, status }) => {
375
389
  setFieldUploading(fieldPath, status === 'uploading')
390
+ },
391
+ {
392
+ // Document context for server-side upload hooks: the persisted
393
+ // document id (edit mode only) plus any `upload.context` form
394
+ // values declared on the schema field. See UploadConfig.context
395
+ // in @byline/core.
396
+ documentId:
397
+ mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
398
+ fields,
399
+ getFormValues: getFieldValues,
376
400
  }
377
401
  )
378
402
 
@@ -672,6 +696,8 @@ const FormContent = ({
672
696
  defaultLocale={defaultLocale}
673
697
  activeLocale={contentLocale}
674
698
  mode={mode}
699
+ slugifier={pathSlugifier}
700
+ sourceLocked={pathSourceLocked}
675
701
  />
676
702
  )}
677
703
  {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
@@ -749,6 +775,7 @@ export const FormRenderer = ({
749
775
  <FormProvider
750
776
  key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
751
777
  initialData={initialData}
778
+ documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
752
779
  >
753
780
  <FormContent
754
781
  mode={mode}
@@ -105,6 +105,9 @@ describe('PathWidget', () => {
105
105
  props: Partial<{
106
106
  useAsPath: string | undefined
107
107
  mode: 'create' | 'edit'
108
+ activeLocale: string
109
+ slugifier: (value: string, ctx: { locale: string; collectionPath: string }) => string
110
+ sourceLocked: boolean
108
111
  }> = {}
109
112
  ) => {
110
113
  act(() => {
@@ -113,7 +116,10 @@ describe('PathWidget', () => {
113
116
  useAsPath={props.useAsPath ?? 'title'}
114
117
  collectionPath="pages"
115
118
  defaultLocale="en"
119
+ activeLocale={props.activeLocale ?? 'en'}
116
120
  mode={props.mode ?? 'create'}
121
+ slugifier={props.slugifier}
122
+ sourceLocked={props.sourceLocked}
117
123
  />
118
124
  )
119
125
  })
@@ -214,4 +220,31 @@ describe('PathWidget', () => {
214
220
  render({ mode: 'edit', useAsPath: undefined })
215
221
  expect(container.querySelector('button')).toBeNull()
216
222
  })
223
+
224
+ it('derives the preview with the provided slugifier instead of the default', () => {
225
+ // A custom slugifier that zero-pads a numeric serial to 7 digits — the
226
+ // shape used when a `counter` field feeds the path.
227
+ const padSerial = (value: string) => value.padStart(7, '0')
228
+ setFixture({ systemPath: null, sourceValue: 1 })
229
+ render({ mode: 'create', slugifier: padSerial })
230
+
231
+ const input = getInput()
232
+ // Default slugify would yield "1"; the custom slugifier yields "0000001".
233
+ expect(input.getAttribute('placeholder')).toBe('Will be saved as "0000001"')
234
+ })
235
+
236
+ it('suppresses the create-mode placeholder preview when the source is locked', () => {
237
+ setFixture({ systemPath: null, sourceValue: 1 })
238
+ render({ mode: 'create', sourceLocked: true })
239
+ expect(getInput().getAttribute('placeholder')).toBeNull()
240
+ })
241
+
242
+ it('suppresses the Regenerate button when the source is locked', () => {
243
+ // Persisted path differs from what a naive re-derivation would produce,
244
+ // which would normally surface Regenerate — but a locked (server-assigned)
245
+ // source can't be regenerated from the form, so it must stay hidden.
246
+ setFixture({ systemPath: '0000001', sourceValue: 1 })
247
+ render({ mode: 'edit', sourceLocked: true })
248
+ expect(container.querySelector('button')).toBeNull()
249
+ })
217
250
  })
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useCallback, useMemo } from 'react'
12
12
 
13
+ import type { SlugifierFn } from '@byline/core'
13
14
  import { slugify } from '@byline/core'
14
15
  import { useTranslation } from '@byline/i18n/react'
15
16
  import { Input, Label } from '@byline/ui/react'
@@ -47,6 +48,23 @@ export interface PathWidgetProps {
47
48
  activeLocale: string
48
49
  /** `'create'` shows the live derived preview as placeholder text. */
49
50
  mode: 'create' | 'edit'
51
+ /**
52
+ * Installation slugifier used to derive the live preview. Must match the
53
+ * server-side `ServerConfig.slugifier` so the preview agrees with what is
54
+ * persisted. Defaults to the built-in `slugify` when omitted — callers
55
+ * that keep the default slugifier need not pass this.
56
+ */
57
+ slugifier?: SlugifierFn
58
+ /**
59
+ * When `true`, the `useAsPath` source field's value is not editable through
60
+ * this form (e.g. it is an allocator-assigned `counter`, or an otherwise
61
+ * read-only field). Its value is either server-assigned — and so not
62
+ * reproducible client-side — or simply cannot change, which means the
63
+ * source-derived live preview and the "Regenerate" affordance are
64
+ * meaningless. When set, the widget suppresses both and just shows the
65
+ * persisted path. Defaults to `false`.
66
+ */
67
+ sourceLocked?: boolean
50
68
  }
51
69
 
52
70
  /**
@@ -71,12 +89,20 @@ export const PathWidget = ({
71
89
  defaultLocale,
72
90
  activeLocale,
73
91
  mode,
92
+ slugifier,
93
+ sourceLocked = false,
74
94
  }: PathWidgetProps) => {
75
95
  const { setSystemPath } = useFormContext()
76
96
  const { t } = useTranslation('byline-admin')
77
97
  const systemPath = useSystemPath()
78
98
  const sourceValue = useFieldValue<unknown>(useAsPath ?? '')
79
99
 
100
+ // The installation slugifier, or the built-in default. Server-side path
101
+ // derivation uses `ServerConfig.slugifier`; this must be the same function
102
+ // (registered via `ClientConfig.slugifier`) or the preview will disagree
103
+ // with what the server persists.
104
+ const runSlugify = slugifier ?? slugify
105
+
80
106
  // Phase 1: paths are written/edited only under the default content
81
107
  // locale. When editing a translation, the widget locks down — the
82
108
  // input is read-only, the Regenerate action is suppressed, and a
@@ -87,11 +113,13 @@ export const PathWidget = ({
87
113
  // field value if no override were set. Used as placeholder in create
88
114
  // mode and as the target of the "Regenerate" action.
89
115
  const livePreview = useMemo(() => {
90
- if (!useAsPath) return ''
116
+ // A locked source (server-assigned counter / read-only field) can't be
117
+ // previewed or regenerated from the form — suppress the derivation.
118
+ if (!useAsPath || sourceLocked) return ''
91
119
  const asString = coerceToString(sourceValue)
92
120
  if (asString.length === 0) return ''
93
- return slugify(asString, { locale: defaultLocale, collectionPath })
94
- }, [useAsPath, sourceValue, defaultLocale, collectionPath])
121
+ return runSlugify(asString, { locale: defaultLocale, collectionPath })
122
+ }, [useAsPath, sourceLocked, sourceValue, defaultLocale, collectionPath, runSlugify])
95
123
 
96
124
  const inputValue = systemPath ?? ''
97
125
 
@@ -115,8 +143,8 @@ export const PathWidget = ({
115
143
  // field-hook advisory behaviour).
116
144
  const formatted = useMemo(() => {
117
145
  if (inputValue.length === 0) return ''
118
- return slugify(inputValue, { locale: defaultLocale, collectionPath })
119
- }, [inputValue, defaultLocale, collectionPath])
146
+ return runSlugify(inputValue, { locale: defaultLocale, collectionPath })
147
+ }, [inputValue, defaultLocale, collectionPath, runSlugify])
120
148
 
121
149
  const validationHint =
122
150
  inputValue.length > 0 && formatted !== inputValue
@@ -0,0 +1,176 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { Field } from '@byline/core'
10
+ import { describe, expect, it, vi } from 'vitest'
11
+
12
+ import { executeUploads } from './upload-executor'
13
+ import type { PendingUpload } from './form-context'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixtures
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Schema mirroring the publications files-array shape. */
20
+ const publicationsFields: Field[] = [
21
+ { name: 'title', label: 'Title', type: 'text' },
22
+ { name: 'serialNumber', label: 'Serial', type: 'counter', group: 'test-serial' },
23
+ {
24
+ name: 'files',
25
+ label: 'Files',
26
+ type: 'array',
27
+ fields: [
28
+ {
29
+ name: 'filesGroup',
30
+ type: 'group',
31
+ fields: [
32
+ {
33
+ name: 'publicationFile',
34
+ label: 'File',
35
+ type: 'file',
36
+ upload: {
37
+ mimeTypes: ['application/pdf'],
38
+ context: ['language', '/serialNumber', '../missingSibling'],
39
+ },
40
+ },
41
+ {
42
+ name: 'language',
43
+ label: 'Language',
44
+ type: 'relation',
45
+ targetCollection: 'languages',
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ name: 'cover',
53
+ label: 'Cover',
54
+ type: 'image',
55
+ upload: { mimeTypes: ['image/*'] },
56
+ },
57
+ ]
58
+
59
+ function pendingUpload(name = 'test.pdf'): PendingUpload {
60
+ return {
61
+ file: new File(['%PDF'], name, { type: 'application/pdf' }),
62
+ previewUrl: 'blob:mock',
63
+ collectionPath: 'publications',
64
+ }
65
+ }
66
+
67
+ /** Capture the FormData each call receives; echo a minimal result. */
68
+ function captureUploadField() {
69
+ const bodies: FormData[] = []
70
+ const fn = vi.fn(async (_collection: string, formData: FormData) => {
71
+ bodies.push(formData)
72
+ return { storedFile: { fileId: '1', filename: 'stored.pdf' } as any }
73
+ })
74
+ return { fn, bodies }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tests
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('executeUploads — context transmission', () => {
82
+ it('always sends field (leaf name) and fieldPath; documentId only when provided', async () => {
83
+ const { fn, bodies } = captureUploadField()
84
+ const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]])
85
+
86
+ await executeUploads(uploads, fn, {
87
+ documentId: 'doc-123',
88
+ fields: publicationsFields,
89
+ getFormValues: () => ({}),
90
+ })
91
+
92
+ const body = bodies[0]!
93
+ expect(body.get('field')).toBe('publicationFile')
94
+ expect(body.get('fieldPath')).toBe('files[0].filesGroup.publicationFile')
95
+ expect(body.get('documentId')).toBe('doc-123')
96
+ })
97
+
98
+ it('omits documentId in create mode (undefined)', async () => {
99
+ const { fn, bodies } = captureUploadField()
100
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
101
+
102
+ await executeUploads(uploads, fn, { fields: publicationsFields, getFormValues: () => ({}) })
103
+
104
+ expect(bodies[0]?.get('documentId')).toBeNull()
105
+ })
106
+
107
+ it('resolves sibling, root-absolute, and missing context paths against the item scope', async () => {
108
+ const { fn, bodies } = captureUploadField()
109
+ const uploads = new Map([['files[1].filesGroup.publicationFile', pendingUpload()]])
110
+
111
+ await executeUploads(uploads, fn, {
112
+ documentId: 'doc-123',
113
+ fields: publicationsFields,
114
+ getFormValues: () => ({
115
+ serialNumber: 447,
116
+ files: [
117
+ { filesGroup: { language: { targetDocumentId: 'lang-th', targetCollectionId: 'c' } } },
118
+ { filesGroup: { language: { targetDocumentId: 'lang-en', targetCollectionId: 'c' } } },
119
+ ],
120
+ }),
121
+ })
122
+
123
+ const body = bodies[0]!
124
+ // Sibling path reads THIS item's language, not item 0's.
125
+ expect(body.get('language')).toBe('lang-en')
126
+ // Root-absolute path.
127
+ expect(body.get('serialNumber')).toBe('447')
128
+ // Unresolvable context values are omitted, not sent as ''.
129
+ expect(body.get('missingSibling')).toBeNull()
130
+ })
131
+
132
+ it('serialises relation envelopes to their targetDocumentId', async () => {
133
+ const { fn, bodies } = captureUploadField()
134
+ const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]])
135
+
136
+ await executeUploads(uploads, fn, {
137
+ fields: publicationsFields,
138
+ getFormValues: () => ({
139
+ serialNumber: 1,
140
+ files: [
141
+ { filesGroup: { language: { targetDocumentId: 'lang-de', targetCollectionId: 'c' } } },
142
+ ],
143
+ }),
144
+ })
145
+
146
+ expect(bodies[0]?.get('language')).toBe('lang-de')
147
+ })
148
+
149
+ it('fields without upload.context send no extra entries', async () => {
150
+ const { fn, bodies } = captureUploadField()
151
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
152
+
153
+ await executeUploads(uploads, fn, {
154
+ documentId: 'doc-9',
155
+ fields: publicationsFields,
156
+ getFormValues: () => ({ serialNumber: 5 }),
157
+ })
158
+
159
+ const body = bodies[0]!
160
+ expect(body.get('field')).toBe('cover')
161
+ expect(body.get('serialNumber')).toBeNull()
162
+ })
163
+
164
+ it('works without an execution context (legacy call shape)', async () => {
165
+ const { fn, bodies } = captureUploadField()
166
+ const uploads = new Map([['cover', pendingUpload('c.png')]])
167
+
168
+ const result = await executeUploads(uploads, fn)
169
+
170
+ expect(result.allSucceeded).toBe(true)
171
+ const body = bodies[0]!
172
+ expect(body.get('field')).toBe('cover')
173
+ expect(body.get('fieldPath')).toBe('cover')
174
+ expect(body.get('documentId')).toBeNull()
175
+ })
176
+ })