@crombolo/backoffice-host 0.9.1 → 0.11.0

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.9.1",
3
+ "version": "0.11.0",
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.9.1",
34
+ "@crombolo/core": "0.11.0",
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.9.1",
5
+ "version": "0.11.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -0,0 +1,369 @@
1
+ /**
2
+ * The Audit log dashboard in Settings: who did what, when and from where,
3
+ * newest first, filtered by person, kind of change, words and dates.
4
+ * docs/audit-log.md.
5
+ */
6
+ import { css, html, LitElement, nothing } from '@umbraco-cms/backoffice/external/lit'
7
+
8
+ const PAGE_SIZE = 50
9
+
10
+ const CATEGORIES = [
11
+ ['', 'Everything'],
12
+ ['content', 'Content'],
13
+ ['media', 'Media'],
14
+ ['member', 'Members'],
15
+ ['schema', 'Types and templates'],
16
+ ['settings', 'Settings'],
17
+ ['user', 'Users'],
18
+ ['permission', 'Permissions'],
19
+ ['sign-in', 'Sign-ins'],
20
+ ['system', 'System'],
21
+ ]
22
+
23
+ const ACTIONS = {
24
+ Create: 'Created',
25
+ CreateAndPublish: 'Created and published',
26
+ Save: 'Saved',
27
+ SaveAndPublish: 'Saved and published',
28
+ Publish: 'Published',
29
+ PublishWithDescendants: 'Published with everything below it',
30
+ Unpublish: 'Unpublished',
31
+ Delete: 'Deleted',
32
+ Move: 'Moved',
33
+ Trash: 'Moved to the recycle bin',
34
+ Restore: 'Restored from the recycle bin',
35
+ EmptyRecycleBin: 'Emptied the recycle bin',
36
+ Copy: 'Copied',
37
+ Sort: 'Sorted',
38
+ Rename: 'Renamed',
39
+ Rollback: 'Rolled back',
40
+ Revert: 'Reverted',
41
+ PreventCleanup: 'Kept a version',
42
+ AssignDomains: 'Changed domains',
43
+ SetPublicAccess: 'Restricted access',
44
+ AddPublicAccess: 'Restricted access',
45
+ RemovePublicAccess: 'Removed access restrictions',
46
+ SetNotifications: 'Changed notifications',
47
+ Import: 'Imported',
48
+ CreateTemplate: 'Created a template',
49
+ ChangePassword: 'Changed password',
50
+ ResetPassword: 'Reset password',
51
+ ResetPasswordByLink: 'Reset password from an e-mailed link',
52
+ Invite: 'Invited',
53
+ ResendInvite: 'Resent the invitation',
54
+ AcceptInvite: 'Accepted the invitation',
55
+ Disable: 'Disabled',
56
+ Enable: 'Enabled',
57
+ Unlock: 'Unlocked',
58
+ SetUserGroups: 'Changed groups',
59
+ AddUsers: 'Added people',
60
+ RemoveUsers: 'Removed people',
61
+ SaveProfile: 'Updated their profile',
62
+ SetAvatar: 'Changed the picture',
63
+ RemoveAvatar: 'Removed the picture',
64
+ EnableTwoFactor: 'Turned on two-factor sign-in',
65
+ DisableTwoFactor: 'Turned off two-factor sign-in',
66
+ AddClientCredentials: 'Added an API key',
67
+ RemoveClientCredentials: 'Removed an API key',
68
+ SignIn: 'Signed in',
69
+ SignInFailed: 'Failed to sign in',
70
+ LockedOut: 'Locked out',
71
+ SignOut: 'Signed out',
72
+ Rebuild: 'Rebuilt',
73
+ Reload: 'Reloaded',
74
+ Build: 'Built',
75
+ Install: 'Installed',
76
+ Uninstall: 'Uninstalled',
77
+ Commit: 'Sent to source control',
78
+ Sync: 'Applied the schema files',
79
+ Purge: 'Purged',
80
+ Upgrade: 'Upgraded the site',
81
+ UpgradeFix: 'Prepared an upgrade',
82
+ PauseEditing: 'Paused editing',
83
+ ResumeEditing: 'Resumed editing',
84
+ RunMigration: 'Ran a package update',
85
+ }
86
+
87
+ const ITEM_TYPES = {
88
+ Document: 'Page',
89
+ DocumentBlueprint: 'Document blueprint',
90
+ DocumentType: 'Document type',
91
+ DictionaryItem: 'Dictionary item',
92
+ MediaType: 'Media type',
93
+ MemberType: 'Member type',
94
+ MemberGroup: 'Member group',
95
+ DataType: 'Data type',
96
+ PartialView: 'Partial view',
97
+ UserGroup: 'User group',
98
+ SeoSettings: 'Search and sharing settings',
99
+ }
100
+
101
+ const WORKSPACE = {
102
+ Document: (key) => `section/content/workspace/document/edit/${key}`,
103
+ Media: (key) => `section/media/workspace/media/edit/${key}`,
104
+ Member: (key) => `section/member-management/workspace/member/edit/${key}`,
105
+ User: (key) => `section/user-management/workspace/user/edit/${key}`,
106
+ }
107
+
108
+ const words = (name) =>
109
+ name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
110
+ const actionLabel = (action) => ACTIONS[action] ?? words(action)
111
+ const itemType = (type) => ITEM_TYPES[type] ?? words(type.replace(/Folder$/, ' folder'))
112
+
113
+ function apiBase() {
114
+ const base = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
115
+ return `${base.replace(/\/$/, '')}/crombolo/api/audit`
116
+ }
117
+
118
+ async function get(path, params) {
119
+ const query = params ? `?${new URLSearchParams(params)}` : ''
120
+ const response = await fetch(`${apiBase()}${path}${query}`, {
121
+ credentials: 'include',
122
+ headers: { accept: 'application/json' },
123
+ })
124
+ if (!response.ok) throw new Error(`The audit log could not be read (${response.status}).`)
125
+ return response.json()
126
+ }
127
+
128
+ function fromWhere(entry) {
129
+ if (entry.source === 'cli') return 'Command line'
130
+ if (entry.source === 'job') return 'Scheduled'
131
+ const forwarded = entry.forwardedFor?.split(',')[0]?.trim()
132
+ return forwarded || entry.ipAddress || '—'
133
+ }
134
+
135
+ export default class CromboloAuditDashboardElement extends LitElement {
136
+ static properties = {
137
+ _page: { state: true },
138
+ _users: { state: true },
139
+ _error: { state: true },
140
+ _filter: { state: true },
141
+ _skip: { state: true },
142
+ }
143
+
144
+ constructor() {
145
+ super()
146
+ this._page = undefined
147
+ this._users = []
148
+ this._error = undefined
149
+ this._filter = { user: '', category: '', text: '', since: '', until: '' }
150
+ this._skip = 0
151
+ }
152
+
153
+ connectedCallback() {
154
+ super.connectedCallback()
155
+ get('/users')
156
+ .then((users) => {
157
+ this._users = users.items
158
+ })
159
+ .catch(() => {})
160
+ this.#load()
161
+ }
162
+
163
+ async #load() {
164
+ const params = { skip: String(this._skip), take: String(PAGE_SIZE) }
165
+ const { user, category, text, since, until } = this._filter
166
+ if (user) params.user = user
167
+ if (category) params.category = category
168
+ if (text.trim()) params.text = text.trim()
169
+ // Whole local days: "To" includes the day it names.
170
+ if (since) params.since = new Date(`${since}T00:00:00`).toISOString()
171
+ if (until)
172
+ params.until = new Date(new Date(`${until}T00:00:00`).getTime() + 86_400_000).toISOString()
173
+ try {
174
+ this._page = await get('', params)
175
+ this._error = undefined
176
+ } catch (error) {
177
+ this._error = error.message
178
+ }
179
+ }
180
+
181
+ #set(field, value) {
182
+ this._filter = { ...this._filter, [field]: value }
183
+ this._skip = 0
184
+ this.#load()
185
+ }
186
+
187
+ #go(skip) {
188
+ this._skip = Math.max(0, skip)
189
+ this.#load()
190
+ }
191
+
192
+ render() {
193
+ return html`
194
+ <uui-box headline="Audit log">
195
+ <p class="intro">
196
+ Every change made in the backoffice, every sign-in, and what the command line and
197
+ scheduled publishing changed.
198
+ ${
199
+ this._page?.retentionDays
200
+ ? html`Entries are kept for ${this._page.retentionDays} days.`
201
+ : this._page
202
+ ? html`Entries are kept until they are removed.`
203
+ : nothing
204
+ }
205
+ </p>
206
+ ${this.#renderFilters()}
207
+ ${this.#renderBody()}
208
+ </uui-box>
209
+ `
210
+ }
211
+
212
+ #renderFilters() {
213
+ const f = this._filter
214
+ return html`
215
+ <div class="filters">
216
+ <label>
217
+ Who
218
+ <select .value=${f.user} @change=${(e) => this.#set('user', e.target.value)}>
219
+ <option value="">Anyone</option>
220
+ ${this._users.map((u) => html`<option value=${u.key}>${u.name || 'Unnamed'}</option>`)}
221
+ </select>
222
+ </label>
223
+ <label>
224
+ What
225
+ <select .value=${f.category} @change=${(e) => this.#set('category', e.target.value)}>
226
+ ${CATEGORIES.map(([value, label]) => html`<option value=${value}>${label}</option>`)}
227
+ </select>
228
+ </label>
229
+ <label>
230
+ Containing
231
+ <uui-input
232
+ label="Containing"
233
+ placeholder="A name"
234
+ .value=${f.text}
235
+ @change=${(e) => this.#set('text', e.target.value)}
236
+ ></uui-input>
237
+ </label>
238
+ <label>
239
+ From
240
+ <input type="date" .value=${f.since} @change=${(e) => this.#set('since', e.target.value)} />
241
+ </label>
242
+ <label>
243
+ To
244
+ <input type="date" .value=${f.until} @change=${(e) => this.#set('until', e.target.value)} />
245
+ </label>
246
+ <uui-button look="secondary" compact label="Refresh" @click=${() => this.#load()}>Refresh</uui-button>
247
+ </div>
248
+ `
249
+ }
250
+
251
+ #renderBody() {
252
+ if (this._error) return html`<p class="error">${this._error}</p>`
253
+ if (!this._page) return html`<uui-loader></uui-loader>`
254
+ const { items, total } = this._page
255
+ if (items.length === 0) return html`<p class="empty">Nothing matches.</p>`
256
+ const last = Math.min(this._skip + items.length, total)
257
+ return html`
258
+ <uui-table>
259
+ <uui-table-head>
260
+ <uui-table-head-cell>When</uui-table-head-cell>
261
+ <uui-table-head-cell>Who</uui-table-head-cell>
262
+ <uui-table-head-cell>What</uui-table-head-cell>
263
+ <uui-table-head-cell>Item</uui-table-head-cell>
264
+ <uui-table-head-cell>From</uui-table-head-cell>
265
+ </uui-table-head>
266
+ ${items.map((entry) => this.#renderRow(entry))}
267
+ </uui-table>
268
+ <div class="pager">
269
+ <span>${this._skip + 1}–${last} of ${total}</span>
270
+ <uui-button
271
+ compact
272
+ look="secondary"
273
+ label="Newer"
274
+ ?disabled=${this._skip === 0}
275
+ @click=${() => this.#go(this._skip - PAGE_SIZE)}
276
+ >Newer</uui-button>
277
+ <uui-button
278
+ compact
279
+ look="secondary"
280
+ label="Older"
281
+ ?disabled=${last >= total}
282
+ @click=${() => this.#go(this._skip + PAGE_SIZE)}
283
+ >Older</uui-button>
284
+ </div>
285
+ `
286
+ }
287
+
288
+ #renderRow(entry) {
289
+ const when = new Date(entry.occurredAt)
290
+ const failed = entry.action === 'SignInFailed' || entry.action === 'LockedOut'
291
+ return html`<uui-table-row>
292
+ <uui-table-cell title=${when.toISOString()}>${when.toLocaleString()}</uui-table-cell>
293
+ <uui-table-cell>${entry.user?.name ?? (entry.source === 'job' ? 'Scheduled publishing' : 'Unknown')}</uui-table-cell>
294
+ <uui-table-cell>
295
+ ${failed ? html`<uui-tag color="danger">${actionLabel(entry.action)}</uui-tag>` : actionLabel(entry.action)}
296
+ ${entry.detail && entry.source !== 'cli' ? html`<small>${entry.detail}</small>` : nothing}
297
+ </uui-table-cell>
298
+ <uui-table-cell>${this.#renderItem(entry)}</uui-table-cell>
299
+ <uui-table-cell title=${entry.forwardedFor ? `Connected from ${entry.ipAddress ?? 'unknown'}` : ''}>
300
+ ${fromWhere(entry)}
301
+ </uui-table-cell>
302
+ </uui-table-row>`
303
+ }
304
+
305
+ #renderItem(entry) {
306
+ const item = entry.item
307
+ if (!item) return nothing
308
+ const label = item.name ?? item.key ?? ''
309
+ const link = item.key && WORKSPACE[item.type]?.(item.key)
310
+ const base = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
311
+ return html`
312
+ <small>${itemType(item.type)}</small>
313
+ ${link ? html`<a href=${`${base.replace(/\/$/, '')}/${link}`}>${label}</a>` : label}
314
+ `
315
+ }
316
+
317
+ static styles = css`
318
+ :host {
319
+ display: block;
320
+ padding: var(--uui-size-layout-1);
321
+ }
322
+ .intro {
323
+ margin-top: 0;
324
+ color: var(--uui-color-text-alt);
325
+ }
326
+ .filters {
327
+ display: flex;
328
+ flex-wrap: wrap;
329
+ gap: var(--uui-size-space-4);
330
+ align-items: flex-end;
331
+ margin-bottom: var(--uui-size-space-5);
332
+ }
333
+ .filters label {
334
+ display: flex;
335
+ flex-direction: column;
336
+ gap: var(--uui-size-space-1);
337
+ font-weight: 600;
338
+ }
339
+ select,
340
+ input[type='date'] {
341
+ font: inherit;
342
+ padding: var(--uui-size-space-2);
343
+ border: 1px solid var(--uui-color-border);
344
+ border-radius: var(--uui-border-radius);
345
+ background: var(--uui-color-surface);
346
+ color: var(--uui-color-text);
347
+ }
348
+ uui-table-cell small {
349
+ display: block;
350
+ color: var(--uui-color-text-alt);
351
+ }
352
+ .pager {
353
+ display: flex;
354
+ gap: var(--uui-size-space-3);
355
+ align-items: center;
356
+ justify-content: flex-end;
357
+ margin-top: var(--uui-size-space-4);
358
+ }
359
+ .empty,
360
+ .error {
361
+ color: var(--uui-color-text-alt);
362
+ }
363
+ .error {
364
+ color: var(--uui-color-danger);
365
+ }
366
+ `
367
+ }
368
+
369
+ customElements.define('crombolo-audit-dashboard', CromboloAuditDashboardElement)
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Umbraco splits the two because Razor needed the distinction for
5
5
  * master-template inheritance; a TSX layout is an import, so here a template is
6
- * just a component a content type names (`docs/05-rendering.md`). The tree that
6
+ * just a component a content type names (`docs/rendering.md`). The tree that
7
7
  * survives is the partial-view one, because it is path-addressed and understands
8
8
  * folders — the template tree is id-addressed and cannot show one.
9
9
  *
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * `sections` and `packager` are the other kind of rename: not the product's name
19
19
  * but a feature's. Umbraco's Packages section builds a `.umb` file; the artifact
20
- * here is a bundle (`docs/17-bundles.md`), so the section and the flow that
20
+ * here is a bundle (`docs/bundles.md`), so the section and the flow that
21
21
  * builds one say so. `build-localizations.ts` reads this file for its key set and
22
22
  * rewrites only values that name the product, so these pass through untouched and
23
23
  * every other language keeps its own word.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What a bundle adds, as the Marketplace and Installed views both show it.
3
+ */
4
+ import { html } from '@umbraco-cms/backoffice/external/lit'
5
+
6
+ /** What a bundle adds, in the words of someone choosing one. */
7
+ export const CARRIES_LABELS = {
8
+ extensions: 'Screens',
9
+ content: 'Content',
10
+ server: 'Server features',
11
+ }
12
+
13
+ export const carriesTags = (carries) =>
14
+ Object.entries(CARRIES_LABELS)
15
+ .filter(([key]) => carries?.[key])
16
+ .map(([, label]) => html`<uui-tag look="secondary">${label}</uui-tag>`)
@@ -27,8 +27,14 @@ export const searchMarketplace = (query) =>
27
27
 
28
28
  export const listInstalled = () => json('/installed')
29
29
 
30
- export const installBundle = (name, version) =>
31
- json('/install', { method: 'POST', body: JSON.stringify({ name, version }) })
30
+ export const installBundle = (name, version, applyContent = true) =>
31
+ json('/install', { method: 'POST', body: JSON.stringify({ name, version, applyContent }) })
32
32
 
33
33
  export const uninstallBundle = (name) =>
34
34
  json('/uninstall', { method: 'POST', body: JSON.stringify({ name }) })
35
+
36
+ /** The npm package a created bundle downloads as. */
37
+ export const readCreatedPackage = (id) => json(`/created/${encodeURIComponent(id)}/package`)
38
+
39
+ export const saveCreatedPackage = (id, pkg) =>
40
+ json(`/created/${encodeURIComponent(id)}/package`, { method: 'PUT', body: JSON.stringify(pkg) })
@@ -5,13 +5,16 @@
5
5
  * the definitions are the Management API's nine `package` operations, which are
6
6
  * contract-fixed and bearer-authenticated, and the repository already carries
7
7
  * the client's token. The plugin's `/crombolo/api/bundles` routes are a
8
- * different thing — installed npm bundles, on the session cookie.
8
+ * different thing — installed npm bundles, on the session cookie — and one of
9
+ * them is used here too: every bundle downloads as an npm package, and the name
10
+ * and version it is packed under are not part of Umbraco's definition.
9
11
  */
10
12
 
11
13
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
12
14
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
13
15
  import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'
14
16
  import { UmbPackageRepository } from '@umbraco-cms/backoffice/package'
17
+ import { readCreatedPackage, saveCreatedPackage } from './bundles-client.js'
15
18
 
16
19
  const TAKE = 10
17
20
 
@@ -24,6 +27,10 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
24
27
  _loading: { state: true },
25
28
  _total: { state: true },
26
29
  _page: { state: true },
30
+ _packages: { state: true },
31
+ _editing: { state: true },
32
+ _draft: { state: true },
33
+ _problem: { state: true },
27
34
  }
28
35
 
29
36
  #repository = new UmbPackageRepository(this)
@@ -34,6 +41,10 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
34
41
  this._loading = true
35
42
  this._total = 0
36
43
  this._page = 1
44
+ this._packages = {}
45
+ this._editing = ''
46
+ this._draft = { name: '', version: '' }
47
+ this._problem = ''
37
48
  }
38
49
 
39
50
  connectedCallback() {
@@ -48,6 +59,67 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
48
59
  this._items = data?.items ?? []
49
60
  this._total = data?.total ?? 0
50
61
  this._loading = false
62
+ const packages = {}
63
+ await Promise.all(
64
+ this._items
65
+ .filter((item) => item.unique)
66
+ .map(async (item) => {
67
+ packages[item.unique] = await readCreatedPackage(item.unique).catch(() => undefined)
68
+ }),
69
+ )
70
+ this._packages = packages
71
+ }
72
+
73
+ #edit(item) {
74
+ const pkg = this._packages[item.unique]
75
+ this._editing = item.unique
76
+ this._problem = ''
77
+ this._draft = {
78
+ name: pkg?.set?.name ? pkg.name : '',
79
+ version: pkg?.set?.version ? pkg.version : '',
80
+ }
81
+ }
82
+
83
+ async #save() {
84
+ const outcome = await saveCreatedPackage(this._editing, this._draft)
85
+ if (!outcome.ok) {
86
+ this._problem = outcome.message ?? 'Those details could not be saved.'
87
+ return
88
+ }
89
+ this._packages = { ...this._packages, [this._editing]: outcome.package }
90
+ this._editing = ''
91
+ }
92
+
93
+ #renderEditor(item) {
94
+ if (this._editing !== item.unique) return nothing
95
+ const pkg = this._packages[item.unique]
96
+ return html`
97
+ <div class="package-editor">
98
+ <p>
99
+ This bundle downloads as the npm package <code>${pkg?.name}@${pkg?.version}</code>.
100
+ Leave a field empty to go back to the name taken from the bundle's own name, and
101
+ version 1.0.0.
102
+ </p>
103
+ <uui-input
104
+ label="Package name"
105
+ placeholder=${pkg?.name ?? ''}
106
+ .value=${this._draft.name}
107
+ @input=${(event) => (this._draft = { ...this._draft, name: event.target.value })}></uui-input>
108
+ <uui-input
109
+ label="Version"
110
+ placeholder="1.0.0"
111
+ .value=${this._draft.version}
112
+ @input=${(event) => (this._draft = { ...this._draft, version: event.target.value })}></uui-input>
113
+ ${this._problem ? html`<p class="problem">${this._problem}</p>` : nothing}
114
+ <uui-button look="primary" label="Save" @click=${() => this.#save()}></uui-button>
115
+ <uui-button label="Cancel" @click=${() => (this._editing = '')}></uui-button>
116
+ </div>
117
+ `
118
+ }
119
+
120
+ #detail(item) {
121
+ const pkg = this._packages[item.unique]
122
+ return pkg ? `${pkg.name}@${pkg.version}` : ''
51
123
  }
52
124
 
53
125
  #onPageChange(event) {
@@ -109,13 +181,20 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
109
181
  this._items,
110
182
  (item) => item.unique,
111
183
  (item) => html`
112
- <uui-ref-node name=${item.name} @open=${() => this.#open(item)}>
184
+ <uui-ref-node
185
+ name=${item.name}
186
+ detail=${this.#detail(item)}
187
+ @open=${() => this.#open(item)}>
113
188
  <uui-action-bar slot="actions">
189
+ <uui-button
190
+ @click=${() => this.#edit(item)}
191
+ label="Package details"></uui-button>
114
192
  <uui-button
115
193
  @click=${() => this.#remove(item)}
116
194
  label=${this.localize.term('general_delete')}></uui-button>
117
195
  </uui-action-bar>
118
196
  </uui-ref-node>
197
+ ${this.#renderEditor(item)}
119
198
  `,
120
199
  )}
121
200
  </uui-ref-list>
@@ -144,6 +223,21 @@ export default class CromboloBundlesCreatedOverviewElement extends UmbLitElement
144
223
  text-align: center;
145
224
  opacity: 0.6;
146
225
  }
226
+ .package-editor {
227
+ display: flex;
228
+ flex-wrap: wrap;
229
+ align-items: center;
230
+ gap: var(--uui-size-space-3);
231
+ padding: var(--uui-size-space-4) var(--uui-size-space-5);
232
+ background: var(--uui-color-surface-alt);
233
+ }
234
+ .package-editor p {
235
+ flex-basis: 100%;
236
+ margin: 0;
237
+ }
238
+ .problem {
239
+ color: var(--uui-color-danger);
240
+ }
147
241
  `
148
242
  }
149
243
 
@@ -4,16 +4,36 @@
4
4
  * Its manifest declares `overwrites: ['Umb.SectionView.Packages.Installed']`.
5
5
  * Umbraco's view is built around package migrations — it lists installed
6
6
  * packages so that pending C# migrations can be run. Nothing carries those here
7
- * (`docs/17-bundles.md`), so the view would be a list of nothing with a button
7
+ * (`docs/bundles.md`), so the view would be a list of nothing with a button
8
8
  * that cannot apply.
9
9
  *
10
- * What it lists instead is the site's declared `crombolo` dependencies, which is
11
- * what "installed" means here, and where each one came from.
10
+ * What it lists instead is the site's bundle dependencies, which is what
11
+ * "installed" means here: what each one adds, whether its content has been
12
+ * added to this site, and whether its server features are switched on.
12
13
  */
13
14
  import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
14
15
  import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
16
+ import { carriesTags } from './bundles-carries.js'
15
17
  import { listInstalled, uninstallBundle } from './bundles-client.js'
16
18
 
19
+ /** Where each part of a bundle stands on this site, in the words of someone running it. */
20
+ function status(item) {
21
+ const parts = []
22
+ if (item.carries?.content)
23
+ parts.push(
24
+ item.contentApplied
25
+ ? html`<span>Content added (${item.contentApplied})</span>`
26
+ : html`<span>Content not added yet</span>`,
27
+ )
28
+ if (item.carries?.server)
29
+ parts.push(
30
+ item.wired
31
+ ? html`<span>Server features on</span>`
32
+ : html`<span>Server features off until the site's configuration includes it</span>`,
33
+ )
34
+ return parts.length > 0 ? parts : html`<span>Ready</span>`
35
+ }
36
+
17
37
  export default class CromboloBundlesInstalledElement extends UmbLitElement {
18
38
  static properties = {
19
39
  _items: { state: true },
@@ -70,8 +90,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
70
90
  <uui-box headline="No bundles installed">
71
91
  <p>
72
92
  A bundle is one of this site's npm dependencies. Install one from the
73
- Marketplace tab, or with <code>bun add</code> in the site directory — a dependency
74
- that declares a <code>crombolo</code> field appears here.
93
+ Marketplace tab, or with <code>bun add</code> in the site directory, and it
94
+ appears here.
75
95
  </p>
76
96
  </uui-box>
77
97
  `
@@ -82,7 +102,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
82
102
  <uui-table-head-cell>Name</uui-table-head-cell>
83
103
  <uui-table-head-cell>npm package</uui-table-head-cell>
84
104
  <uui-table-head-cell>Version</uui-table-head-cell>
85
- <uui-table-head-cell>Extensions</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>
86
107
  <uui-table-head-cell></uui-table-head-cell>
87
108
  </uui-table-head>
88
109
  ${repeat(
@@ -93,7 +114,8 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
93
114
  <uui-table-cell>${item.name}</uui-table-cell>
94
115
  <uui-table-cell><code>${item.packageName}</code></uui-table-cell>
95
116
  <uui-table-cell>${item.version}</uui-table-cell>
96
- <uui-table-cell>${item.extensionCount}</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>
97
119
  <uui-table-cell>
98
120
  <uui-button
99
121
  look="secondary"
@@ -136,6 +158,13 @@ export default class CromboloBundlesInstalledElement extends UmbLitElement {
136
158
  max-width: 60ch;
137
159
  line-height: 1.5;
138
160
  }
161
+ .carries {
162
+ display: flex;
163
+ gap: var(--uui-size-space-2, 6px);
164
+ }
165
+ .status span {
166
+ display: block;
167
+ }
139
168
  .note {
140
169
  margin-top: var(--uui-size-space-5, 18px);
141
170
  opacity: 0.8;