@crombolo/backoffice-host 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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.16.0",
34
+ "@crombolo/core": "0.17.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.16.0",
5
+ "version": "0.17.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -30,6 +30,9 @@ export const listForms = async () => (await call('')).body.items ?? []
30
30
  /** The field and workflow types this site has, built in and from its bundles. */
31
31
  export const listFormTypes = async () => (await call('/available-types')).body
32
32
 
33
+ /** The site's e-mail templates, for a Send email workflow's "E-mail design". */
34
+ export const listEmailTemplates = async () => (await call('/email-templates')).body.items ?? []
35
+
33
36
  export const readForm = async (key) => (await call(`/${encodeURIComponent(key)}`)).body
34
37
 
35
38
  export const saveForm = (definition) =>
@@ -17,6 +17,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
17
17
  import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'
18
18
  import {
19
19
  deleteForm,
20
+ listEmailTemplates,
20
21
  listForms,
21
22
  listFormTypes,
22
23
  previewChoices,
@@ -40,6 +41,17 @@ import {
40
41
  setPageCaption,
41
42
  withLayout,
42
43
  } from './forms-layout.js'
44
+ import {
45
+ addWorkflow,
46
+ describeProblemPath,
47
+ removeWorkflow,
48
+ setWorkflowName,
49
+ setWorkflowSetting,
50
+ setWorkflowTrigger,
51
+ setWorkflowType,
52
+ switchValue,
53
+ workflowSettings,
54
+ } from './forms-workflows.js'
43
55
 
44
56
  /** What a preview depends on, so it survives a layout change that copies the field. */
45
57
  const sourceKey = (source) => `${source.kind}|${source.parent}`
@@ -60,6 +72,8 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
60
72
  _busy: { state: true },
61
73
  _choices: { state: true },
62
74
  _types: { state: true },
75
+ _workflowTypes: { state: true },
76
+ _designs: { state: true },
63
77
  }
64
78
 
65
79
  #notifications
@@ -78,6 +92,10 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
78
92
  this._choices = new Map()
79
93
  /** Field types by name, as the server has them: built in and from bundles. */
80
94
  this._types = new Map()
95
+ /** Workflow types by name, likewise. */
96
+ this._workflowTypes = new Map()
97
+ /** The site's e-mail templates, for a Send email workflow's design. */
98
+ this._designs = []
81
99
  }
82
100
 
83
101
  connectedCallback() {
@@ -87,9 +105,16 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
87
105
 
88
106
  async #load() {
89
107
  try {
90
- const [forms, types] = await Promise.all([listForms(), listFormTypes()])
108
+ const [forms, types, designs] = await Promise.all([
109
+ listForms(),
110
+ listFormTypes(),
111
+ // The picker can still offer Standard without them.
112
+ listEmailTemplates().catch(() => []),
113
+ ])
91
114
  this._forms = forms
92
115
  this._types = new Map(types.fields.map((info) => [info.type, info]))
116
+ this._workflowTypes = new Map(types.workflows.map((info) => [info.type, info]))
117
+ this._designs = designs
93
118
  } catch (error) {
94
119
  this._forms = []
95
120
  this._notice = error.message
@@ -484,6 +509,137 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
484
509
  </uui-table>`
485
510
  }
486
511
 
512
+ /** What happens after someone sends the form: each workflow with its settings. */
513
+ #workflows() {
514
+ const form = this._editing
515
+ const workflows = form.workflows ?? []
516
+ const types = [...this._workflowTypes.values()]
517
+ return html`<uui-box headline="Workflows" class="workflows">
518
+ <p class="offered">What happens after someone sends this form.</p>
519
+ ${
520
+ workflows.length === 0
521
+ ? html`<p>Nothing happens yet${form.storeEntries === false ? '' : ' beyond keeping the entry'}.</p>`
522
+ : nothing
523
+ }
524
+ ${repeat(
525
+ workflows,
526
+ (workflow, index) => workflow.key ?? index,
527
+ (
528
+ workflow,
529
+ index,
530
+ ) => html`<section class="workflow" aria-label=${workflow.name || `Workflow ${index + 1}`}>
531
+ <div class="bar">
532
+ <uui-input
533
+ label="Workflow name"
534
+ placeholder="Name"
535
+ .value=${workflow.name ?? ''}
536
+ @change=${(event) => this.#apply(setWorkflowName, index, event.target.value)}
537
+ ></uui-input>
538
+ <select
539
+ aria-label="What it does"
540
+ @change=${(event) => this.#apply(setWorkflowType, index, event.target.value)}
541
+ >
542
+ ${types.map(
543
+ (info) =>
544
+ html`<option value=${info.type} ?selected=${info.type === workflow.type}>${info.label}</option>`,
545
+ )}
546
+ </select>
547
+ <select
548
+ aria-label="When it runs"
549
+ @change=${(event) => this.#apply(setWorkflowTrigger, index, event.target.value)}
550
+ >
551
+ <option value="submit" ?selected=${workflow.on !== 'approve'}>When the form is sent</option>
552
+ <option value="approve" ?selected=${workflow.on === 'approve'}>When an entry is approved</option>
553
+ </select>
554
+ <uui-button compact look="secondary" color="danger" label="Remove workflow"
555
+ @click=${() => {
556
+ if (!confirm(`Remove the workflow "${workflow.name || 'untitled'}"?`)) return
557
+ this.#apply(removeWorkflow, index)
558
+ }}></uui-button>
559
+ </div>
560
+ ${this.#workflowSettings(workflow, index)}
561
+ </section>`,
562
+ )}
563
+ <uui-button
564
+ look="secondary"
565
+ label="Add a workflow"
566
+ @click=${() => {
567
+ const first = this._workflowTypes.get('sendEmail') ?? types[0]
568
+ if (first) this.#apply(addWorkflow, first.type, first.label)
569
+ }}
570
+ ></uui-button>
571
+ </uui-box>`
572
+ }
573
+
574
+ #workflowSettings(workflow, index) {
575
+ const settings = workflowSettings(workflow.type, this._workflowTypes.get(workflow.type))
576
+ if (settings.length === 0) return nothing
577
+ const write = (key, value) => this.#apply(setWorkflowSetting, index, key, value)
578
+ return html`<div class="workflow-settings">
579
+ ${settings.map((setting) => {
580
+ const value = workflow.settings?.[setting.key]
581
+ const label = `${setting.label}${setting.required ? ' *' : ''}`
582
+ const hint = setting.description
583
+ ? html`<small class="offered">${setting.description}</small>`
584
+ : nothing
585
+ if (setting.kind === 'boolean')
586
+ return html`<div class="switch">
587
+ <uui-toggle
588
+ label=${label}
589
+ .checked=${value === undefined ? setting.defaultValue === true : value === true}
590
+ @change=${(event) => write(setting.key, switchValue(setting, event.target.checked))}
591
+ ></uui-toggle>
592
+ ${hint}
593
+ </div>`
594
+ if (setting.kind === 'design') {
595
+ const known = this._designs.some((design) => design.alias === value)
596
+ return html`<label>
597
+ <span>${label}</span>
598
+ <select
599
+ aria-label=${setting.label}
600
+ @change=${(event) => write(setting.key, event.target.value || undefined)}
601
+ >
602
+ <option value="" ?selected=${!value}>Standard</option>
603
+ ${this._designs.map(
604
+ (design) =>
605
+ html`<option value=${design.alias} ?selected=${design.alias === value}>${design.name}</option>`,
606
+ )}
607
+ ${
608
+ value && !known
609
+ ? html`<option value=${value} selected>${value} (not found)</option>`
610
+ : nothing
611
+ }
612
+ </select>
613
+ ${hint}
614
+ </label>`
615
+ }
616
+ return html`<label>
617
+ <span>${label}</span>
618
+ <uui-input
619
+ label=${setting.label}
620
+ type=${setting.kind === 'number' ? 'number' : 'text'}
621
+ .value=${value === undefined ? '' : Array.isArray(value) ? value.join(', ') : String(value)}
622
+ @change=${(event) => {
623
+ const raw = event.target.value.trim()
624
+ if (raw === '') return write(setting.key, undefined)
625
+ if (setting.kind === 'number') return write(setting.key, Number(raw))
626
+ if (setting.kind === 'list')
627
+ return write(
628
+ setting.key,
629
+ raw
630
+ .split(',')
631
+ .map((item) => item.trim())
632
+ .filter(Boolean),
633
+ )
634
+ write(setting.key, raw)
635
+ }}
636
+ ></uui-input>
637
+ ${hint}
638
+ </label>`
639
+ })}
640
+ </div>`
641
+ }
642
+
487
643
  #touch() {
488
644
  // Lit tracks the reference, not what is inside it.
489
645
  this._editing = { ...this._editing }
@@ -636,12 +792,22 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
636
792
  <uui-button look="secondary" label="Add a page" @click=${() => this.#apply(addPage)}></uui-button>
637
793
  </div>
638
794
  </uui-box>
795
+ ${this.#workflows()}
639
796
  ${
640
797
  this._problems.length > 0
641
798
  ? html`<div class="problems" role="alert">
642
799
  <p>This form was not saved:</p>
643
800
  <ul>
644
- ${this._problems.map((problem) => html`<li>${problem.path ? html`<code>${problem.path}</code> ` : nothing}${problem.message}</li>`)}
801
+ ${this._problems.map((problem) => {
802
+ const where = describeProblemPath(form, problem.path, this._workflowTypes)
803
+ return html`<li>${
804
+ !where
805
+ ? nothing
806
+ : where === problem.path
807
+ ? html`<code>${where}</code> `
808
+ : html`<strong>${where}</strong>: `
809
+ }${problem.message}</li>`
810
+ })}
645
811
  </ul>
646
812
  </div>`
647
813
  : nothing
@@ -712,6 +878,25 @@ export default class CromboloFormsOverviewElement extends UmbLitElement {
712
878
  color: var(--uui-color-text-alt, #555);
713
879
  font-size: 0.9em;
714
880
  }
881
+ .workflows {
882
+ margin-top: var(--uui-size-layout-1, 24px);
883
+ }
884
+ .workflow {
885
+ border: 1px solid var(--uui-color-border, #ddd);
886
+ border-radius: var(--uui-border-radius, 3px);
887
+ padding: var(--uui-size-space-4, 12px);
888
+ margin: var(--uui-size-space-4, 12px) 0;
889
+ }
890
+ .workflow-settings {
891
+ display: grid;
892
+ gap: var(--uui-size-space-3, 9px);
893
+ max-width: 60ch;
894
+ }
895
+ .workflow-settings label,
896
+ .workflow-settings .switch {
897
+ display: grid;
898
+ gap: 2px;
899
+ }
715
900
  :host {
716
901
  display: block;
717
902
  height: 100%;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The forms designer's workflow operations: adding, retyping and removing a
3
+ * form's workflows, and what each built-in type asks for. Pure, so each is
4
+ * tested on its own (tests/forms-designer-workflows.test.ts).
5
+ *
6
+ * Every operation returns a new form. A setting the designer draws no field for
7
+ * (`cc`, `body`, `map` and the rest) is kept as the file has it.
8
+ */
9
+
10
+ /**
11
+ * The settings the designer offers for the built-in types, in the shape a
12
+ * bundle's type declares them. `design` is the e-mail design picker.
13
+ */
14
+ export const BUILT_IN_WORKFLOW_SETTINGS = {
15
+ sendEmail: [
16
+ {
17
+ key: 'to',
18
+ label: 'Send to',
19
+ kind: 'text',
20
+ required: true,
21
+ description:
22
+ 'One or more addresses, separated by commas. An alias in braces, such as {email}, puts in that answer.',
23
+ },
24
+ { key: 'subject', label: 'Subject', kind: 'text', required: true },
25
+ { key: 'reply-to', label: 'Reply to', kind: 'text' },
26
+ {
27
+ key: 'template',
28
+ label: 'E-mail design',
29
+ kind: 'design',
30
+ description: 'Standard lists each question with its answer.',
31
+ },
32
+ { key: 'attach-uploads', label: 'Attach uploaded files', kind: 'boolean' },
33
+ {
34
+ key: 'include-sensitive',
35
+ label: 'Include sensitive answers',
36
+ kind: 'boolean',
37
+ description: 'Left off, answers to fields marked sensitive are not sent.',
38
+ },
39
+ ],
40
+ saveAsContent: [
41
+ { key: 'content-type', label: 'Document type alias', kind: 'text', required: true },
42
+ {
43
+ key: 'parent',
44
+ label: 'Create under',
45
+ kind: 'text',
46
+ required: true,
47
+ description: "The parent page's key, or root for the top of the tree.",
48
+ },
49
+ { key: 'name-field', label: 'Name it from the field', kind: 'text' },
50
+ { key: 'publish', label: 'Publish straight away', kind: 'boolean' },
51
+ ],
52
+ sendToUrl: [
53
+ { key: 'url', label: 'Address', kind: 'text', required: true },
54
+ {
55
+ key: 'include-standard-fields',
56
+ label: "Include the form's name and when it was sent",
57
+ kind: 'boolean',
58
+ defaultValue: true,
59
+ },
60
+ ],
61
+ }
62
+
63
+ /**
64
+ * What a switch writes: nothing when it matches the default, so a flag that is
65
+ * off by default is absent rather than `false`, and one on by default the reverse.
66
+ */
67
+ export const switchValue = (setting, checked) =>
68
+ checked === (setting.defaultValue === true) ? undefined : checked
69
+
70
+ /** The fields to draw for a workflow type: the built-in table, or what a bundle declares. */
71
+ export const workflowSettings = (type, info) =>
72
+ BUILT_IN_WORKFLOW_SETTINGS[type] ?? info?.settings ?? []
73
+
74
+ /** A name no other workflow on the form has, from the type's label. */
75
+ export function uniqueWorkflowName(form, label) {
76
+ const taken = new Set((form.workflows ?? []).map((workflow) => workflow.name.toLowerCase()))
77
+ if (!taken.has(label.toLowerCase())) return label
78
+ for (let n = 2; ; n++) if (!taken.has(`${label} ${n}`.toLowerCase())) return `${label} ${n}`
79
+ }
80
+
81
+ export function addWorkflow(form, type, label) {
82
+ const workflow = {
83
+ key: crypto.randomUUID(),
84
+ type,
85
+ name: uniqueWorkflowName(form, label),
86
+ on: 'submit',
87
+ settings: {},
88
+ }
89
+ return { ...form, workflows: [...(form.workflows ?? []), workflow] }
90
+ }
91
+
92
+ export function removeWorkflow(form, index) {
93
+ return { ...form, workflows: form.workflows.filter((_, at) => at !== index) }
94
+ }
95
+
96
+ const replace = (form, index, change) => ({
97
+ ...form,
98
+ workflows: form.workflows.map((workflow, at) =>
99
+ at === index ? { ...workflow, ...change(workflow) } : workflow,
100
+ ),
101
+ })
102
+
103
+ /** A new type takes none of the old one's settings: they would be refused as unknown. */
104
+ export const setWorkflowType = (form, index, type) =>
105
+ replace(form, index, () => ({ type, settings: {} }))
106
+
107
+ export const setWorkflowName = (form, index, name) => replace(form, index, () => ({ name }))
108
+
109
+ export const setWorkflowTrigger = (form, index, on) =>
110
+ replace(form, index, () => ({ on: on === 'approve' ? 'approve' : 'submit' }))
111
+
112
+ /** An empty text or `undefined` removes the setting, so the file stays as short as it can. */
113
+ export const setWorkflowSetting = (form, index, key, value) =>
114
+ replace(form, index, (workflow) => {
115
+ const settings = { ...workflow.settings }
116
+ if (value === undefined || value === '') delete settings[key]
117
+ else settings[key] = value
118
+ return { settings }
119
+ })
120
+
121
+ /** Settings only the file sets, named as a person would say them when one is refused. */
122
+ const FILE_ONLY_LABELS = {
123
+ body: 'Message text',
124
+ cc: 'Copy to',
125
+ bcc: 'Blind copy to',
126
+ from: 'Send from',
127
+ }
128
+
129
+ /**
130
+ * Where a problem the server found is, in words: "Tell the team: E-mail design"
131
+ * for `workflow[0].settings.template`. A path elsewhere in the form is left as
132
+ * it is.
133
+ */
134
+ export function describeProblemPath(form, path, types = new Map()) {
135
+ const found = /^workflow\[(\d+)\](?:\.settings(?:\.([\w-]+))?|\.(name|on))?/.exec(path ?? '')
136
+ if (!found) return path
137
+ const index = Number(found[1])
138
+ const workflow = form.workflows?.[index]
139
+ const name = workflow?.name || `Workflow ${index + 1}`
140
+ if (found[3] === 'name') return `${name}: Name`
141
+ if (found[3] === 'on') return `${name}: When it runs`
142
+ if (!found[2]) return name
143
+ const setting = workflowSettings(workflow?.type, types.get(workflow?.type)).find(
144
+ (candidate) => candidate.key === found[2],
145
+ )
146
+ return `${name}: ${setting?.label ?? FILE_ONLY_LABELS[found[2]] ?? found[2]}`
147
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Localizations",
4
4
  "id": "Crombolo.Localizations",
5
- "version": "0.16.0",
5
+ "version": "0.17.0",
6
6
  "allowPublicAccess": true,
7
7
  "extensions": [
8
8
  {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Umbraco's notifications dialog, on a site that cannot send e-mail (story
3
+ * 045): a banner saying so, and every toggle disabled, rather than choices
4
+ * saved for e-mails that never come.
5
+ *
6
+ * The dialog is not an extension point and its class is not exported, so it is
7
+ * patched rather than replaced, as the log viewer's menu is: once the element
8
+ * is defined, each update disables its toggles and Save and puts the banner
9
+ * above them. `tests/notifications-dialog.test.ts` pins the vendored dialog
10
+ * this relies on, so a re-vendor that moves it fails the suite rather than the
11
+ * editor. The server refuses a new subscription regardless.
12
+ */
13
+ const TAG = 'umb-document-notifications-modal'
14
+ export const BANNER_CLASS = 'crombolo-notifications-unavailable'
15
+ export const UNAVAILABLE =
16
+ "This site can't send e-mail yet, so it can't tell you when this page changes. Ask an administrator to set up e-mail, then come back to choose what to hear about."
17
+
18
+ /** Whether this site can send notification e-mails; asked once per page load. */
19
+ let answer
20
+ export function canSendNotifications(fetcher = fetch) {
21
+ answer ??= (async () => {
22
+ const href = document.querySelector('base')?.getAttribute('href') ?? '/umbraco/'
23
+ const response = await fetcher(`${href.replace(/\/$/, '')}/crombolo/api/email`, {
24
+ credentials: 'include',
25
+ headers: { accept: 'application/json' },
26
+ })
27
+ // Unknown is not unavailable: Umbraco's dialog stays as it is.
28
+ if (!response.ok) return true
29
+ return (await response.json()).canSendNotifications !== false
30
+ })().catch(() => true)
31
+ return answer
32
+ }
33
+
34
+ /** Disables the dialog's choices and says why. Safe to call on every update. */
35
+ export function markUnavailable(root, message = UNAVAILABLE) {
36
+ for (const toggle of root.querySelectorAll('uui-toggle')) toggle.disabled = true
37
+ for (const button of root.querySelectorAll('uui-button[color="positive"]')) button.disabled = true
38
+ const box = root.querySelector('uui-box')
39
+ if (!box || box.querySelector(`.${BANNER_CLASS}`)) return
40
+ const banner = box.ownerDocument.createElement('p')
41
+ banner.className = BANNER_CLASS
42
+ banner.setAttribute('role', 'status')
43
+ banner.setAttribute(
44
+ 'style',
45
+ 'margin: 0 0 var(--uui-size-space-4); color: var(--uui-color-text-alt);',
46
+ )
47
+ banner.textContent = message
48
+ box.prepend(banner)
49
+ }
50
+
51
+ export const onInit = async () => {
52
+ // The dialog is loaded the first time someone opens it.
53
+ const element = await customElements.whenDefined(TAG)
54
+ const prototype = element.prototype
55
+ const original = prototype.updated
56
+ prototype.updated = function patched(changed) {
57
+ original?.call(this, changed)
58
+ void canSendNotifications().then((ok) => {
59
+ if (!ok && this.renderRoot) markUnavailable(this.renderRoot)
60
+ })
61
+ }
62
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Search & sharing",
4
4
  "id": "Crombolo.Seo",
5
- "version": "0.16.0",
5
+ "version": "0.17.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo Sign-in Dialog",
4
4
  "id": "Crombolo.SignInDialog",
5
- "version": "0.16.0",
5
+ "version": "0.17.0",
6
6
  "allowPublicAccess": true,
7
7
  "extensions": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
3
3
  "name": "Crombolo",
4
4
  "id": "Crombolo.Core",
5
- "version": "0.16.0",
5
+ "version": "0.17.0",
6
6
  "allowPublicAccess": false,
7
7
  "extensions": [
8
8
  {
@@ -418,6 +418,12 @@
418
418
  "name": "Crombolo Log Viewer Search Menu",
419
419
  "js": "/crombolo-plugin/branding/log-viewer-menu.js"
420
420
  },
421
+ {
422
+ "type": "backofficeEntryPoint",
423
+ "alias": "Crombolo.EntryPoint.NotificationsDialog",
424
+ "name": "Crombolo Notifications Dialog Without E-mail",
425
+ "js": "/crombolo-plugin/notifications-dialog.js"
426
+ },
421
427
  {
422
428
  "type": "globalContext",
423
429
  "alias": "Crombolo.GlobalContext.Icons",