@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.
@@ -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
+ `