@crombolo/backoffice-host 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/assets/login.js +352 -0
  4. package/package.json +35 -4
  5. package/plugin/assistant/client.js +53 -0
  6. package/plugin/assistant/drawer.js +865 -0
  7. package/plugin/assistant/umbraco-package.json +16 -0
  8. package/plugin/branding/backdrop.svg +1 -0
  9. package/plugin/branding/branding.js +100 -0
  10. package/plugin/branding/bundle-builder-labels.js +63 -0
  11. package/plugin/branding/client-credentials.js +49 -0
  12. package/plugin/branding/components-tree.js +40 -0
  13. package/plugin/branding/content-type-labels.js +27 -0
  14. package/plugin/branding/favicon.svg +1 -0
  15. package/plugin/branding/help-menu.js +46 -0
  16. package/plugin/branding/icon-mark.js +15 -0
  17. package/plugin/branding/icon-registry.js +40 -0
  18. package/plugin/branding/localization-en.js +115 -0
  19. package/plugin/branding/log-viewer-menu.js +70 -0
  20. package/plugin/branding/logo-header.svg +1 -0
  21. package/plugin/branding/logo-surface.svg +1 -0
  22. package/plugin/branding/query-builder.js +28 -0
  23. package/plugin/branding/sign-in.js +80 -0
  24. package/plugin/branding/sysinfo.js +89 -0
  25. package/plugin/branding/theme.css +102 -0
  26. package/plugin/bundles-client.js +34 -0
  27. package/plugin/bundles-created-overview.js +150 -0
  28. package/plugin/bundles-created.js +70 -0
  29. package/plugin/bundles-installed.js +147 -0
  30. package/plugin/bundles-marketplace.js +199 -0
  31. package/plugin/changes-dashboard.js +132 -0
  32. package/plugin/element-type-hint.js +58 -0
  33. package/plugin/form-picker.js +116 -0
  34. package/plugin/forms-client.js +52 -0
  35. package/plugin/forms-entries.js +244 -0
  36. package/plugin/forms-overview.js +380 -0
  37. package/plugin/localizations/ar.js +97 -0
  38. package/plugin/localizations/bs.js +86 -0
  39. package/plugin/localizations/cs.js +73 -0
  40. package/plugin/localizations/cy.js +92 -0
  41. package/plugin/localizations/da.js +101 -0
  42. package/plugin/localizations/de.js +79 -0
  43. package/plugin/localizations/en.js +71 -0
  44. package/plugin/localizations/es.js +52 -0
  45. package/plugin/localizations/fr.js +78 -0
  46. package/plugin/localizations/he.js +24 -0
  47. package/plugin/localizations/hr.js +89 -0
  48. package/plugin/localizations/it.js +86 -0
  49. package/plugin/localizations/ja.js +43 -0
  50. package/plugin/localizations/ko.js +24 -0
  51. package/plugin/localizations/nb.js +44 -0
  52. package/plugin/localizations/nl.js +85 -0
  53. package/plugin/localizations/pl.js +44 -0
  54. package/plugin/localizations/pt-br.js +25 -0
  55. package/plugin/localizations/pt.js +100 -0
  56. package/plugin/localizations/ro.js +9 -0
  57. package/plugin/localizations/ru.js +46 -0
  58. package/plugin/localizations/sv.js +46 -0
  59. package/plugin/localizations/tr.js +80 -0
  60. package/plugin/localizations/uk.js +46 -0
  61. package/plugin/localizations/umbraco-package.json +279 -0
  62. package/plugin/localizations/vi.js +104 -0
  63. package/plugin/localizations/zh-tw.js +38 -0
  64. package/plugin/localizations/zh.js +38 -0
  65. package/plugin/read-only-banner.js +48 -0
  66. package/plugin/report-client.js +13 -0
  67. package/plugin/seo/client.js +35 -0
  68. package/plugin/seo/dashboard.js +242 -0
  69. package/plugin/seo/document-view.js +161 -0
  70. package/plugin/seo/type-hint.js +45 -0
  71. package/plugin/seo/umbraco-package.json +61 -0
  72. package/plugin/settings-dashboard.js +123 -0
  73. package/plugin/tsx-editors.js +337 -0
  74. package/plugin/tsx-snippets.js +63 -0
  75. package/plugin/umbraco-package.json +298 -0
  76. package/plugin/welcome-dashboard.js +106 -0
  77. package/src/branding.ts +59 -0
  78. package/src/extensions.ts +228 -0
  79. package/src/graphics.ts +87 -0
  80. package/src/index.ts +7 -0
  81. package/src/login-assets.ts +18 -0
  82. package/src/manifests.ts +154 -0
  83. package/src/paths.ts +117 -0
  84. package/src/shell.ts +139 -0
  85. package/src/static.ts +107 -0
  86. package/src/text-imports.d.ts +23 -0
  87. package/README.md +0 -3
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads and drives the Forms endpoints the server keeps at
3
+ * <backoffice>/crombolo/api/forms/*. The session cookie carries the auth.
4
+ */
5
+ const base = () => {
6
+ const href = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
7
+ return `${href.replace(/\/$/, '')}/crombolo/api/forms`
8
+ }
9
+
10
+ const call = async (path, init) => {
11
+ const response = await fetch(`${base()}${path}`, {
12
+ credentials: 'include',
13
+ headers: {
14
+ accept: 'application/json',
15
+ ...(init?.body ? { 'content-type': 'application/json' } : {}),
16
+ },
17
+ ...init,
18
+ })
19
+ if (response.status === 403) throw new Error('You do not have permission for that.')
20
+ const body = await response.json().catch(() => ({}))
21
+ if (!response.ok && response.status !== 400 && response.status !== 409)
22
+ throw new Error(`forms responded ${response.status}`)
23
+ return { ok: response.ok, status: response.status, body }
24
+ }
25
+
26
+ export const listForms = async () => (await call('')).body.items ?? []
27
+
28
+ export const readForm = async (key) => (await call(`/${encodeURIComponent(key)}`)).body
29
+
30
+ export const saveForm = (definition) =>
31
+ call('', { method: 'PUT', body: JSON.stringify(definition) })
32
+
33
+ export const deleteForm = (key) => call(`/${encodeURIComponent(key)}`, { method: 'DELETE' })
34
+
35
+ export const listEntries = async (key, query = {}) => {
36
+ const params = new URLSearchParams()
37
+ for (const [name, value] of Object.entries(query))
38
+ if (value !== undefined && value !== '') params.set(name, String(value))
39
+ const suffix = params.toString() ? `?${params}` : ''
40
+ return (await call(`/${encodeURIComponent(key)}/entries${suffix}`)).body
41
+ }
42
+
43
+ export const setEntryState = (id, state) =>
44
+ call(`/entries/${encodeURIComponent(id)}/state`, {
45
+ method: 'POST',
46
+ body: JSON.stringify({ state }),
47
+ })
48
+
49
+ export const deleteEntry = (id) => call(`/entries/${encodeURIComponent(id)}`, { method: 'DELETE' })
50
+
51
+ /** The CSV is a download, so it is a link rather than a fetch. */
52
+ export const entriesCsvUrl = (key) => `${base()}/${encodeURIComponent(key)}/entries.csv`
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The Forms section's Entries view.
3
+ *
4
+ * What a person actually does here: read what was submitted, approve or reject
5
+ * it, delete it, and take the lot away as CSV.
6
+ *
7
+ * A sensitive field arrives already redacted — the server withholds it rather
8
+ * than the UI hiding it — so this shows that it is withheld rather than
9
+ * pretending the answer was blank.
10
+ */
11
+ import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
12
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
13
+ import {
14
+ deleteEntry,
15
+ entriesCsvUrl,
16
+ listEntries,
17
+ listForms,
18
+ setEntryState,
19
+ } from './forms-client.js'
20
+
21
+ export default class CromboloFormsEntriesElement extends UmbLitElement {
22
+ static properties = {
23
+ _forms: { state: true },
24
+ _selected: { state: true },
25
+ _entries: { state: true },
26
+ _total: { state: true },
27
+ _state: { state: true },
28
+ _search: { state: true },
29
+ _includeSpam: { state: true },
30
+ _notice: { state: true },
31
+ }
32
+
33
+ constructor() {
34
+ super()
35
+ this._forms = undefined
36
+ this._selected = ''
37
+ this._entries = []
38
+ this._total = 0
39
+ this._state = ''
40
+ this._search = ''
41
+ this._includeSpam = false
42
+ this._notice = ''
43
+ }
44
+
45
+ connectedCallback() {
46
+ super.connectedCallback()
47
+ listForms().then(
48
+ (forms) => {
49
+ this._forms = forms
50
+ this._selected = forms[0]?.key ?? ''
51
+ if (this._selected) this.#load()
52
+ },
53
+ (error) => {
54
+ this._forms = []
55
+ this._notice = error.message
56
+ },
57
+ )
58
+ }
59
+
60
+ async #load() {
61
+ this._notice = ''
62
+ try {
63
+ const page = await listEntries(this._selected, {
64
+ state: this._state,
65
+ search: this._search,
66
+ includeSpam: this._includeSpam ? 'true' : '',
67
+ take: 200,
68
+ })
69
+ this._entries = page.items ?? []
70
+ this._total = page.total ?? 0
71
+ } catch (error) {
72
+ this._entries = []
73
+ this._notice = error.message
74
+ }
75
+ }
76
+
77
+ async #setState(entry, state) {
78
+ try {
79
+ const result = await setEntryState(entry.id, state)
80
+ if (!result.ok) this._notice = 'That entry could not be changed.'
81
+ await this.#load()
82
+ } catch (error) {
83
+ this._notice = error.message
84
+ }
85
+ }
86
+
87
+ async #remove(entry) {
88
+ if (!confirm('Delete this entry? It cannot be undone.')) return
89
+ try {
90
+ await deleteEntry(entry.id)
91
+ await this.#load()
92
+ } catch (error) {
93
+ this._notice = error.message
94
+ }
95
+ }
96
+
97
+ render() {
98
+ if (this._forms === undefined) return html`<uui-loader></uui-loader>`
99
+ if (this._forms.length === 0)
100
+ return html`<uui-box headline="Entries"><p>There are no forms yet.</p></uui-box>`
101
+
102
+ return html`
103
+ <uui-box headline="Entries">
104
+ <div class="filters">
105
+ <select
106
+ @change=${(event) => {
107
+ this._selected = event.target.value
108
+ this.#load()
109
+ }}
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
117
+ @change=${(event) => {
118
+ this._state = event.target.value
119
+ this.#load()
120
+ }}
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>
127
+ <uui-input
128
+ placeholder="Search the answers"
129
+ .value=${this._search}
130
+ @change=${(event) => {
131
+ this._search = event.target.value
132
+ this.#load()
133
+ }}
134
+ ></uui-input>
135
+ <uui-toggle
136
+ label="Include spam"
137
+ .checked=${this._includeSpam}
138
+ @change=${(event) => {
139
+ this._includeSpam = event.target.checked
140
+ this.#load()
141
+ }}
142
+ ></uui-toggle>
143
+ <a class="csv" href=${entriesCsvUrl(this._selected)} download>Export CSV</a>
144
+ </div>
145
+
146
+ ${this._notice ? html`<p class="notice" role="alert">${this._notice}</p>` : nothing}
147
+ <p>${this._total} entr${this._total === 1 ? 'y' : 'ies'}.</p>
148
+
149
+ ${
150
+ this._entries.length === 0
151
+ ? html`<p>Nothing to show.</p>`
152
+ : repeat(
153
+ this._entries,
154
+ (entry) => entry.id,
155
+ (entry) => html`
156
+ <div class="entry">
157
+ <div class="meta">
158
+ <strong>${new Date(entry.createDate).toLocaleString()}</strong>
159
+ <span class="state">${entry.state}</span>
160
+ ${entry.spam ? html`<span class="spam">spam</span>` : nothing}
161
+ </div>
162
+ <dl>
163
+ ${entry.values.map(
164
+ (value) => html`
165
+ <dt>${value.fieldAlias}</dt>
166
+ <dd>
167
+ ${
168
+ value.redacted
169
+ ? html`<em>withheld — needs sensitive-data access</em>`
170
+ : value.values.join(', ') || html`<em>not answered</em>`
171
+ }
172
+ </dd>
173
+ `,
174
+ )}
175
+ </dl>
176
+ <div class="actions">
177
+ <uui-button look="secondary" label="Approve" @click=${() => this.#setState(entry, 'approved')}></uui-button>
178
+ <uui-button look="secondary" label="Reject" @click=${() => this.#setState(entry, 'rejected')}></uui-button>
179
+ <uui-button look="secondary" color="danger" label="Delete" @click=${() => this.#remove(entry)}></uui-button>
180
+ </div>
181
+ </div>
182
+ `,
183
+ )
184
+ }
185
+ </uui-box>
186
+ `
187
+ }
188
+
189
+ static styles = css`
190
+ :host {
191
+ display: block;
192
+ padding: var(--uui-size-layout-1, 24px);
193
+ }
194
+ .filters {
195
+ display: flex;
196
+ gap: var(--uui-size-space-4, 12px);
197
+ align-items: center;
198
+ flex-wrap: wrap;
199
+ margin-bottom: var(--uui-size-layout-1, 24px);
200
+ }
201
+ .entry {
202
+ border-top: 1px solid var(--uui-color-border, #e5e5e5);
203
+ padding: var(--uui-size-space-4, 12px) 0;
204
+ }
205
+ .meta {
206
+ display: flex;
207
+ gap: var(--uui-size-space-4, 12px);
208
+ align-items: baseline;
209
+ }
210
+ .state,
211
+ .spam {
212
+ font-size: var(--uui-type-small-size, 0.8rem);
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.04em;
215
+ }
216
+ .spam {
217
+ color: var(--uui-color-danger, #d42054);
218
+ }
219
+ dl {
220
+ display: grid;
221
+ grid-template-columns: minmax(8rem, 14rem) 1fr;
222
+ gap: var(--uui-size-space-2, 6px) var(--uui-size-space-4, 12px);
223
+ margin: var(--uui-size-space-4, 12px) 0;
224
+ }
225
+ dt {
226
+ color: var(--uui-color-text-alt, #666);
227
+ }
228
+ dd {
229
+ margin: 0;
230
+ }
231
+ .actions {
232
+ display: flex;
233
+ gap: var(--uui-size-space-4, 12px);
234
+ }
235
+ .notice {
236
+ color: var(--uui-color-danger, #d42054);
237
+ }
238
+ .csv {
239
+ margin-left: auto;
240
+ }
241
+ `
242
+ }
243
+
244
+ customElements.define('crombolo-forms-entries', CromboloFormsEntriesElement)
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The Forms section's list, and the editor for one form.
3
+ *
4
+ * Umbraco Forms' designer is drag-and-drop over a canvas. This is a table: a
5
+ * form is a file, and what somebody edits here is written straight back to
6
+ * TOML, so the shape that matters is the one a diff will show. Reordering,
7
+ * multi-page layout and the richer per-type settings are refinements on top.
8
+ *
9
+ * Saving sends the whole definition. The server writes it to TOML and reads it
10
+ * back with the strict parser before keeping it, so a refusal here is the same
11
+ * refusal a hand-written file would get.
12
+ */
13
+ import { css, html, nothing, repeat } from '@umbraco-cms/backoffice/external/lit'
14
+ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
15
+ import { deleteForm, listForms, readForm, saveForm } from './forms-client.js'
16
+
17
+ const FIELD_TYPES = [
18
+ 'shortAnswer',
19
+ 'longAnswer',
20
+ 'email',
21
+ 'number',
22
+ 'date',
23
+ 'checkbox',
24
+ 'dropdown',
25
+ 'singleChoice',
26
+ 'multipleChoice',
27
+ 'fileUpload',
28
+ 'dataConsent',
29
+ 'titleAndDescription',
30
+ 'richText',
31
+ 'hidden',
32
+ ]
33
+ const CHOICE_TYPES = new Set(['dropdown', 'singleChoice', 'multipleChoice'])
34
+
35
+ export default class CromboloFormsOverviewElement extends UmbLitElement {
36
+ static properties = {
37
+ _forms: { state: true },
38
+ _editing: { state: true },
39
+ _problems: { state: true },
40
+ _notice: { state: true },
41
+ _busy: { state: true },
42
+ }
43
+
44
+ constructor() {
45
+ super()
46
+ this._forms = undefined
47
+ this._editing = undefined
48
+ this._problems = []
49
+ this._notice = ''
50
+ this._busy = false
51
+ }
52
+
53
+ connectedCallback() {
54
+ super.connectedCallback()
55
+ this.#load()
56
+ }
57
+
58
+ async #load() {
59
+ try {
60
+ this._forms = await listForms()
61
+ } catch (error) {
62
+ this._forms = []
63
+ this._notice = error.message
64
+ }
65
+ }
66
+
67
+ async #edit(key) {
68
+ this._problems = []
69
+ this._notice = ''
70
+ const detail = await readForm(key)
71
+ this._editing = structuredClone(detail.definition)
72
+ }
73
+
74
+ #new() {
75
+ this._problems = []
76
+ this._notice = ''
77
+ this._editing = {
78
+ key: crypto.randomUUID(),
79
+ alias: '',
80
+ name: '',
81
+ storeEntries: true,
82
+ requiresApproval: false,
83
+ honeypot: true,
84
+ pages: [{ groups: [{ columns: 1, fields: [] }] }],
85
+ workflows: [],
86
+ }
87
+ }
88
+
89
+ get #fields() {
90
+ return this._editing?.pages?.[0]?.groups?.[0]?.fields ?? []
91
+ }
92
+
93
+ #touch() {
94
+ // Lit tracks the reference, not what is inside it.
95
+ this._editing = { ...this._editing }
96
+ }
97
+
98
+ async #save() {
99
+ this._busy = true
100
+ this._problems = []
101
+ this._notice = ''
102
+ try {
103
+ const result = await saveForm(this._editing)
104
+ if (result.ok) {
105
+ this._notice = 'Saved.'
106
+ this._editing = undefined
107
+ await this.#load()
108
+ } else {
109
+ this._problems = result.body.problems ?? []
110
+ if (result.body.status === 'readOnly')
111
+ this._notice = 'The schema directory is read-only on this node.'
112
+ }
113
+ } catch (error) {
114
+ this._notice = error.message
115
+ } finally {
116
+ this._busy = false
117
+ }
118
+ }
119
+
120
+ async #remove(form) {
121
+ if (!confirm(`Delete the form "${form.name}"? Its entries are kept.`)) return
122
+ try {
123
+ const result = await deleteForm(form.key)
124
+ if (!result.ok) this._notice = 'That form could not be deleted.'
125
+ await this.#load()
126
+ } catch (error) {
127
+ this._notice = error.message
128
+ }
129
+ }
130
+
131
+ render() {
132
+ if (this._forms === undefined) return html`<uui-loader></uui-loader>`
133
+ return html`
134
+ ${this._notice ? html`<uui-box><p>${this._notice}</p></uui-box>` : nothing}
135
+ ${this._editing ? this.#editor() : this.#list()}
136
+ `
137
+ }
138
+
139
+ #list() {
140
+ return html`
141
+ <uui-box headline="Forms">
142
+ <p>
143
+ A form is a file in <code>schema/forms/</code>. Editing one here writes that
144
+ file, so the change can be reviewed in a pull request like any other type.
145
+ </p>
146
+ ${
147
+ this._forms.length === 0
148
+ ? html`<p>There are no forms yet.</p>`
149
+ : html`
150
+ <uui-table>
151
+ <uui-table-head>
152
+ <uui-table-head-cell>Name</uui-table-head-cell>
153
+ <uui-table-head-cell>Alias</uui-table-head-cell>
154
+ <uui-table-head-cell>Fields</uui-table-head-cell>
155
+ <uui-table-head-cell>Entries</uui-table-head-cell>
156
+ <uui-table-head-cell></uui-table-head-cell>
157
+ </uui-table-head>
158
+ ${repeat(
159
+ this._forms,
160
+ (form) => form.key,
161
+ (form) => html`
162
+ <uui-table-row>
163
+ <uui-table-cell>${form.name}</uui-table-cell>
164
+ <uui-table-cell><code>${form.alias}</code></uui-table-cell>
165
+ <uui-table-cell>${form.fieldCount}</uui-table-cell>
166
+ <uui-table-cell>${form.storeEntries ? 'stored' : 'not stored'}</uui-table-cell>
167
+ <uui-table-cell>
168
+ <uui-button look="secondary" label="Edit" @click=${() => this.#edit(form.key)}></uui-button>
169
+ <uui-button look="secondary" color="danger" label="Delete" @click=${() => this.#remove(form)}></uui-button>
170
+ </uui-table-cell>
171
+ </uui-table-row>
172
+ `,
173
+ )}
174
+ </uui-table>
175
+ `
176
+ }
177
+ <div class="actions">
178
+ <uui-button look="primary" label="Add a form" @click=${() => this.#new()}></uui-button>
179
+ </div>
180
+ </uui-box>
181
+ `
182
+ }
183
+
184
+ #editor() {
185
+ const form = this._editing
186
+ const text = (key, label, hint) => html`
187
+ <uui-form-layout-item>
188
+ <uui-label slot="label">${label}</uui-label>
189
+ ${hint ? html`<span slot="description">${hint}</span>` : nothing}
190
+ <uui-input
191
+ .value=${form[key] ?? ''}
192
+ @change=${(event) => {
193
+ form[key] = event.target.value
194
+ this.#touch()
195
+ }}
196
+ ></uui-input>
197
+ </uui-form-layout-item>
198
+ `
199
+ return html`
200
+ <uui-box headline=${form.alias ? `Form: ${form.name || form.alias}` : 'A new form'}>
201
+ ${
202
+ this._problems.length > 0
203
+ ? html`<div class="problems" role="alert">
204
+ <p>This form was not saved:</p>
205
+ <ul>
206
+ ${this._problems.map((problem) => html`<li><code>${problem.path}</code> ${problem.message}</li>`)}
207
+ </ul>
208
+ </div>`
209
+ : nothing
210
+ }
211
+ ${text('name', 'Name')}
212
+ ${text('alias', 'Alias', 'Letters and numbers, starting with a letter. The file is named after it.')}
213
+ ${text('messageOnSubmit', 'Message on submit')}
214
+ ${text('submitLabel', 'Submit button')}
215
+ <uui-form-layout-item>
216
+ <uui-label slot="label">Store entries</uui-label>
217
+ <uui-toggle
218
+ .checked=${form.storeEntries !== false}
219
+ @change=${(event) => {
220
+ form.storeEntries = event.target.checked
221
+ this.#touch()
222
+ }}
223
+ ></uui-toggle>
224
+ </uui-form-layout-item>
225
+
226
+ <h4>Fields</h4>
227
+ <uui-table>
228
+ <uui-table-head>
229
+ <uui-table-head-cell>Caption</uui-table-head-cell>
230
+ <uui-table-head-cell>Alias</uui-table-head-cell>
231
+ <uui-table-head-cell>Type</uui-table-head-cell>
232
+ <uui-table-head-cell>Choices</uui-table-head-cell>
233
+ <uui-table-head-cell>Required</uui-table-head-cell>
234
+ <uui-table-head-cell>Sensitive</uui-table-head-cell>
235
+ <uui-table-head-cell></uui-table-head-cell>
236
+ </uui-table-head>
237
+ ${repeat(
238
+ this.#fields,
239
+ (_field, index) => index,
240
+ (field, index) => html`
241
+ <uui-table-row>
242
+ <uui-table-cell>
243
+ <uui-input
244
+ .value=${field.caption ?? ''}
245
+ @change=${(event) => {
246
+ field.caption = event.target.value
247
+ this.#touch()
248
+ }}
249
+ ></uui-input>
250
+ </uui-table-cell>
251
+ <uui-table-cell>
252
+ <uui-input
253
+ .value=${field.alias ?? ''}
254
+ @change=${(event) => {
255
+ field.alias = event.target.value
256
+ this.#touch()
257
+ }}
258
+ ></uui-input>
259
+ </uui-table-cell>
260
+ <uui-table-cell>
261
+ <select
262
+ @change=${(event) => {
263
+ field.type = event.target.value
264
+ if (!CHOICE_TYPES.has(field.type)) field.values = []
265
+ this.#touch()
266
+ }}
267
+ >
268
+ ${FIELD_TYPES.map(
269
+ (type) =>
270
+ html`<option value=${type} ?selected=${type === field.type}>${type}</option>`,
271
+ )}
272
+ </select>
273
+ </uui-table-cell>
274
+ <uui-table-cell>
275
+ ${
276
+ CHOICE_TYPES.has(field.type)
277
+ ? html`<uui-input
278
+ placeholder="One, Two, Three"
279
+ .value=${(field.values ?? []).join(', ')}
280
+ @change=${(event) => {
281
+ field.values = event.target.value
282
+ .split(',')
283
+ .map((value) => value.trim())
284
+ .filter(Boolean)
285
+ this.#touch()
286
+ }}
287
+ ></uui-input>`
288
+ : nothing
289
+ }
290
+ </uui-table-cell>
291
+ <uui-table-cell>
292
+ <uui-toggle
293
+ .checked=${field.mandatory === true}
294
+ @change=${(event) => {
295
+ field.mandatory = event.target.checked
296
+ this.#touch()
297
+ }}
298
+ ></uui-toggle>
299
+ </uui-table-cell>
300
+ <uui-table-cell>
301
+ <uui-toggle
302
+ .checked=${field.sensitive === true}
303
+ @change=${(event) => {
304
+ field.sensitive = event.target.checked
305
+ this.#touch()
306
+ }}
307
+ ></uui-toggle>
308
+ </uui-table-cell>
309
+ <uui-table-cell>
310
+ <uui-button
311
+ look="secondary"
312
+ color="danger"
313
+ label="Remove"
314
+ @click=${() => {
315
+ this.#fields.splice(index, 1)
316
+ this.#touch()
317
+ }}
318
+ ></uui-button>
319
+ </uui-table-cell>
320
+ </uui-table-row>
321
+ `,
322
+ )}
323
+ </uui-table>
324
+ <div class="actions">
325
+ <uui-button
326
+ look="secondary"
327
+ label="Add a field"
328
+ @click=${() => {
329
+ this.#fields.push({
330
+ alias: '',
331
+ type: 'shortAnswer',
332
+ caption: '',
333
+ mandatory: false,
334
+ sensitive: false,
335
+ values: [],
336
+ accept: [],
337
+ })
338
+ this.#touch()
339
+ }}
340
+ ></uui-button>
341
+ <uui-button look="primary" label="Save" ?disabled=${this._busy} @click=${() => this.#save()}></uui-button>
342
+ <uui-button look="secondary" label="Cancel" @click=${() => {
343
+ this._editing = undefined
344
+ this._problems = []
345
+ }}></uui-button>
346
+ </div>
347
+ </uui-box>
348
+ `
349
+ }
350
+
351
+ static styles = css`
352
+ :host {
353
+ display: block;
354
+ padding: var(--uui-size-layout-1, 24px);
355
+ }
356
+ p,
357
+ ul {
358
+ max-width: 70ch;
359
+ line-height: 1.5;
360
+ }
361
+ h4 {
362
+ margin-top: var(--uui-size-layout-1, 24px);
363
+ }
364
+ .actions {
365
+ display: flex;
366
+ gap: var(--uui-size-space-4, 12px);
367
+ margin-top: var(--uui-size-layout-1, 24px);
368
+ }
369
+ .problems {
370
+ border-left: 3px solid var(--uui-color-danger, #d42054);
371
+ padding-left: var(--uui-size-space-4, 12px);
372
+ margin-bottom: var(--uui-size-layout-1, 24px);
373
+ }
374
+ code {
375
+ font-size: 0.9em;
376
+ }
377
+ `
378
+ }
379
+
380
+ customElements.define('crombolo-forms-overview', CromboloFormsOverviewElement)