@crombolo/core 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 +1 -1
- package/src/api-paths.ts +6 -0
- package/src/form-submissions.ts +34 -5
- package/src/forms.ts +275 -15
- package/src/index.ts +2 -0
- package/src/json-patch.ts +144 -0
- package/src/problem-details.ts +4 -0
- package/src/sections.ts +2 -0
- package/src/workflows.ts +227 -0
package/package.json
CHANGED
package/src/api-paths.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const MANAGEMENT_API_PREFIX = '/umbraco/management/api/'
|
|
|
12
12
|
|
|
13
13
|
export const MANAGEMENT_API_PATH = `${MANAGEMENT_API_PREFIX}v1`
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Umbraco's Delivery API, at the path its contract and every headless client
|
|
17
|
+
* use, wherever the backoffice is mounted.
|
|
18
|
+
*/
|
|
19
|
+
export const DELIVERY_API_PREFIX = '/umbraco/delivery/api'
|
|
20
|
+
|
|
15
21
|
/** `server-event.context.js`: `${serverURL}/umbraco/serverEventHub`. */
|
|
16
22
|
export const SERVER_EVENT_HUB_PATH = '/umbraco/serverEventHub'
|
|
17
23
|
|
package/src/form-submissions.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* is a client-side concern tied to CSS classes.
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
+
choiceOptions,
|
|
14
15
|
type FormCondition,
|
|
15
16
|
type FormConditionRule,
|
|
17
|
+
type FormTypes,
|
|
16
18
|
formFieldType,
|
|
17
19
|
type SchemaForm,
|
|
18
20
|
type SchemaFormField,
|
|
@@ -30,6 +32,11 @@ export interface FormFieldError {
|
|
|
30
32
|
export interface FormValueToStore {
|
|
31
33
|
fieldAlias: string
|
|
32
34
|
values: string[]
|
|
35
|
+
/**
|
|
36
|
+
* What the visitor saw for each value, for a choice drawn from a source: the
|
|
37
|
+
* value is a key, and the label keeps the entry readable once that key is gone.
|
|
38
|
+
*/
|
|
39
|
+
labels?: string[]
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export interface SubmissionResult {
|
|
@@ -168,6 +175,8 @@ export interface ValidateSubmissionOptions {
|
|
|
168
175
|
* forward. Values for other pages are still carried through.
|
|
169
176
|
*/
|
|
170
177
|
page?: number
|
|
178
|
+
/** The field types in play: the built-ins, and the wired bundles'. */
|
|
179
|
+
types?: FormTypes
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
const DATE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?$/
|
|
@@ -189,7 +198,7 @@ export function validateSubmission(
|
|
|
189
198
|
errors.push({ field: field.alias, message })
|
|
190
199
|
|
|
191
200
|
for (const { field, page } of visibleFields(form, submitted)) {
|
|
192
|
-
const info = formFieldType(field.type)
|
|
201
|
+
const info = formFieldType(field.type, options.types)
|
|
193
202
|
// A presentational field collects nothing, so there is nothing to check and
|
|
194
203
|
// nothing to store — and a value posted for one is ignored, not kept.
|
|
195
204
|
if (!info || info.presentational) continue
|
|
@@ -222,6 +231,7 @@ export function validateSubmission(
|
|
|
222
231
|
}
|
|
223
232
|
|
|
224
233
|
let accepted = present
|
|
234
|
+
const allowed = new Map(choiceOptions(field).map((option) => [option.value, option.label]))
|
|
225
235
|
switch (info.stores) {
|
|
226
236
|
case 'string': {
|
|
227
237
|
const value = first(present)
|
|
@@ -239,7 +249,7 @@ export function validateSubmission(
|
|
|
239
249
|
fail(field, `${label(field)} cannot be after ${field.max}`)
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
|
-
if (info.choices && !
|
|
252
|
+
if (info.choices && !allowed.has(value))
|
|
243
253
|
fail(field, `${label(field)} is not one of the choices`)
|
|
244
254
|
if (field.pattern) {
|
|
245
255
|
let matches = true
|
|
@@ -256,9 +266,9 @@ export function validateSubmission(
|
|
|
256
266
|
break
|
|
257
267
|
}
|
|
258
268
|
case 'strings': {
|
|
259
|
-
const unknown = present.filter((value) => !
|
|
269
|
+
const unknown = present.filter((value) => !allowed.has(value))
|
|
260
270
|
if (unknown.length > 0) fail(field, `${label(field)} is not one of the choices`)
|
|
261
|
-
accepted = present.filter((value) =>
|
|
271
|
+
accepted = present.filter((value) => allowed.has(value))
|
|
262
272
|
break
|
|
263
273
|
}
|
|
264
274
|
case 'number': {
|
|
@@ -285,7 +295,26 @@ export function validateSubmission(
|
|
|
285
295
|
break
|
|
286
296
|
}
|
|
287
297
|
}
|
|
288
|
-
|
|
298
|
+
// A bundle's own check, once the kind's have passed: a broken one refuses
|
|
299
|
+
// the value rather than the whole submission.
|
|
300
|
+
if (info.validate && !errors.some((error) => error.field === field.alias)) {
|
|
301
|
+
let message: string | undefined
|
|
302
|
+
try {
|
|
303
|
+
message = info.validate(accepted, field.settings ?? {})
|
|
304
|
+
} catch {
|
|
305
|
+
message = `${label(field)} could not be checked`
|
|
306
|
+
}
|
|
307
|
+
if (message) fail(field, message)
|
|
308
|
+
}
|
|
309
|
+
values.push(
|
|
310
|
+
field.source
|
|
311
|
+
? {
|
|
312
|
+
fieldAlias: field.alias,
|
|
313
|
+
values: accepted,
|
|
314
|
+
labels: accepted.map((value) => allowed.get(value) ?? value),
|
|
315
|
+
}
|
|
316
|
+
: { fieldAlias: field.alias, values: accepted },
|
|
317
|
+
)
|
|
289
318
|
}
|
|
290
319
|
|
|
291
320
|
return { ok: errors.length === 0, errors, values }
|
package/src/forms.ts
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* renderer need it, and `render` depends on `core` alone. What a form *is* is
|
|
6
6
|
* domain knowledge; how it is written to a file is not.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* The built-in field and workflow types are fixed here. A server bundle adds
|
|
9
|
+
* its own through `FormTypes`, which is passed to whatever parses, validates or
|
|
10
|
+
* renders a form rather than held globally (`docs/forms.md`, "Types from a
|
|
11
|
+
* bundle").
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** A field type's name: a built-in one, or one a wired bundle provides. */
|
|
15
|
+
export type FormFieldType = string
|
|
16
|
+
|
|
17
|
+
export type BuiltInFormFieldType =
|
|
15
18
|
| 'shortAnswer'
|
|
16
19
|
| 'longAnswer'
|
|
17
20
|
| 'email'
|
|
@@ -30,6 +33,23 @@ export type FormFieldType =
|
|
|
30
33
|
/** How a submitted value is stored, which decides how an entry reads it back. */
|
|
31
34
|
export type FormValueKind = 'string' | 'strings' | 'number' | 'boolean' | 'files' | 'none'
|
|
32
35
|
|
|
36
|
+
/** What a declared setting holds, which is also the editor the designer draws for it. */
|
|
37
|
+
export type FormSettingKind = 'text' | 'number' | 'boolean' | 'list'
|
|
38
|
+
|
|
39
|
+
export type FormSettingValue = string | number | boolean | string[]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One setting a type accepts. For a built-in type `key` is the field's own
|
|
43
|
+
* property; for a bundle's type the value lives in the field's `settings`.
|
|
44
|
+
*/
|
|
45
|
+
export interface FormSettingDeclaration {
|
|
46
|
+
key: string
|
|
47
|
+
label: string
|
|
48
|
+
kind: FormSettingKind
|
|
49
|
+
required?: boolean
|
|
50
|
+
description?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
export interface FormFieldTypeInfo {
|
|
34
54
|
type: FormFieldType
|
|
35
55
|
label: string
|
|
@@ -43,6 +63,15 @@ export interface FormFieldTypeInfo {
|
|
|
43
63
|
presentational: boolean
|
|
44
64
|
/** The settings this type accepts beyond the ones every field has. */
|
|
45
65
|
settings: readonly FormFieldSetting[]
|
|
66
|
+
/** The same, as the designer edits them; a bundle's type declares only these. */
|
|
67
|
+
declared: readonly FormSettingDeclaration[]
|
|
68
|
+
/** The wired bundle that provides it; absent for a built-in type. */
|
|
69
|
+
bundle?: string
|
|
70
|
+
/** A check of a submitted value beyond its kind's: a message when it is wrong. */
|
|
71
|
+
validate?(
|
|
72
|
+
values: readonly string[],
|
|
73
|
+
settings: Readonly<Record<string, FormSettingValue>>,
|
|
74
|
+
): string | undefined
|
|
46
75
|
}
|
|
47
76
|
|
|
48
77
|
export type FormFieldSetting =
|
|
@@ -60,7 +89,24 @@ export type FormFieldSetting =
|
|
|
60
89
|
|
|
61
90
|
const TEXTUAL = ['placeholder', 'maxlength', 'default-value'] as const
|
|
62
91
|
|
|
63
|
-
|
|
92
|
+
/** How the designer edits each built-in setting, by its file key. */
|
|
93
|
+
const SETTING_DECLARATIONS: Record<FormFieldSetting, FormSettingDeclaration> = {
|
|
94
|
+
placeholder: { key: 'placeholder', label: 'Placeholder', kind: 'text' },
|
|
95
|
+
maxlength: { key: 'maxlength', label: 'Maximum length', kind: 'number' },
|
|
96
|
+
rows: { key: 'rows', label: 'Rows', kind: 'number' },
|
|
97
|
+
min: { key: 'min', label: 'Minimum', kind: 'number' },
|
|
98
|
+
max: { key: 'max', label: 'Maximum', kind: 'number' },
|
|
99
|
+
step: { key: 'step', label: 'Step', kind: 'number' },
|
|
100
|
+
accept: { key: 'accept', label: 'Accepted file types', kind: 'list' },
|
|
101
|
+
'max-size-kb': { key: 'maxSizeKb', label: 'Largest file (KB)', kind: 'number' },
|
|
102
|
+
content: { key: 'content', label: 'Content', kind: 'text' },
|
|
103
|
+
'default-value': { key: 'defaultValue', label: 'Default value', kind: 'text' },
|
|
104
|
+
multiple: { key: 'multiple', label: 'Allow several files', kind: 'boolean' },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type BuiltInInfo = Omit<FormFieldTypeInfo, 'declared' | 'type'> & { type: BuiltInFormFieldType }
|
|
108
|
+
|
|
109
|
+
const BUILT_IN_INFO: readonly BuiltInInfo[] = [
|
|
64
110
|
{
|
|
65
111
|
type: 'shortAnswer',
|
|
66
112
|
label: 'Short answer',
|
|
@@ -176,11 +222,10 @@ export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = [
|
|
|
176
222
|
},
|
|
177
223
|
]
|
|
178
224
|
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
225
|
+
export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = BUILT_IN_INFO.map((info) => ({
|
|
226
|
+
...info,
|
|
227
|
+
declared: info.settings.map((setting) => SETTING_DECLARATIONS[setting]),
|
|
228
|
+
}))
|
|
184
229
|
|
|
185
230
|
export const FORM_FIELD_TYPE_NAMES: readonly string[] = FORM_FIELD_TYPES.map((info) => info.type)
|
|
186
231
|
|
|
@@ -230,6 +275,8 @@ export interface SchemaFormField {
|
|
|
230
275
|
key?: string
|
|
231
276
|
alias: string
|
|
232
277
|
type: FormFieldType
|
|
278
|
+
/** A bundle type's own settings, as it declares them; never set on a built-in type. */
|
|
279
|
+
settings?: Record<string, FormSettingValue>
|
|
233
280
|
caption: string
|
|
234
281
|
helpText?: string
|
|
235
282
|
mandatory: boolean
|
|
@@ -243,6 +290,13 @@ export interface SchemaFormField {
|
|
|
243
290
|
autocomplete?: string
|
|
244
291
|
/** A choice list, for the types that take one. */
|
|
245
292
|
values: string[]
|
|
293
|
+
/** Where the choice list comes from instead, kept once rather than in every form. */
|
|
294
|
+
source?: ChoiceSource
|
|
295
|
+
/**
|
|
296
|
+
* The options a source resolved to, filled in at render and at submit; never
|
|
297
|
+
* read from or written to the file.
|
|
298
|
+
*/
|
|
299
|
+
options?: ChoiceOption[]
|
|
246
300
|
placeholder?: string
|
|
247
301
|
defaultValue?: string
|
|
248
302
|
content?: string
|
|
@@ -257,6 +311,53 @@ export interface SchemaFormField {
|
|
|
257
311
|
condition?: FormCondition
|
|
258
312
|
}
|
|
259
313
|
|
|
314
|
+
/** The kinds of list a choice field can draw its options from. */
|
|
315
|
+
export const CHOICE_SOURCE_KINDS = ['content', 'dictionary'] as const
|
|
316
|
+
|
|
317
|
+
export type ChoiceSourceKind = (typeof CHOICE_SOURCE_KINDS)[number]
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A choice list kept somewhere else: the children of a content node (by key), or
|
|
321
|
+
* the children of a dictionary item (by name). docs/forms.md, "Choices from a source".
|
|
322
|
+
*/
|
|
323
|
+
export interface ChoiceSource {
|
|
324
|
+
kind: ChoiceSourceKind
|
|
325
|
+
parent: string
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One option: what is stored when it is chosen, and what a visitor sees. */
|
|
329
|
+
export interface ChoiceOption {
|
|
330
|
+
value: string
|
|
331
|
+
label: string
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The options a choice field offers: its source's, once resolved, or its own values. */
|
|
335
|
+
export function choiceOptions(field: SchemaFormField): ChoiceOption[] {
|
|
336
|
+
if (field.options) return field.options
|
|
337
|
+
if (field.source) return []
|
|
338
|
+
return field.values.map((value) => ({ value, label: value }))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The form with every source-backed field's options filled in by `resolve`. */
|
|
342
|
+
export function withResolvedChoices(
|
|
343
|
+
form: SchemaForm,
|
|
344
|
+
resolve: (source: ChoiceSource) => ChoiceOption[],
|
|
345
|
+
): SchemaForm {
|
|
346
|
+
if (!allFormFields(form).some((field) => field.source)) return form
|
|
347
|
+
return {
|
|
348
|
+
...form,
|
|
349
|
+
pages: form.pages.map((page) => ({
|
|
350
|
+
...page,
|
|
351
|
+
groups: page.groups.map((group) => ({
|
|
352
|
+
...group,
|
|
353
|
+
fields: group.fields.map((field) =>
|
|
354
|
+
field.source ? { ...field, options: resolve(field.source) } : field,
|
|
355
|
+
),
|
|
356
|
+
})),
|
|
357
|
+
})),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
260
361
|
export interface SchemaFormGroup {
|
|
261
362
|
caption?: string
|
|
262
363
|
/** A simple column count; the renderer lays the fields out across it. */
|
|
@@ -271,14 +372,165 @@ export interface SchemaFormPage {
|
|
|
271
372
|
condition?: FormCondition
|
|
272
373
|
}
|
|
273
374
|
|
|
274
|
-
|
|
375
|
+
/** A workflow type's name: a built-in one, or one a wired bundle provides. */
|
|
376
|
+
export type FormWorkflowType = string
|
|
377
|
+
|
|
378
|
+
export type BuiltInFormWorkflowType = 'sendEmail' | 'saveAsContent' | 'sendToUrl'
|
|
275
379
|
|
|
276
|
-
export const FORM_WORKFLOW_TYPES: readonly
|
|
380
|
+
export const FORM_WORKFLOW_TYPES: readonly BuiltInFormWorkflowType[] = [
|
|
277
381
|
'sendEmail',
|
|
278
382
|
'saveAsContent',
|
|
279
383
|
'sendToUrl',
|
|
280
384
|
]
|
|
281
385
|
|
|
386
|
+
export interface FormWorkflowTypeInfo {
|
|
387
|
+
type: FormWorkflowType
|
|
388
|
+
label: string
|
|
389
|
+
/** A bundle type's settings; the built-in three are checked by the validator's own table. */
|
|
390
|
+
declared: readonly FormSettingDeclaration[]
|
|
391
|
+
bundle?: string
|
|
392
|
+
/** A check across its settings beyond their declarations: a message when they are wrong. */
|
|
393
|
+
validate?(settings: Readonly<Record<string, unknown>>): string | undefined
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The field and workflow types a site can use: the built-ins and its wired bundles'. */
|
|
397
|
+
export interface FormTypes {
|
|
398
|
+
fields: ReadonlyMap<string, FormFieldTypeInfo>
|
|
399
|
+
workflows: ReadonlyMap<string, FormWorkflowTypeInfo>
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const BUILT_IN_WORKFLOW_LABELS: Record<BuiltInFormWorkflowType, string> = {
|
|
403
|
+
sendEmail: 'Send an e-mail',
|
|
404
|
+
saveAsContent: 'Save as content',
|
|
405
|
+
sendToUrl: 'Send to a URL',
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const BUILT_IN_FORM_TYPES: FormTypes = {
|
|
409
|
+
fields: new Map(FORM_FIELD_TYPES.map((info) => [info.type, info])),
|
|
410
|
+
workflows: new Map(
|
|
411
|
+
FORM_WORKFLOW_TYPES.map((type) => [
|
|
412
|
+
type,
|
|
413
|
+
{ type, label: BUILT_IN_WORKFLOW_LABELS[type], declared: [] },
|
|
414
|
+
]),
|
|
415
|
+
),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function isBuiltInFieldType(type: string): type is BuiltInFormFieldType {
|
|
419
|
+
return FORM_FIELD_TYPE_NAMES.includes(type)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function isBuiltInWorkflowType(type: string): type is BuiltInFormWorkflowType {
|
|
423
|
+
return (FORM_WORKFLOW_TYPES as readonly string[]).includes(type)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function formFieldType(
|
|
427
|
+
type: string,
|
|
428
|
+
types: FormTypes = BUILT_IN_FORM_TYPES,
|
|
429
|
+
): FormFieldTypeInfo | undefined {
|
|
430
|
+
return types.fields.get(type)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const TYPE_NAME = /^[a-z][A-Za-z0-9]{1,31}$/
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The built-ins plus a bundle's types, or why they cannot be added: a name that
|
|
437
|
+
* is not camelCase, one already taken, or a setting declared twice. Pure.
|
|
438
|
+
*/
|
|
439
|
+
export function formTypesWith(
|
|
440
|
+
base: FormTypes,
|
|
441
|
+
bundle: string,
|
|
442
|
+
added: {
|
|
443
|
+
fields?: readonly Omit<FormFieldTypeInfo, 'bundle' | 'settings'>[]
|
|
444
|
+
workflows?: readonly Omit<FormWorkflowTypeInfo, 'bundle'>[]
|
|
445
|
+
},
|
|
446
|
+
): { types: FormTypes; problems: string[] } {
|
|
447
|
+
const problems: string[] = []
|
|
448
|
+
const fields = new Map(base.fields)
|
|
449
|
+
const workflows = new Map(base.workflows)
|
|
450
|
+
const check = (
|
|
451
|
+
kind: 'field' | 'workflow',
|
|
452
|
+
type: string,
|
|
453
|
+
taken: ReadonlyMap<string, { bundle?: string }>,
|
|
454
|
+
declared: readonly FormSettingDeclaration[],
|
|
455
|
+
): boolean => {
|
|
456
|
+
const owner = taken.get(type)
|
|
457
|
+
if (!TYPE_NAME.test(type)) {
|
|
458
|
+
problems.push(
|
|
459
|
+
`The bundle "${bundle}" names a ${kind} type "${type}", which is not a camelCase name of 2–32 letters and digits.`,
|
|
460
|
+
)
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
if (owner) {
|
|
464
|
+
problems.push(
|
|
465
|
+
`The bundle "${bundle}" adds the ${kind} type "${type}", which ${owner.bundle ? `the bundle "${owner.bundle}" already provides` : 'is built in'}.`,
|
|
466
|
+
)
|
|
467
|
+
return false
|
|
468
|
+
}
|
|
469
|
+
const keys = new Set<string>()
|
|
470
|
+
for (const setting of declared) {
|
|
471
|
+
if (keys.has(setting.key))
|
|
472
|
+
problems.push(
|
|
473
|
+
`The bundle "${bundle}"'s ${kind} type "${type}" declares the setting "${setting.key}" twice.`,
|
|
474
|
+
)
|
|
475
|
+
keys.add(setting.key)
|
|
476
|
+
}
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
for (const field of added.fields ?? [])
|
|
480
|
+
if (check('field', field.type, fields, field.declared))
|
|
481
|
+
fields.set(field.type, { ...field, settings: [], bundle })
|
|
482
|
+
for (const workflow of added.workflows ?? [])
|
|
483
|
+
if (check('workflow', workflow.type, workflows, workflow.declared))
|
|
484
|
+
workflows.set(workflow.type, { ...workflow, bundle })
|
|
485
|
+
return { types: { fields, workflows }, problems }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Why `settings` does not satisfy `declared`: a setting it does not declare, a
|
|
490
|
+
* required one missing, or a value of the wrong kind. Pure; `where` prefixes
|
|
491
|
+
* each message.
|
|
492
|
+
*/
|
|
493
|
+
export function settingsProblems(
|
|
494
|
+
declared: readonly FormSettingDeclaration[],
|
|
495
|
+
settings: Readonly<Record<string, unknown>>,
|
|
496
|
+
where: string,
|
|
497
|
+
): string[] {
|
|
498
|
+
const problems: string[] = []
|
|
499
|
+
const known = new Map(declared.map((setting) => [setting.key, setting]))
|
|
500
|
+
for (const key of Object.keys(settings))
|
|
501
|
+
if (!known.has(key))
|
|
502
|
+
problems.push(
|
|
503
|
+
`${where} has the setting "${key}", which it does not take${known.size > 0 ? `; it takes ${[...known.keys()].join(', ')}` : ''}.`,
|
|
504
|
+
)
|
|
505
|
+
for (const setting of declared) {
|
|
506
|
+
const value = settings[setting.key]
|
|
507
|
+
if (value === undefined || value === '') {
|
|
508
|
+
if (setting.required) problems.push(`${where} needs the setting "${setting.key}".`)
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
const fits =
|
|
512
|
+
setting.kind === 'text'
|
|
513
|
+
? typeof value === 'string'
|
|
514
|
+
: setting.kind === 'number'
|
|
515
|
+
? typeof value === 'number' && Number.isFinite(value)
|
|
516
|
+
: setting.kind === 'boolean'
|
|
517
|
+
? typeof value === 'boolean'
|
|
518
|
+
: Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
519
|
+
if (!fits)
|
|
520
|
+
problems.push(
|
|
521
|
+
`${where} has the setting "${setting.key}" as ${JSON.stringify(value)}, which is not ${KIND_WORDS[setting.kind]}.`,
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
return problems
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const KIND_WORDS: Record<FormSettingKind, string> = {
|
|
528
|
+
text: 'text',
|
|
529
|
+
number: 'a number',
|
|
530
|
+
boolean: 'true or false',
|
|
531
|
+
list: 'a list of text',
|
|
532
|
+
}
|
|
533
|
+
|
|
282
534
|
/** The state transition a workflow runs on. */
|
|
283
535
|
export type FormWorkflowTrigger = 'submit' | 'approve'
|
|
284
536
|
|
|
@@ -300,6 +552,11 @@ export interface SchemaForm {
|
|
|
300
552
|
storeEntries: boolean
|
|
301
553
|
/** Entries wait as `submitted` until someone approves them. */
|
|
302
554
|
requiresApproval: boolean
|
|
555
|
+
/**
|
|
556
|
+
* Shows one page at a time in the browser, checking each on the server before
|
|
557
|
+
* the next. Without JavaScript every page shows at once, as when it is off.
|
|
558
|
+
*/
|
|
559
|
+
stepPages?: boolean
|
|
303
560
|
submitLabel?: string
|
|
304
561
|
nextLabel?: string
|
|
305
562
|
previousLabel?: string
|
|
@@ -326,6 +583,9 @@ export function allFormFields(form: SchemaForm): SchemaFormField[] {
|
|
|
326
583
|
}
|
|
327
584
|
|
|
328
585
|
/** Every field that stores something, which is what an entry and an export carry. */
|
|
329
|
-
export function storingFormFields(
|
|
330
|
-
|
|
586
|
+
export function storingFormFields(
|
|
587
|
+
form: SchemaForm,
|
|
588
|
+
types: FormTypes = BUILT_IN_FORM_TYPES,
|
|
589
|
+
): SchemaFormField[] {
|
|
590
|
+
return allFormFields(form).filter((field) => formFieldType(field.type, types)?.stores !== 'none')
|
|
331
591
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './content-types.ts'
|
|
|
3
3
|
export * from './documents.ts'
|
|
4
4
|
export * from './form-submissions.ts'
|
|
5
5
|
export * from './forms.ts'
|
|
6
|
+
export * from './json-patch.ts'
|
|
6
7
|
export * from './notifications.ts'
|
|
7
8
|
export * from './object-types.ts'
|
|
8
9
|
export * from './paging.ts'
|
|
@@ -13,3 +14,4 @@ export * from './return-urls.ts'
|
|
|
13
14
|
export * from './sections.ts'
|
|
14
15
|
export * from './uploads.ts'
|
|
15
16
|
export * from './users.ts'
|
|
17
|
+
export * from './workflows.ts'
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Patch with Umbraco's filter segments: `replace`, `add` and `remove` over
|
|
3
|
+
* paths like `/values[alias=title,culture=null,segment=null]/value`. Operations
|
|
4
|
+
* apply in order to a copy; any failure fails the lot. docs/api-contract.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface PatchOperation {
|
|
8
|
+
op: string
|
|
9
|
+
path: string
|
|
10
|
+
value?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PatchResult = { ok: true; value: unknown } | { ok: false; reason: string }
|
|
14
|
+
|
|
15
|
+
type Step =
|
|
16
|
+
| { kind: 'key'; key: string }
|
|
17
|
+
| { kind: 'append' }
|
|
18
|
+
| { kind: 'filter'; conditions: Array<[string, string | null]> }
|
|
19
|
+
|
|
20
|
+
class PatchError extends Error {}
|
|
21
|
+
|
|
22
|
+
const unescapeSegment = (segment: string) => segment.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
23
|
+
|
|
24
|
+
function parseFilter(body: string, path: string): Step {
|
|
25
|
+
const conditions = body.split(',').map((pair): [string, string | null] => {
|
|
26
|
+
const at = pair.indexOf('=')
|
|
27
|
+
if (at <= 0) throw new PatchError(`The filter "[${body}]" in "${path}" needs key=value pairs.`)
|
|
28
|
+
const value = unescapeSegment(pair.slice(at + 1))
|
|
29
|
+
return [unescapeSegment(pair.slice(0, at)), value.toLowerCase() === 'null' ? null : value]
|
|
30
|
+
})
|
|
31
|
+
return { kind: 'filter', conditions }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parsePatchPath(path: string): Step[] {
|
|
35
|
+
if (!path.startsWith('/') || path.length < 2)
|
|
36
|
+
throw new PatchError(`"${path}" is not a path: it must start with "/" and name something.`)
|
|
37
|
+
const steps: Step[] = []
|
|
38
|
+
for (const segment of path.slice(1).split('/')) {
|
|
39
|
+
const open = segment.indexOf('[')
|
|
40
|
+
if (open === -1) {
|
|
41
|
+
steps.push(
|
|
42
|
+
segment === '-' ? { kind: 'append' } : { kind: 'key', key: unescapeSegment(segment) },
|
|
43
|
+
)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (!segment.endsWith(']')) throw new PatchError(`The filter in "${path}" is not closed.`)
|
|
47
|
+
if (open > 0) steps.push({ kind: 'key', key: unescapeSegment(segment.slice(0, open)) })
|
|
48
|
+
steps.push(parseFilter(segment.slice(open + 1, -1), path))
|
|
49
|
+
}
|
|
50
|
+
steps.forEach((step, index) => {
|
|
51
|
+
if (step.kind === 'append' && index !== steps.length - 1)
|
|
52
|
+
throw new PatchError(`"-" may only end a path, in "${path}".`)
|
|
53
|
+
})
|
|
54
|
+
return steps
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
58
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
59
|
+
|
|
60
|
+
function matchIndex(array: unknown[], step: Extract<Step, { kind: 'filter' }>, path: string) {
|
|
61
|
+
const index = array.findIndex(
|
|
62
|
+
(element) =>
|
|
63
|
+
isRecord(element) &&
|
|
64
|
+
step.conditions.every(([key, value]) =>
|
|
65
|
+
value === null
|
|
66
|
+
? element[key] === undefined || element[key] === null
|
|
67
|
+
: element[key] === value,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
if (index === -1) throw new PatchError(`Nothing in the list matches the filter in "${path}".`)
|
|
71
|
+
return index
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function arrayIndex(array: unknown[], key: string, path: string, allowEnd: boolean): number {
|
|
75
|
+
if (!/^(0|[1-9]\d*)$/.test(key))
|
|
76
|
+
throw new PatchError(`"${key}" is not a list position, in "${path}".`)
|
|
77
|
+
const index = Number(key)
|
|
78
|
+
if (index > array.length || (!allowEnd && index === array.length))
|
|
79
|
+
throw new PatchError(`Position ${index} is past the end of the list, in "${path}".`)
|
|
80
|
+
return index
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function child(container: unknown, step: Step, path: string): unknown {
|
|
84
|
+
if (Array.isArray(container)) {
|
|
85
|
+
if (step.kind === 'filter') return container[matchIndex(container, step, path)]
|
|
86
|
+
if (step.kind === 'key') return container[arrayIndex(container, step.key, path, false)]
|
|
87
|
+
} else if (isRecord(container) && step.kind === 'key') {
|
|
88
|
+
if (!(step.key in container)) throw new PatchError(`There is no "${step.key}" in "${path}".`)
|
|
89
|
+
return container[step.key]
|
|
90
|
+
}
|
|
91
|
+
throw new PatchError(`"${path}" does not lead anywhere in the document.`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function applyOne(root: unknown, operation: PatchOperation): unknown {
|
|
95
|
+
const { op, path } = operation
|
|
96
|
+
if (op !== 'replace' && op !== 'add' && op !== 'remove')
|
|
97
|
+
throw new PatchError(
|
|
98
|
+
`"${op}" is not an operation this endpoint supports: use replace, add or remove.`,
|
|
99
|
+
)
|
|
100
|
+
if (op !== 'remove' && !('value' in operation))
|
|
101
|
+
throw new PatchError(`"${op}" at "${path}" needs a value.`)
|
|
102
|
+
const steps = parsePatchPath(path)
|
|
103
|
+
const last = steps[steps.length - 1] as Step
|
|
104
|
+
if (last.kind === 'append' && op !== 'add')
|
|
105
|
+
throw new PatchError(`"-" appends, so only add may use it, in "${path}".`)
|
|
106
|
+
let parent = root
|
|
107
|
+
for (const step of steps.slice(0, -1)) parent = child(parent, step, path)
|
|
108
|
+
|
|
109
|
+
if (Array.isArray(parent)) {
|
|
110
|
+
const index =
|
|
111
|
+
last.kind === 'append'
|
|
112
|
+
? parent.length
|
|
113
|
+
: last.kind === 'filter'
|
|
114
|
+
? matchIndex(parent, last, path)
|
|
115
|
+
: arrayIndex(parent, last.key, path, op === 'add')
|
|
116
|
+
if (op === 'remove') parent.splice(index, 1)
|
|
117
|
+
else if (op === 'add' && last.kind !== 'filter') parent.splice(index, 0, operation.value)
|
|
118
|
+
else parent[index] = operation.value
|
|
119
|
+
return root
|
|
120
|
+
}
|
|
121
|
+
if (isRecord(parent) && last.kind === 'key') {
|
|
122
|
+
if (op === 'remove') {
|
|
123
|
+
if (!(last.key in parent))
|
|
124
|
+
throw new PatchError(`There is no "${last.key}" to remove, in "${path}".`)
|
|
125
|
+
delete parent[last.key]
|
|
126
|
+
} else parent[last.key] = operation.value
|
|
127
|
+
return root
|
|
128
|
+
}
|
|
129
|
+
throw new PatchError(`"${path}" does not lead anywhere in the document.`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function applyJsonPatch(
|
|
133
|
+
document: unknown,
|
|
134
|
+
operations: readonly PatchOperation[],
|
|
135
|
+
): PatchResult {
|
|
136
|
+
try {
|
|
137
|
+
let value = structuredClone(document)
|
|
138
|
+
for (const operation of operations) value = applyOne(value, operation)
|
|
139
|
+
return { ok: true, value }
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof PatchError) return { ok: false, reason: error.message }
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/problem-details.ts
CHANGED
|
@@ -31,6 +31,10 @@ export function problemResponse(problem: ProblemDetails, headers?: HeadersInit):
|
|
|
31
31
|
})
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Contention that outlasted the wait: the caller may simply try again. */
|
|
35
|
+
export const serviceBusy = (detail: string) =>
|
|
36
|
+
problemDetails({ title: 'Service Unavailable', status: 503, detail, operationStatus: 'Busy' })
|
|
37
|
+
|
|
34
38
|
export const notFound = (title = 'Not Found', detail?: string) =>
|
|
35
39
|
problemDetails({ title, status: 404, detail, operationStatus: 'NotFound' })
|
|
36
40
|
|
package/src/sections.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const SECTION_ALIASES = {
|
|
|
25
25
|
// commercial add-on there. Form building is in core here, so the section is
|
|
26
26
|
// real and the alias resolves (`docs/forms.md`).
|
|
27
27
|
forms: 'Crombolo.Section.Forms',
|
|
28
|
+
// Approval workflows: Umbraco Workflow's section, in core here (story 028).
|
|
29
|
+
workflow: 'Crombolo.Section.Workflow',
|
|
28
30
|
} as const
|
|
29
31
|
|
|
30
32
|
export type AppAlias = keyof typeof SECTION_ALIASES
|
package/src/workflows.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval workflows (story 028): a workflow is an acyclic graph of stages, each
|
|
3
|
+
* approved by named user groups. Arrows out of a stage are followed in
|
|
4
|
+
* parallel; a stage with several arrows in waits for all of them; when every
|
|
5
|
+
* last stage has approved, the page is published. `docs/workflows.md`.
|
|
6
|
+
*
|
|
7
|
+
* The model and the graph rules are here, pure, so the schema check, the
|
|
8
|
+
* engine and the designer agree on what a workflow means.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How many of a stage's approvers must approve: one, a majority, all, or a number. */
|
|
12
|
+
export type StageRequirement = 'one' | 'majority' | 'all' | number
|
|
13
|
+
|
|
14
|
+
/** What saving a page does while it waits for approval. */
|
|
15
|
+
export type WorkflowOnEdit = 'withdraw' | 'approve-latest' | 'lock' | 'lock-after-first-approval'
|
|
16
|
+
|
|
17
|
+
/** Whether the person who made the change may approve it. */
|
|
18
|
+
export type WorkflowSelfApproval = 'refused' | 'allowed' | 'automatic'
|
|
19
|
+
|
|
20
|
+
/** When a decision needs a comment. */
|
|
21
|
+
export type WorkflowComments = 'optional' | 'on-send-back' | 'always'
|
|
22
|
+
|
|
23
|
+
/** What a request in flight does when its workflow changes. */
|
|
24
|
+
export type WorkflowOnChange = 'keep' | 'restart' | 'withdraw'
|
|
25
|
+
|
|
26
|
+
export const WORKFLOW_ON_EDIT: readonly WorkflowOnEdit[] = [
|
|
27
|
+
'withdraw',
|
|
28
|
+
'approve-latest',
|
|
29
|
+
'lock',
|
|
30
|
+
'lock-after-first-approval',
|
|
31
|
+
]
|
|
32
|
+
export const WORKFLOW_SELF_APPROVAL: readonly WorkflowSelfApproval[] = [
|
|
33
|
+
'refused',
|
|
34
|
+
'allowed',
|
|
35
|
+
'automatic',
|
|
36
|
+
]
|
|
37
|
+
export const WORKFLOW_COMMENTS: readonly WorkflowComments[] = ['optional', 'on-send-back', 'always']
|
|
38
|
+
export const WORKFLOW_ON_CHANGE: readonly WorkflowOnChange[] = ['keep', 'restart', 'withdraw']
|
|
39
|
+
|
|
40
|
+
/** Where a send-back from a stage goes. */
|
|
41
|
+
export const SEND_BACK_TO_EDITOR = 'editor'
|
|
42
|
+
|
|
43
|
+
export interface WorkflowStage {
|
|
44
|
+
alias: string
|
|
45
|
+
name?: string
|
|
46
|
+
/** User group aliases. */
|
|
47
|
+
approvers: string[]
|
|
48
|
+
required: StageRequirement
|
|
49
|
+
/** Stage aliases that follow this one; empty for a last stage. */
|
|
50
|
+
next: string[]
|
|
51
|
+
/** `editor`, or the alias of an earlier stage on this one's path. */
|
|
52
|
+
sendBack: string
|
|
53
|
+
/** Where the designer drew it. */
|
|
54
|
+
position?: { x: number; y: number }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SchemaWorkflow {
|
|
58
|
+
key?: string
|
|
59
|
+
alias: string
|
|
60
|
+
name: string
|
|
61
|
+
appliesTo: { branches: string[]; contentTypes: string[] }
|
|
62
|
+
publishersMaySkip: boolean
|
|
63
|
+
onEdit: WorkflowOnEdit
|
|
64
|
+
selfApproval: WorkflowSelfApproval
|
|
65
|
+
comments: WorkflowComments
|
|
66
|
+
unpublish: boolean
|
|
67
|
+
onChange: WorkflowOnChange
|
|
68
|
+
stages: WorkflowStage[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const byAlias = (workflow: SchemaWorkflow) => new Map(workflow.stages.map((s) => [s.alias, s]))
|
|
72
|
+
|
|
73
|
+
/** The stages with no arrow in: a valid workflow has exactly one. Pure. */
|
|
74
|
+
export function startStages(workflow: SchemaWorkflow): string[] {
|
|
75
|
+
const reached = new Set(workflow.stages.flatMap((s) => s.next))
|
|
76
|
+
return workflow.stages.filter((s) => !reached.has(s.alias)).map((s) => s.alias)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The stages with an arrow into this one. Pure. */
|
|
80
|
+
export function incomingStages(workflow: SchemaWorkflow, alias: string): string[] {
|
|
81
|
+
return workflow.stages.filter((s) => s.next.includes(alias)).map((s) => s.alias)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every stage this one can be reached from. Pure. */
|
|
85
|
+
export function ancestorStages(workflow: SchemaWorkflow, alias: string): Set<string> {
|
|
86
|
+
const found = new Set<string>()
|
|
87
|
+
const visit = (at: string) => {
|
|
88
|
+
for (const parent of incomingStages(workflow, at)) {
|
|
89
|
+
if (found.has(parent)) continue
|
|
90
|
+
found.add(parent)
|
|
91
|
+
visit(parent)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
visit(alias)
|
|
95
|
+
return found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every stage reachable from this one, itself excluded. Pure. */
|
|
99
|
+
export function descendantStages(workflow: SchemaWorkflow, alias: string): Set<string> {
|
|
100
|
+
const stages = byAlias(workflow)
|
|
101
|
+
const found = new Set<string>()
|
|
102
|
+
const visit = (at: string) => {
|
|
103
|
+
for (const child of stages.get(at)?.next ?? []) {
|
|
104
|
+
if (found.has(child)) continue
|
|
105
|
+
found.add(child)
|
|
106
|
+
visit(child)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
visit(alias)
|
|
110
|
+
return found
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether adding an arrow `from` → `to` would close a loop. Pure. */
|
|
114
|
+
export function wouldLoop(workflow: SchemaWorkflow, from: string, to: string): boolean {
|
|
115
|
+
return from === to || descendantStages(workflow, to).has(from)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What is wrong with a workflow's graph, in words; empty when it is sound. Pure. */
|
|
119
|
+
export function workflowGraphProblems(
|
|
120
|
+
workflow: SchemaWorkflow,
|
|
121
|
+
): Array<{ path: string; message: string }> {
|
|
122
|
+
const problems: Array<{ path: string; message: string }> = []
|
|
123
|
+
const seen = new Set<string>()
|
|
124
|
+
workflow.stages.forEach((stage, i) => {
|
|
125
|
+
if (seen.has(stage.alias))
|
|
126
|
+
problems.push({ path: `stage[${i}].alias`, message: `"${stage.alias}" names two stages` })
|
|
127
|
+
seen.add(stage.alias)
|
|
128
|
+
})
|
|
129
|
+
if (workflow.stages.length === 0)
|
|
130
|
+
return [{ path: 'stage', message: 'a workflow needs at least one stage' }]
|
|
131
|
+
workflow.stages.forEach((stage, i) => {
|
|
132
|
+
for (const next of stage.next)
|
|
133
|
+
if (!seen.has(next))
|
|
134
|
+
problems.push({ path: `stage[${i}].next`, message: `there is no stage "${next}"` })
|
|
135
|
+
if (stage.approvers.length === 0)
|
|
136
|
+
problems.push({ path: `stage[${i}].approvers`, message: 'a stage needs an approver group' })
|
|
137
|
+
if (
|
|
138
|
+
typeof stage.required === 'number' &&
|
|
139
|
+
(!Number.isInteger(stage.required) || stage.required < 1)
|
|
140
|
+
)
|
|
141
|
+
problems.push({ path: `stage[${i}].required`, message: 'a number of approvers is 1 or more' })
|
|
142
|
+
})
|
|
143
|
+
if (problems.length > 0) return problems
|
|
144
|
+
|
|
145
|
+
// A loop first: the rest of the rules assume there is none.
|
|
146
|
+
const state = new Map<string, 'visiting' | 'done'>()
|
|
147
|
+
const stages = byAlias(workflow)
|
|
148
|
+
let loop: string | undefined
|
|
149
|
+
const visit = (alias: string): void => {
|
|
150
|
+
if (loop) return
|
|
151
|
+
state.set(alias, 'visiting')
|
|
152
|
+
for (const next of stages.get(alias)?.next ?? []) {
|
|
153
|
+
if (state.get(next) === 'visiting') loop = `${alias} → ${next}`
|
|
154
|
+
else if (!state.has(next)) visit(next)
|
|
155
|
+
}
|
|
156
|
+
state.set(alias, 'done')
|
|
157
|
+
}
|
|
158
|
+
for (const stage of workflow.stages) if (!state.has(stage.alias)) visit(stage.alias)
|
|
159
|
+
if (loop) return [{ path: 'stage', message: `the stages loop (${loop})` }]
|
|
160
|
+
|
|
161
|
+
const starts = startStages(workflow)
|
|
162
|
+
if (starts.length !== 1)
|
|
163
|
+
problems.push({
|
|
164
|
+
path: 'stage',
|
|
165
|
+
message: `a workflow starts at one stage, and this one starts at ${starts.length}: ${starts.join(', ')}`,
|
|
166
|
+
})
|
|
167
|
+
workflow.stages.forEach((stage, i) => {
|
|
168
|
+
if (stage.sendBack === SEND_BACK_TO_EDITOR) return
|
|
169
|
+
if (!ancestorStages(workflow, stage.alias).has(stage.sendBack))
|
|
170
|
+
problems.push({
|
|
171
|
+
path: `stage[${i}].send-back`,
|
|
172
|
+
message: `"${stage.alias}" can send back only to the editor or a stage before it, and "${stage.sendBack}" is not`,
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
return problems
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How many approvals a stage needs, given how many people its groups hold. Pure. */
|
|
179
|
+
export function approvalsNeeded(required: StageRequirement, members: number): number {
|
|
180
|
+
const available = Math.max(members, 1)
|
|
181
|
+
if (required === 'one') return 1
|
|
182
|
+
if (required === 'all') return available
|
|
183
|
+
if (required === 'majority') return Math.floor(available / 2) + 1
|
|
184
|
+
// More than the groups hold could never be met; the groups are the ceiling.
|
|
185
|
+
return Math.min(required, available)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The stages that become waiting now that `approved` are done: every stage
|
|
190
|
+
* whose arrows in all come from approved stages, not already reached. Pure.
|
|
191
|
+
*/
|
|
192
|
+
export function stagesNowWaiting(
|
|
193
|
+
workflow: SchemaWorkflow,
|
|
194
|
+
approved: ReadonlySet<string>,
|
|
195
|
+
reached: ReadonlySet<string>,
|
|
196
|
+
): string[] {
|
|
197
|
+
return workflow.stages
|
|
198
|
+
.filter((stage) => !reached.has(stage.alias))
|
|
199
|
+
.filter((stage) => {
|
|
200
|
+
const from = incomingStages(workflow, stage.alias)
|
|
201
|
+
return from.length > 0 && from.every((alias) => approved.has(alias))
|
|
202
|
+
})
|
|
203
|
+
.map((stage) => stage.alias)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether every last stage has approved, which publishes the page. Pure. */
|
|
207
|
+
export function workflowComplete(workflow: SchemaWorkflow, approved: ReadonlySet<string>): boolean {
|
|
208
|
+
return workflow.stages.filter((s) => s.next.length === 0).every((s) => approved.has(s.alias))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The workflow that covers a page: the nearest branch root among the page and
|
|
213
|
+
* its ancestors (nearest first), and only then one naming its content type. Pure.
|
|
214
|
+
*/
|
|
215
|
+
export function workflowFor(
|
|
216
|
+
workflows: readonly SchemaWorkflow[],
|
|
217
|
+
chain: readonly string[],
|
|
218
|
+
contentTypeAlias: string | undefined,
|
|
219
|
+
): SchemaWorkflow | undefined {
|
|
220
|
+
const lower = (value: string) => value.toLowerCase()
|
|
221
|
+
for (const key of [...chain].reverse()) {
|
|
222
|
+
const found = workflows.find((w) => w.appliesTo.branches.some((b) => lower(b) === lower(key)))
|
|
223
|
+
if (found) return found
|
|
224
|
+
}
|
|
225
|
+
if (!contentTypeAlias) return undefined
|
|
226
|
+
return workflows.find((w) => w.appliesTo.contentTypes.includes(contentTypeAlias))
|
|
227
|
+
}
|