@byline/core 3.16.1 → 3.17.1
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/@types/field-types.d.ts +43 -0
- package/dist/services/document-lifecycle/delete.js +16 -10
- package/dist/services/document-lifecycle.test.node.js +52 -0
- package/dist/utils/storage-utils.d.ts +11 -10
- package/dist/utils/storage-utils.js +30 -11
- package/dist/utils/storage-utils.test.node.d.ts +8 -0
- package/dist/utils/storage-utils.test.node.js +90 -0
- package/package.json +2 -2
|
@@ -71,6 +71,21 @@ export interface FieldHookContext {
|
|
|
71
71
|
* derived value at submit time while showing advisory errors on change.
|
|
72
72
|
*/
|
|
73
73
|
operation: 'change' | 'submit';
|
|
74
|
+
/**
|
|
75
|
+
* Write **another** field's value in the form store.
|
|
76
|
+
*
|
|
77
|
+
* This is a raw write: the target field's own hooks do NOT run (which
|
|
78
|
+
* forecloses hook recursion), but the write is otherwise a normal form
|
|
79
|
+
* edit — it emits a `field.set` patch (so it persists on save), marks the
|
|
80
|
+
* form dirty, and store-subscribed widgets re-render immediately.
|
|
81
|
+
*
|
|
82
|
+
* Use for cross-field behaviour such as mutual exclusivity across array
|
|
83
|
+
* items (a checkbox that unchecks its siblings) or clearing a dependent
|
|
84
|
+
* field when its driver changes. Paths use the same dot + bracket
|
|
85
|
+
* notation as `FieldHookContext.path`, e.g.
|
|
86
|
+
* `files[1].filesGroup.generateThumbnail`.
|
|
87
|
+
*/
|
|
88
|
+
setFieldValue: (path: string, value: any) => void;
|
|
74
89
|
}
|
|
75
90
|
/**
|
|
76
91
|
* Return type of `beforeValidate` hooks.
|
|
@@ -140,6 +155,17 @@ export interface ValidationRule {
|
|
|
140
155
|
message?: string;
|
|
141
156
|
}
|
|
142
157
|
type FieldValidateFn = (value: any, data: Record<string, any>) => string | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* Visibility predicate for a field — see {@link BaseField.condition}.
|
|
160
|
+
*
|
|
161
|
+
* @param data - The full live form data (the document's field values).
|
|
162
|
+
* @param siblingData - The field's immediate scope: the enclosing group /
|
|
163
|
+
* array item's values. For a field at `files[2].filesGroup.thumbnailPage`
|
|
164
|
+
* this is the item's `filesGroup` object, so conditions inside array items
|
|
165
|
+
* observe their own item, not the document root. For root-level fields
|
|
166
|
+
* `siblingData` is the same object as `data`.
|
|
167
|
+
*/
|
|
168
|
+
export type FieldCondition = (data: Record<string, any>, siblingData: Record<string, any>) => boolean;
|
|
143
169
|
/**
|
|
144
170
|
* Base properties shared by all fields
|
|
145
171
|
*/
|
|
@@ -178,6 +204,23 @@ interface BaseField {
|
|
|
178
204
|
* @see FieldHooks
|
|
179
205
|
*/
|
|
180
206
|
hooks?: FieldHooks;
|
|
207
|
+
/**
|
|
208
|
+
* Optional visibility condition. When provided, the admin form renders
|
|
209
|
+
* this field only while the function returns `true`. Receives the full
|
|
210
|
+
* live form data plus the field's sibling scope (the enclosing group /
|
|
211
|
+
* array item), and is re-evaluated on every form edit — so a field can
|
|
212
|
+
* appear and disappear in response to its neighbours, e.g.
|
|
213
|
+
*
|
|
214
|
+
* condition: (_data, siblingData) => Boolean(siblingData.generateThumbnail)
|
|
215
|
+
*
|
|
216
|
+
* Like `readOnly`, this is a **rendering hint only** — it is not enforced
|
|
217
|
+
* server-side. While hidden, the field's stored value is retained (no
|
|
218
|
+
* clearing write is emitted) and the field is exempt from client-side
|
|
219
|
+
* validation. Server-side schema validation knows nothing about
|
|
220
|
+
* conditions, so pair conditionally-hidden required fields with
|
|
221
|
+
* `optional: true` or a `defaultValue`.
|
|
222
|
+
*/
|
|
223
|
+
condition?: FieldCondition;
|
|
181
224
|
/**
|
|
182
225
|
* Optional submit-time validator. Called by `validateForm()` for every field
|
|
183
226
|
* type — including structure fields (group, array, blocks).
|
|
@@ -9,7 +9,8 @@ import { resolveHooks } from '../../@types/index.js';
|
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
-
import {
|
|
12
|
+
import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
|
|
13
|
+
import { walkFieldTree } from '../walk-field-tree.js';
|
|
13
14
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
14
15
|
import { invokeHook } from './internals.js';
|
|
15
16
|
import { promoteChildrenAndRemove } from './tree.js';
|
|
@@ -46,8 +47,7 @@ export async function deleteDocument(ctx, params) {
|
|
|
46
47
|
// AND a storage provider, fetch with reconstruct: true so we
|
|
47
48
|
// can read the stored file paths (and persisted variant paths)
|
|
48
49
|
// from the field values before the DB rows are deleted.
|
|
49
|
-
const
|
|
50
|
-
const isUploadCollection = uploadFieldNames.length > 0 && ctx.storage != null;
|
|
50
|
+
const isUploadCollection = hasUploadField(definition) && ctx.storage != null;
|
|
51
51
|
const latest = await db.queries.documents.getDocumentById({
|
|
52
52
|
collection_id: ctx.collectionId,
|
|
53
53
|
document_id: params.documentId,
|
|
@@ -60,21 +60,27 @@ export async function deleteDocument(ctx, params) {
|
|
|
60
60
|
}).log(ctx.logger);
|
|
61
61
|
}
|
|
62
62
|
// Collect storage paths for every upload-capable field on the doc:
|
|
63
|
-
// the original file plus every persisted variant.
|
|
63
|
+
// the original file plus every persisted variant. The schema/data
|
|
64
|
+
// walk descends into `group` / `array` / `blocks`, so upload fields
|
|
65
|
+
// nested in repeating structures are cleaned up too. Reading the
|
|
64
66
|
// variants from the field value (rather than re-deriving from
|
|
65
67
|
// `upload.sizes`) keeps cleanup correct even when the size set
|
|
66
68
|
// changes between upload and delete.
|
|
67
69
|
const storagePathsToDelete = [];
|
|
68
70
|
if (isUploadCollection) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
const data = latest?.fields;
|
|
72
|
+
for (const leaf of walkFieldTree(definition.fields, data)) {
|
|
73
|
+
if (!isUploadField(leaf.field))
|
|
74
|
+
continue;
|
|
75
|
+
const fieldValue = leaf.value;
|
|
71
76
|
if (!fieldValue || typeof fieldValue !== 'object')
|
|
72
77
|
continue;
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
const stored = fieldValue;
|
|
79
|
+
if (typeof stored.storagePath === 'string') {
|
|
80
|
+
storagePathsToDelete.push(stored.storagePath);
|
|
75
81
|
}
|
|
76
|
-
if (Array.isArray(
|
|
77
|
-
for (const variant of
|
|
82
|
+
if (Array.isArray(stored.variants)) {
|
|
83
|
+
for (const variant of stored.variants) {
|
|
78
84
|
if (variant && typeof variant.storagePath === 'string') {
|
|
79
85
|
storagePathsToDelete.push(variant.storagePath);
|
|
80
86
|
}
|
|
@@ -926,6 +926,58 @@ describe('Document lifecycle service', () => {
|
|
|
926
926
|
action: 'document.deleted',
|
|
927
927
|
}));
|
|
928
928
|
});
|
|
929
|
+
it('cleans up stored files for upload fields at any nesting depth', async () => {
|
|
930
|
+
const { db, getDocumentById } = createMockDb();
|
|
931
|
+
const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
|
|
932
|
+
const definition = {
|
|
933
|
+
...minimalCollection,
|
|
934
|
+
fields: [
|
|
935
|
+
{ name: 'cover', label: 'Cover', type: 'image', upload },
|
|
936
|
+
{
|
|
937
|
+
name: 'files',
|
|
938
|
+
label: 'Files',
|
|
939
|
+
type: 'array',
|
|
940
|
+
fields: [
|
|
941
|
+
{
|
|
942
|
+
name: 'filesGroup',
|
|
943
|
+
type: 'group',
|
|
944
|
+
fields: [{ name: 'publicationFile', label: 'File', type: 'file', upload }],
|
|
945
|
+
},
|
|
946
|
+
],
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
};
|
|
950
|
+
getDocumentById.mockResolvedValue({
|
|
951
|
+
document_version_id: 'ver-1',
|
|
952
|
+
document_id: 'doc-1',
|
|
953
|
+
path: 'doc-to-delete',
|
|
954
|
+
fields: {
|
|
955
|
+
cover: {
|
|
956
|
+
storagePath: 'covers/original.jpg',
|
|
957
|
+
variants: [{ storagePath: 'covers/thumb.avif' }],
|
|
958
|
+
},
|
|
959
|
+
files: [
|
|
960
|
+
{ filesGroup: { publicationFile: { storagePath: 'files/a.pdf' } } },
|
|
961
|
+
{ filesGroup: { publicationFile: null } },
|
|
962
|
+
{ filesGroup: { publicationFile: { storagePath: 'files/b.pdf' } } },
|
|
963
|
+
],
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
const storageDelete = vi.fn().mockResolvedValue(undefined);
|
|
967
|
+
const ctx = {
|
|
968
|
+
...buildCtx(db, definition),
|
|
969
|
+
storage: { delete: storageDelete },
|
|
970
|
+
};
|
|
971
|
+
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
972
|
+
// reconstruct: true because the collection is upload-capable
|
|
973
|
+
expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
|
|
974
|
+
expect(storageDelete.mock.calls.map((c) => c[0])).toEqual([
|
|
975
|
+
'covers/original.jpg',
|
|
976
|
+
'covers/thumb.avif',
|
|
977
|
+
'files/a.pdf',
|
|
978
|
+
'files/b.pdf',
|
|
979
|
+
]);
|
|
980
|
+
});
|
|
929
981
|
});
|
|
930
982
|
// -----------------------------------------------------------------------
|
|
931
983
|
// updateDocumentSystemFields (audited, non-versioned)
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { type Field, type FileField, type ImageField } from '../@types/field-types.js';
|
|
8
9
|
import type { CollectionDefinition } from '../@types/collection-types.js';
|
|
9
|
-
import type { Field, FileField, ImageField } from '../@types/field-types.js';
|
|
10
10
|
/**
|
|
11
11
|
* Predicate: does this field carry an `upload` config? True for any
|
|
12
12
|
* `image` / `file` field with an `upload` block declared.
|
|
@@ -15,20 +15,21 @@ export declare function isUploadField(field: Field): field is (ImageField | File
|
|
|
15
15
|
upload: NonNullable<(ImageField | FileField)['upload']>;
|
|
16
16
|
};
|
|
17
17
|
/**
|
|
18
|
-
* Walk the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Walk the field set and return every upload-capable image/file field
|
|
19
|
+
* on the collection, recursing into `group` / `array` / `blocks`
|
|
20
|
+
* structure fields. Used by the delete path, the upload-route resolver,
|
|
21
|
+
* and any UI that needs to reason about "is this collection
|
|
22
|
+
* upload-capable, and which fields take uploads?"
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* Field names are the upload transport's selector, so a schema should
|
|
25
|
+
* not declare two upload fields with the same name in different nesting
|
|
26
|
+
* scopes — resolvers match by name and take the first hit in
|
|
27
|
+
* declaration order.
|
|
27
28
|
*/
|
|
28
29
|
export declare function getUploadFields(definition: Pick<CollectionDefinition, 'fields'>): (ImageField | FileField)[];
|
|
29
30
|
/**
|
|
30
31
|
* Convenience: does this collection have at least one upload-capable
|
|
31
|
-
* image/file field at
|
|
32
|
+
* image/file field at any nesting depth? Replaces the old
|
|
32
33
|
* `definition.upload != null` discriminator that "this collection is
|
|
33
34
|
* an upload collection / media library."
|
|
34
35
|
*/
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { isArrayField, isBlocksField, isGroupField, } from '../@types/field-types.js';
|
|
8
9
|
/**
|
|
9
10
|
* Predicate: does this field carry an `upload` config? True for any
|
|
10
11
|
* `image` / `file` field with an `upload` block declared.
|
|
@@ -13,25 +14,43 @@ export function isUploadField(field) {
|
|
|
13
14
|
return (field.type === 'image' || field.type === 'file') && field.upload != null;
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* Walk the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Walk the field set and return every upload-capable image/file field
|
|
18
|
+
* on the collection, recursing into `group` / `array` / `blocks`
|
|
19
|
+
* structure fields. Used by the delete path, the upload-route resolver,
|
|
20
|
+
* and any UI that needs to reason about "is this collection
|
|
21
|
+
* upload-capable, and which fields take uploads?"
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Field names are the upload transport's selector, so a schema should
|
|
24
|
+
* not declare two upload fields with the same name in different nesting
|
|
25
|
+
* scopes — resolvers match by name and take the first hit in
|
|
26
|
+
* declaration order.
|
|
25
27
|
*/
|
|
26
28
|
export function getUploadFields(definition) {
|
|
27
|
-
|
|
29
|
+
const found = [];
|
|
30
|
+
collectUploadFields(definition.fields, found);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
function collectUploadFields(fields, found) {
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (isUploadField(field)) {
|
|
36
|
+
found.push(field);
|
|
37
|
+
}
|
|
38
|
+
else if (isGroupField(field) || isArrayField(field)) {
|
|
39
|
+
collectUploadFields(field.fields, found);
|
|
40
|
+
}
|
|
41
|
+
else if (isBlocksField(field)) {
|
|
42
|
+
for (const block of field.blocks) {
|
|
43
|
+
collectUploadFields(block.fields, found);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
}
|
|
29
48
|
/**
|
|
30
49
|
* Convenience: does this collection have at least one upload-capable
|
|
31
|
-
* image/file field at
|
|
50
|
+
* image/file field at any nesting depth? Replaces the old
|
|
32
51
|
* `definition.upload != null` discriminator that "this collection is
|
|
33
52
|
* an upload collection / media library."
|
|
34
53
|
*/
|
|
35
54
|
export function hasUploadField(definition) {
|
|
36
|
-
return definition.
|
|
55
|
+
return getUploadFields(definition).length > 0;
|
|
37
56
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { getUploadFields, hasUploadField, isUploadField } from './storage-utils.js';
|
|
10
|
+
const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
|
|
11
|
+
const coverField = { name: 'cover', label: 'Cover', type: 'image', upload };
|
|
12
|
+
const nestedFileField = { name: 'publicationFile', label: 'File', type: 'file', upload };
|
|
13
|
+
describe('isUploadField', () => {
|
|
14
|
+
it('is true for image/file fields with an upload block', () => {
|
|
15
|
+
expect(isUploadField(coverField)).toBe(true);
|
|
16
|
+
expect(isUploadField(nestedFileField)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('is false for image/file fields without an upload block', () => {
|
|
19
|
+
expect(isUploadField({ name: 'pic', label: 'Pic', type: 'image' })).toBe(false);
|
|
20
|
+
expect(isUploadField({ name: 'doc', label: 'Doc', type: 'file' })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it('is false for non-media fields', () => {
|
|
23
|
+
expect(isUploadField({ name: 'title', label: 'Title', type: 'text' })).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('getUploadFields', () => {
|
|
27
|
+
it('returns top-level upload fields', () => {
|
|
28
|
+
const fields = [{ name: 'title', label: 'Title', type: 'text' }, coverField];
|
|
29
|
+
expect(getUploadFields({ fields }).map((f) => f.name)).toEqual(['cover']);
|
|
30
|
+
});
|
|
31
|
+
it('recurses into group / array / blocks structure fields', () => {
|
|
32
|
+
const fields = [
|
|
33
|
+
coverField,
|
|
34
|
+
{
|
|
35
|
+
name: 'files',
|
|
36
|
+
label: 'Files',
|
|
37
|
+
type: 'array',
|
|
38
|
+
fields: [
|
|
39
|
+
{
|
|
40
|
+
name: 'filesGroup',
|
|
41
|
+
type: 'group',
|
|
42
|
+
fields: [nestedFileField, { name: 'label', label: 'Label', type: 'text' }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'content',
|
|
48
|
+
label: 'Content',
|
|
49
|
+
type: 'blocks',
|
|
50
|
+
blocks: [
|
|
51
|
+
{
|
|
52
|
+
blockType: 'photo',
|
|
53
|
+
label: 'Photo',
|
|
54
|
+
fields: [{ name: 'photo', label: 'Photo', type: 'image', upload }],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
expect(getUploadFields({ fields }).map((f) => f.name)).toEqual([
|
|
60
|
+
'cover',
|
|
61
|
+
'publicationFile',
|
|
62
|
+
'photo',
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
it('skips nested image/file fields without an upload block', () => {
|
|
66
|
+
const fields = [
|
|
67
|
+
{
|
|
68
|
+
name: 'gallery',
|
|
69
|
+
label: 'Gallery',
|
|
70
|
+
type: 'group',
|
|
71
|
+
fields: [{ name: 'pic', label: 'Pic', type: 'image' }],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
expect(getUploadFields({ fields })).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('hasUploadField', () => {
|
|
78
|
+
it('detects upload fields at any nesting depth', () => {
|
|
79
|
+
const fields = [
|
|
80
|
+
{
|
|
81
|
+
name: 'files',
|
|
82
|
+
label: 'Files',
|
|
83
|
+
type: 'array',
|
|
84
|
+
fields: [{ name: 'filesGroup', type: 'group', fields: [nestedFileField] }],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
expect(hasUploadField({ fields })).toBe(true);
|
|
88
|
+
expect(hasUploadField({ fields: [{ name: 'title', label: 'Title', type: 'text' }] })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.17.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"pino": "^10.3.1",
|
|
77
77
|
"sharp": "^0.34.5",
|
|
78
78
|
"zod": "^4.4.3",
|
|
79
|
-
"@byline/auth": "3.
|
|
79
|
+
"@byline/auth": "3.17.1"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@biomejs/biome": "2.4.15",
|