@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/backoffice-host",
3
- "version": "0.12.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.12.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.12.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'
@@ -40,6 +43,9 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
40
43
  _loading: { state: true },
41
44
  _busy: { state: true },
42
45
  _notice: { state: true },
46
+ _installs: { state: true },
47
+ _filter: { state: true },
48
+ _selected: { state: true },
43
49
  }
44
50
 
45
51
  constructor() {
@@ -48,6 +54,9 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
48
54
  this._loading = true
49
55
  this._busy = ''
50
56
  this._notice = ''
57
+ this._installs = true
58
+ this._filter = ''
59
+ this._selected = ''
51
60
  }
52
61
 
53
62
  connectedCallback() {
@@ -58,7 +67,11 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
58
67
  async #load() {
59
68
  this._loading = true
60
69
  try {
61
- this._items = (await listInstalled()).items ?? []
70
+ const result = await listInstalled()
71
+ this._items = result.items ?? []
72
+ this._installs = result.installs !== false
73
+ if (!this._items.some((item) => item.packageName === this._selected))
74
+ this._selected = this._items[0]?.packageName ?? ''
62
75
  } catch (error) {
63
76
  this._notice = error instanceof Error ? error.message : String(error)
64
77
  } finally {
@@ -79,63 +92,98 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
79
92
  }
80
93
  }
81
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
+
82
110
  render() {
83
111
  if (this._loading) return html`<div id="loader"><uui-loader></uui-loader></div>`
84
-
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
85
121
  return html`
86
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>
87
176
  ${
88
- this._items.length === 0
89
- ? html`
90
- <uui-box headline="No bundles installed">
91
- <p>
92
- A bundle is one of this site's npm dependencies. Install one from the
93
- Marketplace tab, or with <code>bun add</code> in the site directory, and it
94
- appears here.
95
- </p>
96
- </uui-box>
97
- `
98
- : html`
99
- <uui-box headline="Installed bundles">
100
- <uui-table>
101
- <uui-table-head>
102
- <uui-table-head-cell>Name</uui-table-head-cell>
103
- <uui-table-head-cell>npm package</uui-table-head-cell>
104
- <uui-table-head-cell>Version</uui-table-head-cell>
105
- <uui-table-head-cell>Adds</uui-table-head-cell>
106
- <uui-table-head-cell>On this site</uui-table-head-cell>
107
- <uui-table-head-cell></uui-table-head-cell>
108
- </uui-table-head>
109
- ${repeat(
110
- this._items,
111
- (item) => item.packageName,
112
- (item) => html`
113
- <uui-table-row>
114
- <uui-table-cell>${item.name}</uui-table-cell>
115
- <uui-table-cell><code>${item.packageName}</code></uui-table-cell>
116
- <uui-table-cell>${item.version}</uui-table-cell>
117
- <uui-table-cell class="carries">${carriesTags(item.carries)}</uui-table-cell>
118
- <uui-table-cell class="status">${status(item)}</uui-table-cell>
119
- <uui-table-cell>
120
- <uui-button
121
- look="secondary"
122
- color="danger"
123
- label="Uninstall"
124
- .state=${this._busy === item.packageName ? 'waiting' : undefined}
125
- @click=${() => this.#uninstall(item)}></uui-button>
126
- </uui-table-cell>
127
- </uui-table-row>
128
- `,
129
- )}
130
- </uui-table>
131
- <p class="note">
132
- An install writes this site's <code>package.json</code> and
133
- <code>bun.lock</code>. Commit both to keep it across deployments.
134
- </p>
135
- </uui-box>
136
- `
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>`
137
185
  }
138
- `
186
+ </uui-box>`
139
187
  }
140
188
 
141
189
  static styles = css`
@@ -154,10 +202,42 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
154
202
  border-left: 3px solid var(--uui-color-positive, #2bc37c);
155
203
  background: var(--uui-color-surface-alt, #f3f3f5);
156
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
+ }
157
225
  p {
158
226
  max-width: 60ch;
159
227
  line-height: 1.5;
160
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
+ }
161
241
  .carries {
162
242
  display: flex;
163
243
  gap: var(--uui-size-space-2, 6px);
@@ -166,7 +246,6 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
166
246
  display: block;
167
247
  }
168
248
  .note {
169
- margin-top: var(--uui-size-space-5, 18px);
170
249
  opacity: 0.8;
171
250
  font-size: 0.9em;
172
251
  }
@@ -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">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
+ }
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
- : html`
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"
@@ -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}