@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/assets/login.js +352 -0
- package/package.json +35 -4
- package/plugin/assistant/client.js +53 -0
- package/plugin/assistant/drawer.js +865 -0
- package/plugin/assistant/umbraco-package.json +16 -0
- package/plugin/branding/backdrop.svg +1 -0
- package/plugin/branding/branding.js +100 -0
- package/plugin/branding/bundle-builder-labels.js +63 -0
- package/plugin/branding/client-credentials.js +49 -0
- package/plugin/branding/components-tree.js +40 -0
- package/plugin/branding/content-type-labels.js +27 -0
- package/plugin/branding/favicon.svg +1 -0
- package/plugin/branding/help-menu.js +46 -0
- package/plugin/branding/icon-mark.js +15 -0
- package/plugin/branding/icon-registry.js +40 -0
- package/plugin/branding/localization-en.js +115 -0
- package/plugin/branding/log-viewer-menu.js +70 -0
- package/plugin/branding/logo-header.svg +1 -0
- package/plugin/branding/logo-surface.svg +1 -0
- package/plugin/branding/query-builder.js +28 -0
- package/plugin/branding/sign-in.js +80 -0
- package/plugin/branding/sysinfo.js +89 -0
- package/plugin/branding/theme.css +102 -0
- package/plugin/bundles-client.js +34 -0
- package/plugin/bundles-created-overview.js +150 -0
- package/plugin/bundles-created.js +70 -0
- package/plugin/bundles-installed.js +147 -0
- package/plugin/bundles-marketplace.js +199 -0
- package/plugin/changes-dashboard.js +132 -0
- package/plugin/element-type-hint.js +58 -0
- package/plugin/form-picker.js +116 -0
- package/plugin/forms-client.js +52 -0
- package/plugin/forms-entries.js +244 -0
- package/plugin/forms-overview.js +380 -0
- package/plugin/localizations/ar.js +97 -0
- package/plugin/localizations/bs.js +86 -0
- package/plugin/localizations/cs.js +73 -0
- package/plugin/localizations/cy.js +92 -0
- package/plugin/localizations/da.js +101 -0
- package/plugin/localizations/de.js +79 -0
- package/plugin/localizations/en.js +71 -0
- package/plugin/localizations/es.js +52 -0
- package/plugin/localizations/fr.js +78 -0
- package/plugin/localizations/he.js +24 -0
- package/plugin/localizations/hr.js +89 -0
- package/plugin/localizations/it.js +86 -0
- package/plugin/localizations/ja.js +43 -0
- package/plugin/localizations/ko.js +24 -0
- package/plugin/localizations/nb.js +44 -0
- package/plugin/localizations/nl.js +85 -0
- package/plugin/localizations/pl.js +44 -0
- package/plugin/localizations/pt-br.js +25 -0
- package/plugin/localizations/pt.js +100 -0
- package/plugin/localizations/ro.js +9 -0
- package/plugin/localizations/ru.js +46 -0
- package/plugin/localizations/sv.js +46 -0
- package/plugin/localizations/tr.js +80 -0
- package/plugin/localizations/uk.js +46 -0
- package/plugin/localizations/umbraco-package.json +279 -0
- package/plugin/localizations/vi.js +104 -0
- package/plugin/localizations/zh-tw.js +38 -0
- package/plugin/localizations/zh.js +38 -0
- package/plugin/read-only-banner.js +48 -0
- package/plugin/report-client.js +13 -0
- package/plugin/seo/client.js +35 -0
- package/plugin/seo/dashboard.js +242 -0
- package/plugin/seo/document-view.js +161 -0
- package/plugin/seo/type-hint.js +45 -0
- package/plugin/seo/umbraco-package.json +61 -0
- package/plugin/settings-dashboard.js +123 -0
- package/plugin/tsx-editors.js +337 -0
- package/plugin/tsx-snippets.js +63 -0
- package/plugin/umbraco-package.json +298 -0
- package/plugin/welcome-dashboard.js +106 -0
- package/src/branding.ts +59 -0
- package/src/extensions.ts +228 -0
- package/src/graphics.ts +87 -0
- package/src/index.ts +7 -0
- package/src/login-assets.ts +18 -0
- package/src/manifests.ts +154 -0
- package/src/paths.ts +117 -0
- package/src/shell.ts +139 -0
- package/src/static.ts +107 -0
- package/src/text-imports.d.ts +23 -0
- 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)
|