@crombolo/backoffice-host 0.12.1 → 0.13.2

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.
@@ -0,0 +1,565 @@
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, nothing, unsafeCSS } from '@umbraco-cms/backoffice/external/lit'
13
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
14
+ import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'
15
+ import { deleteWorkflow, listWorkflows, previewWorkflow, saveWorkflow } from './workflow-client.js'
16
+ import { ancestors, BOX, drawGraph, GRAPH_STYLES, layout, wouldLoop } from './workflow-graph.js'
17
+
18
+ const ON_EDIT = {
19
+ withdraw: 'Saving withdraws the request; the editor sends it again',
20
+ 'approve-latest': 'Saving goes into the request; approvers approve the latest',
21
+ lock: 'The page cannot be changed while it waits',
22
+ 'lock-after-first-approval': 'The page can be changed until someone approves',
23
+ }
24
+ const SELF = {
25
+ refused: 'Someone else must approve it',
26
+ allowed: 'They may approve it like anyone in the group',
27
+ automatic: 'Their approval counts automatically where they are in the group',
28
+ }
29
+ const COMMENTS = {
30
+ optional: 'Optional',
31
+ 'on-send-back': 'Required when sending back',
32
+ always: 'Required with every decision',
33
+ }
34
+ const ON_CHANGE = {
35
+ keep: 'They finish under the version they started with',
36
+ restart: 'They start again under the new version',
37
+ withdraw: 'They are withdrawn, and sent again',
38
+ }
39
+
40
+ const blank = () => ({
41
+ alias: 'new-workflow',
42
+ name: 'New workflow',
43
+ appliesTo: { branches: [], contentTypes: [] },
44
+ publishersMaySkip: true,
45
+ onEdit: 'withdraw',
46
+ selfApproval: 'refused',
47
+ comments: 'optional',
48
+ unpublish: false,
49
+ onChange: 'keep',
50
+ stages: [
51
+ {
52
+ alias: 'review',
53
+ name: 'Review',
54
+ approvers: [],
55
+ required: 'one',
56
+ next: [],
57
+ sendBack: 'editor',
58
+ position: { x: 0, y: 0 },
59
+ },
60
+ ],
61
+ })
62
+
63
+ const select = (
64
+ value,
65
+ options,
66
+ onChange,
67
+ ) => html`<select @change=${(e) => onChange(e.target.value)}>
68
+ ${Object.entries(options).map(([key, label]) => html`<option value=${key} ?selected=${key === value}>${label}</option>`)}
69
+ </select>`
70
+
71
+ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
72
+ static properties = {
73
+ _list: { state: true },
74
+ _editing: { state: true },
75
+ _originalAlias: { state: true },
76
+ _selected: { state: true },
77
+ _connecting: { state: true },
78
+ _problems: { state: true },
79
+ _affected: { state: true },
80
+ _message: { state: true },
81
+ }
82
+
83
+ #drag
84
+ #notifications
85
+
86
+ constructor() {
87
+ super()
88
+ this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
89
+ this.#notifications = context
90
+ })
91
+ }
92
+
93
+ connectedCallback() {
94
+ super.connectedCallback()
95
+ this.#load()
96
+ }
97
+
98
+ async #load() {
99
+ const result = await listWorkflows()
100
+ if (!result.ok) {
101
+ this._message = result.message
102
+ return
103
+ }
104
+ this._list = result
105
+ }
106
+
107
+ #edit(workflow) {
108
+ const copy = structuredClone(workflow)
109
+ // Every stage gets the place the canvas would draw it, so dragging starts from there.
110
+ const positions = layout(copy.stages)
111
+ for (const stage of copy.stages) stage.position = stage.position ?? positions.get(stage.alias)
112
+ this._editing = copy
113
+ this._originalAlias = workflow.alias
114
+ this._selected = copy.stages[0]?.alias
115
+ this._problems = undefined
116
+ this._affected = undefined
117
+ this._message = undefined
118
+ }
119
+
120
+ #touch() {
121
+ this._editing = { ...this._editing }
122
+ this._affected = undefined
123
+ }
124
+
125
+ #stage(alias) {
126
+ return this._editing.stages.find((s) => s.alias === alias)
127
+ }
128
+
129
+ #addStage() {
130
+ let n = this._editing.stages.length + 1
131
+ while (this.#stage(`stage-${n}`)) n++
132
+ const xs = this._editing.stages.map((s) => s.position?.x ?? 0)
133
+ this._editing.stages.push({
134
+ alias: `stage-${n}`,
135
+ name: `Stage ${n}`,
136
+ approvers: [],
137
+ required: 'one',
138
+ next: [],
139
+ sendBack: 'editor',
140
+ position: { x: Math.max(0, ...xs) + BOX.width + 80, y: 0 },
141
+ })
142
+ this._selected = `stage-${n}`
143
+ this.#touch()
144
+ }
145
+
146
+ #removeStage(alias) {
147
+ this._editing.stages = this._editing.stages
148
+ .filter((s) => s.alias !== alias)
149
+ .map((s) => ({
150
+ ...s,
151
+ next: s.next.filter((n) => n !== alias),
152
+ sendBack: s.sendBack === alias ? 'editor' : s.sendBack,
153
+ }))
154
+ this._selected = this._editing.stages[0]?.alias
155
+ this.#touch()
156
+ }
157
+
158
+ #renameStage(stage, alias) {
159
+ const clean = alias.trim().replace(/[^A-Za-z0-9_-]/g, '-')
160
+ if (!clean || this.#stage(clean)) return
161
+ for (const other of this._editing.stages) {
162
+ other.next = other.next.map((n) => (n === stage.alias ? clean : n))
163
+ if (other.sendBack === stage.alias) other.sendBack = clean
164
+ }
165
+ stage.alias = clean
166
+ this._selected = clean
167
+ this.#touch()
168
+ }
169
+
170
+ #onStage(alias) {
171
+ if (this.#drag?.moved) return
172
+ if (!this._connecting) {
173
+ this._selected = alias
174
+ return
175
+ }
176
+ if (this._connecting === true) {
177
+ this._connecting = alias
178
+ return
179
+ }
180
+ const from = this._connecting
181
+ this._connecting = undefined
182
+ if (wouldLoop(this._editing.stages, from, alias)) {
183
+ this._message = `An arrow from ${from} to ${alias} would make the stages loop, so it was not drawn.`
184
+ return
185
+ }
186
+ const source = this.#stage(from)
187
+ if (!source.next.includes(alias)) source.next = [...source.next, alias]
188
+ this._message = undefined
189
+ this.#touch()
190
+ }
191
+
192
+ #onArrow(from, to) {
193
+ const source = this.#stage(from)
194
+ source.next = source.next.filter((n) => n !== to)
195
+ const target = this.#stage(to)
196
+ if (
197
+ target &&
198
+ !ancestors(this._editing.stages, to).has(target.sendBack) &&
199
+ target.sendBack !== 'editor'
200
+ )
201
+ target.sendBack = 'editor'
202
+ this.#touch()
203
+ }
204
+
205
+ #pointerDown(alias, event) {
206
+ if (this._connecting) return
207
+ const stage = this.#stage(alias)
208
+ this.#drag = {
209
+ alias,
210
+ startX: event.clientX,
211
+ startY: event.clientY,
212
+ from: { ...stage.position },
213
+ moved: false,
214
+ }
215
+ }
216
+
217
+ #pointerMove(event) {
218
+ if (!this.#drag) return
219
+ const stage = this.#stage(this.#drag.alias)
220
+ const dx = event.clientX - this.#drag.startX
221
+ const dy = event.clientY - this.#drag.startY
222
+ if (Math.abs(dx) + Math.abs(dy) > 3) this.#drag.moved = true
223
+ stage.position = {
224
+ x: Math.max(0, Math.round(this.#drag.from.x + dx)),
225
+ y: Math.max(0, Math.round(this.#drag.from.y + dy)),
226
+ }
227
+ this._editing = { ...this._editing }
228
+ }
229
+
230
+ #pointerUp() {
231
+ const drag = this.#drag
232
+ // The click that follows a drag selects nothing.
233
+ setTimeout(() => {
234
+ if (this.#drag === drag) this.#drag = undefined
235
+ })
236
+ }
237
+
238
+ async #save(confirmed = false) {
239
+ this._problems = undefined
240
+ if (!confirmed) {
241
+ const preview = await previewWorkflow(
242
+ this._originalAlias ?? this._editing.alias,
243
+ this._editing,
244
+ )
245
+ if (!preview.ok) {
246
+ this._problems = preview.problems ?? [{ message: preview.message }]
247
+ return
248
+ }
249
+ if (preview.affected.length > 0) {
250
+ this._affected = preview.affected
251
+ return
252
+ }
253
+ }
254
+ const saved = await saveWorkflow(this._originalAlias ?? this._editing.alias, this._editing)
255
+ if (!saved.ok) {
256
+ this._problems = saved.problems ?? [{ message: saved.message }]
257
+ return
258
+ }
259
+ this._affected = undefined
260
+ // The green toast every workspace shows on a save.
261
+ this.#notifications?.peek('positive', { data: { message: 'Workflow saved' } })
262
+ this._originalAlias = this._editing.alias
263
+ await this.#load()
264
+ }
265
+
266
+ /** From the list or the editor: the workflow by its name and the alias its file has. */
267
+ async #delete(name, alias) {
268
+ if (
269
+ !window.confirm(
270
+ `Delete the workflow "${name}"? Pages it covers will publish without approval.`,
271
+ )
272
+ )
273
+ return
274
+ const result = await deleteWorkflow(alias)
275
+ if (!result.ok) {
276
+ this._message = result.message
277
+ return
278
+ }
279
+ this._editing = undefined
280
+ await this.#load()
281
+ }
282
+
283
+ render() {
284
+ if (this._message && !this._list)
285
+ return html`<div class="list"><uui-box headline="Workflows">${this._message}</uui-box></div>`
286
+ if (!this._list)
287
+ return html`<div class="list"><uui-box headline="Workflows"><uui-loader></uui-loader></uui-box></div>`
288
+ if (!this._editing) return this.#renderList()
289
+ return this.#renderEditor()
290
+ }
291
+
292
+ #renderList() {
293
+ const { items, editable } = this._list
294
+ // The create button above the list, as the Created bundles view has it.
295
+ return html`<div class="list">
296
+ ${
297
+ editable
298
+ ? html`<uui-button class="create" color="default" look="outline" label="Create" @click=${() => this.#edit(blank())}>Create</uui-button>`
299
+ : nothing
300
+ }
301
+ ${this._message ? html`<uui-box><p class="message">${this._message}</p></uui-box>` : nothing}
302
+ <uui-box headline="Workflows">
303
+ ${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>`}
304
+ ${
305
+ items.length === 0
306
+ ? html`<p>No workflow yet: every page publishes as soon as someone who may publish does.</p>`
307
+ : html`<uui-table>
308
+ <uui-table-head>
309
+ <uui-table-head-cell>Workflow</uui-table-head-cell>
310
+ <uui-table-head-cell>Covers</uui-table-head-cell>
311
+ <uui-table-head-cell>Stages</uui-table-head-cell>
312
+ <uui-table-head-cell></uui-table-head-cell>
313
+ </uui-table-head>
314
+ ${items.map(
315
+ (w) => html`<uui-table-row>
316
+ <uui-table-cell>${w.name}</uui-table-cell>
317
+ <uui-table-cell>${[
318
+ w.appliesTo.branches.length ? `${w.appliesTo.branches.length} branch(es)` : '',
319
+ ...w.appliesTo.contentTypes,
320
+ ]
321
+ .filter(Boolean)
322
+ .join(', ')}</uui-table-cell>
323
+ <uui-table-cell>${w.stages.length}</uui-table-cell>
324
+ <uui-table-cell>
325
+ <uui-button look="secondary" label=${editable ? 'Edit' : 'View'} @click=${() => this.#edit(w)}></uui-button>
326
+ ${
327
+ editable
328
+ ? html`<uui-button look="secondary" color="danger" label="Delete" @click=${() => this.#delete(w.name, w.alias)}></uui-button>`
329
+ : nothing
330
+ }
331
+ </uui-table-cell>
332
+ </uui-table-row>`,
333
+ )}
334
+ </uui-table>`
335
+ }
336
+ </uui-box>
337
+ </div>`
338
+ }
339
+
340
+ #renderEditor() {
341
+ const w = this._editing
342
+ const editable = this._list.editable
343
+ const stage = this.#stage(this._selected)
344
+ const leave = () => {
345
+ this._editing = undefined
346
+ this._message = undefined
347
+ this._problems = undefined
348
+ }
349
+ // Umbraco's own layout, so Save sits bottom right in the footer as it does
350
+ // in every workspace, with Back beside it.
351
+ return html`<umb-body-layout headline=${w.name || 'Workflow'}>
352
+ <uui-box>
353
+ ${
354
+ editable
355
+ ? html`<div class="toolbar">
356
+ <uui-button look="secondary" label="Add a stage" @click=${() => this.#addStage()}>Add a stage</uui-button>
357
+ <uui-button look=${this._connecting ? 'primary' : 'secondary'} label="Connect stages"
358
+ @click=${() => {
359
+ this._connecting = this._connecting ? undefined : true
360
+ }}
361
+ >${this._connecting === true ? 'Click the stage to draw from' : this._connecting ? 'Now click the stage it leads to' : 'Connect stages'}</uui-button>
362
+ </div>`
363
+ : nothing
364
+ }
365
+ ${this._message ? html`<p class="message">${this._message}</p>` : nothing}
366
+ <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>
367
+ <div class="canvas" @pointermove=${(e) => editable && this.#pointerMove(e)} @pointerup=${() => this.#pointerUp()} @pointerleave=${() => this.#pointerUp()}>
368
+ ${drawGraph(w.stages, {
369
+ selected: this._selected,
370
+ onStage: (alias) => this.#onStage(alias),
371
+ onArrow: (from, to) => editable && this.#onArrow(from, to),
372
+ onPointerDown: (alias, event) => editable && this.#pointerDown(alias, event),
373
+ })}
374
+ </div>
375
+ </uui-box>
376
+ <div class="panels">
377
+ ${stage ? this.#renderStage(stage, editable) : nothing}
378
+ ${this.#renderSettings(w, editable)}
379
+ </div>
380
+ ${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}
381
+ ${
382
+ this._affected
383
+ ? html`<div class="affected">
384
+ <p>Saving changes the rules for ${this._affected.length} page(s) waiting for approval:</p>
385
+ <ul>${this._affected.map((a) => html`<li>${a.page}: ${ON_CHANGE[a.outcome]}</li>`)}</ul>
386
+ <uui-button look="primary" label="Save anyway" @click=${() => this.#save(true)}>Save anyway</uui-button>
387
+ </div>`
388
+ : nothing
389
+ }
390
+ ${
391
+ this._problems
392
+ ? html`<span slot="footer-info" class="not-saved">Not saved: ${this._problems.length} problem(s), listed above</span>`
393
+ : nothing
394
+ }
395
+ <uui-button slot="actions" look="secondary" label="Back to the list" @click=${leave}>Back to the list</uui-button>
396
+ ${
397
+ editable
398
+ ? html`<uui-button slot="actions" look="primary" color="positive" label="Save" @click=${() => this.#save()}>Save</uui-button>`
399
+ : nothing
400
+ }
401
+ </umb-body-layout>`
402
+ }
403
+
404
+ #renderStage(stage, editable) {
405
+ const earlier = [...ancestors(this._editing.stages, stage.alias)]
406
+ const requiredKind = typeof stage.required === 'number' ? 'number' : stage.required
407
+ return html`<uui-box headline=${`Stage: ${stage.name || stage.alias}`}>
408
+ <fieldset ?disabled=${!editable}>
409
+ <label>Name <uui-input label="Name" .value=${stage.name ?? ''} @change=${(e) => {
410
+ stage.name = e.target.value
411
+ this.#touch()
412
+ }}></uui-input></label>
413
+ <label>Alias <uui-input label="Alias" .value=${stage.alias} @change=${(e) => this.#renameStage(stage, e.target.value)}></uui-input></label>
414
+ <div class="field"><span>Approved by</span>
415
+ ${this._list.groups.map(
416
+ (g) =>
417
+ html`<label class="check"><input type="checkbox" .checked=${stage.approvers.includes(g.alias)} @change=${(
418
+ e,
419
+ ) => {
420
+ stage.approvers = e.target.checked
421
+ ? [...stage.approvers, g.alias]
422
+ : stage.approvers.filter((a) => a !== g.alias)
423
+ this.#touch()
424
+ }} /> ${g.name}</label>`,
425
+ )}
426
+ </div>
427
+ <label>How many must approve
428
+ ${select(
429
+ requiredKind,
430
+ {
431
+ one: 'Any one of them',
432
+ majority: 'A majority',
433
+ all: 'All of them',
434
+ number: 'A number of them',
435
+ },
436
+ (value) => {
437
+ stage.required = value === 'number' ? 2 : value
438
+ this.#touch()
439
+ },
440
+ )}
441
+ ${
442
+ typeof stage.required === 'number'
443
+ ? html`<input type="number" min="1" .value=${String(stage.required)} @change=${(
444
+ e,
445
+ ) => {
446
+ stage.required = Math.max(1, Number(e.target.value) || 1)
447
+ this.#touch()
448
+ }} />`
449
+ : nothing
450
+ }
451
+ </label>
452
+ <label>Sending back goes to
453
+ ${select(
454
+ stage.sendBack,
455
+ {
456
+ editor: 'The editor (the request ends)',
457
+ ...Object.fromEntries(earlier.map((a) => [a, `The ${a} stage`])),
458
+ },
459
+ (value) => {
460
+ stage.sendBack = value
461
+ this.#touch()
462
+ },
463
+ )}
464
+ </label>
465
+ ${
466
+ editable && this._editing.stages.length > 1
467
+ ? html`<uui-button look="secondary" color="danger" label="Remove stage" @click=${() => this.#removeStage(stage.alias)}>Remove this stage</uui-button>`
468
+ : nothing
469
+ }
470
+ </fieldset>
471
+ </uui-box>`
472
+ }
473
+
474
+ #renderSettings(w, editable) {
475
+ return html`<uui-box headline="Settings">
476
+ <fieldset ?disabled=${!editable}>
477
+ <label>Name <uui-input label="Name" .value=${w.name} @change=${(e) => {
478
+ w.name = e.target.value
479
+ this.#touch()
480
+ }}></uui-input></label>
481
+ <label>Alias <uui-input label="Alias" .value=${w.alias} @change=${(e) => {
482
+ w.alias = e.target.value.trim()
483
+ this.#touch()
484
+ }}></uui-input></label>
485
+ <div class="field"><span>Covers the pages under</span>
486
+ <umb-input-document
487
+ .selection=${w.appliesTo.branches}
488
+ @change=${(e) => {
489
+ w.appliesTo.branches = [...(e.target.selection ?? [])]
490
+ this.#touch()
491
+ }}
492
+ ></umb-input-document>
493
+ </div>
494
+ <div class="field"><span>And every page of these types</span>
495
+ ${this._list.contentTypes.map(
496
+ (t) =>
497
+ html`<label class="check"><input type="checkbox" .checked=${w.appliesTo.contentTypes.includes(t.alias)} @change=${(
498
+ e,
499
+ ) => {
500
+ w.appliesTo.contentTypes = e.target.checked
501
+ ? [...w.appliesTo.contentTypes, t.alias]
502
+ : w.appliesTo.contentTypes.filter((a) => a !== t.alias)
503
+ this.#touch()
504
+ }} /> ${t.name}</label>`,
505
+ )}
506
+ </div>
507
+ <label class="check"><input type="checkbox" .checked=${w.publishersMaySkip} @change=${(
508
+ e,
509
+ ) => {
510
+ w.publishersMaySkip = e.target.checked
511
+ this.#touch()
512
+ }} /> People who may publish can publish directly</label>
513
+ <label class="check"><input type="checkbox" .checked=${w.unpublish} @change=${(e) => {
514
+ w.unpublish = e.target.checked
515
+ this.#touch()
516
+ }} /> Taking a page down needs approval too</label>
517
+ <label>When a waiting page is changed ${select(w.onEdit, ON_EDIT, (v) => {
518
+ w.onEdit = v
519
+ this.#touch()
520
+ })}</label>
521
+ <label>When the person who made the change could approve it ${select(
522
+ w.selfApproval,
523
+ SELF,
524
+ (v) => {
525
+ w.selfApproval = v
526
+ this.#touch()
527
+ },
528
+ )}</label>
529
+ <label>Comments ${select(w.comments, COMMENTS, (v) => {
530
+ w.comments = v
531
+ this.#touch()
532
+ })}</label>
533
+ <label>When this workflow changes, pages waiting on it ${select(
534
+ w.onChange,
535
+ ON_CHANGE,
536
+ (v) => {
537
+ w.onChange = v
538
+ this.#touch()
539
+ },
540
+ )}</label>
541
+ </fieldset>
542
+ </uui-box>`
543
+ }
544
+
545
+ static styles = css`
546
+ :host { display: block; height: 100%; }
547
+ .list { padding: var(--uui-size-layout-1); }
548
+ .create { margin-bottom: var(--uui-size-space-5); }
549
+ .toolbar { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-4); }
550
+ .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; }
551
+ .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); }
552
+ fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--uui-size-space-4); }
553
+ label, .field { display: flex; flex-direction: column; gap: var(--uui-size-space-2); }
554
+ label.check { flex-direction: row; align-items: center; }
555
+ .field > span { font-weight: 700; }
556
+ .hint { opacity: 0.8; font-size: 0.9em; }
557
+ .message { color: var(--uui-color-warning-emphasis, #9e7300); }
558
+ .problems { color: var(--uui-color-danger); margin-top: var(--uui-size-space-5); }
559
+ .not-saved { color: var(--uui-color-danger); }
560
+ .affected { margin-top: var(--uui-size-space-5); border: 1px solid var(--uui-color-warning); padding: var(--uui-size-space-4); border-radius: 6px; margin-bottom: var(--uui-size-space-4); }
561
+ ${unsafeCSS(GRAPH_STYLES)}
562
+ `
563
+ }
564
+
565
+ 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
+ `