@crombolo/backoffice-host 0.12.1 → 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.12.1",
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.12.1",
34
+ "@crombolo/core": "0.13.1",
35
35
  "@crombolo/backoffice-dist": "18.2.0"
36
36
  }
37
37
  }
@@ -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.12.1",
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"
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Localizations",
4
4
  "id": "Crombolo.Localizations",
5
- "version": "0.12.1",
5
+ "version": "0.13.1",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Search & sharing",
4
4
  "id": "Crombolo.Seo",
5
- "version": "0.12.1",
5
+ "version": "0.13.1",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo",
4
4
  "id": "Crombolo.Core",
5
- "version": "0.12.1",
5
+ "version": "0.13.1",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -345,6 +345,112 @@
345
345
  "name": "Crombolo Icons Context",
346
346
  "overwrites": ["Umb.GlobalContext.Icons"],
347
347
  "api": "/crombolo-plugin/branding/icon-registry.js"
348
+ },
349
+ {
350
+ "type": "section",
351
+ "alias": "Crombolo.Section.Workflow",
352
+ "name": "Crombolo Workflow Section",
353
+ "weight": 40,
354
+ "meta": {
355
+ "label": "Workflow",
356
+ "pathname": "workflow"
357
+ }
358
+ },
359
+ {
360
+ "type": "sectionView",
361
+ "alias": "Crombolo.SectionView.Workflow.Waiting",
362
+ "name": "Crombolo Workflow Waiting",
363
+ "element": "/crombolo-plugin/workflow-requests.js",
364
+ "elementName": "crombolo-workflow-waiting",
365
+ "weight": 400,
366
+ "meta": {
367
+ "label": "Waiting for me",
368
+ "pathname": "waiting",
369
+ "icon": "icon-inbox"
370
+ },
371
+ "conditions": [
372
+ {
373
+ "alias": "Umb.Condition.SectionAlias",
374
+ "match": "Crombolo.Section.Workflow"
375
+ }
376
+ ]
377
+ },
378
+ {
379
+ "type": "sectionView",
380
+ "alias": "Crombolo.SectionView.Workflow.InFlight",
381
+ "name": "Crombolo Workflow In Flight",
382
+ "element": "/crombolo-plugin/workflow-requests.js",
383
+ "elementName": "crombolo-workflow-in-flight",
384
+ "weight": 300,
385
+ "meta": {
386
+ "label": "In flight",
387
+ "pathname": "in-flight",
388
+ "icon": "icon-time"
389
+ },
390
+ "conditions": [
391
+ {
392
+ "alias": "Umb.Condition.SectionAlias",
393
+ "match": "Crombolo.Section.Workflow"
394
+ }
395
+ ]
396
+ },
397
+ {
398
+ "type": "sectionView",
399
+ "alias": "Crombolo.SectionView.Workflow.History",
400
+ "name": "Crombolo Workflow History",
401
+ "element": "/crombolo-plugin/workflow-requests.js",
402
+ "elementName": "crombolo-workflow-history",
403
+ "weight": 200,
404
+ "meta": {
405
+ "label": "Decided",
406
+ "pathname": "history",
407
+ "icon": "icon-check"
408
+ },
409
+ "conditions": [
410
+ {
411
+ "alias": "Umb.Condition.SectionAlias",
412
+ "match": "Crombolo.Section.Workflow"
413
+ }
414
+ ]
415
+ },
416
+ {
417
+ "type": "sectionView",
418
+ "alias": "Crombolo.SectionView.Workflow.Designer",
419
+ "name": "Crombolo Workflow Designer",
420
+ "element": "/crombolo-plugin/workflow-designer.js",
421
+ "weight": 100,
422
+ "meta": {
423
+ "label": "Workflows",
424
+ "pathname": "workflows",
425
+ "icon": "icon-mindmap"
426
+ },
427
+ "conditions": [
428
+ {
429
+ "alias": "Umb.Condition.SectionAlias",
430
+ "match": "Crombolo.Section.Workflow"
431
+ },
432
+ {
433
+ "alias": "Umb.Condition.CurrentUser.IsAdmin"
434
+ }
435
+ ]
436
+ },
437
+ {
438
+ "type": "workspaceView",
439
+ "alias": "Crombolo.WorkspaceView.Document.Workflow",
440
+ "name": "Crombolo Document Workflow",
441
+ "element": "/crombolo-plugin/workflow-tab.js",
442
+ "weight": 50,
443
+ "meta": {
444
+ "label": "Workflow",
445
+ "pathname": "workflow",
446
+ "icon": "icon-mindmap"
447
+ },
448
+ "conditions": [
449
+ {
450
+ "alias": "Umb.Condition.WorkspaceAlias",
451
+ "match": "Umb.Workspace.Document"
452
+ }
453
+ ]
348
454
  }
349
455
  ]
350
456
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The Workflow screens' calls to <backoffice>/crombolo/api/approvals/ (story 028).
3
+ * The cookie carries the session. Every answer resolves; a refusal carries the
4
+ * server's own words in `message`.
5
+ */
6
+ const apiBase = () =>
7
+ `${(document.querySelector('base')?.getAttribute('href') ?? '/umbraco/').replace(/\/$/, '')}/crombolo/api/approvals`
8
+
9
+ async function call(path, init = {}) {
10
+ const response = await fetch(`${apiBase()}/${path}`, {
11
+ credentials: 'include',
12
+ ...init,
13
+ headers: { accept: 'application/json', 'content-type': 'application/json', ...init.headers },
14
+ })
15
+ const body = await response.json().catch(() => ({}))
16
+ if (response.status === 403 && !body.message)
17
+ return { ok: false, message: 'You are not allowed to do that.' }
18
+ return { ok: response.ok, status: response.status, ...body }
19
+ }
20
+
21
+ const post = (path, body = {}) => call(path, { method: 'POST', body: JSON.stringify(body) })
22
+
23
+ export const pageApproval = (key) => call(`page/${encodeURIComponent(key)}`)
24
+ export const sendForApproval = (key, action, comment) =>
25
+ post(`page/${encodeURIComponent(key)}/send`, { action, comment })
26
+ export const decide = (requestKey, verb, body) =>
27
+ post(`request/${encodeURIComponent(requestKey)}/${verb}`, body)
28
+ export const waitingForMe = () => call('waiting')
29
+ export const inFlight = () => call('in-flight')
30
+ export const finished = (skip = 0, take = 25) => call(`history?skip=${skip}&take=${take}`)
31
+ export const listWorkflows = () => call('workflows')
32
+ export const previewWorkflow = (alias, workflow) =>
33
+ post(`workflows/${encodeURIComponent(alias)}/preview`, { workflow })
34
+ export const saveWorkflow = (alias, workflow) =>
35
+ call(`workflows/${encodeURIComponent(alias)}`, {
36
+ method: 'PUT',
37
+ body: JSON.stringify({ workflow }),
38
+ })
39
+ export const deleteWorkflow = (alias) =>
40
+ call(`workflows/${encodeURIComponent(alias)}`, { method: 'DELETE' })
@@ -0,0 +1,529 @@
1
+ /**
2
+ * The workflow designer (story 028): stages drawn on a canvas and connected by
3
+ * arrows, each stage's approvers and send-back beside it, and the workflow's
4
+ * own settings above. Saving writes `schema/workflows/<alias>.toml`; before it
5
+ * does, the requests in flight that the change affects are named.
6
+ *
7
+ * An arrow that would close a loop is refused as it is drawn. The server holds
8
+ * the file to the same rules on save, so this only spares a round trip.
9
+ */
10
+ // For its side effect: registers `umb-input-document`, which the shell loads lazily.
11
+ import '@umbraco-cms/backoffice/document'
12
+ import { css, html, LitElement, nothing, unsafeCSS } from '@umbraco-cms/backoffice/external/lit'
13
+ import { deleteWorkflow, listWorkflows, previewWorkflow, saveWorkflow } from './workflow-client.js'
14
+ import { ancestors, BOX, drawGraph, GRAPH_STYLES, layout, wouldLoop } from './workflow-graph.js'
15
+
16
+ const ON_EDIT = {
17
+ withdraw: 'Saving withdraws the request; the editor sends it again',
18
+ 'approve-latest': 'Saving goes into the request; approvers approve the latest',
19
+ lock: 'The page cannot be changed while it waits',
20
+ 'lock-after-first-approval': 'The page can be changed until someone approves',
21
+ }
22
+ const SELF = {
23
+ refused: 'Someone else must approve it',
24
+ allowed: 'They may approve it like anyone in the group',
25
+ automatic: 'Their approval counts automatically where they are in the group',
26
+ }
27
+ const COMMENTS = {
28
+ optional: 'Optional',
29
+ 'on-send-back': 'Required when sending back',
30
+ always: 'Required with every decision',
31
+ }
32
+ const ON_CHANGE = {
33
+ keep: 'They finish under the version they started with',
34
+ restart: 'They start again under the new version',
35
+ withdraw: 'They are withdrawn, and sent again',
36
+ }
37
+
38
+ const blank = () => ({
39
+ alias: 'new-workflow',
40
+ name: 'New workflow',
41
+ appliesTo: { branches: [], contentTypes: [] },
42
+ publishersMaySkip: true,
43
+ onEdit: 'withdraw',
44
+ selfApproval: 'refused',
45
+ comments: 'optional',
46
+ unpublish: false,
47
+ onChange: 'keep',
48
+ stages: [
49
+ {
50
+ alias: 'review',
51
+ name: 'Review',
52
+ approvers: [],
53
+ required: 'one',
54
+ next: [],
55
+ sendBack: 'editor',
56
+ position: { x: 0, y: 0 },
57
+ },
58
+ ],
59
+ })
60
+
61
+ const select = (
62
+ value,
63
+ options,
64
+ onChange,
65
+ ) => html`<select @change=${(e) => onChange(e.target.value)}>
66
+ ${Object.entries(options).map(([key, label]) => html`<option value=${key} ?selected=${key === value}>${label}</option>`)}
67
+ </select>`
68
+
69
+ export default class CromboloWorkflowDesignerElement extends LitElement {
70
+ static properties = {
71
+ _list: { state: true },
72
+ _editing: { state: true },
73
+ _originalAlias: { state: true },
74
+ _selected: { state: true },
75
+ _connecting: { state: true },
76
+ _problems: { state: true },
77
+ _affected: { state: true },
78
+ _message: { state: true },
79
+ }
80
+
81
+ #drag
82
+
83
+ connectedCallback() {
84
+ super.connectedCallback()
85
+ this.#load()
86
+ }
87
+
88
+ async #load() {
89
+ const result = await listWorkflows()
90
+ if (!result.ok) {
91
+ this._message = result.message
92
+ return
93
+ }
94
+ this._list = result
95
+ }
96
+
97
+ #edit(workflow) {
98
+ const copy = structuredClone(workflow)
99
+ // Every stage gets the place the canvas would draw it, so dragging starts from there.
100
+ const positions = layout(copy.stages)
101
+ for (const stage of copy.stages) stage.position = stage.position ?? positions.get(stage.alias)
102
+ this._editing = copy
103
+ this._originalAlias = workflow.alias
104
+ this._selected = copy.stages[0]?.alias
105
+ this._problems = undefined
106
+ this._affected = undefined
107
+ this._message = undefined
108
+ }
109
+
110
+ #touch() {
111
+ this._editing = { ...this._editing }
112
+ this._affected = undefined
113
+ }
114
+
115
+ #stage(alias) {
116
+ return this._editing.stages.find((s) => s.alias === alias)
117
+ }
118
+
119
+ #addStage() {
120
+ let n = this._editing.stages.length + 1
121
+ while (this.#stage(`stage-${n}`)) n++
122
+ const xs = this._editing.stages.map((s) => s.position?.x ?? 0)
123
+ this._editing.stages.push({
124
+ alias: `stage-${n}`,
125
+ name: `Stage ${n}`,
126
+ approvers: [],
127
+ required: 'one',
128
+ next: [],
129
+ sendBack: 'editor',
130
+ position: { x: Math.max(0, ...xs) + BOX.width + 80, y: 0 },
131
+ })
132
+ this._selected = `stage-${n}`
133
+ this.#touch()
134
+ }
135
+
136
+ #removeStage(alias) {
137
+ this._editing.stages = this._editing.stages
138
+ .filter((s) => s.alias !== alias)
139
+ .map((s) => ({
140
+ ...s,
141
+ next: s.next.filter((n) => n !== alias),
142
+ sendBack: s.sendBack === alias ? 'editor' : s.sendBack,
143
+ }))
144
+ this._selected = this._editing.stages[0]?.alias
145
+ this.#touch()
146
+ }
147
+
148
+ #renameStage(stage, alias) {
149
+ const clean = alias.trim().replace(/[^A-Za-z0-9_-]/g, '-')
150
+ if (!clean || this.#stage(clean)) return
151
+ for (const other of this._editing.stages) {
152
+ other.next = other.next.map((n) => (n === stage.alias ? clean : n))
153
+ if (other.sendBack === stage.alias) other.sendBack = clean
154
+ }
155
+ stage.alias = clean
156
+ this._selected = clean
157
+ this.#touch()
158
+ }
159
+
160
+ #onStage(alias) {
161
+ if (this.#drag?.moved) return
162
+ if (!this._connecting) {
163
+ this._selected = alias
164
+ return
165
+ }
166
+ if (this._connecting === true) {
167
+ this._connecting = alias
168
+ return
169
+ }
170
+ const from = this._connecting
171
+ this._connecting = undefined
172
+ if (wouldLoop(this._editing.stages, from, alias)) {
173
+ this._message = `An arrow from ${from} to ${alias} would make the stages loop, so it was not drawn.`
174
+ return
175
+ }
176
+ const source = this.#stage(from)
177
+ if (!source.next.includes(alias)) source.next = [...source.next, alias]
178
+ this._message = undefined
179
+ this.#touch()
180
+ }
181
+
182
+ #onArrow(from, to) {
183
+ const source = this.#stage(from)
184
+ source.next = source.next.filter((n) => n !== to)
185
+ const target = this.#stage(to)
186
+ if (
187
+ target &&
188
+ !ancestors(this._editing.stages, to).has(target.sendBack) &&
189
+ target.sendBack !== 'editor'
190
+ )
191
+ target.sendBack = 'editor'
192
+ this.#touch()
193
+ }
194
+
195
+ #pointerDown(alias, event) {
196
+ if (this._connecting) return
197
+ const stage = this.#stage(alias)
198
+ this.#drag = {
199
+ alias,
200
+ startX: event.clientX,
201
+ startY: event.clientY,
202
+ from: { ...stage.position },
203
+ moved: false,
204
+ }
205
+ }
206
+
207
+ #pointerMove(event) {
208
+ if (!this.#drag) return
209
+ const stage = this.#stage(this.#drag.alias)
210
+ const dx = event.clientX - this.#drag.startX
211
+ const dy = event.clientY - this.#drag.startY
212
+ if (Math.abs(dx) + Math.abs(dy) > 3) this.#drag.moved = true
213
+ stage.position = {
214
+ x: Math.max(0, Math.round(this.#drag.from.x + dx)),
215
+ y: Math.max(0, Math.round(this.#drag.from.y + dy)),
216
+ }
217
+ this._editing = { ...this._editing }
218
+ }
219
+
220
+ #pointerUp() {
221
+ const drag = this.#drag
222
+ // The click that follows a drag selects nothing.
223
+ setTimeout(() => {
224
+ if (this.#drag === drag) this.#drag = undefined
225
+ })
226
+ }
227
+
228
+ async #save(confirmed = false) {
229
+ this._problems = undefined
230
+ if (!confirmed) {
231
+ const preview = await previewWorkflow(
232
+ this._originalAlias ?? this._editing.alias,
233
+ this._editing,
234
+ )
235
+ if (!preview.ok) {
236
+ this._problems = preview.problems ?? [{ message: preview.message }]
237
+ return
238
+ }
239
+ if (preview.affected.length > 0) {
240
+ this._affected = preview.affected
241
+ return
242
+ }
243
+ }
244
+ const saved = await saveWorkflow(this._originalAlias ?? this._editing.alias, this._editing)
245
+ if (!saved.ok) {
246
+ this._problems = saved.problems ?? [{ message: saved.message }]
247
+ return
248
+ }
249
+ this._affected = undefined
250
+ this._message = 'Saved.'
251
+ this._originalAlias = this._editing.alias
252
+ await this.#load()
253
+ }
254
+
255
+ async #delete() {
256
+ if (
257
+ !window.confirm(
258
+ `Delete the workflow "${this._editing.name}"? Pages it covers will publish without approval.`,
259
+ )
260
+ )
261
+ return
262
+ const result = await deleteWorkflow(this._originalAlias)
263
+ if (!result.ok) {
264
+ this._message = result.message
265
+ return
266
+ }
267
+ this._editing = undefined
268
+ await this.#load()
269
+ }
270
+
271
+ render() {
272
+ if (this._message && !this._list)
273
+ return html`<uui-box headline="Workflows">${this._message}</uui-box>`
274
+ if (!this._list) return html`<uui-box headline="Workflows"><uui-loader></uui-loader></uui-box>`
275
+ if (!this._editing) return this.#renderList()
276
+ return this.#renderEditor()
277
+ }
278
+
279
+ #renderList() {
280
+ const { items, editable } = this._list
281
+ return html`<uui-box headline="Workflows">
282
+ ${editable ? nothing : html`<p>Workflows are kept with the site's files, which cannot be changed on this server. Change them in the repository and deploy.</p>`}
283
+ ${
284
+ items.length === 0
285
+ ? html`<p>No workflow yet: every page publishes as soon as someone who may publish does.</p>`
286
+ : html`<uui-table>
287
+ <uui-table-head>
288
+ <uui-table-head-cell>Workflow</uui-table-head-cell>
289
+ <uui-table-head-cell>Covers</uui-table-head-cell>
290
+ <uui-table-head-cell>Stages</uui-table-head-cell>
291
+ </uui-table-head>
292
+ ${items.map(
293
+ (w) => html`<uui-table-row>
294
+ <uui-table-cell><a href="#" @click=${(e) => {
295
+ e.preventDefault()
296
+ this.#edit(w)
297
+ }}>${w.name}</a></uui-table-cell>
298
+ <uui-table-cell>${[
299
+ w.appliesTo.branches.length ? `${w.appliesTo.branches.length} branch(es)` : '',
300
+ ...w.appliesTo.contentTypes,
301
+ ]
302
+ .filter(Boolean)
303
+ .join(', ')}</uui-table-cell>
304
+ <uui-table-cell>${w.stages.length}</uui-table-cell>
305
+ </uui-table-row>`,
306
+ )}
307
+ </uui-table>`
308
+ }
309
+ ${editable ? html`<uui-button look="primary" label="New workflow" @click=${() => this.#edit(blank())}>New workflow</uui-button>` : nothing}
310
+ </uui-box>`
311
+ }
312
+
313
+ #renderEditor() {
314
+ const w = this._editing
315
+ const editable = this._list.editable
316
+ const stage = this.#stage(this._selected)
317
+ return html`
318
+ <uui-box headline=${w.name || 'Workflow'}>
319
+ <div class="toolbar">
320
+ <uui-button look="secondary" label="Back" @click=${() => {
321
+ this._editing = undefined
322
+ }}>Back to the list</uui-button>
323
+ ${
324
+ editable
325
+ ? html`
326
+ <uui-button look="secondary" label="Add a stage" @click=${() => this.#addStage()}>Add a stage</uui-button>
327
+ <uui-button look=${this._connecting ? 'primary' : 'secondary'} label="Connect stages"
328
+ @click=${() => {
329
+ this._connecting = this._connecting ? undefined : true
330
+ }}
331
+ >${this._connecting === true ? 'Click the stage to draw from' : this._connecting ? 'Now click the stage it leads to' : 'Connect stages'}</uui-button>
332
+ <uui-button look="primary" label="Save" @click=${() => this.#save()}>Save</uui-button>
333
+ ${
334
+ this._originalAlias &&
335
+ this._list.items.some((i) => i.alias === this._originalAlias)
336
+ ? html`<uui-button look="secondary" color="danger" label="Delete" @click=${() => this.#delete()}>Delete</uui-button>`
337
+ : nothing
338
+ }
339
+ `
340
+ : nothing
341
+ }
342
+ </div>
343
+ ${this._message ? html`<p class="message">${this._message}</p>` : nothing}
344
+ ${this._problems ? html`<ul class="problems">${this._problems.map((p) => html`<li>${p.path ? html`<code>${p.path}</code> ` : nothing}${p.message}</li>`)}</ul>` : nothing}
345
+ ${
346
+ this._affected
347
+ ? html`<div class="affected">
348
+ <p>Saving changes the rules for ${this._affected.length} page(s) waiting for approval:</p>
349
+ <ul>${this._affected.map((a) => html`<li>${a.page}: ${ON_CHANGE[a.outcome]}</li>`)}</ul>
350
+ <uui-button look="primary" label="Save anyway" @click=${() => this.#save(true)}>Save anyway</uui-button>
351
+ </div>`
352
+ : nothing
353
+ }
354
+ <p class="hint">Arrows run left to right: the stages an arrow leads to wait for every stage with an arrow into them, and run side by side. Click an arrow to remove it.</p>
355
+ <div class="canvas" @pointermove=${(e) => editable && this.#pointerMove(e)} @pointerup=${() => this.#pointerUp()} @pointerleave=${() => this.#pointerUp()}>
356
+ ${drawGraph(w.stages, {
357
+ selected: this._selected,
358
+ onStage: (alias) => this.#onStage(alias),
359
+ onArrow: (from, to) => editable && this.#onArrow(from, to),
360
+ onPointerDown: (alias, event) => editable && this.#pointerDown(alias, event),
361
+ })}
362
+ </div>
363
+ </uui-box>
364
+ <div class="panels">
365
+ ${stage ? this.#renderStage(stage, editable) : nothing}
366
+ ${this.#renderSettings(w, editable)}
367
+ </div>
368
+ `
369
+ }
370
+
371
+ #renderStage(stage, editable) {
372
+ const earlier = [...ancestors(this._editing.stages, stage.alias)]
373
+ const requiredKind = typeof stage.required === 'number' ? 'number' : stage.required
374
+ return html`<uui-box headline=${`Stage: ${stage.name || stage.alias}`}>
375
+ <fieldset ?disabled=${!editable}>
376
+ <label>Name <uui-input label="Name" .value=${stage.name ?? ''} @change=${(e) => {
377
+ stage.name = e.target.value
378
+ this.#touch()
379
+ }}></uui-input></label>
380
+ <label>Alias <uui-input label="Alias" .value=${stage.alias} @change=${(e) => this.#renameStage(stage, e.target.value)}></uui-input></label>
381
+ <div class="field"><span>Approved by</span>
382
+ ${this._list.groups.map(
383
+ (g) =>
384
+ html`<label class="check"><input type="checkbox" .checked=${stage.approvers.includes(g.alias)} @change=${(
385
+ e,
386
+ ) => {
387
+ stage.approvers = e.target.checked
388
+ ? [...stage.approvers, g.alias]
389
+ : stage.approvers.filter((a) => a !== g.alias)
390
+ this.#touch()
391
+ }} /> ${g.name}</label>`,
392
+ )}
393
+ </div>
394
+ <label>How many must approve
395
+ ${select(
396
+ requiredKind,
397
+ {
398
+ one: 'Any one of them',
399
+ majority: 'A majority',
400
+ all: 'All of them',
401
+ number: 'A number of them',
402
+ },
403
+ (value) => {
404
+ stage.required = value === 'number' ? 2 : value
405
+ this.#touch()
406
+ },
407
+ )}
408
+ ${
409
+ typeof stage.required === 'number'
410
+ ? html`<input type="number" min="1" .value=${String(stage.required)} @change=${(
411
+ e,
412
+ ) => {
413
+ stage.required = Math.max(1, Number(e.target.value) || 1)
414
+ this.#touch()
415
+ }} />`
416
+ : nothing
417
+ }
418
+ </label>
419
+ <label>Sending back goes to
420
+ ${select(
421
+ stage.sendBack,
422
+ {
423
+ editor: 'The editor (the request ends)',
424
+ ...Object.fromEntries(earlier.map((a) => [a, `The ${a} stage`])),
425
+ },
426
+ (value) => {
427
+ stage.sendBack = value
428
+ this.#touch()
429
+ },
430
+ )}
431
+ </label>
432
+ ${
433
+ editable && this._editing.stages.length > 1
434
+ ? html`<uui-button look="secondary" color="danger" label="Remove stage" @click=${() => this.#removeStage(stage.alias)}>Remove this stage</uui-button>`
435
+ : nothing
436
+ }
437
+ </fieldset>
438
+ </uui-box>`
439
+ }
440
+
441
+ #renderSettings(w, editable) {
442
+ return html`<uui-box headline="Settings">
443
+ <fieldset ?disabled=${!editable}>
444
+ <label>Name <uui-input label="Name" .value=${w.name} @change=${(e) => {
445
+ w.name = e.target.value
446
+ this.#touch()
447
+ }}></uui-input></label>
448
+ <label>Alias <uui-input label="Alias" .value=${w.alias} @change=${(e) => {
449
+ w.alias = e.target.value.trim()
450
+ this.#touch()
451
+ }}></uui-input></label>
452
+ <div class="field"><span>Covers the pages under</span>
453
+ <umb-input-document
454
+ .selection=${w.appliesTo.branches}
455
+ @change=${(e) => {
456
+ w.appliesTo.branches = [...(e.target.selection ?? [])]
457
+ this.#touch()
458
+ }}
459
+ ></umb-input-document>
460
+ </div>
461
+ <div class="field"><span>And every page of these types</span>
462
+ ${this._list.contentTypes.map(
463
+ (t) =>
464
+ html`<label class="check"><input type="checkbox" .checked=${w.appliesTo.contentTypes.includes(t.alias)} @change=${(
465
+ e,
466
+ ) => {
467
+ w.appliesTo.contentTypes = e.target.checked
468
+ ? [...w.appliesTo.contentTypes, t.alias]
469
+ : w.appliesTo.contentTypes.filter((a) => a !== t.alias)
470
+ this.#touch()
471
+ }} /> ${t.name}</label>`,
472
+ )}
473
+ </div>
474
+ <label class="check"><input type="checkbox" .checked=${w.publishersMaySkip} @change=${(
475
+ e,
476
+ ) => {
477
+ w.publishersMaySkip = e.target.checked
478
+ this.#touch()
479
+ }} /> People who may publish can publish directly</label>
480
+ <label class="check"><input type="checkbox" .checked=${w.unpublish} @change=${(e) => {
481
+ w.unpublish = e.target.checked
482
+ this.#touch()
483
+ }} /> Taking a page down needs approval too</label>
484
+ <label>When a waiting page is changed ${select(w.onEdit, ON_EDIT, (v) => {
485
+ w.onEdit = v
486
+ this.#touch()
487
+ })}</label>
488
+ <label>When the person who made the change could approve it ${select(
489
+ w.selfApproval,
490
+ SELF,
491
+ (v) => {
492
+ w.selfApproval = v
493
+ this.#touch()
494
+ },
495
+ )}</label>
496
+ <label>Comments ${select(w.comments, COMMENTS, (v) => {
497
+ w.comments = v
498
+ this.#touch()
499
+ })}</label>
500
+ <label>When this workflow changes, pages waiting on it ${select(
501
+ w.onChange,
502
+ ON_CHANGE,
503
+ (v) => {
504
+ w.onChange = v
505
+ this.#touch()
506
+ },
507
+ )}</label>
508
+ </fieldset>
509
+ </uui-box>`
510
+ }
511
+
512
+ static styles = css`
513
+ :host { display: block; padding: var(--uui-size-layout-1); }
514
+ .toolbar { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-4); }
515
+ .canvas { overflow: auto; border: 1px dashed var(--uui-color-border); border-radius: 6px; padding: var(--uui-size-space-5); min-height: 200px; touch-action: none; }
516
+ .panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: var(--uui-size-space-5); margin-top: var(--uui-size-space-5); }
517
+ fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--uui-size-space-4); }
518
+ label, .field { display: flex; flex-direction: column; gap: var(--uui-size-space-2); }
519
+ label.check { flex-direction: row; align-items: center; }
520
+ .field > span { font-weight: 700; }
521
+ .hint { opacity: 0.8; font-size: 0.9em; }
522
+ .message { color: var(--uui-color-warning-emphasis, #9e7300); }
523
+ .problems { color: var(--uui-color-danger); }
524
+ .affected { border: 1px solid var(--uui-color-warning); padding: var(--uui-size-space-4); border-radius: 6px; margin-bottom: var(--uui-size-space-4); }
525
+ ${unsafeCSS(GRAPH_STYLES)}
526
+ `
527
+ }
528
+
529
+ customElements.define('crombolo-workflow-designer', CromboloWorkflowDesignerElement)
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A workflow's stages drawn as boxes and arrows (story 028): read-only on a
3
+ * page's Workflow tab, and the canvas the designer edits. The graph rules here
4
+ * mirror `@crombolo/core`'s, which the server applies again on save.
5
+ */
6
+ import { svg } from '@umbraco-cms/backoffice/external/lit'
7
+
8
+ export const BOX = { width: 160, height: 56 }
9
+
10
+ /** Every stage reachable from `alias`. */
11
+ export function descendants(stages, alias) {
12
+ const byAlias = new Map(stages.map((s) => [s.alias, s]))
13
+ const found = new Set()
14
+ const visit = (at) => {
15
+ for (const next of byAlias.get(at)?.next ?? []) {
16
+ if (found.has(next)) continue
17
+ found.add(next)
18
+ visit(next)
19
+ }
20
+ }
21
+ visit(alias)
22
+ return found
23
+ }
24
+
25
+ /** Every stage `alias` can be reached from: where it may send back to. */
26
+ export function ancestors(stages, alias) {
27
+ const found = new Set()
28
+ const visit = (at) => {
29
+ for (const parent of stages.filter((s) => s.next.includes(at))) {
30
+ if (found.has(parent.alias)) continue
31
+ found.add(parent.alias)
32
+ visit(parent.alias)
33
+ }
34
+ }
35
+ visit(alias)
36
+ return found
37
+ }
38
+
39
+ export const wouldLoop = (stages, from, to) => from === to || descendants(stages, to).has(from)
40
+
41
+ /** A position for every stage: its own, or one laid out by depth. */
42
+ export function layout(stages) {
43
+ const depth = new Map()
44
+ const starts = stages.filter((s) => !stages.some((o) => o.next.includes(s.alias)))
45
+ const walk = (stage, d) => {
46
+ if ((depth.get(stage.alias) ?? -1) >= d) return
47
+ depth.set(stage.alias, d)
48
+ for (const next of stage.next) {
49
+ const child = stages.find((s) => s.alias === next)
50
+ if (child) walk(child, d + 1)
51
+ }
52
+ }
53
+ for (const start of starts) walk(start, 0)
54
+ const rows = new Map()
55
+ return new Map(
56
+ stages.map((stage) => {
57
+ if (stage.position) return [stage.alias, stage.position]
58
+ const d = depth.get(stage.alias) ?? 0
59
+ const row = rows.get(d) ?? 0
60
+ rows.set(d, row + 1)
61
+ return [stage.alias, { x: d * (BOX.width + 80), y: row * (BOX.height + 40) }]
62
+ }),
63
+ )
64
+ }
65
+
66
+ const STATE_FILL = {
67
+ approved: 'var(--uui-color-positive-standalone, #2bc37c)',
68
+ waiting: 'var(--uui-color-warning-standalone, #f0ac00)',
69
+ discarded: 'var(--uui-color-disabled, #ccc)',
70
+ }
71
+
72
+ /**
73
+ * The arrows and boxes as SVG. `states` colours each stage by where a request
74
+ * stands; `selected` outlines one; the callbacks make it a canvas.
75
+ */
76
+ export function drawGraph(stages, options = {}) {
77
+ const positions = layout(stages)
78
+ const at = (alias) => positions.get(alias) ?? { x: 0, y: 0 }
79
+ const arrows = stages.flatMap((stage) =>
80
+ stage.next.map((next) => {
81
+ const a = at(stage.alias)
82
+ const b = at(next)
83
+ const x1 = a.x + BOX.width
84
+ const y1 = a.y + BOX.height / 2
85
+ const x2 = b.x
86
+ const y2 = b.y + BOX.height / 2
87
+ const mid = (x1 + x2) / 2
88
+ return svg`<path
89
+ class="arrow"
90
+ d="M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 6} ${y2}"
91
+ marker-end="url(#crombolo-arrowhead)"
92
+ @click=${(event) => options.onArrow?.(stage.alias, next, event)}
93
+ ><title>${stage.alias} → ${next}</title></path>`
94
+ }),
95
+ )
96
+ const boxes = stages.map((stage) => {
97
+ const p = at(stage.alias)
98
+ const state = options.states?.[stage.alias]
99
+ return svg`<g
100
+ class="stage ${options.selected === stage.alias ? 'selected' : ''}"
101
+ transform="translate(${p.x}, ${p.y})"
102
+ @pointerdown=${(event) => options.onPointerDown?.(stage.alias, event)}
103
+ @click=${(event) => options.onStage?.(stage.alias, event)}
104
+ >
105
+ <rect width=${BOX.width} height=${BOX.height} rx="6" fill=${STATE_FILL[state] ?? 'var(--uui-color-surface, #fff)'}></rect>
106
+ <text x="10" y="22" class="name">${stage.name || stage.alias}</text>
107
+ <text x="10" y="42" class="detail">${stage.approvers.join(', ') || 'no approvers'}</text>
108
+ </g>`
109
+ })
110
+ const width = Math.max(...[...positions.values()].map((p) => p.x + BOX.width), 0) + 40
111
+ const height = Math.max(...[...positions.values()].map((p) => p.y + BOX.height), 0) + 40
112
+ return svg`<svg class="graph" width=${Math.max(width, 320)} height=${Math.max(height, 120)}>
113
+ <defs>
114
+ <marker id="crombolo-arrowhead" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
115
+ <path d="M0,0 L8,4 L0,8 z" fill="currentColor"></path>
116
+ </marker>
117
+ </defs>
118
+ ${arrows}
119
+ ${boxes}
120
+ </svg>`
121
+ }
122
+
123
+ export const GRAPH_STYLES = `
124
+ .graph { color: var(--uui-color-text, #1b264f); overflow: visible; }
125
+ .graph .arrow { fill: none; stroke: currentColor; stroke-width: 1.5; cursor: pointer; }
126
+ .graph .arrow:hover { stroke: var(--uui-color-danger, #d42054); }
127
+ .graph .stage rect { stroke: var(--uui-color-border, #d8d7d9); stroke-width: 1; }
128
+ .graph .stage.selected rect { stroke: var(--uui-color-interactive, #3544b1); stroke-width: 2; }
129
+ .graph .stage { cursor: grab; }
130
+ .graph .name { font-weight: 700; font-size: 13px; }
131
+ .graph .detail { font-size: 11px; opacity: 0.8; }
132
+ `
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The Workflow section's lists (story 028): what waits for the reader's
3
+ * approval, every request in flight, and those decided. Each row links to the
4
+ * page, whose Workflow tab is where a request is acted on.
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+ import { finished, inFlight, waitingForMe } from './workflow-client.js'
8
+
9
+ const STATE = {
10
+ waiting: ['warning', 'Waiting'],
11
+ approved: ['positive', 'Approved'],
12
+ 'sent-back': ['danger', 'Sent back'],
13
+ withdrawn: ['default', 'Withdrawn'],
14
+ }
15
+
16
+ const pageLink = (key) => {
17
+ const base = (document.querySelector('base')?.getAttribute('href') ?? '/umbraco/').replace(
18
+ /\/$/,
19
+ '',
20
+ )
21
+ return `${base}/section/content/workspace/document/edit/${key}/view/workflow`
22
+ }
23
+
24
+ class CromboloWorkflowListElement extends LitElement {
25
+ static properties = {
26
+ _items: { state: true },
27
+ _message: { state: true },
28
+ _total: { state: true },
29
+ _skip: { state: true },
30
+ }
31
+
32
+ /** Overridden: what this list is, and how it loads. */
33
+ get headline() {
34
+ return ''
35
+ }
36
+ get empty() {
37
+ return ''
38
+ }
39
+ async fetch(_skip) {
40
+ return { ok: true, items: [] }
41
+ }
42
+
43
+ connectedCallback() {
44
+ super.connectedCallback()
45
+ this._skip = 0
46
+ this.#load()
47
+ }
48
+
49
+ async #load() {
50
+ const result = await this.fetch(this._skip)
51
+ if (!result.ok) {
52
+ this._message = result.message
53
+ return
54
+ }
55
+ this._items = result.items
56
+ this._total = result.total
57
+ }
58
+
59
+ render() {
60
+ if (this._message) return html`<uui-box headline=${this.headline}>${this._message}</uui-box>`
61
+ if (!this._items)
62
+ return html`<uui-box headline=${this.headline}><uui-loader></uui-loader></uui-box>`
63
+ return html`<uui-box headline=${this.headline}>
64
+ ${
65
+ this._items.length === 0
66
+ ? html`<p>${this.empty}</p>`
67
+ : html`<uui-table>
68
+ <uui-table-head>
69
+ <uui-table-head-cell>Page</uui-table-head-cell>
70
+ <uui-table-head-cell>Workflow</uui-table-head-cell>
71
+ <uui-table-head-cell>Asked for</uui-table-head-cell>
72
+ <uui-table-head-cell>Where it stands</uui-table-head-cell>
73
+ <uui-table-head-cell>Sent</uui-table-head-cell>
74
+ </uui-table-head>
75
+ ${this._items.map((item) => {
76
+ const [color, label] = STATE[item.state] ?? ['default', item.state]
77
+ const waiting = item.stages.filter((s) => s.state === 'waiting').map((s) => s.alias)
78
+ return html`<uui-table-row>
79
+ <uui-table-cell><a href=${pageLink(item.nodeKey)}>${item.nodeName}</a></uui-table-cell>
80
+ <uui-table-cell>${item.workflow.name}</uui-table-cell>
81
+ <uui-table-cell>${item.action === 'unpublish' ? 'Taking it down' : 'Publishing'}</uui-table-cell>
82
+ <uui-table-cell>
83
+ <uui-tag color=${color}>${label}</uui-tag>
84
+ ${waiting.length > 0 ? html`<small>at ${waiting.join(', ')}</small>` : nothing}
85
+ </uui-table-cell>
86
+ <uui-table-cell>${new Date(item.requestedAt).toLocaleString()}</uui-table-cell>
87
+ </uui-table-row>`
88
+ })}
89
+ </uui-table>`
90
+ }
91
+ ${
92
+ this._total !== undefined && this._total > this._items.length + this._skip
93
+ ? html`<uui-button look="secondary" label="Older" @click=${() => {
94
+ this._skip += this._items.length
95
+ this.#load()
96
+ }}>Older</uui-button>`
97
+ : nothing
98
+ }
99
+ </uui-box>`
100
+ }
101
+
102
+ static styles = css`
103
+ :host { display: block; padding: var(--uui-size-layout-1); }
104
+ small { margin-left: var(--uui-size-space-2); opacity: 0.8; }
105
+ `
106
+ }
107
+
108
+ customElements.define(
109
+ 'crombolo-workflow-waiting',
110
+ class extends CromboloWorkflowListElement {
111
+ get headline() {
112
+ return 'Waiting for my approval'
113
+ }
114
+ get empty() {
115
+ return 'Nothing is waiting for your approval.'
116
+ }
117
+ fetch() {
118
+ return waitingForMe()
119
+ }
120
+ },
121
+ )
122
+
123
+ customElements.define(
124
+ 'crombolo-workflow-in-flight',
125
+ class extends CromboloWorkflowListElement {
126
+ get headline() {
127
+ return 'In flight'
128
+ }
129
+ get empty() {
130
+ return 'No page is waiting for approval.'
131
+ }
132
+ fetch() {
133
+ return inFlight()
134
+ }
135
+ },
136
+ )
137
+
138
+ customElements.define(
139
+ 'crombolo-workflow-history',
140
+ class extends CromboloWorkflowListElement {
141
+ get headline() {
142
+ return 'Decided'
143
+ }
144
+ get empty() {
145
+ return 'No request has been decided yet.'
146
+ }
147
+ fetch(skip) {
148
+ return finished(skip, 25)
149
+ }
150
+ },
151
+ )
@@ -0,0 +1,219 @@
1
+ /**
2
+ * A page's Workflow tab (story 028): which workflow covers it, where a request
3
+ * stands in its graph, what the reader may do about it, and its history.
4
+ *
5
+ * Sending for approval sends what is saved: the editor saves first, as they
6
+ * would before publishing.
7
+ */
8
+ import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/document'
9
+ import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'
10
+ import { css, html, LitElement, nothing, unsafeCSS } from '@umbraco-cms/backoffice/external/lit'
11
+ import { decide, pageApproval, sendForApproval } from './workflow-client.js'
12
+ import { drawGraph, GRAPH_STYLES } from './workflow-graph.js'
13
+
14
+ const KIND = {
15
+ sent: 'Sent for approval',
16
+ approved: 'Approved',
17
+ 'sent-back': 'Sent back',
18
+ withdrawn: 'Withdrawn',
19
+ edited: 'Changed while waiting',
20
+ restarted: 'Started again',
21
+ published: 'Published',
22
+ unpublished: 'Taken down',
23
+ }
24
+ const STATE = {
25
+ waiting: 'Waiting for approval',
26
+ approved: 'Approved',
27
+ 'sent-back': 'Sent back',
28
+ withdrawn: 'Withdrawn',
29
+ }
30
+
31
+ export default class CromboloWorkflowTabElement extends UmbElementMixin(LitElement) {
32
+ static properties = {
33
+ _key: { state: true },
34
+ _data: { state: true },
35
+ _message: { state: true },
36
+ _busy: { state: true },
37
+ _comment: { state: true },
38
+ }
39
+
40
+ constructor() {
41
+ super()
42
+ this._comment = ''
43
+ this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => {
44
+ if (!context) return
45
+ this.observe(context.unique, (unique) => {
46
+ if (!unique || unique === this._key) return
47
+ this._key = unique
48
+ this.#load()
49
+ })
50
+ })
51
+ }
52
+
53
+ async #load() {
54
+ const result = await pageApproval(this._key)
55
+ this._data = result.ok ? result : undefined
56
+ if (!result.ok) this._message = result.message
57
+ }
58
+
59
+ async #act(run) {
60
+ this._busy = true
61
+ this._message = undefined
62
+ const result = await run()
63
+ this._busy = false
64
+ if (!result.ok) {
65
+ this._message = result.message ?? 'That did not work.'
66
+ return
67
+ }
68
+ this._comment = ''
69
+ await this.#load()
70
+ }
71
+
72
+ render() {
73
+ if (!this._data)
74
+ return html`<uui-box headline="Workflow">${this._message ?? html`<uui-loader></uui-loader>`}</uui-box>`
75
+ const { workflow, request, past, can } = this._data
76
+ return html`
77
+ <uui-box headline=${workflow ? `Workflow: ${workflow.name}` : 'Workflow'}>
78
+ ${
79
+ workflow
80
+ ? html`<p class="lead">
81
+ ${
82
+ can.publishDirectly
83
+ ? 'This page is covered by this workflow, and you may publish it directly.'
84
+ : 'Publishing this page goes through this workflow.'
85
+ }
86
+ </p>`
87
+ : html`<p class="lead">No workflow covers this page: it publishes as soon as someone who may publish does.</p>`
88
+ }
89
+ ${this._message ? html`<p class="message">${this._message}</p>` : nothing}
90
+ ${request ? this.#renderRequest(request) : workflow ? this.#renderSend(workflow, can) : nothing}
91
+ </uui-box>
92
+ ${past.length > 0 ? html`<uui-box headline="Earlier requests">${past.map((r) => this.#renderPast(r))}</uui-box>` : nothing}
93
+ `
94
+ }
95
+
96
+ #renderSend(workflow, can) {
97
+ if (!can.send && !can.requestUnpublish) return nothing
98
+ return html`
99
+ <div class="graph-wrap">${drawGraph(workflow.stages)}</div>
100
+ <uui-textarea
101
+ label="A note for the approvers"
102
+ placeholder="A note for the approvers (optional)"
103
+ .value=${this._comment}
104
+ @input=${(e) => {
105
+ this._comment = e.target.value
106
+ }}
107
+ ></uui-textarea>
108
+ <div class="actions">
109
+ ${
110
+ can.send
111
+ ? html`<uui-button look="primary" ?disabled=${this._busy} label="Send for approval"
112
+ @click=${() => this.#act(() => sendForApproval(this._key, 'publish', this._comment))}
113
+ >Send for approval</uui-button>`
114
+ : nothing
115
+ }
116
+ ${
117
+ can.requestUnpublish
118
+ ? html`<uui-button look="secondary" ?disabled=${this._busy} label="Request unpublish"
119
+ @click=${() => this.#act(() => sendForApproval(this._key, 'unpublish', this._comment))}
120
+ >Request unpublish</uui-button>`
121
+ : nothing
122
+ }
123
+ </div>
124
+ `
125
+ }
126
+
127
+ #renderRequest(request) {
128
+ const states = Object.fromEntries(request.stages.map((s) => [s.alias, s.state]))
129
+ const progress = request.stages
130
+ .filter((s) => s.state === 'waiting')
131
+ .map((s) => `${s.alias}: ${s.approvals} of ${s.needed}`)
132
+ .join(' · ')
133
+ return html`
134
+ <p>
135
+ <uui-tag color="warning">${STATE[request.state]}</uui-tag>
136
+ ${request.action === 'unpublish' ? 'To take this page down.' : 'To publish this page.'}
137
+ ${progress ? html`<small>${progress}</small>` : nothing}
138
+ </p>
139
+ <div class="graph-wrap">${drawGraph(request.workflow.stages, { states })}</div>
140
+ ${
141
+ request.can.approve.length > 0 || request.can.sendBack.length > 0 || request.can.withdraw
142
+ ? html`
143
+ <uui-textarea
144
+ label="Comment"
145
+ placeholder=${request.workflow.comments === 'optional' ? 'A comment (optional)' : 'A comment'}
146
+ .value=${this._comment}
147
+ @input=${(e) => {
148
+ this._comment = e.target.value
149
+ }}
150
+ ></uui-textarea>
151
+ <div class="actions">
152
+ ${request.can.approve.map(
153
+ (
154
+ stage,
155
+ ) => html`<uui-button look="primary" color="positive" ?disabled=${this._busy}
156
+ label="Approve"
157
+ @click=${() => this.#act(() => decide(request.key, 'approve', { stage, comment: this._comment }))}
158
+ >Approve${request.can.approve.length > 1 ? ` (${stage})` : ''}</uui-button>`,
159
+ )}
160
+ ${request.can.sendBack.map(
161
+ (
162
+ stage,
163
+ ) => html`<uui-button look="secondary" color="danger" ?disabled=${this._busy}
164
+ label="Send back"
165
+ @click=${() => this.#act(() => decide(request.key, 'send-back', { stage, comment: this._comment }))}
166
+ >Send back${request.can.sendBack.length > 1 ? ` (${stage})` : ''}</uui-button>`,
167
+ )}
168
+ ${
169
+ request.can.withdraw
170
+ ? html`<uui-button look="secondary" ?disabled=${this._busy} label="Withdraw"
171
+ @click=${() => this.#act(() => decide(request.key, 'withdraw', {}))}
172
+ >Withdraw</uui-button>`
173
+ : nothing
174
+ }
175
+ </div>
176
+ `
177
+ : nothing
178
+ }
179
+ ${this.#renderHistory(request)}
180
+ `
181
+ }
182
+
183
+ #renderPast(request) {
184
+ return html`<details>
185
+ <summary>${STATE[request.state]} — ${new Date(request.requestedAt).toLocaleString()}</summary>
186
+ ${this.#renderHistory(request)}
187
+ </details>`
188
+ }
189
+
190
+ #renderHistory(request) {
191
+ return html`<ol class="history">
192
+ ${request.history.map(
193
+ (h) => html`<li>
194
+ <strong>${KIND[h.kind] ?? h.kind}</strong>
195
+ ${h.stage ? html`<span>at ${h.stage}</span>` : nothing}
196
+ <small>${new Date(h.at).toLocaleString()}</small>
197
+ ${h.comment ? html`<blockquote>${h.comment}</blockquote>` : nothing}
198
+ </li>`,
199
+ )}
200
+ </ol>`
201
+ }
202
+
203
+ static styles = css`
204
+ :host { display: block; padding: var(--uui-size-layout-1); }
205
+ uui-box + uui-box { margin-top: var(--uui-size-space-5); }
206
+ .lead { margin-top: 0; }
207
+ .message { color: var(--uui-color-danger); }
208
+ .graph-wrap { overflow-x: auto; margin: var(--uui-size-space-4) 0; }
209
+ .actions { display: flex; gap: var(--uui-size-space-3); margin: var(--uui-size-space-3) 0; }
210
+ uui-textarea { width: 100%; }
211
+ .history { padding-left: var(--uui-size-space-5); }
212
+ .history li { margin-bottom: var(--uui-size-space-2); }
213
+ .history small { opacity: 0.7; margin-left: var(--uui-size-space-2); }
214
+ blockquote { margin: var(--uui-size-space-1) 0 0; padding-left: var(--uui-size-space-3); border-left: 3px solid var(--uui-color-border); }
215
+ ${unsafeCSS(GRAPH_STYLES)}
216
+ `
217
+ }
218
+
219
+ customElements.define('crombolo-workflow-tab', CromboloWorkflowTabElement)