@crombolo/backoffice-host 0.10.0 → 0.12.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 +2 -2
- package/plugin/addresses-dashboard.js +100 -0
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/audit-dashboard.js +369 -0
- package/plugin/changes-dashboard.js +245 -8
- package/plugin/forms-client.js +7 -0
- package/plugin/forms-entries.js +2 -1
- package/plugin/forms-layout.js +128 -0
- package/plugin/forms-overview.js +484 -134
- package/plugin/localizations/umbraco-package.json +1 -1
- package/plugin/report-client.js +21 -0
- package/plugin/seo/umbraco-package.json +1 -1
- package/plugin/tsx-editors.js +5 -5
- package/plugin/umbraco-package.json +53 -1
- package/plugin/webhook-storage.js +107 -0
- package/plugin/welcome-dashboard.js +1 -1
|
@@ -9,19 +9,37 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Below them, each installed bundle that keeps data of its own lists the
|
|
11
11
|
* changes it has made to its storage here, and any still to make.
|
|
12
|
+
*
|
|
13
|
+
* A content bundle waiting to be imported can be answered and imported here,
|
|
14
|
+
* by those allowed to install packages: each finding offers the answers its
|
|
15
|
+
* kind allows, and the import runs once nothing is outstanding.
|
|
12
16
|
*/
|
|
13
17
|
import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
|
|
14
|
-
import { fetchReport } from './report-client.js'
|
|
18
|
+
import { answersUrl, fetchReport, transferAction } from './report-client.js'
|
|
15
19
|
|
|
16
20
|
const KIND_LABEL = { blocking: 'Blocking', person: 'Needs a person', auto: 'Automatic' }
|
|
17
21
|
const KIND_COLOR = { blocking: 'danger', person: 'warning', auto: 'positive' }
|
|
18
22
|
const SOURCE_LABEL = { upgrade: 'Schema and upgrade', transfer: 'Content transfer' }
|
|
23
|
+
const CHOICE_LABEL = {
|
|
24
|
+
'take-bundle': 'Use the incoming version',
|
|
25
|
+
'keep-local': 'Keep this site’s version',
|
|
26
|
+
skip: 'Leave this item out',
|
|
27
|
+
}
|
|
28
|
+
const ANSWERED_LABEL = {
|
|
29
|
+
'take-bundle': 'uses the incoming version',
|
|
30
|
+
'keep-local': 'keeps this site’s version',
|
|
31
|
+
skip: 'is left out',
|
|
32
|
+
}
|
|
19
33
|
|
|
20
34
|
export default class CromboloChangesDashboardElement extends LitElement {
|
|
21
35
|
static properties = {
|
|
22
36
|
_report: { state: true },
|
|
23
37
|
_error: { state: true },
|
|
24
38
|
_showResolved: { state: true },
|
|
39
|
+
_busy: { state: true },
|
|
40
|
+
_outcome: { state: true },
|
|
41
|
+
_backupTaken: { state: true },
|
|
42
|
+
_passphrase: { state: true },
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
constructor() {
|
|
@@ -29,6 +47,28 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
29
47
|
this._report = undefined
|
|
30
48
|
this._error = undefined
|
|
31
49
|
this._showResolved = false
|
|
50
|
+
this._busy = undefined
|
|
51
|
+
this._outcome = {}
|
|
52
|
+
this._backupTaken = {}
|
|
53
|
+
// Held in this page only, sent with each request, never stored.
|
|
54
|
+
this._passphrase = {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async #act(bundleId, verb, body) {
|
|
58
|
+
this._busy = bundleId
|
|
59
|
+
const label = (this._report?.transfers ?? []).find((t) => t.bundleId === bundleId)?.label
|
|
60
|
+
try {
|
|
61
|
+
const passphrase = this._passphrase[bundleId]
|
|
62
|
+
const result = await transferAction(
|
|
63
|
+
bundleId,
|
|
64
|
+
verb,
|
|
65
|
+
passphrase ? { ...body, passphrase } : body,
|
|
66
|
+
)
|
|
67
|
+
this._outcome = { ...this._outcome, [bundleId]: { verb, label, ...result } }
|
|
68
|
+
} finally {
|
|
69
|
+
this._busy = undefined
|
|
70
|
+
await this.#load()
|
|
71
|
+
}
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
connectedCallback() {
|
|
@@ -45,8 +85,15 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
|
|
48
|
-
#group(findings) {
|
|
88
|
+
#group(findings, transfers = []) {
|
|
49
89
|
const groups = new Map()
|
|
90
|
+
// A bundle with nothing to answer is still waiting to be imported.
|
|
91
|
+
for (const pending of transfers)
|
|
92
|
+
groups.set(`transfer|${pending.bundleId}`, {
|
|
93
|
+
source: 'transfer',
|
|
94
|
+
scope: pending.bundleId,
|
|
95
|
+
findings: [],
|
|
96
|
+
})
|
|
50
97
|
for (const f of findings) {
|
|
51
98
|
const source = f.source ?? 'upgrade'
|
|
52
99
|
const key = `${source}|${f.scope ?? ''}`
|
|
@@ -60,8 +107,9 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
60
107
|
render() {
|
|
61
108
|
if (this._error) return html`<uui-box headline="Changes"><p>${this._error}</p></uui-box>`
|
|
62
109
|
if (!this._report) return html`<uui-box headline="Changes"><uui-loader></uui-loader></uui-box>`
|
|
63
|
-
const { health, findings, counts, bundles = [] } = this._report
|
|
110
|
+
const { health, findings, counts, bundles = [], upgrades, transfers = [] } = this._report
|
|
64
111
|
const shown = findings.filter((f) => this._showResolved || f.status === 'open')
|
|
112
|
+
const groups = this.#group(shown, transfers)
|
|
65
113
|
return html`
|
|
66
114
|
<uui-box headline="Changes">
|
|
67
115
|
<p>
|
|
@@ -72,6 +120,14 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
72
120
|
: html`<uui-tag color="positive">current</uui-tag>`
|
|
73
121
|
}
|
|
74
122
|
</p>
|
|
123
|
+
${upgrades ? html`<p class="release">${this.#describeUpgrades(upgrades)}</p>` : nothing}
|
|
124
|
+
${Object.entries(this._outcome)
|
|
125
|
+
.filter(
|
|
126
|
+
([id, o]) => o.verb === 'import' && o.ok && !transfers.some((t) => t.bundleId === id),
|
|
127
|
+
)
|
|
128
|
+
.map(
|
|
129
|
+
([id, o]) => html`<h4>${o.label ?? id}</h4>${this.#renderOutcome({ bundleId: id }, o)}`,
|
|
130
|
+
)}
|
|
75
131
|
<div class="counts">
|
|
76
132
|
${Object.entries(KIND_LABEL).map(
|
|
77
133
|
([kind, label]) =>
|
|
@@ -85,15 +141,27 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
85
141
|
></uui-toggle>
|
|
86
142
|
</div>
|
|
87
143
|
${
|
|
88
|
-
|
|
144
|
+
groups.length === 0
|
|
89
145
|
? html`<p class="empty">Nothing outstanding. <code>crombolo upgrade check</code> and <code>crombolo content check</code> write findings here.</p>`
|
|
90
|
-
:
|
|
146
|
+
: groups.map((group) => this.#renderGroup(group))
|
|
91
147
|
}
|
|
92
148
|
${bundles.map((bundle) => this.#renderBundle(bundle))}
|
|
93
149
|
</uui-box>
|
|
94
150
|
`
|
|
95
151
|
}
|
|
96
152
|
|
|
153
|
+
#describeUpgrades({ version, installedAt, lastUpgrade }) {
|
|
154
|
+
const day = (at) => at?.slice(0, 10) ?? ''
|
|
155
|
+
const parts = [`Crombolo ${version}`]
|
|
156
|
+
if (installedAt) parts.push(`installed ${day(installedAt)}`)
|
|
157
|
+
if (lastUpgrade)
|
|
158
|
+
parts.push(
|
|
159
|
+
`last upgraded ${day(lastUpgrade.at)}${lastUpgrade.release ? ` to ${lastUpgrade.release}` : ''}, ` +
|
|
160
|
+
`${lastUpgrade.steps} storage change${lastUpgrade.steps === 1 ? '' : 's'}`,
|
|
161
|
+
)
|
|
162
|
+
return parts.join(' · ')
|
|
163
|
+
}
|
|
164
|
+
|
|
97
165
|
#renderBundle(bundle) {
|
|
98
166
|
const steps = [
|
|
99
167
|
...bundle.pending.map((step) => ({ ...step, done: false })),
|
|
@@ -128,16 +196,25 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
128
196
|
}
|
|
129
197
|
|
|
130
198
|
#renderGroup(group) {
|
|
199
|
+
const pending =
|
|
200
|
+
group.source === 'transfer'
|
|
201
|
+
? (this._report.transfers ?? []).find((t) => t.bundleId === group.scope)
|
|
202
|
+
: undefined
|
|
203
|
+
const answering = Boolean(pending && this._report.mayImport)
|
|
131
204
|
return html`
|
|
132
205
|
<h4>
|
|
133
206
|
${SOURCE_LABEL[group.source] ?? group.source}
|
|
134
|
-
${group.scope ? html`<code>${group.scope}</code>` : nothing}
|
|
207
|
+
${pending?.label ? html`<strong>${pending.label}</strong>` : group.scope ? html`<code>${group.scope}</code>` : nothing}
|
|
135
208
|
</h4>
|
|
136
|
-
|
|
209
|
+
${
|
|
210
|
+
group.findings.length === 0
|
|
211
|
+
? nothing
|
|
212
|
+
: html`<uui-table>
|
|
137
213
|
<uui-table-head>
|
|
138
214
|
<uui-table-head-cell>Kind</uui-table-head-cell>
|
|
139
215
|
<uui-table-head-cell>What</uui-table-head-cell>
|
|
140
216
|
<uui-table-head-cell>Where</uui-table-head-cell>
|
|
217
|
+
${answering ? html`<uui-table-head-cell>Answer</uui-table-head-cell>` : nothing}
|
|
141
218
|
</uui-table-head>
|
|
142
219
|
${group.findings.map(
|
|
143
220
|
(f) => html`<uui-table-row class=${f.status}>
|
|
@@ -148,12 +225,167 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
148
225
|
${f.propertyAlias ? html`<code>${f.propertyAlias}</code>` : nothing}
|
|
149
226
|
${f.culture ? html`<small>${f.culture}</small>` : nothing}
|
|
150
227
|
</uui-table-cell>
|
|
228
|
+
${answering ? html`<uui-table-cell>${this.#renderChoices(pending, f)}</uui-table-cell>` : nothing}
|
|
151
229
|
</uui-table-row>`,
|
|
152
230
|
)}
|
|
153
|
-
</uui-table
|
|
231
|
+
</uui-table>`
|
|
232
|
+
}
|
|
233
|
+
${answering ? this.#renderPending(pending, group.findings) : nothing}
|
|
154
234
|
`
|
|
155
235
|
}
|
|
156
236
|
|
|
237
|
+
#renderChoices(pending, finding) {
|
|
238
|
+
if (finding.status !== 'open' || !finding.choices?.length) return nothing
|
|
239
|
+
return html`<div class="choices">
|
|
240
|
+
${finding.choices.map(
|
|
241
|
+
(choice) => html`<uui-button
|
|
242
|
+
look="secondary"
|
|
243
|
+
compact
|
|
244
|
+
?disabled=${this._busy === pending.bundleId}
|
|
245
|
+
label=${CHOICE_LABEL[choice.resolution]}
|
|
246
|
+
@click=${() =>
|
|
247
|
+
this.#act(pending.bundleId, 'answer', {
|
|
248
|
+
nodeKey: finding.subjectKey,
|
|
249
|
+
handle: choice.handle,
|
|
250
|
+
resolution: choice.resolution,
|
|
251
|
+
})}
|
|
252
|
+
>${CHOICE_LABEL[choice.resolution]}</uui-button>`,
|
|
253
|
+
)}
|
|
254
|
+
</div>`
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#describeAnswer(answer) {
|
|
258
|
+
const named = (this._report.findings ?? []).find((f) => f.subjectKey === answer.nodeKey)
|
|
259
|
+
const name = named?.subjectName ?? 'An item'
|
|
260
|
+
if (answer.handle === '*') return `${name} ${ANSWERED_LABEL[answer.resolution] ?? ''}`
|
|
261
|
+
const [field, culture] = answer.handle.split('@')
|
|
262
|
+
return `${name}: its ${field}${culture ? ` (${culture})` : ''} ${ANSWERED_LABEL[answer.resolution] ?? ''}`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#renderPending(pending, findings) {
|
|
266
|
+
const open = findings.filter((f) => f.status === 'open')
|
|
267
|
+
const waiting = open.filter((f) => f.kind === 'blocking' || f.kind === 'person')
|
|
268
|
+
const blocking = waiting.filter((f) => f.kind === 'blocking').length
|
|
269
|
+
const missingFiles = pending.allowMissingBlobs || open.some((f) => f.code === 'missing-blob')
|
|
270
|
+
const busy = this._busy === pending.bundleId
|
|
271
|
+
const outcome = this._outcome[pending.bundleId]
|
|
272
|
+
const encrypted =
|
|
273
|
+
open.some((f) => f.code === 'content-sealed') ||
|
|
274
|
+
outcome?.needsPassphrase === true ||
|
|
275
|
+
this._passphrase[pending.bundleId] !== undefined
|
|
276
|
+
return html`<div class="pending">
|
|
277
|
+
${
|
|
278
|
+
encrypted
|
|
279
|
+
? html`<p class="passphrase">
|
|
280
|
+
<span>This content is encrypted. Enter the passphrase it was exported with.</span>
|
|
281
|
+
<uui-input
|
|
282
|
+
type="password"
|
|
283
|
+
label="Passphrase"
|
|
284
|
+
autocomplete="off"
|
|
285
|
+
.value=${this._passphrase[pending.bundleId] ?? ''}
|
|
286
|
+
@input=${(e) => {
|
|
287
|
+
this._passphrase = { ...this._passphrase, [pending.bundleId]: e.target.value }
|
|
288
|
+
}}
|
|
289
|
+
></uui-input>
|
|
290
|
+
<uui-button
|
|
291
|
+
look="secondary"
|
|
292
|
+
?disabled=${busy || !this._passphrase[pending.bundleId]}
|
|
293
|
+
label="Open"
|
|
294
|
+
@click=${() => this.#act(pending.bundleId, 'check')}
|
|
295
|
+
>Open</uui-button>
|
|
296
|
+
</p>`
|
|
297
|
+
: nothing
|
|
298
|
+
}
|
|
299
|
+
${
|
|
300
|
+
pending.answers.length > 0
|
|
301
|
+
? html`<p><strong>Answers given</strong></p>
|
|
302
|
+
<ul>
|
|
303
|
+
${pending.answers.map(
|
|
304
|
+
(answer) => html`<li>
|
|
305
|
+
${this.#describeAnswer(answer)}
|
|
306
|
+
<uui-button
|
|
307
|
+
look="secondary"
|
|
308
|
+
compact
|
|
309
|
+
?disabled=${busy}
|
|
310
|
+
label="Change"
|
|
311
|
+
@click=${() =>
|
|
312
|
+
this.#act(pending.bundleId, 'answer', {
|
|
313
|
+
nodeKey: answer.nodeKey,
|
|
314
|
+
handle: answer.handle,
|
|
315
|
+
resolution: null,
|
|
316
|
+
})}
|
|
317
|
+
>Change</uui-button>
|
|
318
|
+
</li>`,
|
|
319
|
+
)}
|
|
320
|
+
</ul>`
|
|
321
|
+
: nothing
|
|
322
|
+
}
|
|
323
|
+
${
|
|
324
|
+
missingFiles
|
|
325
|
+
? html`<uui-toggle
|
|
326
|
+
label="Import even where a media file is missing"
|
|
327
|
+
.checked=${pending.allowMissingBlobs}
|
|
328
|
+
?disabled=${busy}
|
|
329
|
+
@change=${(e) => this.#act(pending.bundleId, 'missing-files', { allow: e.target.checked })}
|
|
330
|
+
></uui-toggle>`
|
|
331
|
+
: nothing
|
|
332
|
+
}
|
|
333
|
+
<p class="state">
|
|
334
|
+
${
|
|
335
|
+
waiting.length === 0
|
|
336
|
+
? html`<uui-tag color="positive">Ready to import</uui-tag>`
|
|
337
|
+
: blocking > 0
|
|
338
|
+
? html`<uui-tag color="danger">${blocking} blocking: these need a change to this site before the import can run</uui-tag>`
|
|
339
|
+
: html`<uui-tag color="warning">${waiting.length} still to answer</uui-tag>`
|
|
340
|
+
}
|
|
341
|
+
${pending.publish ? html`<small>Pages live where the content came from are published too.</small>` : nothing}
|
|
342
|
+
</p>
|
|
343
|
+
<div class="actions">
|
|
344
|
+
<uui-button
|
|
345
|
+
look="primary"
|
|
346
|
+
?disabled=${busy || waiting.length > 0}
|
|
347
|
+
label="Import"
|
|
348
|
+
@click=${() =>
|
|
349
|
+
this.#act(pending.bundleId, 'import', {
|
|
350
|
+
backupTaken: this._backupTaken[pending.bundleId] === true,
|
|
351
|
+
})}
|
|
352
|
+
>Import</uui-button>
|
|
353
|
+
<uui-button look="secondary" ?disabled=${busy} label="Check again" @click=${() => this.#act(pending.bundleId, 'check')}>Check again</uui-button>
|
|
354
|
+
<uui-button look="secondary" href=${answersUrl(pending.bundleId)} label="Download answers">Download answers</uui-button>
|
|
355
|
+
</div>
|
|
356
|
+
${outcome ? this.#renderOutcome(pending, outcome) : nothing}
|
|
357
|
+
</div>`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#renderOutcome(pending, outcome) {
|
|
361
|
+
if (outcome.verb === 'import' && outcome.ok) {
|
|
362
|
+
const c = outcome.counts ?? {}
|
|
363
|
+
return html`<p class="outcome">
|
|
364
|
+
<uui-tag color="positive">Imported</uui-tag>
|
|
365
|
+
${c.create ?? 0} new, ${c.update ?? 0} updated, ${c.unchanged ?? 0} unchanged, ${c.skip ?? 0} left out${
|
|
366
|
+
outcome.published?.length ? html`; ${outcome.published.length} published` : nothing
|
|
367
|
+
}.
|
|
368
|
+
${
|
|
369
|
+
outcome.publishFailures?.length
|
|
370
|
+
? html`<ul>${outcome.publishFailures.map((f) => html`<li>${f.name} was not published: ${f.reason}</li>`)}</ul>`
|
|
371
|
+
: nothing
|
|
372
|
+
}
|
|
373
|
+
</p>`
|
|
374
|
+
}
|
|
375
|
+
if (outcome.needsBackup)
|
|
376
|
+
return html`<p class="outcome">
|
|
377
|
+
${outcome.message}
|
|
378
|
+
<uui-checkbox
|
|
379
|
+
label="A backup of this site has been taken"
|
|
380
|
+
.checked=${this._backupTaken[pending.bundleId] === true}
|
|
381
|
+
@change=${(e) => (this._backupTaken = { ...this._backupTaken, [pending.bundleId]: e.target.checked })}
|
|
382
|
+
>A backup of this site has been taken</uui-checkbox>
|
|
383
|
+
</p>`
|
|
384
|
+
if (outcome.ok === false)
|
|
385
|
+
return html`<p class="outcome"><uui-tag color="danger">Not done</uui-tag> ${outcome.message ?? 'Something is still outstanding.'}</p>`
|
|
386
|
+
return nothing
|
|
387
|
+
}
|
|
388
|
+
|
|
157
389
|
static styles = css`
|
|
158
390
|
:host { display: block; padding: var(--uui-size-layout-1); }
|
|
159
391
|
.counts { display: flex; gap: var(--uui-size-space-3); align-items: center; margin-bottom: var(--uui-size-space-4); }
|
|
@@ -163,6 +395,11 @@ export default class CromboloChangesDashboardElement extends LitElement {
|
|
|
163
395
|
code { margin-left: var(--uui-size-space-2); }
|
|
164
396
|
small { margin-left: var(--uui-size-space-2); }
|
|
165
397
|
.empty { color: var(--uui-color-text-alt); }
|
|
398
|
+
.choices, .actions { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-2); }
|
|
399
|
+
.pending { margin: var(--uui-size-space-3) 0 var(--uui-size-space-5); }
|
|
400
|
+
.pending ul { margin: 0 0 var(--uui-size-space-3); padding-left: var(--uui-size-space-5); }
|
|
401
|
+
.state { display: flex; gap: var(--uui-size-space-3); align-items: center; }
|
|
402
|
+
.passphrase { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); align-items: center; }
|
|
166
403
|
`
|
|
167
404
|
}
|
|
168
405
|
|
package/plugin/forms-client.js
CHANGED
|
@@ -25,11 +25,18 @@ const call = async (path, init) => {
|
|
|
25
25
|
|
|
26
26
|
export const listForms = async () => (await call('')).body.items ?? []
|
|
27
27
|
|
|
28
|
+
/** The field and workflow types this site has, built in and from its bundles. */
|
|
29
|
+
export const listFormTypes = async () => (await call('/available-types')).body
|
|
30
|
+
|
|
28
31
|
export const readForm = async (key) => (await call(`/${encodeURIComponent(key)}`)).body
|
|
29
32
|
|
|
30
33
|
export const saveForm = (definition) =>
|
|
31
34
|
call('', { method: 'PUT', body: JSON.stringify(definition) })
|
|
32
35
|
|
|
36
|
+
/** The options a choice source offers right now, as a visitor would see them. */
|
|
37
|
+
export const previewChoices = (source) =>
|
|
38
|
+
call('/choices', { method: 'POST', body: JSON.stringify({ source }) })
|
|
39
|
+
|
|
33
40
|
export const deleteForm = (key) => call(`/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
|
34
41
|
|
|
35
42
|
export const listEntries = async (key, query = {}) => {
|
package/plugin/forms-entries.js
CHANGED
|
@@ -167,7 +167,8 @@ export default class CromboloFormsEntriesElement extends UmbLitElement {
|
|
|
167
167
|
${
|
|
168
168
|
value.redacted
|
|
169
169
|
? html`<em>withheld — needs sensitive-data access</em>`
|
|
170
|
-
: value.values.join(', ') ||
|
|
170
|
+
: (value.labels ?? value.values).join(', ') ||
|
|
171
|
+
html`<em>not answered</em>`
|
|
171
172
|
}
|
|
172
173
|
</dd>
|
|
173
174
|
`,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form designer's layout operations: pages, groups and fields moved, added
|
|
3
|
+
* and removed. Pure — each takes a form and answers a new one, leaving the
|
|
4
|
+
* argument untouched — so the designer's element only has to render, and each
|
|
5
|
+
* operation is tested on its own (tests/forms-layout.test.ts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const copy = (form) => structuredClone(form)
|
|
9
|
+
|
|
10
|
+
/** `list` with the item at `from` moved to `to`, clamped to the list. */
|
|
11
|
+
export function moved(list, from, to) {
|
|
12
|
+
const out = [...list]
|
|
13
|
+
if (from < 0 || from >= out.length) return out
|
|
14
|
+
const [item] = out.splice(from, 1)
|
|
15
|
+
out.splice(Math.max(0, Math.min(to, out.length)), 0, item)
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const emptyGroup = () => ({ columns: 1, fields: [] })
|
|
20
|
+
export const emptyPage = () => ({ groups: [emptyGroup()] })
|
|
21
|
+
|
|
22
|
+
/** Every page with at least one group, which is what the designer edits. */
|
|
23
|
+
export function withLayout(form) {
|
|
24
|
+
const next = copy(form)
|
|
25
|
+
next.pages = next.pages?.length ? next.pages : [emptyPage()]
|
|
26
|
+
for (const page of next.pages) page.groups = page.groups?.length ? page.groups : [emptyGroup()]
|
|
27
|
+
return next
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addPage(form) {
|
|
31
|
+
const next = copy(form)
|
|
32
|
+
next.pages.push(emptyPage())
|
|
33
|
+
return next
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function movePage(form, index, to) {
|
|
37
|
+
const next = copy(form)
|
|
38
|
+
next.pages = moved(next.pages, index, to)
|
|
39
|
+
return next
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A form keeps one page at least: the last one cannot be removed. */
|
|
43
|
+
export function removePage(form, index) {
|
|
44
|
+
if (form.pages.length <= 1) return form
|
|
45
|
+
const next = copy(form)
|
|
46
|
+
next.pages.splice(index, 1)
|
|
47
|
+
return next
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function setPageCaption(form, index, caption) {
|
|
51
|
+
const next = copy(form)
|
|
52
|
+
if (caption.trim()) next.pages[index].caption = caption
|
|
53
|
+
else delete next.pages[index].caption
|
|
54
|
+
return next
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function addGroup(form, page) {
|
|
58
|
+
const next = copy(form)
|
|
59
|
+
next.pages[page].groups.push(emptyGroup())
|
|
60
|
+
return next
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function moveGroup(form, page, index, to) {
|
|
64
|
+
const next = copy(form)
|
|
65
|
+
next.pages[page].groups = moved(next.pages[page].groups, index, to)
|
|
66
|
+
return next
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A page keeps one group at least. */
|
|
70
|
+
export function removeGroup(form, page, index) {
|
|
71
|
+
if (form.pages[page].groups.length <= 1) return form
|
|
72
|
+
const next = copy(form)
|
|
73
|
+
next.pages[page].groups.splice(index, 1)
|
|
74
|
+
return next
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function setGroupCaption(form, page, index, caption) {
|
|
78
|
+
const next = copy(form)
|
|
79
|
+
if (caption.trim()) next.pages[page].groups[index].caption = caption
|
|
80
|
+
else delete next.pages[page].groups[index].caption
|
|
81
|
+
return next
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Between 1 and 4, as the file allows. */
|
|
85
|
+
export function setColumns(form, page, index, columns) {
|
|
86
|
+
const next = copy(form)
|
|
87
|
+
next.pages[page].groups[index].columns = Math.max(1, Math.min(4, Math.round(columns) || 1))
|
|
88
|
+
return next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Moves a field to a place, `{ page, group, index }`, in its own group or
|
|
93
|
+
* another. Within one group the index is where it ends up.
|
|
94
|
+
*/
|
|
95
|
+
export function moveField(form, from, to) {
|
|
96
|
+
const next = copy(form)
|
|
97
|
+
const source = next.pages[from.page]?.groups[from.group]?.fields
|
|
98
|
+
const target = next.pages[to.page]?.groups[to.group]?.fields
|
|
99
|
+
if (!source || !target || from.index < 0 || from.index >= source.length) return form
|
|
100
|
+
const [field] = source.splice(from.index, 1)
|
|
101
|
+
target.splice(Math.max(0, Math.min(to.index, target.length)), 0, field)
|
|
102
|
+
return next
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One step up or down within a group, or into the neighbouring group at its edge. */
|
|
106
|
+
export function nudgeField(form, at, step) {
|
|
107
|
+
const groups = form.pages.flatMap((page, p) => page.groups.map((_, g) => ({ page: p, group: g })))
|
|
108
|
+
const here = groups.findIndex((place) => place.page === at.page && place.group === at.group)
|
|
109
|
+
const fields = form.pages[at.page].groups[at.group].fields
|
|
110
|
+
const index = at.index + step
|
|
111
|
+
if (index >= 0 && index < fields.length) return moveField(form, at, { ...at, index })
|
|
112
|
+
const neighbour = groups[here + step]
|
|
113
|
+
if (!neighbour) return form
|
|
114
|
+
const length = form.pages[neighbour.page].groups[neighbour.group].fields.length
|
|
115
|
+
return moveField(form, at, { ...neighbour, index: step < 0 ? length : 0 })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether removing it would take fields with it, so the designer asks first. */
|
|
119
|
+
export const pageHasFields = (page) => page.groups.some((group) => group.fields.length > 0)
|
|
120
|
+
export const groupHasFields = (group) => group.fields.length > 0
|
|
121
|
+
|
|
122
|
+
/** The group's fields laid out in rows of `columns`, as the rendered form arranges them. */
|
|
123
|
+
export function layoutRows(group) {
|
|
124
|
+
const rows = []
|
|
125
|
+
for (let at = 0; at < group.fields.length; at += group.columns)
|
|
126
|
+
rows.push(group.fields.slice(at, at + group.columns))
|
|
127
|
+
return rows
|
|
128
|
+
}
|