@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,164 @@
1
+ /**
2
+ * The Workflows 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, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
8
+ import { finished, inFlight, waitingForMe } from './workflow-client.js'
9
+
10
+ const STATE = {
11
+ waiting: ['warning', 'Waiting'],
12
+ approved: ['positive', 'Approved'],
13
+ 'sent-back': ['danger', 'Sent back'],
14
+ withdrawn: ['default', 'Withdrawn'],
15
+ }
16
+
17
+ /** The page's Workflow tab: the editor's route is `edit/<key>/<variant>/view/<tab>`. */
18
+ const pageLink = (item) => {
19
+ const base = (document.querySelector('base')?.getAttribute('href') ?? '/umbraco/').replace(
20
+ /\/$/,
21
+ '',
22
+ )
23
+ return `${base}/section/content/workspace/document/edit/${item.nodeKey}/${encodeURIComponent(item.variant ?? 'invariant')}/view/workflow`
24
+ }
25
+
26
+ /**
27
+ * Navigates within the backoffice, as Umbraco's own code does, so the page
28
+ * opens on its Workflow tab without reloading the app. A modified click still
29
+ * opens a new tab.
30
+ */
31
+ function openInBackoffice(event, href) {
32
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
33
+ event.preventDefault()
34
+ history.pushState(null, '', href)
35
+ }
36
+
37
+ class CromboloWorkflowListElement extends UmbLitElement {
38
+ static properties = {
39
+ _items: { state: true },
40
+ _message: { state: true },
41
+ _total: { state: true },
42
+ _skip: { state: true },
43
+ }
44
+
45
+ /** Overridden: what this list is, and how it loads. */
46
+ get headline() {
47
+ return ''
48
+ }
49
+ get empty() {
50
+ return ''
51
+ }
52
+ async fetch(_skip) {
53
+ return { ok: true, items: [] }
54
+ }
55
+
56
+ connectedCallback() {
57
+ super.connectedCallback()
58
+ this._skip = 0
59
+ this.#load()
60
+ }
61
+
62
+ async #load() {
63
+ const result = await this.fetch(this._skip)
64
+ if (!result.ok) {
65
+ this._message = result.message
66
+ return
67
+ }
68
+ this._items = result.items
69
+ this._total = result.total
70
+ }
71
+
72
+ render() {
73
+ if (this._message) return html`<uui-box headline=${this.headline}>${this._message}</uui-box>`
74
+ if (!this._items)
75
+ return html`<uui-box headline=${this.headline}><uui-loader></uui-loader></uui-box>`
76
+ return html`<uui-box headline=${this.headline}>
77
+ ${
78
+ this._items.length === 0
79
+ ? html`<p>${this.empty}</p>`
80
+ : html`<uui-table>
81
+ <uui-table-head>
82
+ <uui-table-head-cell>Page</uui-table-head-cell>
83
+ <uui-table-head-cell>Workflow</uui-table-head-cell>
84
+ <uui-table-head-cell>Asked for</uui-table-head-cell>
85
+ <uui-table-head-cell>Where it stands</uui-table-head-cell>
86
+ <uui-table-head-cell>Sent</uui-table-head-cell>
87
+ </uui-table-head>
88
+ ${this._items.map((item) => {
89
+ const [color, label] = STATE[item.state] ?? ['default', item.state]
90
+ const waiting = item.stages.filter((s) => s.state === 'waiting').map((s) => s.alias)
91
+ return html`<uui-table-row>
92
+ <uui-table-cell><a href=${pageLink(item)} @click=${(e) => openInBackoffice(e, pageLink(item))}>${item.nodeName}</a></uui-table-cell>
93
+ <uui-table-cell>${item.workflow.name}</uui-table-cell>
94
+ <uui-table-cell>${item.action === 'unpublish' ? 'Taking it down' : 'Publishing'}</uui-table-cell>
95
+ <uui-table-cell>
96
+ <uui-tag color=${color}>${label}</uui-tag>
97
+ ${waiting.length > 0 ? html`<small>at ${waiting.join(', ')}</small>` : nothing}
98
+ </uui-table-cell>
99
+ <uui-table-cell>${new Date(item.requestedAt).toLocaleString()}</uui-table-cell>
100
+ </uui-table-row>`
101
+ })}
102
+ </uui-table>`
103
+ }
104
+ ${
105
+ this._total !== undefined && this._total > this._items.length + this._skip
106
+ ? html`<uui-button look="secondary" label="Older" @click=${() => {
107
+ this._skip += this._items.length
108
+ this.#load()
109
+ }}>Older</uui-button>`
110
+ : nothing
111
+ }
112
+ </uui-box>`
113
+ }
114
+
115
+ static styles = css`
116
+ :host { display: block; padding: var(--uui-size-layout-1); }
117
+ small { margin-left: var(--uui-size-space-2); opacity: 0.8; }
118
+ `
119
+ }
120
+
121
+ customElements.define(
122
+ 'crombolo-workflow-waiting',
123
+ class extends CromboloWorkflowListElement {
124
+ get headline() {
125
+ return 'Waiting for my approval'
126
+ }
127
+ get empty() {
128
+ return 'Nothing is waiting for your approval.'
129
+ }
130
+ fetch() {
131
+ return waitingForMe()
132
+ }
133
+ },
134
+ )
135
+
136
+ customElements.define(
137
+ 'crombolo-workflow-in-flight',
138
+ class extends CromboloWorkflowListElement {
139
+ get headline() {
140
+ return 'In flight'
141
+ }
142
+ get empty() {
143
+ return 'No page is waiting for approval.'
144
+ }
145
+ fetch() {
146
+ return inFlight()
147
+ }
148
+ },
149
+ )
150
+
151
+ customElements.define(
152
+ 'crombolo-workflow-history',
153
+ class extends CromboloWorkflowListElement {
154
+ get headline() {
155
+ return 'Decided'
156
+ }
157
+ get empty() {
158
+ return 'No request has been decided yet.'
159
+ }
160
+ fetch(skip) {
161
+ return finished(skip, 25)
162
+ }
163
+ },
164
+ )
@@ -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)