@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.
- package/dist/fields/file/file-field.js +8 -2
- package/dist/fields/image/image-field.js +8 -2
- package/dist/forms/form-context.d.ts +13 -1
- package/dist/forms/form-context.js +2 -1
- package/dist/forms/form-renderer.js +18 -1
- package/dist/forms/path-widget.d.ts +19 -1
- package/dist/forms/path-widget.js +10 -6
- package/dist/forms/upload-executor.d.ts +27 -3
- package/dist/forms/upload-executor.js +83 -8
- package/dist/forms/upload-executor.test.node.d.ts +8 -0
- package/dist/modules/admin-account/errors.d.ts +2 -2
- package/dist/modules/admin-activity/abilities.d.ts +1 -1
- package/dist/modules/admin-permissions/abilities.d.ts +2 -2
- package/dist/modules/admin-permissions/errors.d.ts +2 -2
- package/dist/modules/admin-permissions/schemas.d.ts +4 -4
- package/dist/modules/admin-roles/abilities.d.ts +4 -4
- package/dist/modules/admin-roles/errors.d.ts +4 -4
- package/dist/modules/admin-users/abilities.d.ts +5 -5
- package/dist/modules/admin-users/errors.d.ts +5 -5
- package/dist/modules/admin-users/schemas.d.ts +6 -6
- package/dist/vendor/noble-argon2/blake2.js +17 -17
- package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +4 -18
- package/package.json +16 -16
- package/src/fields/file/file-field.tsx +16 -2
- package/src/fields/image/image-field.tsx +16 -2
- package/src/forms/form-context.tsx +14 -0
- package/src/forms/form-renderer.tsx +27 -0
- package/src/forms/path-widget.test.tsx +33 -0
- package/src/forms/path-widget.tsx +33 -5
- package/src/forms/upload-executor.test.node.ts +176 -0
- 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
|
|
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 =
|
|
31
|
-
Ah =
|
|
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:
|
|
39
|
-
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 } =
|
|
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:
|
|
48
|
-
Bl:
|
|
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 =
|
|
62
|
-
Ah =
|
|
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:
|
|
70
|
-
Dl:
|
|
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 } =
|
|
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:
|
|
79
|
-
Bl:
|
|
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 } =
|
|
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 } =
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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.
|
|
156
|
+
"uuid": "^14.0.1",
|
|
157
157
|
"zod": "^4.4.3",
|
|
158
|
-
"@byline/auth": "3.
|
|
159
|
-
"@byline/
|
|
160
|
-
"@byline/
|
|
161
|
-
"@byline/
|
|
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.
|
|
169
|
-
"@rsbuild/plugin-react": "^2.
|
|
170
|
-
"@rslib/core": "^0.
|
|
171
|
-
"@types/node": "^
|
|
172
|
-
"@types/react": "19.2.
|
|
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.
|
|
175
|
-
"react-dom": "19.2.
|
|
174
|
+
"react": "^19.2.7",
|
|
175
|
+
"react-dom": "^19.2.7",
|
|
176
176
|
"rimraf": "^6.1.3",
|
|
177
|
-
"tsx": "^4.
|
|
178
|
-
"typescript": "
|
|
177
|
+
"tsx": "^4.23.0",
|
|
178
|
+
"typescript": "^7.0.2",
|
|
179
179
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
180
|
-
"vitest": "^4.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
})
|