@crombolo/backoffice-host 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "Serves the Umbraco backoffice SPA: shell HTML, import map and package manifests.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@crombolo/core": "0.11.0",
34
+ "@crombolo/core": "0.13.1",
35
35
  "@crombolo/backoffice-dist": "18.2.0"
36
36
  }
37
37
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Page addresses: whether the top-level page's name is part of every address
3
+ * below it, as Umbraco's `HideTopLevelNodeFromPath` sets it. Saving moves the
4
+ * addresses, and the old ones redirect to the new. docs/rendering.md, "URLs".
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+
8
+ const endpoint = () =>
9
+ `${(document.querySelector('base')?.getAttribute('href') ?? '/umbraco/').replace(/\/$/, '')}/crombolo/api/url-options`
10
+
11
+ export default class CromboloAddressesDashboardElement extends LitElement {
12
+ static properties = {
13
+ _settings: { state: true },
14
+ _showTopLevel: { state: true },
15
+ _message: { state: true },
16
+ _busy: { state: true },
17
+ }
18
+
19
+ constructor() {
20
+ super()
21
+ this._settings = undefined
22
+ this._showTopLevel = false
23
+ this._message = undefined
24
+ this._busy = false
25
+ }
26
+
27
+ connectedCallback() {
28
+ super.connectedCallback()
29
+ this.#load()
30
+ }
31
+
32
+ async #load() {
33
+ const response = await fetch(endpoint(), {
34
+ credentials: 'include',
35
+ headers: { accept: 'application/json' },
36
+ })
37
+ if (!response.ok) {
38
+ this._message = 'The address settings could not be read.'
39
+ return
40
+ }
41
+ this._settings = await response.json()
42
+ this._showTopLevel = !this._settings.hideTopLevel
43
+ }
44
+
45
+ async #save() {
46
+ this._busy = true
47
+ try {
48
+ const response = await fetch(endpoint(), {
49
+ method: 'PUT',
50
+ credentials: 'include',
51
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
52
+ body: JSON.stringify({ hideTopLevel: !this._showTopLevel }),
53
+ })
54
+ const result = await response.json().catch(() => ({}))
55
+ this._message = response.ok
56
+ ? 'Saved. Pages that moved still answer on their old addresses, which now redirect.'
57
+ : (result.message ?? 'The setting was not saved.')
58
+ if (response.ok) this._settings = result
59
+ } finally {
60
+ this._busy = false
61
+ }
62
+ }
63
+
64
+ render() {
65
+ if (!this._settings)
66
+ return html`<uui-box headline="Page addresses">${this._message ?? html`<uui-loader></uui-loader>`}</uui-box>`
67
+ const changed = this._showTopLevel === this._settings.hideTopLevel
68
+ return html`
69
+ <uui-box headline="Page addresses">
70
+ <p>
71
+ Normally the first top-level page is the home page at <code>/</code>, and the top-level
72
+ page's name is left out of the addresses of the pages below it.
73
+ </p>
74
+ <uui-toggle
75
+ label="Include the top-level page in addresses"
76
+ .checked=${this._showTopLevel}
77
+ ?disabled=${!this._settings.editable || this._busy}
78
+ @change=${(e) => (this._showTopLevel = e.target.checked)}
79
+ >Include the top-level page in addresses</uui-toggle>
80
+ <p class="example">
81
+ A page called News under a top-level page called Home is at
82
+ <code>${this._showTopLevel ? '/home/news/' : '/news/'}</code>.
83
+ </p>
84
+ ${
85
+ this._settings.editable
86
+ ? html`<uui-button look="primary" label="Save" ?disabled=${!changed || this._busy} @click=${() => this.#save()}>Save</uui-button>`
87
+ : html`<p class="note">This site keeps this setting with its files, so it is changed there rather than here.</p>`
88
+ }
89
+ ${this._message ? html`<p>${this._message}</p>` : nothing}
90
+ </uui-box>
91
+ `
92
+ }
93
+
94
+ static styles = css`
95
+ :host { display: block; padding: var(--uui-size-layout-1); }
96
+ .example, .note { color: var(--uui-color-text-alt); }
97
+ `
98
+ }
99
+
100
+ customElements.define('crombolo-addresses-dashboard', CromboloAddressesDashboardElement)
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Assistant",
4
4
  "id": "Crombolo.Assistant",
5
- "version": "0.11.0",
5
+ "version": "0.13.1",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -40,6 +40,7 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
40
40
  _loading: { state: true },
41
41
  _busy: { state: true },
42
42
  _notice: { state: true },
43
+ _installs: { state: true },
43
44
  }
44
45
 
45
46
  constructor() {
@@ -48,6 +49,7 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
48
49
  this._loading = true
49
50
  this._busy = ''
50
51
  this._notice = ''
52
+ this._installs = true
51
53
  }
52
54
 
53
55
  connectedCallback() {
@@ -58,7 +60,9 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
58
60
  async #load() {
59
61
  this._loading = true
60
62
  try {
61
- this._items = (await listInstalled()).items ?? []
63
+ const result = await listInstalled()
64
+ this._items = result.items ?? []
65
+ this._installs = result.installs !== false
62
66
  } catch (error) {
63
67
  this._notice = error instanceof Error ? error.message : String(error)
64
68
  } finally {
@@ -117,20 +121,29 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
117
121
  <uui-table-cell class="carries">${carriesTags(item.carries)}</uui-table-cell>
118
122
  <uui-table-cell class="status">${status(item)}</uui-table-cell>
119
123
  <uui-table-cell>
120
- <uui-button
121
- look="secondary"
122
- color="danger"
123
- label="Uninstall"
124
- .state=${this._busy === item.packageName ? 'waiting' : undefined}
125
- @click=${() => this.#uninstall(item)}></uui-button>
124
+ ${
125
+ this._installs
126
+ ? html`<uui-button
127
+ look="secondary"
128
+ color="danger"
129
+ label="Uninstall"
130
+ .state=${this._busy === item.packageName ? 'waiting' : undefined}
131
+ @click=${() => this.#uninstall(item)}></uui-button>`
132
+ : nothing
133
+ }
126
134
  </uui-table-cell>
127
135
  </uui-table-row>
128
136
  `,
129
137
  )}
130
138
  </uui-table>
131
139
  <p class="note">
132
- An install writes this site's <code>package.json</code> and
133
- <code>bun.lock</code>. Commit both to keep it across deployments.
140
+ ${
141
+ this._installs
142
+ ? html`An install writes this site's <code>package.json</code> and
143
+ <code>bun.lock</code>. Commit both to keep it across deployments.`
144
+ : html`Packages are added and removed by changing this site's
145
+ <code>package.json</code> and deploying, so every server has the same ones.`
146
+ }
134
147
  </p>
135
148
  </uui-box>
136
149
  `
@@ -27,6 +27,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
27
27
  _url: { state: true },
28
28
  _appliesContent: { state: true },
29
29
  _declined: { state: true },
30
+ _installs: { state: true },
30
31
  }
31
32
 
32
33
  constructor() {
@@ -41,6 +42,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
41
42
  this._query = ''
42
43
  this._appliesContent = true
43
44
  this._declined = new Set()
45
+ this._installs = true
44
46
  }
45
47
 
46
48
  connectedCallback() {
@@ -57,6 +59,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
57
59
  this._keyword = result.keyword ?? this._keyword
58
60
  this._url = result.url ?? ''
59
61
  this._appliesContent = result.appliesContent !== false
62
+ this._installs = result.installs !== false
60
63
  } catch (error) {
61
64
  this._error = error instanceof Error ? error.message : String(error)
62
65
  } finally {
@@ -97,7 +100,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
97
100
  }
98
101
 
99
102
  #contentChoice(item) {
100
- if (!item.carries?.content || item.installedVersion) return nothing
103
+ if (!item.carries?.content || item.installedVersion || !this._installs) return nothing
101
104
  if (!this._appliesContent)
102
105
  return html`<p class="meta">Its content is added separately on this site.</p>`
103
106
  return html`
@@ -135,6 +138,11 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
135
138
  </div>
136
139
  ${this._error ? html`<uui-box headline="The registry could not be reached"><p>${this._error}</p></uui-box>` : nothing}
137
140
  ${this._notice ? html`<div id="notice">${this._notice}</div>` : nothing}
141
+ ${
142
+ this._installs
143
+ ? nothing
144
+ : html`<div id="notice">Packages are added to this site by a deploy, so that every server has them: add one to the site's <code>package.json</code> and deploy it.</div>`
145
+ }
138
146
  ${
139
147
  this._items.length === 0 && !this._error
140
148
  ? this.#empty()
@@ -161,7 +169,9 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
161
169
  ${
162
170
  item.installedVersion
163
171
  ? html`<uui-tag look="positive">Installed ${item.installedVersion}</uui-tag>`
164
- : html`
172
+ : !this._installs
173
+ ? html`<p class="meta">Add it to the site with <code>bun add ${item.name}</code>, then deploy.</p>`
174
+ : html`
165
175
  <uui-button
166
176
  look="primary"
167
177
  label="Install"
@@ -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
- shown.length === 0
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
- : this.#group(shown).map((group) => this.#renderGroup(group))
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
- <uui-table>
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
 
@@ -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 = {}) => {
@@ -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(', ') || html`<em>not answered</em>`
170
+ : (value.labels ?? value.values).join(', ') ||
171
+ html`<em>not answered</em>`
171
172
  }
172
173
  </dd>
173
174
  `,