@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 +2 -2
- package/plugin/addresses-dashboard.js +100 -0
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/bundles-installed.js +22 -9
- package/plugin/bundles-marketplace.js +12 -2
- 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 +142 -1
- package/plugin/webhook-storage.js +107 -0
- package/plugin/welcome-dashboard.js +1 -1
- package/plugin/workflow-client.js +40 -0
- package/plugin/workflow-designer.js +529 -0
- package/plugin/workflow-graph.js +132 -0
- package/plugin/workflow-requests.js +151 -0
- package/plugin/workflow-tab.js +219 -0
|
@@ -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
|
+
`
|