@crombolo/backoffice-host 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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.13.1",
34
+ "@crombolo/core": "0.13.2",
35
35
  "@crombolo/backoffice-dist": "18.2.0"
36
36
  }
37
37
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Assistant",
4
4
  "id": "Crombolo.Assistant",
5
- "version": "0.13.1",
5
+ "version": "0.13.2",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The two labels Umbraco's bundle builder hard-codes.
2
+ * The two labels and two toasts Umbraco's bundle builder hard-codes.
3
3
  *
4
4
  * The builder workspace is vendored and its copy is English in the template
5
5
  * rather than dictionary keys, so there is no key to override: `Name of the
@@ -21,6 +21,16 @@ export const BUILDER_LABELS = [
21
21
  ['Package Content', 'Bundle content'],
22
22
  ]
23
23
 
24
+ /**
25
+ * The builder's toasts on saving a definition. Shown through the notification
26
+ * context, so they are corrected where every toast passes rather than in the
27
+ * element, whose `peek` call is its own.
28
+ */
29
+ export const BUILDER_TOASTS = [
30
+ ['Package saved', 'Bundle saved'],
31
+ ['Package updated', 'Bundle saved'],
32
+ ]
33
+
24
34
  /** The replacement for a vendored string, or undefined when it is not one of ours. */
25
35
  export function correctionFor(text) {
26
36
  if (typeof text !== 'string') return undefined
@@ -55,7 +65,26 @@ function patch(element) {
55
65
  return true
56
66
  }
57
67
 
68
+ // Loaded here rather than at the top, so the module's pure parts import
69
+ // anywhere — the tests read `BUILDER_LABELS` and `BUILDER_TOASTS` outside a browser.
70
+ async function correctToasts() {
71
+ const { UmbNotificationContext } = await import('@umbraco-cms/backoffice/notification')
72
+ const proto = UmbNotificationContext.prototype
73
+ const peek = proto.peek
74
+ if (typeof peek !== 'function') return
75
+ proto.peek = function correctedPeek(color, options) {
76
+ const message = options?.data?.message
77
+ const replacement = BUILDER_TOASTS.find(([before]) => before === message)?.[1]
78
+ return peek.call(
79
+ this,
80
+ color,
81
+ replacement ? { ...options, data: { ...options.data, message: replacement } } : options,
82
+ )
83
+ }
84
+ }
85
+
58
86
  export const onInit = () => {
87
+ correctToasts().catch(() => {})
59
88
  customElements
60
89
  .whenDefined('umb-workspace-package-builder')
61
90
  .then(patch)
@@ -66,7 +66,38 @@ class CromboloAuthProviderElement extends LitElement {
66
66
 
67
67
  if (!customElements.get(TAG)) customElements.define(TAG, CromboloAuthProviderElement)
68
68
 
69
+ /**
70
+ * Signing in again after a timeout closes its modal twice.
71
+ *
72
+ * `umb-app-auth-modal` submits when the session reports itself authorised, and
73
+ * `umb-auth-view` calls the same submit as its `onSuccess` once
74
+ * `makeAuthorizationRequest` returns. Whichever runs first closes the modal and
75
+ * destroys its value state, so the second's `value = { success: true }` throws
76
+ * `_subject is undefined` — and the view shows that as a sign-in error, though
77
+ * the editor is signed in. The second assignment has nothing left to say, so it
78
+ * is dropped. Patched on the element's prototype, as `tsx-editors.js` patches
79
+ * upstream, rather than in the vendored bundle.
80
+ */
81
+ async function guardTimedOutSignIn() {
82
+ const Modal = await customElements.whenDefined('umb-app-auth-modal')
83
+ let descriptor
84
+ for (let proto = Modal.prototype; proto && !descriptor; proto = Object.getPrototypeOf(proto))
85
+ descriptor = Object.getOwnPropertyDescriptor(proto, 'value')
86
+ if (!descriptor?.set) return
87
+ const { get, set } = descriptor
88
+ Object.defineProperty(Modal.prototype, 'value', {
89
+ configurable: true,
90
+ get,
91
+ set(value) {
92
+ if (this.__cromboloSubmitted) return
93
+ set.call(this, value)
94
+ this.__cromboloSubmitted = true
95
+ },
96
+ })
97
+ }
98
+
69
99
  export const onInit = (_host, extensionRegistry) => {
100
+ void guardTimedOutSignIn()
70
101
  extensionRegistry.unregister(VENDORED)
71
102
  extensionRegistry.register({
72
103
  type: 'authProvider',
@@ -101,7 +101,7 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
101
101
  version 1.0.0.
102
102
  </p>
103
103
  <uui-input
104
- label="Package name"
104
+ label="Name"
105
105
  placeholder=${pkg?.name ?? ''}
106
106
  .value=${this._draft.name}
107
107
  @input=${(event) => (this._draft = { ...this._draft, name: event.target.value })}></uui-input>
@@ -166,38 +166,47 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
166
166
  // look empty on every page change.
167
167
  return html`
168
168
  <uui-button
169
- look="primary"
169
+ color="default"
170
+ look="outline"
170
171
  href=${`${BUILDER_PATH}/create`}
171
- label=${this.localize.term('packager_createPackage')}></uui-button>
172
+ label="Create"></uui-button>
172
173
  ${
173
174
  this._loading
174
175
  ? html`<div id="loader"><uui-loader></uui-loader></div>`
175
176
  : this._items.length === 0
176
177
  ? html`<h2 id="empty">${this.localize.term('packager_noPackagesCreated')}</h2>`
177
178
  : html`
178
- <uui-box headline="Created bundles" style="--uui-box-default-padding:0;">
179
- <uui-ref-list>
179
+ <uui-box headline="Created bundles">
180
+ <uui-table>
181
+ <uui-table-head>
182
+ <uui-table-head-cell>Name</uui-table-head-cell>
183
+ <uui-table-head-cell>Downloads as</uui-table-head-cell>
184
+ <uui-table-head-cell></uui-table-head-cell>
185
+ </uui-table-head>
180
186
  ${repeat(
181
187
  this._items,
182
188
  (item) => item.unique,
183
189
  (item) => html`
184
- <uui-ref-node
185
- name=${item.name}
186
- detail=${this.#detail(item)}
187
- @open=${() => this.#open(item)}>
188
- <uui-action-bar slot="actions">
190
+ <uui-table-row>
191
+ <uui-table-cell>${item.name}</uui-table-cell>
192
+ <uui-table-cell><code>${this.#detail(item)}</code></uui-table-cell>
193
+ <uui-table-cell class="buttons">
194
+ <uui-button look="secondary" label="Edit" @click=${() => this.#open(item)}></uui-button>
189
195
  <uui-button
190
- @click=${() => this.#edit(item)}
191
- label="Package details"></uui-button>
196
+ look="secondary"
197
+ label="Name and version"
198
+ @click=${() => this.#edit(item)}></uui-button>
192
199
  <uui-button
193
- @click=${() => this.#remove(item)}
194
- label=${this.localize.term('general_delete')}></uui-button>
195
- </uui-action-bar>
196
- </uui-ref-node>
197
- ${this.#renderEditor(item)}
200
+ look="secondary"
201
+ color="danger"
202
+ label=${this.localize.term('general_delete')}
203
+ @click=${() => this.#remove(item)}></uui-button>
204
+ </uui-table-cell>
205
+ </uui-table-row>
198
206
  `,
199
207
  )}
200
- </uui-ref-list>
208
+ </uui-table>
209
+ ${this._items.map((item) => this.#renderEditor(item))}
201
210
  </uui-box>
202
211
  `
203
212
  }
@@ -238,6 +247,10 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
238
247
  .problem {
239
248
  color: var(--uui-color-danger);
240
249
  }
250
+ .buttons {
251
+ white-space: nowrap;
252
+ text-align: right;
253
+ }
241
254
  `
242
255
  }
243
256
 
@@ -10,6 +10,9 @@
10
10
  * What it lists instead is the site's bundle dependencies, which is what
11
11
  * "installed" means here: what each one adds, whether its content has been
12
12
  * added to this site, and whether its server features are switched on.
13
+ *
14
+ * Two columns: the bundles on the left, filtered as you type; the one chosen
15
+ * on the right, with its details and the button that uninstalls it.
13
16
  */
14
17
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
15
18
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
@@ -41,6 +44,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
41
44
  _busy: { state: true },
42
45
  _notice: { state: true },
43
46
  _installs: { state: true },
47
+ _filter: { state: true },
48
+ _selected: { state: true },
44
49
  }
45
50
 
46
51
  constructor() {
@@ -50,6 +55,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
50
55
  this._busy = ''
51
56
  this._notice = ''
52
57
  this._installs = true
58
+ this._filter = ''
59
+ this._selected = ''
53
60
  }
54
61
 
55
62
  connectedCallback() {
@@ -63,6 +70,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
63
70
  const result = await listInstalled()
64
71
  this._items = result.items ?? []
65
72
  this._installs = result.installs !== false
73
+ if (!this._items.some((item) => item.packageName === this._selected))
74
+ this._selected = this._items[0]?.packageName ?? ''
66
75
  } catch (error) {
67
76
  this._notice = error instanceof Error ? error.message : String(error)
68
77
  } finally {
@@ -83,72 +92,98 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
83
92
  }
84
93
  }
85
94
 
95
+ #pick(item) {
96
+ this._selected = item.packageName
97
+ this.requestUpdate()
98
+ }
99
+
100
+ get #shown() {
101
+ const words = this._filter.trim().toLowerCase()
102
+ if (!words) return this._items
103
+ return this._items.filter((item) =>
104
+ [item.name, item.packageName, item.description].some((text) =>
105
+ text?.toLowerCase().includes(words),
106
+ ),
107
+ )
108
+ }
109
+
86
110
  render() {
87
111
  if (this._loading) return html`<div id="loader"><uui-loader></uui-loader></div>`
88
-
112
+ if (this._items.length === 0)
113
+ return html`
114
+ ${this._notice ? html`<div id="notice">${this._notice}</div>` : nothing}
115
+ <uui-box headline="No bundles installed">
116
+ <p>Install one from the Marketplace tab, and it appears here.</p>
117
+ </uui-box>
118
+ `
119
+ const selected = this._items.find((item) => item.packageName === this._selected)
120
+ const shown = this.#shown
89
121
  return html`
90
122
  ${this._notice ? html`<div id="notice">${this._notice}</div>` : nothing}
123
+ <div id="columns">
124
+ <uui-box headline="Installed bundles" style="--uui-box-default-padding:0;">
125
+ <div id="filter">
126
+ <uui-input
127
+ label="Filter"
128
+ placeholder="Filter"
129
+ .value=${this._filter}
130
+ @input=${(event) => {
131
+ this._filter = event.target.value
132
+ }}>
133
+ <uui-icon name="icon-search" slot="prepend"></uui-icon>
134
+ </uui-input>
135
+ </div>
136
+ ${
137
+ shown.length === 0
138
+ ? html`<p class="empty">No bundle matches.</p>`
139
+ : html`<uui-ref-list>
140
+ ${repeat(
141
+ shown,
142
+ (item) => item.packageName,
143
+ (item) => html`<uui-ref-node
144
+ name=${item.name}
145
+ detail=${`${item.packageName} ${item.version}`}
146
+ selectable
147
+ select-only
148
+ ?selected=${item.packageName === this._selected}
149
+ @selected=${() => this.#pick(item)}
150
+ @deselected=${() => this.#pick(item)}
151
+ @open=${() => this.#pick(item)}></uui-ref-node>`,
152
+ )}
153
+ </uui-ref-list>`
154
+ }
155
+ </uui-box>
156
+ ${selected ? this.#details(selected) : nothing}
157
+ </div>
158
+ `
159
+ }
160
+
161
+ #details(item) {
162
+ return html`<uui-box headline=${item.name}>
163
+ ${item.description ? html`<p>${item.description}</p>` : nothing}
164
+ <dl>
165
+ <dt>npm package</dt>
166
+ <dd><code>${item.packageName}</code></dd>
167
+ <dt>Version</dt>
168
+ <dd>${item.version}</dd>
169
+ <dt>Adds</dt>
170
+ <dd class="carries">${carriesTags(item.carries)}</dd>
171
+ <dt>On this site</dt>
172
+ <dd class="status">${status(item)}</dd>
173
+ <dt>Works with</dt>
174
+ <dd>Crombolo ${item.peerRange}</dd>
175
+ </dl>
91
176
  ${
92
- this._items.length === 0
93
- ? html`
94
- <uui-box headline="No bundles installed">
95
- <p>
96
- A bundle is one of this site's npm dependencies. Install one from the
97
- Marketplace tab, or with <code>bun add</code> in the site directory, and it
98
- appears here.
99
- </p>
100
- </uui-box>
101
- `
102
- : html`
103
- <uui-box headline="Installed bundles">
104
- <uui-table>
105
- <uui-table-head>
106
- <uui-table-head-cell>Name</uui-table-head-cell>
107
- <uui-table-head-cell>npm package</uui-table-head-cell>
108
- <uui-table-head-cell>Version</uui-table-head-cell>
109
- <uui-table-head-cell>Adds</uui-table-head-cell>
110
- <uui-table-head-cell>On this site</uui-table-head-cell>
111
- <uui-table-head-cell></uui-table-head-cell>
112
- </uui-table-head>
113
- ${repeat(
114
- this._items,
115
- (item) => item.packageName,
116
- (item) => html`
117
- <uui-table-row>
118
- <uui-table-cell>${item.name}</uui-table-cell>
119
- <uui-table-cell><code>${item.packageName}</code></uui-table-cell>
120
- <uui-table-cell>${item.version}</uui-table-cell>
121
- <uui-table-cell class="carries">${carriesTags(item.carries)}</uui-table-cell>
122
- <uui-table-cell class="status">${status(item)}</uui-table-cell>
123
- <uui-table-cell>
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
- }
134
- </uui-table-cell>
135
- </uui-table-row>
136
- `,
137
- )}
138
- </uui-table>
139
- <p class="note">
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
- }
147
- </p>
148
- </uui-box>
149
- `
177
+ this._installs
178
+ ? html`<uui-button
179
+ look="primary"
180
+ color="danger"
181
+ label="Uninstall"
182
+ .state=${this._busy === item.packageName ? 'waiting' : undefined}
183
+ @click=${() => this.#uninstall(item)}></uui-button>`
184
+ : html`<p class="note">This site adds and removes bundles with a deploy, so every server has the same ones.</p>`
150
185
  }
151
- `
186
+ </uui-box>`
152
187
  }
153
188
 
154
189
  static styles = css`
@@ -167,10 +202,42 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
167
202
  border-left: 3px solid var(--uui-color-positive, #2bc37c);
168
203
  background: var(--uui-color-surface-alt, #f3f3f5);
169
204
  }
205
+ #columns {
206
+ display: grid;
207
+ grid-template-columns: minmax(240px, 1fr) 2fr;
208
+ gap: var(--uui-size-layout-1, 24px);
209
+ align-items: start;
210
+ }
211
+ @media (max-width: 800px) {
212
+ #columns {
213
+ grid-template-columns: 1fr;
214
+ }
215
+ }
216
+ #filter {
217
+ padding: var(--uui-size-space-4, 12px);
218
+ }
219
+ #filter uui-input {
220
+ width: 100%;
221
+ }
222
+ .empty {
223
+ padding: 0 var(--uui-size-space-4, 12px) var(--uui-size-space-4, 12px);
224
+ }
170
225
  p {
171
226
  max-width: 60ch;
172
227
  line-height: 1.5;
173
228
  }
229
+ dl {
230
+ display: grid;
231
+ grid-template-columns: max-content 1fr;
232
+ gap: var(--uui-size-space-3, 9px) var(--uui-size-space-5, 18px);
233
+ margin: 0 0 var(--uui-size-space-5, 18px);
234
+ }
235
+ dt {
236
+ font-weight: 700;
237
+ }
238
+ dd {
239
+ margin: 0;
240
+ }
174
241
  .carries {
175
242
  display: flex;
176
243
  gap: var(--uui-size-space-2, 6px);
@@ -179,7 +246,6 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
179
246
  display: block;
180
247
  }
181
248
  .note {
182
- margin-top: var(--uui-size-space-5, 18px);
183
249
  opacity: 0.8;
184
250
  font-size: 0.9em;
185
251
  }
@@ -141,7 +141,7 @@ export default class CromboloBundlesMarketplaceElement extends UmbLitElement {
141
141
  ${
142
142
  this._installs
143
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>`
144
+ : html`<div id="notice">Bundles 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
145
  }
146
146
  ${
147
147
  this._items.length === 0 && !this._error
@@ -15,6 +15,7 @@
15
15
  * kind allows, and the import runs once nothing is outstanding.
16
16
  */
17
17
  import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
18
+ import { downloadFile } from './download.js'
18
19
  import { answersUrl, fetchReport, transferAction } from './report-client.js'
19
20
 
20
21
  const KIND_LABEL = { blocking: 'Blocking', person: 'Needs a person', auto: 'Automatic' }
@@ -351,7 +352,13 @@ export default class CromboloChangesDashboardElement extends LitElement {
351
352
  })}
352
353
  >Import</uui-button>
353
354
  <uui-button look="secondary" ?disabled=${busy} label="Check again" @click=${() => this.#act(pending.bundleId, 'check')}>Check again</uui-button>
354
- <uui-button look="secondary" href=${answersUrl(pending.bundleId)} label="Download answers">Download answers</uui-button>
355
+ <uui-button look="secondary" label="Download answers" @click=${() =>
356
+ downloadFile(answersUrl(pending.bundleId), 'resolutions.json').catch((error) => {
357
+ this._outcome = {
358
+ ...this._outcome,
359
+ [pending.bundleId]: { ok: false, message: error.message },
360
+ }
361
+ })}>Download answers</uui-button>
355
362
  </div>
356
363
  ${outcome ? this.#renderOutcome(pending, outcome) : nothing}
357
364
  </div>`
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Saves a file the server serves, for a button that downloads one. Fetched
3
+ * rather than linked: the backoffice router takes over every link click, a
4
+ * `download` one included, and answers an address it has no page for with
5
+ * "route could not be found".
6
+ */
7
+ export async function downloadFile(url, fallbackName) {
8
+ const response = await fetch(url, { credentials: 'include' })
9
+ if (response.status === 403) throw new Error('You do not have permission for that.')
10
+ if (!response.ok) throw new Error(`The download responded ${response.status}.`)
11
+ const named = /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')
12
+ const objectUrl = URL.createObjectURL(await response.blob())
13
+ // Never added to the page, so its click reaches no router.
14
+ const link = document.createElement('a')
15
+ link.href = objectUrl
16
+ link.download = named?.[1] ?? fallbackName
17
+ link.click()
18
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
19
+ }
@@ -67,7 +67,7 @@ export default class CromboloFormPickerElement extends UmbLitElement {
67
67
  if (this._items.length === 0)
68
68
  return html`
69
69
  <p class="empty">
70
- This site has no forms. Add one as a TOML file in <code>schema/forms/</code>.
70
+ This site has no forms yet. Add one in the Forms section.
71
71
  </p>
72
72
  `
73
73
  return html`
@@ -2,6 +2,8 @@
2
2
  * Reads and drives the Forms endpoints the server keeps at
3
3
  * <backoffice>/crombolo/api/forms/*. The session cookie carries the auth.
4
4
  */
5
+ import { downloadFile } from './download.js'
6
+
5
7
  const base = () => {
6
8
  const href = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
7
9
  return `${href.replace(/\/$/, '')}/crombolo/api/forms`
@@ -57,3 +59,6 @@ export const deleteEntry = (id) => call(`/entries/${encodeURIComponent(id)}`, {
57
59
 
58
60
  /** The CSV is a download, so it is a link rather than a fetch. */
59
61
  export const entriesCsvUrl = (key) => `${base()}/${encodeURIComponent(key)}/entries.csv`
62
+
63
+ /** Downloads a form's entries as CSV. */
64
+ export const downloadEntriesCsv = (key) => downloadFile(entriesCsvUrl(key), 'entries.csv')
@@ -12,7 +12,7 @@ import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit
12
12
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
13
13
  import {
14
14
  deleteEntry,
15
- entriesCsvUrl,
15
+ downloadEntriesCsv,
16
16
  listEntries,
17
17
  listForms,
18
18
  setEntryState,
@@ -102,28 +102,31 @@ export default class CromboloFormsEntriesElement extends UmbLitElement {
102
102
  return html`
103
103
  <uui-box headline="Entries">
104
104
  <div class="filters">
105
- <select
105
+ <uui-select
106
+ label="Form"
107
+ .options=${this._forms.map((form) => ({
108
+ name: form.name,
109
+ value: form.key,
110
+ selected: form.key === this._selected,
111
+ }))}
106
112
  @change=${(event) => {
107
113
  this._selected = event.target.value
108
114
  this.#load()
109
115
  }}
110
- >
111
- ${this._forms.map(
112
- (form) =>
113
- html`<option value=${form.key} ?selected=${form.key === this._selected}>${form.name}</option>`,
114
- )}
115
- </select>
116
- <select
116
+ ></uui-select>
117
+ <uui-select
118
+ label="State"
119
+ .options=${[
120
+ ['', 'Any state'],
121
+ ['submitted', 'Submitted'],
122
+ ['approved', 'Approved'],
123
+ ['rejected', 'Rejected'],
124
+ ].map(([value, name]) => ({ name, value, selected: value === (this._state ?? '') }))}
117
125
  @change=${(event) => {
118
126
  this._state = event.target.value
119
127
  this.#load()
120
128
  }}
121
- >
122
- <option value="">Any state</option>
123
- <option value="submitted">Submitted</option>
124
- <option value="approved">Approved</option>
125
- <option value="rejected">Rejected</option>
126
- </select>
129
+ ></uui-select>
127
130
  <uui-input
128
131
  placeholder="Search the answers"
129
132
  .value=${this._search}
@@ -140,7 +143,18 @@ export default class CromboloFormsEntriesElement extends UmbLitElement {
140
143
  this.#load()
141
144
  }}
142
145
  ></uui-toggle>
143
- <a class="csv" href=${entriesCsvUrl(this._selected)} download>Export CSV</a>
146
+ <uui-button
147
+ class="csv"
148
+ look="secondary"
149
+ label="Export CSV"
150
+ @click=${async () => {
151
+ try {
152
+ await downloadEntriesCsv(this._selected)
153
+ } catch (error) {
154
+ this._notice = error.message
155
+ }
156
+ }}
157
+ ></uui-button>
144
158
  </div>
145
159
 
146
160
  ${this._notice ? html`<p class="notice" role="alert">${this._notice}</p>` : nothing}
@@ -14,6 +14,7 @@
14
14
  import '@umbraco-cms/backoffice/document'
15
15
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
16
16
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
17
+ import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'
17
18
  import {
18
19
  deleteForm,
19
20
  listForms,
@@ -61,8 +62,13 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
61
62
  _types: { state: true },
62
63
  }
63
64
 
65
+ #notifications
66
+
64
67
  constructor() {
65
68
  super()
69
+ this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
70
+ this.#notifications = context
71
+ })
66
72
  this._forms = undefined
67
73
  this._editing = undefined
68
74
  this._problems = []
@@ -490,16 +496,19 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
490
496
  try {
491
497
  const result = await saveForm(this._editing)
492
498
  if (result.ok) {
493
- this._notice = 'Saved.'
499
+ // The green toast every workspace shows on a save.
500
+ this.#notifications?.peek('positive', { data: { message: 'Form saved' } })
494
501
  this._editing = undefined
495
502
  await this.#load()
496
503
  } else {
497
504
  this._problems = result.body.problems ?? []
498
- if (result.body.status === 'readOnly')
499
- this._notice = 'The schema directory is read-only on this node.'
505
+ if (result.body.status === 'readOnly' && this._problems.length === 0)
506
+ this._problems = [
507
+ { path: '', message: 'The schema directory is read-only on this node.' },
508
+ ]
500
509
  }
501
510
  } catch (error) {
502
- this._notice = error.message
511
+ this._problems = [{ path: '', message: error.message }]
503
512
  } finally {
504
513
  this._busy = false
505
514
  }
@@ -517,20 +526,19 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
517
526
  }
518
527
 
519
528
  render() {
520
- if (this._forms === undefined) return html`<uui-loader></uui-loader>`
521
- return html`
529
+ if (this._forms === undefined) return html`<div class="list"><uui-loader></uui-loader></div>`
530
+ if (this._editing) return this.#editor()
531
+ return html`<div class="list">
522
532
  ${this._notice ? html`<uui-box><p>${this._notice}</p></uui-box>` : nothing}
523
- ${this._editing ? this.#editor() : this.#list()}
524
- `
533
+ ${this.#list()}
534
+ </div>`
525
535
  }
526
536
 
527
537
  #list() {
538
+ // The create button above the list, as the workflows list has it.
528
539
  return html`
540
+ <uui-button class="create" color="default" look="outline" label="Create" @click=${() => this.#new()}></uui-button>
529
541
  <uui-box headline="Forms">
530
- <p>
531
- A form is a file in <code>schema/forms/</code>. Editing one here writes that
532
- file, so the change can be reviewed in a pull request like any other type.
533
- </p>
534
542
  ${
535
543
  this._forms.length === 0
536
544
  ? html`<p>There are no forms yet.</p>`
@@ -562,9 +570,6 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
562
570
  </uui-table>
563
571
  `
564
572
  }
565
- <div class="actions">
566
- <uui-button look="primary" label="Add a form" @click=${() => this.#new()}></uui-button>
567
- </div>
568
573
  </uui-box>
569
574
  `
570
575
  }
@@ -584,18 +589,14 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
584
589
  ></uui-input>
585
590
  </uui-form-layout-item>
586
591
  `
587
- return html`
588
- <uui-box headline=${form.alias ? `Form: ${form.name || form.alias}` : 'A new form'}>
589
- ${
590
- this._problems.length > 0
591
- ? html`<div class="problems" role="alert">
592
- <p>This form was not saved:</p>
593
- <ul>
594
- ${this._problems.map((problem) => html`<li><code>${problem.path}</code> ${problem.message}</li>`)}
595
- </ul>
596
- </div>`
597
- : nothing
598
- }
592
+ const leave = () => {
593
+ this._editing = undefined
594
+ this._problems = []
595
+ }
596
+ // Umbraco's own layout, as the workflow designer has it: Save bottom right,
597
+ // and what stopped a save listed just above it.
598
+ return html`<umb-body-layout headline=${form.alias ? `Form: ${form.name || form.alias}` : 'A new form'}>
599
+ <uui-box>
599
600
  ${text('name', 'Name')}
600
601
  ${text('alias', 'Alias', 'Letters and numbers, starting with a letter. The file is named after it.')}
601
602
  ${text('messageOnSubmit', 'Message on submit')}
@@ -633,14 +634,26 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
633
634
  ${this._editing.pages.map((page, p) => this.#page(page, p))}
634
635
  <div class="actions">
635
636
  <uui-button look="secondary" label="Add a page" @click=${() => this.#apply(addPage)}></uui-button>
636
- <uui-button look="primary" label="Save" ?disabled=${this._busy} @click=${() => this.#save()}></uui-button>
637
- <uui-button look="secondary" label="Cancel" @click=${() => {
638
- this._editing = undefined
639
- this._problems = []
640
- }}></uui-button>
641
637
  </div>
642
638
  </uui-box>
643
- `
639
+ ${
640
+ this._problems.length > 0
641
+ ? html`<div class="problems" role="alert">
642
+ <p>This form was not saved:</p>
643
+ <ul>
644
+ ${this._problems.map((problem) => html`<li>${problem.path ? html`<code>${problem.path}</code> ` : nothing}${problem.message}</li>`)}
645
+ </ul>
646
+ </div>`
647
+ : nothing
648
+ }
649
+ ${
650
+ this._problems.length > 0
651
+ ? html`<span slot="footer-info" class="not-saved">Not saved: ${this._problems.length} problem(s), listed above</span>`
652
+ : nothing
653
+ }
654
+ <uui-button slot="actions" look="secondary" label="Back to the list" @click=${leave}>Back to the list</uui-button>
655
+ <uui-button slot="actions" look="primary" color="positive" label="Save" ?disabled=${this._busy} @click=${() => this.#save()}>Save</uui-button>
656
+ </umb-body-layout>`
644
657
  }
645
658
 
646
659
  static styles = css`
@@ -701,8 +714,17 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
701
714
  }
702
715
  :host {
703
716
  display: block;
717
+ height: 100%;
718
+ }
719
+ .list {
704
720
  padding: var(--uui-size-layout-1, 24px);
705
721
  }
722
+ .create {
723
+ margin-bottom: var(--uui-size-space-5, 18px);
724
+ }
725
+ .not-saved {
726
+ color: var(--uui-color-danger, #d42054);
727
+ }
706
728
  p,
707
729
  ul {
708
730
  max-width: 70ch;
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Localizations",
4
4
  "id": "Crombolo.Localizations",
5
- "version": "0.13.1",
5
+ "version": "0.13.2",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Search & sharing",
4
4
  "id": "Crombolo.Seo",
5
- "version": "0.13.1",
5
+ "version": "0.13.2",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -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.13.1",
5
+ "version": "0.13.2",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -246,7 +246,7 @@
246
246
  "alias": "Crombolo.SectionView.Forms.Overview",
247
247
  "name": "Crombolo Forms Overview",
248
248
  "element": "/crombolo-plugin/forms-overview.js",
249
- "weight": 200,
249
+ "weight": 100,
250
250
  "meta": {
251
251
  "label": "Forms",
252
252
  "pathname": "forms",
@@ -264,7 +264,7 @@
264
264
  "alias": "Crombolo.SectionView.Forms.Entries",
265
265
  "name": "Crombolo Forms Entries",
266
266
  "element": "/crombolo-plugin/forms-entries.js",
267
- "weight": 100,
267
+ "weight": 200,
268
268
  "meta": {
269
269
  "label": "Entries",
270
270
  "pathname": "entries",
@@ -352,7 +352,7 @@
352
352
  "name": "Crombolo Workflow Section",
353
353
  "weight": 40,
354
354
  "meta": {
355
- "label": "Workflow",
355
+ "label": "Workflows",
356
356
  "pathname": "workflow"
357
357
  }
358
358
  },
@@ -9,7 +9,9 @@
9
9
  */
10
10
  // For its side effect: registers `umb-input-document`, which the shell loads lazily.
11
11
  import '@umbraco-cms/backoffice/document'
12
- import { css, html, LitElement, nothing, unsafeCSS } from '@umbraco-cms/backoffice/external/lit'
12
+ import { css, html, nothing, unsafeCSS } from '@umbraco-cms/backoffice/external/lit'
13
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
14
+ import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'
13
15
  import { deleteWorkflow, listWorkflows, previewWorkflow, saveWorkflow } from './workflow-client.js'
14
16
  import { ancestors, BOX, drawGraph, GRAPH_STYLES, layout, wouldLoop } from './workflow-graph.js'
15
17
 
@@ -66,7 +68,7 @@ const select = (
66
68
  ${Object.entries(options).map(([key, label]) => html`<option value=${key} ?selected=${key === value}>${label}</option>`)}
67
69
  </select>`
68
70
 
69
- export default class CromboloWorkflowDesignerElement extends LitElement {
71
+ export default class CromboloWorkflowDesignerElement extends UmbLitElement {
70
72
  static properties = {
71
73
  _list: { state: true },
72
74
  _editing: { state: true },
@@ -79,6 +81,14 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
79
81
  }
80
82
 
81
83
  #drag
84
+ #notifications
85
+
86
+ constructor() {
87
+ super()
88
+ this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
89
+ this.#notifications = context
90
+ })
91
+ }
82
92
 
83
93
  connectedCallback() {
84
94
  super.connectedCallback()
@@ -247,19 +257,21 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
247
257
  return
248
258
  }
249
259
  this._affected = undefined
250
- this._message = 'Saved.'
260
+ // The green toast every workspace shows on a save.
261
+ this.#notifications?.peek('positive', { data: { message: 'Workflow saved' } })
251
262
  this._originalAlias = this._editing.alias
252
263
  await this.#load()
253
264
  }
254
265
 
255
- async #delete() {
266
+ /** From the list or the editor: the workflow by its name and the alias its file has. */
267
+ async #delete(name, alias) {
256
268
  if (
257
269
  !window.confirm(
258
- `Delete the workflow "${this._editing.name}"? Pages it covers will publish without approval.`,
270
+ `Delete the workflow "${name}"? Pages it covers will publish without approval.`,
259
271
  )
260
272
  )
261
273
  return
262
- const result = await deleteWorkflow(this._originalAlias)
274
+ const result = await deleteWorkflow(alias)
263
275
  if (!result.ok) {
264
276
  this._message = result.message
265
277
  return
@@ -270,15 +282,24 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
270
282
 
271
283
  render() {
272
284
  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>`
285
+ return html`<div class="list"><uui-box headline="Workflows">${this._message}</uui-box></div>`
286
+ if (!this._list)
287
+ return html`<div class="list"><uui-box headline="Workflows"><uui-loader></uui-loader></uui-box></div>`
275
288
  if (!this._editing) return this.#renderList()
276
289
  return this.#renderEditor()
277
290
  }
278
291
 
279
292
  #renderList() {
280
293
  const { items, editable } = this._list
281
- return html`<uui-box headline="Workflows">
294
+ // The create button above the list, as the Created bundles view has it.
295
+ return html`<div class="list">
296
+ ${
297
+ editable
298
+ ? html`<uui-button class="create" color="default" look="outline" label="Create" @click=${() => this.#edit(blank())}>Create</uui-button>`
299
+ : nothing
300
+ }
301
+ ${this._message ? html`<uui-box><p class="message">${this._message}</p></uui-box>` : nothing}
302
+ <uui-box headline="Workflows">
282
303
  ${editable ? nothing : html`<p>Workflows are kept with the site's files, which cannot be changed on this server. Change them in the repository and deploy.</p>`}
283
304
  ${
284
305
  items.length === 0
@@ -288,13 +309,11 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
288
309
  <uui-table-head-cell>Workflow</uui-table-head-cell>
289
310
  <uui-table-head-cell>Covers</uui-table-head-cell>
290
311
  <uui-table-head-cell>Stages</uui-table-head-cell>
312
+ <uui-table-head-cell></uui-table-head-cell>
291
313
  </uui-table-head>
292
314
  ${items.map(
293
315
  (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>
316
+ <uui-table-cell>${w.name}</uui-table-cell>
298
317
  <uui-table-cell>${[
299
318
  w.appliesTo.branches.length ? `${w.appliesTo.branches.length} branch(es)` : '',
300
319
  ...w.appliesTo.contentTypes,
@@ -302,55 +321,48 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
302
321
  .filter(Boolean)
303
322
  .join(', ')}</uui-table-cell>
304
323
  <uui-table-cell>${w.stages.length}</uui-table-cell>
324
+ <uui-table-cell>
325
+ <uui-button look="secondary" label=${editable ? 'Edit' : 'View'} @click=${() => this.#edit(w)}></uui-button>
326
+ ${
327
+ editable
328
+ ? html`<uui-button look="secondary" color="danger" label="Delete" @click=${() => this.#delete(w.name, w.alias)}></uui-button>`
329
+ : nothing
330
+ }
331
+ </uui-table-cell>
305
332
  </uui-table-row>`,
306
333
  )}
307
334
  </uui-table>`
308
335
  }
309
- ${editable ? html`<uui-button look="primary" label="New workflow" @click=${() => this.#edit(blank())}>New workflow</uui-button>` : nothing}
310
- </uui-box>`
336
+ </uui-box>
337
+ </div>`
311
338
  }
312
339
 
313
340
  #renderEditor() {
314
341
  const w = this._editing
315
342
  const editable = this._list.editable
316
343
  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}
344
+ const leave = () => {
345
+ this._editing = undefined
346
+ this._message = undefined
347
+ this._problems = undefined
348
+ }
349
+ // Umbraco's own layout, so Save sits bottom right in the footer as it does
350
+ // in every workspace, with Back beside it.
351
+ return html`<umb-body-layout headline=${w.name || 'Workflow'}>
352
+ <uui-box>
345
353
  ${
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>
354
+ editable
355
+ ? html`<div class="toolbar">
356
+ <uui-button look="secondary" label="Add a stage" @click=${() => this.#addStage()}>Add a stage</uui-button>
357
+ <uui-button look=${this._connecting ? 'primary' : 'secondary'} label="Connect stages"
358
+ @click=${() => {
359
+ this._connecting = this._connecting ? undefined : true
360
+ }}
361
+ >${this._connecting === true ? 'Click the stage to draw from' : this._connecting ? 'Now click the stage it leads to' : 'Connect stages'}</uui-button>
351
362
  </div>`
352
363
  : nothing
353
364
  }
365
+ ${this._message ? html`<p class="message">${this._message}</p>` : nothing}
354
366
  <p class="hint">Arrows run left to right: the stages an arrow leads to wait for every stage with an arrow into them, and run side by side. Click an arrow to remove it.</p>
355
367
  <div class="canvas" @pointermove=${(e) => editable && this.#pointerMove(e)} @pointerup=${() => this.#pointerUp()} @pointerleave=${() => this.#pointerUp()}>
356
368
  ${drawGraph(w.stages, {
@@ -365,7 +377,28 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
365
377
  ${stage ? this.#renderStage(stage, editable) : nothing}
366
378
  ${this.#renderSettings(w, editable)}
367
379
  </div>
368
- `
380
+ ${this._problems ? html`<ul class="problems">${this._problems.map((p) => html`<li>${p.path ? html`<code>${p.path}</code> ` : nothing}${p.message}</li>`)}</ul>` : nothing}
381
+ ${
382
+ this._affected
383
+ ? html`<div class="affected">
384
+ <p>Saving changes the rules for ${this._affected.length} page(s) waiting for approval:</p>
385
+ <ul>${this._affected.map((a) => html`<li>${a.page}: ${ON_CHANGE[a.outcome]}</li>`)}</ul>
386
+ <uui-button look="primary" label="Save anyway" @click=${() => this.#save(true)}>Save anyway</uui-button>
387
+ </div>`
388
+ : nothing
389
+ }
390
+ ${
391
+ this._problems
392
+ ? html`<span slot="footer-info" class="not-saved">Not saved: ${this._problems.length} problem(s), listed above</span>`
393
+ : nothing
394
+ }
395
+ <uui-button slot="actions" look="secondary" label="Back to the list" @click=${leave}>Back to the list</uui-button>
396
+ ${
397
+ editable
398
+ ? html`<uui-button slot="actions" look="primary" color="positive" label="Save" @click=${() => this.#save()}>Save</uui-button>`
399
+ : nothing
400
+ }
401
+ </umb-body-layout>`
369
402
  }
370
403
 
371
404
  #renderStage(stage, editable) {
@@ -510,7 +543,9 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
510
543
  }
511
544
 
512
545
  static styles = css`
513
- :host { display: block; padding: var(--uui-size-layout-1); }
546
+ :host { display: block; height: 100%; }
547
+ .list { padding: var(--uui-size-layout-1); }
548
+ .create { margin-bottom: var(--uui-size-space-5); }
514
549
  .toolbar { display: flex; flex-wrap: wrap; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-4); }
515
550
  .canvas { overflow: auto; border: 1px dashed var(--uui-color-border); border-radius: 6px; padding: var(--uui-size-space-5); min-height: 200px; touch-action: none; }
516
551
  .panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: var(--uui-size-space-5); margin-top: var(--uui-size-space-5); }
@@ -520,8 +555,9 @@ export default class CromboloWorkflowDesignerElement extends LitElement {
520
555
  .field > span { font-weight: 700; }
521
556
  .hint { opacity: 0.8; font-size: 0.9em; }
522
557
  .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); }
558
+ .problems { color: var(--uui-color-danger); margin-top: var(--uui-size-space-5); }
559
+ .not-saved { color: var(--uui-color-danger); }
560
+ .affected { margin-top: var(--uui-size-space-5); border: 1px solid var(--uui-color-warning); padding: var(--uui-size-space-4); border-radius: 6px; margin-bottom: var(--uui-size-space-4); }
525
561
  ${unsafeCSS(GRAPH_STYLES)}
526
562
  `
527
563
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The Workflow section's lists (story 028): what waits for the reader's
2
+ * The Workflows section's lists (story 028): what waits for the reader's
3
3
  * approval, every request in flight, and those decided. Each row links to the
4
4
  * page, whose Workflow tab is where a request is acted on.
5
5
  */
6
- import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
6
+ import { css, html, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
7
8
  import { finished, inFlight, waitingForMe } from './workflow-client.js'
8
9
 
9
10
  const STATE = {
@@ -13,15 +14,27 @@ const STATE = {
13
14
  withdrawn: ['default', 'Withdrawn'],
14
15
  }
15
16
 
16
- const pageLink = (key) => {
17
+ /** The page's Workflow tab: the editor's route is `edit/<key>/<variant>/view/<tab>`. */
18
+ const pageLink = (item) => {
17
19
  const base = (document.querySelector('base')?.getAttribute('href') ?? '/umbraco/').replace(
18
20
  /\/$/,
19
21
  '',
20
22
  )
21
- return `${base}/section/content/workspace/document/edit/${key}/view/workflow`
23
+ return `${base}/section/content/workspace/document/edit/${item.nodeKey}/${encodeURIComponent(item.variant ?? 'invariant')}/view/workflow`
22
24
  }
23
25
 
24
- class CromboloWorkflowListElement extends LitElement {
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 {
25
38
  static properties = {
26
39
  _items: { state: true },
27
40
  _message: { state: true },
@@ -76,7 +89,7 @@ class CromboloWorkflowListElement extends LitElement {
76
89
  const [color, label] = STATE[item.state] ?? ['default', item.state]
77
90
  const waiting = item.stages.filter((s) => s.state === 'waiting').map((s) => s.alias)
78
91
  return html`<uui-table-row>
79
- <uui-table-cell><a href=${pageLink(item.nodeKey)}>${item.nodeName}</a></uui-table-cell>
92
+ <uui-table-cell><a href=${pageLink(item)} @click=${(e) => openInBackoffice(e, pageLink(item))}>${item.nodeName}</a></uui-table-cell>
80
93
  <uui-table-cell>${item.workflow.name}</uui-table-cell>
81
94
  <uui-table-cell>${item.action === 'unpublish' ? 'Taking it down' : 'Publishing'}</uui-table-cell>
82
95
  <uui-table-cell>