@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 +2 -2
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/bundles-installed.js +22 -9
- package/plugin/bundles-marketplace.js +12 -2
- package/plugin/localizations/umbraco-package.json +1 -1
- package/plugin/seo/umbraco-package.json +1 -1
- package/plugin/umbraco-package.json +107 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/backoffice-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Serves the Umbraco backoffice SPA: shell HTML, import map and package manifests.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@crombolo/core": "0.
|
|
34
|
+
"@crombolo/core": "0.13.1",
|
|
35
35
|
"@crombolo/backoffice-dist": "18.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
:
|
|
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",
|
|
4
4
|
"id": "Crombolo.Core",
|
|
5
|
-
"version": "0.
|
|
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)
|