@crombolo/schema 0.11.0 → 0.13.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/package.json +3 -3
- package/src/check.ts +53 -20
- package/src/export.ts +4 -1
- package/src/forms.ts +32 -326
- package/src/index.ts +1 -0
- package/src/load.ts +45 -6
- package/src/model.ts +11 -1
- package/src/parse-forms.ts +54 -21
- package/src/parse.ts +26 -2
- package/src/sync.ts +36 -1
- package/src/validate-forms.ts +78 -10
- package/src/validate.ts +8 -0
- package/src/workflows.ts +229 -0
- package/src/write-forms.ts +9 -0
- package/src/write.ts +5 -2
package/src/parse-forms.ts
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* type and the two share nothing but the reader.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
|
+
BUILT_IN_FORM_TYPES,
|
|
10
|
+
type ChoiceSource,
|
|
11
|
+
type ChoiceSourceKind,
|
|
9
12
|
FORM_CONDITION_OPERATORS,
|
|
10
|
-
FORM_FIELD_TYPE_NAMES,
|
|
11
|
-
FORM_WORKFLOW_TYPES,
|
|
12
13
|
type FormCondition,
|
|
13
14
|
type FormConditionOperator,
|
|
14
15
|
type FormConditionRule,
|
|
15
16
|
type FormFieldType,
|
|
17
|
+
type FormSettingValue,
|
|
18
|
+
type FormTypes,
|
|
16
19
|
type FormWorkflowTrigger,
|
|
17
20
|
type FormWorkflowType,
|
|
18
21
|
formFieldType,
|
|
@@ -31,6 +34,7 @@ const FORM_KEYS = [
|
|
|
31
34
|
'notes',
|
|
32
35
|
'store-entries',
|
|
33
36
|
'requires-approval',
|
|
37
|
+
'step-pages',
|
|
34
38
|
'submit-label',
|
|
35
39
|
'next-label',
|
|
36
40
|
'previous-label',
|
|
@@ -59,6 +63,7 @@ const FIELD_KEYS = [
|
|
|
59
63
|
'css-class',
|
|
60
64
|
'autocomplete',
|
|
61
65
|
'values',
|
|
66
|
+
'source',
|
|
62
67
|
'placeholder',
|
|
63
68
|
'default-value',
|
|
64
69
|
'content',
|
|
@@ -70,10 +75,12 @@ const FIELD_KEYS = [
|
|
|
70
75
|
'accept',
|
|
71
76
|
'max-size-kb',
|
|
72
77
|
'multiple',
|
|
78
|
+
'settings',
|
|
73
79
|
'condition',
|
|
74
80
|
] as const
|
|
75
81
|
const CONDITION_KEYS = ['action', 'match', 'rule'] as const
|
|
76
82
|
const RULE_KEYS = ['field', 'operator', 'value'] as const
|
|
83
|
+
const SOURCE_KEYS = ['kind', 'parent'] as const
|
|
77
84
|
const WORKFLOW_KEYS = ['key', 'type', 'name', 'on', 'settings'] as const
|
|
78
85
|
|
|
79
86
|
function condition(r: Reader, value: unknown, path: string): FormCondition | undefined {
|
|
@@ -112,14 +119,30 @@ function condition(r: Reader, value: unknown, path: string): FormCondition | und
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
|
|
122
|
+
/** Kept as written: the validator names an unknown kind, as it does an unknown type. */
|
|
123
|
+
function source(r: Reader, value: unknown, path: string): ChoiceSource | undefined {
|
|
124
|
+
if (value === undefined) return undefined
|
|
125
|
+
const t = r.table(value, path)
|
|
126
|
+
if (!t) return undefined
|
|
127
|
+
r.unknown(t, SOURCE_KEYS, path)
|
|
128
|
+
return {
|
|
129
|
+
kind: (r.str(t, 'kind', path, true) ?? '') as ChoiceSourceKind,
|
|
130
|
+
parent: r.str(t, 'parent', path, true) ?? '',
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Says a type is unknown by its name, and what is known: the built-ins, then each bundle's. */
|
|
135
|
+
function unknownType(kind: 'field' | 'workflow', type: string, known: Iterable<string>): string {
|
|
136
|
+
return `the ${kind} type "${type}" is not built in, and no wired bundle provides it; expected one of ${[...known].join(', ')}`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function field(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormField {
|
|
116
140
|
r.unknown(t, FIELD_KEYS, path)
|
|
117
141
|
const type = r.str(t, 'type', path, true) ?? ''
|
|
118
|
-
if (type && !formFieldType(type))
|
|
119
|
-
r.problem(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
142
|
+
if (type && !formFieldType(type, types))
|
|
143
|
+
r.problem(`${path}.type`, unknownType('field', type, types.fields.keys()))
|
|
144
|
+
const settings =
|
|
145
|
+
t.settings === undefined ? undefined : (r.table(t.settings, `${path}.settings`) ?? {})
|
|
123
146
|
// Consent is mandatory unless the form says otherwise: a consent box that can
|
|
124
147
|
// be skipped records agreement nobody gave.
|
|
125
148
|
const mandatoryByDefault = type === 'dataConsent'
|
|
@@ -137,6 +160,7 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
|
|
|
137
160
|
cssClass: r.str(t, 'css-class', path),
|
|
138
161
|
autocomplete: r.str(t, 'autocomplete', path),
|
|
139
162
|
values: r.strs(t, 'values', path),
|
|
163
|
+
...(t.source !== undefined ? { source: source(r, t.source, `${path}.source`) } : {}),
|
|
140
164
|
placeholder: r.str(t, 'placeholder', path),
|
|
141
165
|
defaultValue: r.str(t, 'default-value', path),
|
|
142
166
|
content: r.str(t, 'content', path),
|
|
@@ -148,37 +172,39 @@ function field(r: Reader, t: Toml, path: string): SchemaFormField {
|
|
|
148
172
|
accept: r.strs(t, 'accept', path),
|
|
149
173
|
maxSizeKb: r.num(t, 'max-size-kb', path),
|
|
150
174
|
...(t.multiple !== undefined ? { multiple: r.bool(t, 'multiple', path, false) } : {}),
|
|
175
|
+
...(settings ? { settings: settings as Record<string, FormSettingValue> } : {}),
|
|
151
176
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
152
177
|
}
|
|
153
178
|
}
|
|
154
179
|
|
|
155
|
-
function group(r: Reader, t: Toml, path: string): SchemaFormGroup {
|
|
180
|
+
function group(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormGroup {
|
|
156
181
|
r.unknown(t, GROUP_KEYS, path)
|
|
157
182
|
return {
|
|
158
183
|
caption: r.str(t, 'caption', path),
|
|
159
184
|
columns: r.num(t, 'columns', path) ?? 1,
|
|
160
|
-
fields: r
|
|
185
|
+
fields: r
|
|
186
|
+
.tables(t.field, `${path}.field`)
|
|
187
|
+
.map((f, i) => field(r, f, `${path}.field[${i}]`, types)),
|
|
161
188
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
162
189
|
}
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
function page(r: Reader, t: Toml, path: string): SchemaFormPage {
|
|
192
|
+
function page(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormPage {
|
|
166
193
|
r.unknown(t, PAGE_KEYS, path)
|
|
167
194
|
return {
|
|
168
195
|
caption: r.str(t, 'caption', path),
|
|
169
|
-
groups: r
|
|
196
|
+
groups: r
|
|
197
|
+
.tables(t.group, `${path}.group`)
|
|
198
|
+
.map((g, i) => group(r, g, `${path}.group[${i}]`, types)),
|
|
170
199
|
condition: condition(r, t.condition, `${path}.condition`),
|
|
171
200
|
}
|
|
172
201
|
}
|
|
173
202
|
|
|
174
|
-
function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
|
|
203
|
+
function workflow(r: Reader, t: Toml, path: string, types: FormTypes): SchemaFormWorkflow {
|
|
175
204
|
r.unknown(t, WORKFLOW_KEYS, path)
|
|
176
205
|
const type = r.str(t, 'type', path, true) ?? ''
|
|
177
|
-
if (type && !
|
|
178
|
-
r.problem(
|
|
179
|
-
`${path}.type`,
|
|
180
|
-
`unknown workflow type; expected one of ${FORM_WORKFLOW_TYPES.join(', ')}`,
|
|
181
|
-
)
|
|
206
|
+
if (type && !types.workflows.has(type))
|
|
207
|
+
r.problem(`${path}.type`, unknownType('workflow', type, types.workflows.keys()))
|
|
182
208
|
const on = r.str(t, 'on', path) ?? 'submit'
|
|
183
209
|
if (on !== 'submit' && on !== 'approve') r.problem(`${path}.on`, "expected 'submit' or 'approve'")
|
|
184
210
|
const settings = t.settings === undefined ? {} : r.table(t.settings, `${path}.settings`)
|
|
@@ -191,7 +217,11 @@ function workflow(r: Reader, t: Toml, path: string): SchemaFormWorkflow {
|
|
|
191
217
|
}
|
|
192
218
|
}
|
|
193
219
|
|
|
194
|
-
export function parseForm(
|
|
220
|
+
export function parseForm(
|
|
221
|
+
file: string,
|
|
222
|
+
source: string,
|
|
223
|
+
types: FormTypes = BUILT_IN_FORM_TYPES,
|
|
224
|
+
): ParseResult<SchemaForm> {
|
|
195
225
|
const r = new Reader(file)
|
|
196
226
|
const root = toml(source, r)
|
|
197
227
|
if (!root) return { value: undefined, problems: r.problems }
|
|
@@ -212,6 +242,7 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
|
|
|
212
242
|
// what people typed would be the worst possible default.
|
|
213
243
|
storeEntries: r.bool(t, 'store-entries', path, true),
|
|
214
244
|
requiresApproval: r.bool(t, 'requires-approval', path, false),
|
|
245
|
+
...(t['step-pages'] !== undefined ? { stepPages: r.bool(t, 'step-pages', path, false) } : {}),
|
|
215
246
|
submitLabel: r.str(t, 'submit-label', path),
|
|
216
247
|
nextLabel: r.str(t, 'next-label', path),
|
|
217
248
|
previousLabel: r.str(t, 'previous-label', path),
|
|
@@ -222,8 +253,10 @@ export function parseForm(file: string, source: string): ParseResult<SchemaForm>
|
|
|
222
253
|
minimumSubmitSeconds: r.num(t, 'minimum-submit-seconds', path),
|
|
223
254
|
maxEntries: r.num(t, 'max-entries', path),
|
|
224
255
|
since: r.str(t, 'since', path),
|
|
225
|
-
pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]
|
|
226
|
-
workflows: r
|
|
256
|
+
pages: r.tables(root.page, 'page').map((p, i) => page(r, p, `page[${i}]`, types)),
|
|
257
|
+
workflows: r
|
|
258
|
+
.tables(root.workflow, 'workflow')
|
|
259
|
+
.map((w, i) => workflow(r, w, `workflow[${i}]`, types)),
|
|
227
260
|
}
|
|
228
261
|
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
229
262
|
}
|
package/src/parse.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type SchemaProperty,
|
|
12
12
|
type SchemaTab,
|
|
13
13
|
type SchemaTypeKind,
|
|
14
|
+
type SchemaUrls,
|
|
14
15
|
TYPE_KIND_FILES,
|
|
15
16
|
} from './model.ts'
|
|
16
17
|
import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
|
|
@@ -251,12 +252,35 @@ export function parseLanguages(file: string, source: string): ParseResult<Schema
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
export function parseSchemaVersion(file: string, source: string): ParseResult<string> {
|
|
255
|
+
const parsed = parseSchemaSettings(file, source)
|
|
256
|
+
return { value: parsed.value?.version, problems: parsed.problems }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `schema/schema.toml`: the schema version, and how the site's URLs are built. */
|
|
260
|
+
export function parseSchemaSettings(
|
|
261
|
+
file: string,
|
|
262
|
+
source: string,
|
|
263
|
+
): ParseResult<{ version: string; urls?: SchemaUrls }> {
|
|
254
264
|
const r = new Reader(file)
|
|
255
265
|
const root = toml(source, r)
|
|
256
266
|
if (!root) return { value: undefined, problems: r.problems }
|
|
257
|
-
r.unknown(root, ['schema'], '')
|
|
267
|
+
r.unknown(root, ['schema', 'urls'], '')
|
|
258
268
|
const t = r.table(root.schema, 'schema')
|
|
259
269
|
const version = t ? r.str(t, 'version', 'schema', true) : undefined
|
|
260
270
|
if (t) r.unknown(t, ['version'], 'schema')
|
|
261
|
-
|
|
271
|
+
let urls: SchemaUrls | undefined
|
|
272
|
+
if (root.urls !== undefined) {
|
|
273
|
+
const u = r.table(root.urls, 'urls')
|
|
274
|
+
if (u) {
|
|
275
|
+
r.unknown(u, ['hide-top-level'], 'urls')
|
|
276
|
+
urls = { hideTopLevel: r.bool(u, 'hide-top-level', 'urls', true) }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
value:
|
|
281
|
+
r.problems.length === 0 && version !== undefined
|
|
282
|
+
? { version, ...(urls ? { urls } : {}) }
|
|
283
|
+
: undefined,
|
|
284
|
+
problems: r.problems,
|
|
285
|
+
}
|
|
262
286
|
}
|
package/src/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* retirement, and a type with content refuses to disappear. The algorithm and
|
|
4
4
|
* the multi-node rules are in docs/schema-as-code.md, "Synchronisation".
|
|
5
5
|
*/
|
|
6
|
+
import type { FormTypes } from '@crombolo/core'
|
|
6
7
|
import { type ContentTypeAggregate, ObjectTypes, type PropertyTypeModel } from '@crombolo/core'
|
|
7
8
|
import {
|
|
8
9
|
appendCacheInstruction,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
DataTypeRepository,
|
|
16
17
|
type Db,
|
|
17
18
|
DbDate,
|
|
19
|
+
DEFAULT_URL_OPTIONS,
|
|
18
20
|
FolderRepository,
|
|
19
21
|
LanguageRepository,
|
|
20
22
|
Locks,
|
|
@@ -25,7 +27,12 @@ import {
|
|
|
25
27
|
SYSTEM_CONTENT_TYPES,
|
|
26
28
|
SYSTEM_MEDIA_TYPES,
|
|
27
29
|
SYSTEM_MEMBER_TYPE,
|
|
30
|
+
sameUrlOptions,
|
|
31
|
+
syncWorkflows,
|
|
32
|
+
type UrlOptions,
|
|
28
33
|
updateSchemaStateHash,
|
|
34
|
+
type WorkflowSyncReport,
|
|
35
|
+
writeUrlOptions,
|
|
29
36
|
} from '@crombolo/data'
|
|
30
37
|
import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
|
|
31
38
|
import { hashSchemaSet, type LoadedSchema } from './load.ts'
|
|
@@ -38,6 +45,7 @@ import {
|
|
|
38
45
|
typeFileKey,
|
|
39
46
|
} from './model.ts'
|
|
40
47
|
import { validateSchemaSet } from './validate.ts'
|
|
48
|
+
import { workflowVersion } from './workflows.ts'
|
|
41
49
|
|
|
42
50
|
export interface SyncNode extends NodeSchemaState {
|
|
43
51
|
nodeId: string
|
|
@@ -56,6 +64,8 @@ export interface SyncOptions {
|
|
|
56
64
|
* is pending everywhere until `upgrade` makes the state current. Default current.
|
|
57
65
|
*/
|
|
58
66
|
status?: 'prepared' | 'current'
|
|
67
|
+
/** The form field and workflow types the site's wired bundles add. */
|
|
68
|
+
formTypes?: FormTypes
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export type SyncAction = 'applied' | 'would-apply' | 'skipped-older' | 'skipped-same' | 'refused'
|
|
@@ -88,6 +98,10 @@ export interface SyncReport {
|
|
|
88
98
|
revived: string[]
|
|
89
99
|
assignedKeys: AssignedKey[]
|
|
90
100
|
deferred: string[]
|
|
101
|
+
/** Set when the sync changed how URLs are built, so the old ones can be redirected. */
|
|
102
|
+
urls?: { before: UrlOptions; after: UrlOptions }
|
|
103
|
+
/** Set when a workflow changed, with what that did to requests in flight. */
|
|
104
|
+
workflows?: WorkflowSyncReport
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export const emptySyncReport = (
|
|
@@ -132,7 +146,10 @@ export async function syncSchema(
|
|
|
132
146
|
): Promise<SyncReport> {
|
|
133
147
|
const problems = [
|
|
134
148
|
...loaded.problems,
|
|
135
|
-
...validateSchemaSet(loaded.set, {
|
|
149
|
+
...validateSchemaSet(loaded.set, {
|
|
150
|
+
componentAliases: options.componentAliases,
|
|
151
|
+
types: options.formTypes,
|
|
152
|
+
}),
|
|
136
153
|
]
|
|
137
154
|
if (problems.length > 0)
|
|
138
155
|
return emptySyncReport('refused', { reason: 'the schema has problems', problems })
|
|
@@ -268,6 +285,24 @@ export async function syncSchema(
|
|
|
268
285
|
report.updated.languages += result.updated
|
|
269
286
|
}
|
|
270
287
|
|
|
288
|
+
// ---- how URLs are built: the files are the source, absent is the default.
|
|
289
|
+
// Only a current sync moves it; a prepared one serves no URLs yet.
|
|
290
|
+
if (status === 'current') {
|
|
291
|
+
const urls = { hideTopLevel: set.urls?.hideTopLevel ?? DEFAULT_URL_OPTIONS.hideTopLevel }
|
|
292
|
+
const before = await writeUrlOptions(tx, urls)
|
|
293
|
+
if (!sameUrlOptions(before, urls)) report.urls = { before, after: urls }
|
|
294
|
+
// Approval workflows likewise: only a current sync changes the rules
|
|
295
|
+
// that pages are published under.
|
|
296
|
+
const workflows = await syncWorkflows(
|
|
297
|
+
tx,
|
|
298
|
+
(set.workflows ?? []).map((workflow) => ({
|
|
299
|
+
workflow,
|
|
300
|
+
version: workflowVersion(workflow),
|
|
301
|
+
})),
|
|
302
|
+
)
|
|
303
|
+
if (workflows.changed.length > 0) report.workflows = workflows
|
|
304
|
+
}
|
|
305
|
+
|
|
271
306
|
// ---- templates referenced by types must have a row
|
|
272
307
|
const componentKeyByAlias = new Map<string, string>()
|
|
273
308
|
for (const alias of new Set(set.contentTypes.flatMap((t) => t.components))) {
|
package/src/validate-forms.ts
CHANGED
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import {
|
|
10
10
|
allFormFields,
|
|
11
|
+
BUILT_IN_FORM_TYPES,
|
|
12
|
+
CHOICE_SOURCE_KINDS,
|
|
11
13
|
type FormCondition,
|
|
14
|
+
type FormTypes,
|
|
12
15
|
formFieldType,
|
|
13
16
|
type SchemaForm,
|
|
14
17
|
type SchemaFormField,
|
|
18
|
+
settingsProblems,
|
|
15
19
|
VALUELESS_OPERATORS,
|
|
16
20
|
} from '@crombolo/core'
|
|
17
21
|
import type { SchemaProblem } from './model.ts'
|
|
18
22
|
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
24
|
+
|
|
19
25
|
const VERSION = /^\d+(\.\d+)*$/
|
|
20
26
|
const ALIAS = /^[a-zA-Z][a-zA-Z0-9]*$/
|
|
21
27
|
|
|
@@ -45,6 +51,8 @@ export interface ValidateFormsOptions {
|
|
|
45
51
|
formThemes?: ReadonlySet<string>
|
|
46
52
|
/** Content type aliases, so `saveAsContent` can be held to a real one. */
|
|
47
53
|
contentTypeAliases?: ReadonlySet<string>
|
|
54
|
+
/** The field and workflow types in play: the built-ins, and the wired bundles'. */
|
|
55
|
+
types?: FormTypes
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export function validateForms(
|
|
@@ -98,7 +106,7 @@ export function validateForms(
|
|
|
98
106
|
'requires store-entries, since there is no entry to approve',
|
|
99
107
|
)
|
|
100
108
|
|
|
101
|
-
validateFields(form, file, add)
|
|
109
|
+
validateFields(form, file, options.types ?? BUILT_IN_FORM_TYPES, add)
|
|
102
110
|
validateWorkflows(form, file, options, add)
|
|
103
111
|
}
|
|
104
112
|
return problems
|
|
@@ -106,7 +114,7 @@ export function validateForms(
|
|
|
106
114
|
|
|
107
115
|
type Add = (file: string, path: string, message: string) => void
|
|
108
116
|
|
|
109
|
-
function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
117
|
+
function validateFields(form: SchemaForm, file: string, types: FormTypes, add: Add): void {
|
|
110
118
|
const fields = allFormFields(form)
|
|
111
119
|
const seen = new Set<string>()
|
|
112
120
|
/** Aliases usable by a condition: a field may only look at earlier ones. */
|
|
@@ -121,7 +129,7 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
121
129
|
add(file, 'page.group.columns', `${group.columns} is not a column count between 1 and 4`)
|
|
122
130
|
for (const field of group.fields) {
|
|
123
131
|
const path = `page.group.field[${index}]`
|
|
124
|
-
const info = formFieldType(field.type)
|
|
132
|
+
const info = formFieldType(field.type, types)
|
|
125
133
|
|
|
126
134
|
if (!ALIAS.test(field.alias))
|
|
127
135
|
add(
|
|
@@ -148,13 +156,40 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
148
156
|
`a ${field.type} field collects nothing, so it cannot be sensitive`,
|
|
149
157
|
)
|
|
150
158
|
}
|
|
151
|
-
|
|
159
|
+
const { source } = field
|
|
160
|
+
if (source && field.values.length > 0)
|
|
161
|
+
add(
|
|
162
|
+
file,
|
|
163
|
+
`${path}.source`,
|
|
164
|
+
'a field takes its choices from values or from a source, not both',
|
|
165
|
+
)
|
|
166
|
+
if (source && !info.choices)
|
|
167
|
+
add(file, `${path}.source`, `a ${field.type} field has nothing to choose from`)
|
|
168
|
+
if (source && info.choices) {
|
|
169
|
+
if (!CHOICE_SOURCE_KINDS.includes(source.kind))
|
|
170
|
+
add(
|
|
171
|
+
file,
|
|
172
|
+
`${path}.source.kind`,
|
|
173
|
+
`"${source.kind}" is not a kind of source; expected one of ${CHOICE_SOURCE_KINDS.join(', ')}`,
|
|
174
|
+
)
|
|
175
|
+
if (source.kind === 'content' && !UUID.test(source.parent))
|
|
176
|
+
add(file, `${path}.source.parent`, 'a content source names its parent page by key')
|
|
177
|
+
if (source.kind === 'dictionary' && source.parent.trim() === '')
|
|
178
|
+
add(
|
|
179
|
+
file,
|
|
180
|
+
`${path}.source.parent`,
|
|
181
|
+
'a dictionary source names its parent dictionary item',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (info.choices && !source && field.values.length === 0)
|
|
152
185
|
add(file, `${path}.values`, `a ${field.type} field needs values to choose from`)
|
|
153
186
|
if (!info.choices && field.values.length > 0)
|
|
154
187
|
add(file, `${path}.values`, `a ${field.type} field has nothing to choose from`)
|
|
155
|
-
// A default has to be one of the choices, or the form opens invalid.
|
|
188
|
+
// A default has to be one of the choices, or the form opens invalid. A
|
|
189
|
+
// source's choices are only known once it is resolved, so not checked here.
|
|
156
190
|
if (
|
|
157
191
|
info.choices &&
|
|
192
|
+
!source &&
|
|
158
193
|
field.defaultValue !== undefined &&
|
|
159
194
|
!field.values.includes(field.defaultValue)
|
|
160
195
|
)
|
|
@@ -165,6 +200,21 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
165
200
|
if (!info.settings.includes(setting as never))
|
|
166
201
|
add(file, `${path}.${setting}`, `does not apply to a ${field.type} field`)
|
|
167
202
|
}
|
|
203
|
+
// A built-in type's settings are keys of the field; only a bundle's
|
|
204
|
+
// type has a settings table, held to what it declares.
|
|
205
|
+
if (!info.bundle && field.settings !== undefined)
|
|
206
|
+
add(
|
|
207
|
+
file,
|
|
208
|
+
`${path}.settings`,
|
|
209
|
+
`a ${field.type} field is built in and takes no settings table; its settings are keys of the field`,
|
|
210
|
+
)
|
|
211
|
+
if (info.bundle)
|
|
212
|
+
for (const message of settingsProblems(
|
|
213
|
+
info.declared,
|
|
214
|
+
field.settings ?? {},
|
|
215
|
+
`a ${field.type} field`,
|
|
216
|
+
))
|
|
217
|
+
add(file, `${path}.settings`, message)
|
|
168
218
|
}
|
|
169
219
|
|
|
170
220
|
if (field.pattern !== undefined) {
|
|
@@ -184,19 +234,27 @@ function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
|
184
234
|
add(file, `${path}.max-size-kb`, 'must be at least 1')
|
|
185
235
|
|
|
186
236
|
if (field.condition)
|
|
187
|
-
validateCondition(field.condition, fields, earlier, file, `${path}.condition`, add)
|
|
237
|
+
validateCondition(field.condition, fields, earlier, file, `${path}.condition`, types, add)
|
|
188
238
|
earlier.add(field.alias.toLowerCase())
|
|
189
239
|
index++
|
|
190
240
|
}
|
|
191
241
|
if (group.condition)
|
|
192
|
-
validateCondition(
|
|
242
|
+
validateCondition(
|
|
243
|
+
group.condition,
|
|
244
|
+
fields,
|
|
245
|
+
earlier,
|
|
246
|
+
file,
|
|
247
|
+
'page.group.condition',
|
|
248
|
+
types,
|
|
249
|
+
add,
|
|
250
|
+
)
|
|
193
251
|
}
|
|
194
252
|
if (page.condition)
|
|
195
|
-
validateCondition(page.condition, fields, earlier, file, 'page.condition', add)
|
|
253
|
+
validateCondition(page.condition, fields, earlier, file, 'page.condition', types, add)
|
|
196
254
|
}
|
|
197
255
|
|
|
198
256
|
if (fields.length === 0) add(file, 'page.group.field', 'a form needs at least one field')
|
|
199
|
-
else if (fields.every((field) => formFieldType(field.type)?.presentational))
|
|
257
|
+
else if (fields.every((field) => formFieldType(field.type, types)?.presentational))
|
|
200
258
|
add(file, 'page.group.field', 'every field is presentational, so the form collects nothing')
|
|
201
259
|
}
|
|
202
260
|
|
|
@@ -206,6 +264,7 @@ function validateCondition(
|
|
|
206
264
|
earlier: ReadonlySet<string>,
|
|
207
265
|
file: string,
|
|
208
266
|
path: string,
|
|
267
|
+
types: FormTypes,
|
|
209
268
|
add: Add,
|
|
210
269
|
): void {
|
|
211
270
|
for (const [index, rule] of condition.rules.entries()) {
|
|
@@ -223,7 +282,7 @@ function validateCondition(
|
|
|
223
282
|
`${rulePath}.field`,
|
|
224
283
|
`"${rule.field}" comes later in the form, so it has no value to compare yet`,
|
|
225
284
|
)
|
|
226
|
-
if (formFieldType(target.type)?.presentational)
|
|
285
|
+
if (formFieldType(target.type, types)?.presentational)
|
|
227
286
|
add(file, `${rulePath}.field`, `"${rule.field}" collects nothing, so it cannot be compared`)
|
|
228
287
|
const valueless = VALUELESS_OPERATORS.includes(rule.operator)
|
|
229
288
|
if (valueless && rule.value !== undefined)
|
|
@@ -275,6 +334,15 @@ function validateWorkflows(
|
|
|
275
334
|
'runs on approval, but the form does not require approval so nothing ever approves it',
|
|
276
335
|
)
|
|
277
336
|
|
|
337
|
+
const bundled = (options.types ?? BUILT_IN_FORM_TYPES).workflows.get(workflow.type)
|
|
338
|
+
if (bundled?.bundle) {
|
|
339
|
+
const where = `a ${workflow.type} workflow`
|
|
340
|
+
const declared = settingsProblems(bundled.declared, workflow.settings, where)
|
|
341
|
+
for (const message of declared) add(file, `${path}.settings`, message)
|
|
342
|
+
const own = declared.length === 0 ? bundled.validate?.(workflow.settings) : undefined
|
|
343
|
+
if (own) add(file, `${path}.settings`, own)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
278
346
|
const rules = WORKFLOW_SETTINGS[workflow.type]
|
|
279
347
|
if (!rules) continue
|
|
280
348
|
const allowed = new Set([...rules.required, ...rules.optional])
|
package/src/validate.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Whole-set validation. Every problem is reported, not just the first, because
|
|
3
3
|
* a person fixing a schema wants the full list.
|
|
4
4
|
*/
|
|
5
|
+
|
|
5
6
|
import { SYSTEM_MEDIA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
|
|
6
7
|
import {
|
|
7
8
|
BUILTIN_DATA_TYPE_ALIASES,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
TYPE_KIND_FILES,
|
|
19
20
|
} from './model.ts'
|
|
20
21
|
import { type ValidateFormsOptions, validateForms } from './validate-forms.ts'
|
|
22
|
+
import { validateWorkflows } from './workflows.ts'
|
|
21
23
|
|
|
22
24
|
export interface ValidateOptions extends ValidateFormsOptions {
|
|
23
25
|
/** Template aliases that exist as view files; omit to skip template checks. */
|
|
@@ -220,6 +222,12 @@ export function validateSchemaSet(set: SchemaSet, options: ValidateOptions = {})
|
|
|
220
222
|
options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
|
|
221
223
|
}),
|
|
222
224
|
)
|
|
225
|
+
problems.push(
|
|
226
|
+
...validateWorkflows(
|
|
227
|
+
set.workflows ?? [],
|
|
228
|
+
options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
223
231
|
return problems
|
|
224
232
|
}
|
|
225
233
|
|