domma-cms 0.80.0 → 0.81.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/CLAUDE.md +6 -1
- package/admin/index.html +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/templates/action-editor.html +1 -0
- package/admin/js/templates/docs/usage-actions.html +5 -0
- package/admin/js/templates/form-editor.html +72 -0
- package/admin/js/views/form-editor.js +7 -7
- package/package.json +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/site.js +1 -1
- package/server/routes/api/forms.js +36 -10
- package/server/services/actions.js +28 -0
- package/server/services/collections.js +10 -4
- package/server/services/notification-sources.js +40 -0
- package/server/templates/page.html +4 -4
package/CLAUDE.md
CHANGED
|
@@ -278,7 +278,12 @@ render. `then` and `otherwise` are separate branch keys, which is what lets an a
|
|
|
278
278
|
re-fire.
|
|
279
279
|
|
|
280
280
|
Anything a trigger decides about submission is re-enforced in `routes/api/forms.js` from the same
|
|
281
|
-
function - a browser-only block is a suggestion.
|
|
281
|
+
function - a browser-only block is a suggestion. `end-form` (state) masks the rest and turns the submit
|
|
282
|
+
into Finish: the runtime captures that click on the wrapper and announces `dm-form-end` with the
|
|
283
|
+
answers so far, which every embed (site.js, the three forms.js paths, the editor preview) posts its
|
|
284
|
+
own way. The route excuses the masked fields - in `createEntry` too, via `excused`, because
|
|
285
|
+
the collection mirrors the form's `required` - and stamps `meta.outcome`. `notify` is server-only:
|
|
286
|
+
`formTriggerNotice()`, source `core:form-triggers`. Four places attach the runtime (three in
|
|
282
287
|
`public/js/forms.js`, one in `site.js`) plus the editor's live preview; they all ask
|
|
283
288
|
`needsLogicRuntime`/`fbNeedsRuntime` rather than spelling the predicate out, because one of them
|
|
284
289
|
forgetting triggers is the obvious failure. `goto-step` is announced as a `dm-form-trigger` DOM event
|
package/admin/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<link rel="stylesheet" href="/admin/css/dashboard.css">
|
|
22
22
|
|
|
23
23
|
<!-- Forms CSS - needed for conditional logic preview (.fb-field-hidden, etc.) -->
|
|
24
|
-
<link rel="stylesheet" href="/public/css/forms.css?v=
|
|
24
|
+
<link rel="stylesheet" href="/public/css/forms.css?v=20260926-end-panel">
|
|
25
25
|
|
|
26
26
|
<!-- Menu item hover / current-page effects - the admin sidebar is a menu
|
|
27
27
|
like any other, so it honours the same `highlight` block. -->
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
81
81
|
|
|
82
82
|
<!-- Form Logic Engine - needed for conditional logic preview -->
|
|
83
|
-
<script src="/public/js/form-logic-engine.js?v=
|
|
83
|
+
<script src="/public/js/form-logic-engine.js?v=20260926-end-no-finish"></script>
|
|
84
84
|
|
|
85
85
|
<!-- Admin App (ES module entry) -->
|
|
86
86
|
<script type="module" src="/admin/js/app.js"></script>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{slugify as
|
|
1
|
+
import{slugify as d}from"./actions-arrange.js";export{d as slugify};export const STEP_TYPES={updateField:{label:"Update a field",help:"Sets one field of the entry to a value - fixed, or built from template variables such as {{now}} or {{user.name}}.",fields:[]},deleteEntry:{label:"Delete the entry",help:"Deletes the entry the action runs on. Put it last - nothing after it has an entry to work with.",fields:[]},moveToCollection:{label:"Move to another collection",help:"Copies the entry into another collection and removes it from this one.",fields:[{name:"targetCollection",label:"Target collection slug",placeholder:"e.g. archived-applications"}]},webhook:{label:"Call a webhook",help:"Sends an HTTP request to another service. The body may use template variables; JSON is sent as JSON.",fields:[{name:"url",label:"URL",placeholder:"https://hooks.example.com/notify"},{name:"method",label:"Method",placeholder:"POST"},{name:"body",label:"Body (JSON)",placeholder:'{"email": "{{entry.data.email}}"}',multiline:!0}]},email:{label:"Send an email",help:"Sends an email through the site's SMTP settings. Every field may use template variables.",fields:[{name:"to",label:"To",placeholder:"{{entry.data.email}}"},{name:"subject",label:"Subject",placeholder:"Your application update"},{name:"template",label:"Body",placeholder:"Your application has been approved.",multiline:!0}]},notify:{label:"Notify the admins",help:'Raises a notification in the admin - the bell, and a desktop notification for anyone who turned those on. Who receives it is set under Notifications > settings, source "Actions". Every field may use template variables.',fields:[{name:"title",label:"Title",placeholder:"New application from {{entry.data.name}}"},{name:"body",label:"Body",placeholder:"Submitted {{now}}.",multiline:!0},{name:"severity",label:"Severity (info, success, warning or critical)",placeholder:"info"},{name:"link",label:"Link (an admin route such as #/forms, or a web address)",placeholder:"#/collections/applications"}]}};const f=["info","success","warning","critical"];export const ROW_LEVEL_MODES=["owner","field"],USER_KEYS=["id","email","name","role"];const n=e=>e==null?"":String(e);function y(e){const t=STEP_TYPES[e?.type]?e.type:String(e?.type||"updateField"),i=e?.config&&typeof e.config=="object"?e.config:{},l={};if(t==="updateField")l.field=n(i.field),l.value=n(i.value);else{for(const r of STEP_TYPES[t]?.fields||[]){const o=i[r.name];l[r.name]=o&&typeof o=="object"?JSON.stringify(o,null,2):n(o)}STEP_TYPES[t]||Object.assign(l,i)}return{type:t,config:l}}export function makeStep(e){return y({type:e,config:{}})}export function formFromAction(e,{project:t=""}={}){const i=e?.trigger||{},l=e?.access?.rowLevel||null,r=e?.transition||null;return{title:n(e?.title),slug:n(e?.slug),description:n(e?.description),collection:n(e?.collection),project:n(e?e.meta?.project:t),bundled:e?.bundled===!0,triggerType:n(i.type)||"manual",triggerLabel:e&&n(i.label)||"Run",triggerIcon:e&&n(i.icon)||"zap",triggerConfirm:n(i.confirmMessage),roles:Array.isArray(e?.access?.roles)?[...e.access.roles]:["admin"],rowLevelEnabled:!!l,rowLevelMode:ROW_LEVEL_MODES.includes(l?.mode)?l.mode:"owner",rowLevelUserKey:USER_KEYS.includes(l?.userKey)?l.userKey:"id",rowLevelField:n(l?.field),transitionEnabled:!!(r&&r.field),transitionField:n(r?.field),transitionFrom:(Array.isArray(r?.from)?r.from:[r?.from]).filter(o=>o!=null&&o!=="").join(", "),transitionTo:n(r?.to),steps:(Array.isArray(e?.steps)?e.steps:[]).map(y)}}export function splitList(e){return n(e).split(",").map(t=>t.trim()).filter(Boolean)}function g(e){const t=n(e).trim();if(!t)return"";try{return JSON.parse(t)}catch{return t}}export function payloadFromForm(e,{meta:t={},editing:i=!1}={}){const l=(e.steps||[]).map(o=>{if(o.type==="updateField")return{type:o.type,config:{field:n(o.config.field).trim(),value:n(o.config.value).trim()}};const s=STEP_TYPES[o.type];if(!s)return{type:o.type,config:{...o.config}};const a={};for(const c of s.fields)a[c.name]=c.multiline?g(o.config[c.name]):n(o.config[c.name]).trim();return{type:o.type,config:a}}),r=e.rowLevelEnabled?{mode:e.rowLevelMode,userKey:e.rowLevelUserKey,...e.rowLevelMode==="field"&&{field:n(e.rowLevelField).trim()}}:null;return{title:n(e.title).trim(),...!i&&n(e.slug).trim()&&{slug:n(e.slug).trim()},description:n(e.description).trim(),collection:e.collection,...e.bundled?{bundled:!0}:{bundled:!1},trigger:{type:e.triggerType||"manual",label:n(e.triggerLabel).trim()||"Run",icon:n(e.triggerIcon).trim()||"zap",confirmMessage:n(e.triggerConfirm).trim()||null},steps:l,transition:e.transitionEnabled&&n(e.transitionField).trim()?{field:n(e.transitionField).trim(),from:splitList(e.transitionFrom),to:n(e.transitionTo).trim()}:null,access:{roles:[...e.roles||[]],rowLevel:r},meta:{...t||{},project:e.project||null}}}export function snapshot(e){const{meta:t,...i}=payloadFromForm(e,{editing:!0});return JSON.stringify({...i,slug:n(e.slug).trim(),project:e.project||""})}const b=/^https?:\/\/\S+$/i,h=/\{\{[^}]+\}\}/;export function checkAction(e){const t={};n(e.title).trim()||(t.title="An action needs a title."),e.collection||(t.collection="Choose the collection it runs on.");const i=n(e.slug).trim();i&&d(i)!==i&&(t.slug=`Lowercase letters, numbers and hyphens - it would be saved as "${d(i)||"?"}".`),e.rowLevelEnabled&&e.rowLevelMode==="field"&&!n(e.rowLevelField).trim()&&(t.rowLevelField="Field match needs the entry field to compare."),e.transitionEnabled&&!n(e.transitionField).trim()&&(t.transitionField="A transition needs the field that holds the state."),(e.roles||[]).length||(t.roles="Nobody could run it - choose at least one role.");const l={};return(e.steps||[]).forEach((r,o)=>{const s=r.config||{};if(r.type==="updateField"&&!n(s.field).trim())l[o]="Choose the field to update.";else if(r.type==="moveToCollection"&&!n(s.targetCollection).trim())l[o]="Give the collection to move it to.";else if(r.type==="webhook"){const a=n(s.url).trim();a?!b.test(a)&&!h.test(a)&&(l[o]="The URL must start with http:// or https://."):l[o]="A webhook needs a URL."}else if(r.type==="email"&&!n(s.to).trim())l[o]="An email needs a To address.";else if(r.type==="notify"){const a=n(s.severity).trim();n(s.title).trim()?a&&!f.includes(a)&&!h.test(a)&&(l[o]=`Severity must be one of ${f.join(", ")}.`):l[o]="A notification needs a title."}}),Object.keys(l).length&&(t.steps=l),t}export function problemsText(e){const t=[];for(const i of["title","collection","slug","roles","rowLevelField","transitionField"])e[i]&&t.push(e[i]);for(const[i,l]of Object.entries(e.steps||{}))t.push(`Step ${Number(i)+1}: ${l}`);return t.join(" ")}export function moveStep(e,t,i){const l=t+i;if(t<0||t>=e.length||l<0||l>=e.length)return e;const r=[...e];return[r[t],r[l]]=[r[l],r[t]],r}export function duplicateStep(e,t){if(t<0||t>=e.length)return e;const i={type:e[t].type,config:{...e[t].config}};return[...e.slice(0,t+1),i,...e.slice(t+1)]}export function removeStep(e,t){return e.filter((i,l)=>l!==t)}const u=(e,t=60)=>e.length>t?`${e.slice(0,t-1)}\u2026`:e;export function stepSummary(e){const t=e?.config||{};switch(e?.type){case"updateField":return t.field?u(`${t.field} \u2192 ${n(t.value)||"(empty)"}`):"No field chosen yet";case"deleteEntry":return"Deletes the entry";case"moveToCollection":return t.targetCollection?`To ${t.targetCollection}`:"No collection given yet";case"webhook":return t.url?u(`${(n(t.method).trim()||"POST").toUpperCase()} ${t.url}`):"No URL yet";case"email":return t.to?u(`To ${t.to}${t.subject?` - ${t.subject}`:""}`):"No recipient yet";case"notify":return t.title?u(t.title):"No title yet";default:return"Unknown step type - kept as it is"}}export function rolePicker(e,t=[]){const i=new Map((e||[]).filter(o=>o?.name).map(o=>[o.name,o])),l=new Set(t),r=[...i.values()].sort((o,s)=>(o.level??99)-(s.level??99)||String(o.name).localeCompare(String(s.name))).map(o=>({name:o.name,label:o.label||o.name,level:o.level??"",levelText:o.level===void 0||o.level===null?"":`level ${o.level}`,missing:!1,on:l.has(o.name)}));for(const o of t)i.has(o)||r.push({name:o,label:o,level:"",levelText:"",missing:!0,on:!0});return r}export const draftKey=e=>`action-editor.draft.${e||"new"}`;export function draftOf(e,t){const i=Array.isArray(t?.steps)?t.steps:[];return{...e,steps:(Array.isArray(e?.steps)?e.steps:[]).map(l=>{if(l?.type!=="webhook")return l;const{url:r,...o}=l.config||{},s=r?i.findIndex(a=>a?.type==="webhook"&&a.config?.url===r):-1;return{type:l.type,config:{...o,url:""},...s>=0&&{savedUrl:s},...r&&s<0&&{lostUrl:!0}}})}}export function restoreDraft(e,t){const i=Array.isArray(t?.steps)?t.steps:[];let l=0;const r=(Array.isArray(e?.steps)?e.steps:[]).map(o=>{if(o?.type!=="webhook")return o;const{savedUrl:s,lostUrl:a,...c}=o,p=Number.isInteger(s)?i[s]:null,m=p?.type==="webhook"?n(p.config?.url):"";return(a||Number.isInteger(s)&&!m)&&l++,{...c,config:{...c.config||{},url:m}}});return{form:{...t,...e,steps:r},lost:l}}
|
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
<option value="moveToCollection">Move to another collection</option>
|
|
149
149
|
<option value="webhook">Call a webhook</option>
|
|
150
150
|
<option value="email">Send an email</option>
|
|
151
|
+
<option value="notify">Notify the admins</option>
|
|
151
152
|
</select>
|
|
152
153
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="addStep"><span data-icon="plus" data-icon-size="14"></span> Add step</button>
|
|
153
154
|
</div>
|
|
@@ -61,6 +61,11 @@
|
|
|
61
61
|
<td>Send an email via the configured SMTP transport</td>
|
|
62
62
|
<td><code>to</code>, <code>subject</code>, <code>template</code></td>
|
|
63
63
|
</tr>
|
|
64
|
+
<tr>
|
|
65
|
+
<td><code>notify</code></td>
|
|
66
|
+
<td>Raise a notification in the admin (the bell, and desktop notifications where switched on). Who receives it is the "Actions" source in Notifications' settings - the admins, unless changed.</td>
|
|
67
|
+
<td><code>title</code>, <code>body</code>, <code>severity</code> (info, success, warning, critical), <code>link</code></td>
|
|
68
|
+
</tr>
|
|
64
69
|
</tbody>
|
|
65
70
|
</table>
|
|
66
71
|
|
|
@@ -272,4 +272,76 @@
|
|
|
272
272
|
border-radius: var(--dm-radius-sm); padding: 0 .35rem; flex-shrink: 0; }
|
|
273
273
|
.fex-kebab:hover { color: var(--dm-primary); }
|
|
274
274
|
#fields-list .fb-field-card:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 2px; }
|
|
275
|
+
|
|
276
|
+
/* ---- Triggers builder ------------------------------------------------------
|
|
277
|
+
Trigger card > When / Then / Otherwise sections (coloured left edge) > action
|
|
278
|
+
cards (filled, collapsible, a one-line summary in the head) > a labelled grid
|
|
279
|
+
of controls plus an Options line. Every level is visibly one step in from the
|
|
280
|
+
last, which is what the old all-same-border layout lacked. */
|
|
281
|
+
.fb-field-triggers { margin-top: .75rem; border-top: 1px solid var(--dm-border); padding-top: .5rem; }
|
|
282
|
+
.fb-trig-panel-head { display: flex; align-items: center; justify-content: space-between; cursor: pointer; padding: .15rem 0; }
|
|
283
|
+
.fb-trig-panel-title { font-size: var(--dm-text-sm); font-weight: 600; color: var(--dm-text-muted); }
|
|
284
|
+
.fb-triggers-body { padding: .5rem 0 .25rem; }
|
|
285
|
+
.fb-trig-blurb { font-size: var(--dm-text-xs); color: var(--dm-text-muted); margin: 0 0 .85rem; }
|
|
286
|
+
.fb-trig-list { display: flex; flex-direction: column; gap: 1rem; margin-bottom: .6rem; }
|
|
287
|
+
.fb-trig-empty { font-size: var(--dm-text-sm); color: var(--dm-text-muted); margin: 0; }
|
|
288
|
+
.fb-trig-add { margin-top: .5rem; }
|
|
289
|
+
.fb-trig-remove { flex-shrink: 0; line-height: 1; opacity: .6; }
|
|
290
|
+
.fb-trig-remove:hover { opacity: 1; color: var(--dm-danger); }
|
|
291
|
+
|
|
292
|
+
.fb-trig-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); background: var(--dm-surface);
|
|
293
|
+
padding: .75rem .85rem .9rem; display: flex; flex-direction: column; gap: .85rem; container-type: inline-size; }
|
|
294
|
+
.fb-trig-card-head { display: flex; align-items: center; gap: .75rem; }
|
|
295
|
+
.fb-trig-card-title { flex: 1; font-weight: 700; font-size: var(--dm-text-sm); }
|
|
296
|
+
|
|
297
|
+
.fb-trig-sec { border-left: 3px solid var(--dm-border); padding: .1rem 0 .1rem .8rem; }
|
|
298
|
+
.fb-trig-sec--when { border-left-color: var(--dm-primary); }
|
|
299
|
+
.fb-trig-sec--then { border-left-color: var(--dm-success); }
|
|
300
|
+
.fb-trig-sec--otherwise { border-left-color: color-mix(in srgb, var(--dm-text-muted) 55%, transparent); }
|
|
301
|
+
.fb-trig-sec-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .55rem; }
|
|
302
|
+
.fb-trig-sec-title { font-size: var(--dm-text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
|
303
|
+
.fb-trig-sec-hint { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
|
|
304
|
+
.fb-trig-sec-head .fb-trig-match { width: auto; flex: 0 1 220px; }
|
|
305
|
+
|
|
306
|
+
.fb-trig-cond-list { display: flex; flex-direction: column; gap: .45rem; }
|
|
307
|
+
.fb-trig-cond-row { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) auto; gap: .5rem; align-items: center; }
|
|
308
|
+
.fb-trig-cond-row .fb-trig-cond-val[hidden] { display: block !important; visibility: hidden; }
|
|
309
|
+
|
|
310
|
+
.fb-trig-action-list { display: flex; flex-direction: column; gap: .55rem; }
|
|
311
|
+
.fb-trig-action-row { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md);
|
|
312
|
+
background: color-mix(in srgb, var(--dm-text) 3%, transparent); }
|
|
313
|
+
.fb-trig-action-row.is-open { background: color-mix(in srgb, var(--dm-text) 5%, transparent); }
|
|
314
|
+
.fb-trig-action-head { display: flex; align-items: center; gap: .55rem; padding: .5rem .6rem; cursor: pointer; border-radius: var(--dm-radius-md); }
|
|
315
|
+
.fb-trig-action-head:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: -2px; }
|
|
316
|
+
.fb-trig-chevron { flex-shrink: 0; color: var(--dm-text-muted); display: inline-flex; }
|
|
317
|
+
.fb-trig-action-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .1rem; }
|
|
318
|
+
.fb-trig-action-main strong { font-size: var(--dm-text-sm); }
|
|
319
|
+
.fb-trig-action-summary { color: var(--dm-text-muted); font-size: var(--dm-text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
320
|
+
.fb-trig-kind { flex-shrink: 0; font-size: var(--dm-text-xs); padding: .1rem .5rem; border-radius: 999px; white-space: nowrap;
|
|
321
|
+
color: var(--dm-text-muted); background: color-mix(in srgb, var(--dm-text) 7%, transparent); }
|
|
322
|
+
.fb-trig-kind--event { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 12%, transparent); }
|
|
323
|
+
.fb-trig-action-body { padding: .75rem .75rem .8rem; border-top: 1px solid var(--dm-border); display: flex; flex-direction: column; gap: .75rem; }
|
|
324
|
+
|
|
325
|
+
.fb-trig-params { display: flex; flex-direction: column; gap: .75rem; }
|
|
326
|
+
.fb-trig-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: .7rem .9rem; }
|
|
327
|
+
/* A multi-select is a list box, not a dropdown: no chevron, its own height. */
|
|
328
|
+
.fb-trig-p-fields { background-image: none; height: auto; min-height: 6rem; padding: .3rem; }
|
|
329
|
+
.fb-trig-f { display: flex; flex-direction: column; gap: .3rem; min-width: 0; margin: 0; }
|
|
330
|
+
.fb-trig-f--full { grid-column: 1 / -1; }
|
|
331
|
+
.fb-trig-f-label { font-size: var(--dm-text-xs); font-weight: 600; color: var(--dm-text-muted); }
|
|
332
|
+
.fb-trig-f-hint { font-size: var(--dm-text-xs); color: var(--dm-text-muted); }
|
|
333
|
+
.fb-trig-opts { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; padding-top: .65rem; border-top: 1px dashed var(--dm-border); }
|
|
334
|
+
.fb-trig-check { display: inline-flex; align-items: center; gap: .4rem; font-size: var(--dm-text-sm); cursor: pointer; margin: 0; }
|
|
335
|
+
|
|
336
|
+
.fb-trig-f[hidden], .fb-trig-check[hidden], .fb-trig-action-body[hidden], .fb-triggers-body[hidden] { display: none !important; }
|
|
337
|
+
|
|
338
|
+
/* Keyed on the card's own width, not the window's: the field panel can be
|
|
339
|
+
narrow on a wide screen (a resized slideover, a two-column editor). */
|
|
340
|
+
@container (max-width: 460px) {
|
|
341
|
+
.fb-trig-cond-row { grid-template-columns: minmax(0, 1fr) auto; }
|
|
342
|
+
.fb-trig-cond-row > .form-select, .fb-trig-cond-row > .form-input { grid-column: 1; }
|
|
343
|
+
.fb-trig-cond-row > .fb-trig-remove { grid-column: 2; grid-row: 1; }
|
|
344
|
+
.fb-trig-cond-row .fb-trig-cond-val[hidden] { display: none !important; }
|
|
345
|
+
.fb-trig-kind { display: none; }
|
|
346
|
+
}
|
|
275
347
|
</style>
|