domma-cms 0.100.2 → 0.101.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/CLAUDE.md +16 -0
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/help-popover.js +1 -1
- package/admin/js/lib/loading.js +1 -0
- package/admin/js/templates/collection-entries.html +9 -7
- package/admin/js/templates/collections.html +2 -2
- package/admin/js/templates/dashboard.html +6 -1
- package/admin/js/templates/form-submissions.html +1 -1
- package/admin/js/templates/forms.html +1 -1
- package/admin/js/templates/media.html +3 -3
- package/admin/js/templates/menus.html +1 -1
- package/admin/js/templates/notifications.html +1 -1
- package/admin/js/templates/pages.html +1 -1
- package/admin/js/templates/plugins.html +1 -1
- package/admin/js/templates/users.html +1 -1
- package/admin/js/views/collection-entries.js +6 -6
- package/admin/js/views/collections.js +4 -4
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/media.js +3 -3
- package/admin/js/views/menus.js +2 -2
- package/admin/js/views/notifications.js +5 -5
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/plugins.js +7 -7
- package/admin/js/views/users.js +2 -2
- package/package.json +2 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}from"../lib/plugin-chrome.js";import{helpPopovers as
|
|
1
|
+
import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}from"../lib/plugin-chrome.js";import{SHAPES as rt,showLoading as dt}from"../lib/loading.js";import{helpPopovers as Re}from"../lib/help-popover.js";import{onceClosed as ut,openMenuAt as Ue,sidebarCount as j,viewLifetime as pt}from"../lib/tool-kit.js";import{MAX_COLUMNS as ft,PAGE_SIZE as oe,SHOW_STEP as X,arrangeEntries as mt,canClear as ht,cellText as ze,countText as yt,draftable as vt,filterChoices as bt,filterText as gt,hasForm as Et,shownValue as ye,snapshot as je,typedValue as kt,valueText as Pe}from"../lib/collection-entries-arrange.js";import{fmtDate as xt,fmtDateTime as P}from"../lib/dates.js";const Be=/^#\/collections\/[^/?#]+\/entries\/?(\?.*)?$/,B=m=>`collection-entries.draft.${m}`,D=m=>String(m??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function ce(m){const h=document.createElement("template");return h.innerHTML=m.trim(),h.content.firstElementChild}const wt='<span class="cex-chip">{{text}}<button type="button" data-idx="{{idx}}" title="Remove this filter" aria-label="Remove filter {{text}}">\xD7</button></span>';function St(m){return`
|
|
2
2
|
<tr class="cex-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<td class="cex-check"><input type="checkbox" class="cex-sel" aria-label="Select this entry" data-bind-checked="sel"></td>
|
|
4
4
|
${Array.from({length:m},(v,a)=>`<td data-col="${a}" title="{{t${a}}}">{{c${a}}}</td>`).join("")}
|
|
5
5
|
<td class="cex-when" title="{{whenFull}}">{{when}}</td>
|
|
6
6
|
<td class="cex-tools"><button type="button" class="cex-run" data-bind-hidden="!runs" title="Run one of this collection's actions">\u26A1 {{runs}}</button><button type="button" class="cex-kebab" aria-label="Actions for this entry" title="Actions">\u22EE</button></td>
|
|
7
|
-
</tr>`}function
|
|
7
|
+
</tr>`}function Ct(m,h){const v={};return m.forEach(a=>{const i={};a.fullWidth?i.span=h:a.span>1&&(i.span=a.span),a.placeholder&&(i.placeholder=a.placeholder),a.helper&&(i.hint=a.helper),a.prefix&&(i.prefix=a.prefix),a.suffix&&(i.suffix=a.suffix);for(const d of["clear","stepper","counter"])a[d]===!0&&(i[d]=!0);const r=a.type==="checkbox"?"boolean":a.type==="date"?"string":a.type;v[a.name]={type:r,label:a.label,required:a.required,options:a.options,...Object.keys(i).length?{formConfig:i}:{}}}),v}function le(m,h){const v={};for(const a of h){const i=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!i.length)continue;const r=i[0];r.type==="radio"?v[a.name]=i.find(d=>d.checked)?.value??"":r.type==="checkbox"&&i.length>1?v[a.name]=i.filter(d=>d.checked).map(d=>d.value):r.type==="checkbox"?v[a.name]=r.checked:r.tagName==="SELECT"&&r.multiple?v[a.name]=[...r.selectedOptions].map(d=>d.value):v[a.name]=kt(a,r.value)}return v}function Fe(m,h,v){for(const a of h){if(!(a.name in v))continue;const i=v[a.name],r=[...m.querySelectorAll(`[name="${CSS.escape(a.name)}"]`)];if(!r.length)continue;const d=r[0];d.type==="radio"?r.forEach(l=>{l.checked=l.value===String(i)}):d.type==="checkbox"&&r.length>1?r.forEach(l=>{l.checked=Array.isArray(i)&&i.includes(l.value)}):d.type==="checkbox"?d.checked=i===!0:d.tagName==="SELECT"&&d.multiple?[...d.options].forEach(l=>{l.selected=Array.isArray(i)&&i.includes(l.value)}):d.value=i??"",r.forEach(l=>l.dispatchEvent(new Event("input",{bubbles:!0})))}}const _e=pt(Be);export const collectionEntriesView={templateUrl:"/admin/js/templates/collection-entries.html",async onMount(m){_e.reset();const h=m.get(0).querySelector(".cex")||m.get(0),v=location.hash.match(/^#\/collections\/([^/?#]+)\/entries/),a=v?decodeURIComponent(v[1]):null,i=()=>Be.test(location.hash)&&h.isConnected&&location.hash.includes(`/collections/${encodeURIComponent(a)}/entries`),r=e=>(_e.add(e),e);if(!a){E.toast("No collection selected.",{type:"error"});return}const d=M.observable(null),l=M.observable([]),x=M.observable(0),Y=M.observable(!0),_=M.observable(""),He=M.computed(()=>d.value!==null),ie=M.observable(""),w=M.observable([]),Z=M.observable({key:"created",dir:"desc"}),y=M.observable([]),q=M.observable(X),H=M.observable([]),Q=M.observable({}),A=M.observable(!1);let ee=1;const re=()=>d.peek()?.fields||[],de=M.computed(()=>(d.value?.fields||[]).slice(0,ft)),J=e=>l.peek().find(t=>t.id===e),ve=e=>H.peek().filter(t=>{const n=Q.peek()[t.slug];return n==null||n.has(e)}),T=M.observable([]),be=M.observable([]);r(M.effect(()=>{const e=mt(l.value,{filters:w.value,query:ie.value,sort:Z.value});T.value=e})),r(M.effect(()=>{const e=de.value,t=new Set(y.value);Q.value,H.value,be.value=T.value.slice(0,q.value).map(n=>{const s={id:n.id,sel:t.has(n.id),rowCls:t.has(n.id)?"is-selected":""};e.forEach((c,u)=>{const f=ye(c,n.data?.[c.name]);s[`c${u}`]=ze(f),s[`t${u}`]=Pe(f)});const o=n.meta?.createdAt;s.when=o?xt(o):"",s.whenFull=o?`Created ${P(o)}${n.meta?.updatedAt?` \xB7 changed ${P(n.meta.updatedAt)}`:""}`:"";const p=ve(n.id).length;return s.runs=p?String(p):"",s})}));const Je=M.computed(()=>w.value.map((e,t)=>({key:`${t}:${e.field}:${e.op}:${e.value}`,idx:t,text:gt(e)}))),Ve=M.computed(()=>y.value.length),Ke={schema:d,loaded:l,loading:Y,loadError:_,ready:He,query:ie,rows:be,chips:Je,busy:A,selectedCount:Ve,countLine:M.computed(()=>yt(T.value.length,l.value.length,x.value)),selectedText:M.computed(()=>`${y.value.length} selected`),canClear:M.computed(()=>ht(a)),introText:M.computed(()=>{const e=d.value;return e?`${e.description?`${e.description} - `:""}${(e.fields||[]).length} field${(e.fields||[]).length===1?"":"s"}${e.storage?.adapter==="mongodb"?", on MongoDB":""}.`:""}),emptyText:M.computed(()=>l.value.length?"No entries match the search or filters.":"No entries yet - Add entry in the banner makes one, or Import brings many."),moreText:M.computed(()=>{const e=T.value.length-q.value;if(e>0)return`Show ${Math.min(X,e)} more (${e} not shown)`;const t=x.value-l.value.length;return t>0?`Load the next ${Math.min(oe,t)} from the server`:""})};async function V({reset:e=!0,pending:t=null}={}){Y.value=!0;try{const n=e?Math.max(oe,l.peek().length):oe;e&&(ee=1);const s=await g.collections.listEntries(a,{limit:n,page:ee});if(t&&await t.done(),!i())return;const o=s?.entries||(Array.isArray(s)?s:[]);l.value=e?o:[...l.peek(),...o.filter(c=>!J(c.id))],x.value=Number(s?.total)||l.peek().length,e&&(ee=Math.max(1,Math.ceil(l.peek().length/oe)));const p=new Set(l.peek().map(c=>c.id));y.value=y.peek().filter(c=>p.has(c)),_.value="",await Xe()}catch(n){_.value=`Could not load the entries: ${n.message||n}`}finally{t&&await t.done(),Y.value=!1}}async function Ge(){ee+=1,await V({reset:!1})}async function We(){try{if((await it("/actions/_count",{method:"GET"}).catch(()=>null))?.available===!1)return;const t=await g.actions.forCollection(a);H.value=Array.isArray(t)?t:[]}catch{H.value=[]}}async function Xe(){const e=H.peek().filter(s=>s.access?.rowLevel);if(!e.length||!l.peek().length)return;const t=l.peek().map(s=>s.id),n={...Q.peek()};await Promise.all(e.map(async s=>{try{n[s.slug]=new Set((await g.actions.checkAccess(s.slug,t)).allowed)}catch{n[s.slug]=null}})),Q.value=n}const K=e=>{const t=re()[0];return(t?ze(ye(t,e?.data?.[t.name]),40):"")||`Entry ${String(e?.id||"").slice(0,8)}`};async function ge(e){if(await E.confirm(`Delete \u201C${D(K(e))}\u201D? This cannot be undone.`))try{await g.collections.deleteEntry(a,e.id),l.value=l.peek().filter(t=>t.id!==e.id),x.value=Math.max(0,x.peek()-1),y.value=y.peek().filter(t=>t!==e.id),j("#/collections"),E.toast("Entry deleted.",{type:"success"})}catch(t){E.toast(`Not deleted: ${t.message||t}`,{type:"error"})}}async function Ye(){const e=[...y.peek()];if(!e.length||!await E.confirm(`Delete ${e.length} entr${e.length===1?"y":"ies"}? This cannot be undone.`))return;A.value=!0;let t=0;const n=[];for(const s of e)try{await g.collections.deleteEntry(a,s),t++}catch(o){n.push(o.message||String(o))}A.value=!1,y.value=[],j("#/collections"),E.toast(n.length?`Deleted ${t}; ${n.length} refused (${n[0]}).`:`Deleted ${t} entr${t===1?"y":"ies"}.`,{type:n.length?"warning":"success",duration:n.length?7e3:4e3}),i()&&await V()}async function Ze(){const e=d.peek();if(await E.confirm(`Delete ALL ${x.peek()} entries in \u201C${D(e?.title||a)}\u201D? This cannot be undone.`)){A.value=!0;try{await g.collections.clearEntries(a),l.value=[],x.value=0,y.value=[],j("#/collections"),E.toast("All entries deleted.",{type:"success"})}catch(t){E.toast(`Not cleared: ${t.message||t}`,{type:"error"})}finally{A.value=!1}}}async function Qe(e){try{const t=await g.collections.createEntry(a,{...e.data||{}});l.value=[t,...l.peek()],x.value=x.peek()+1,j("#/collections"),E.toast("Duplicated - the copy is at the top.",{type:"success"})}catch(t){E.toast(`Not duplicated: ${t.message||t}`,{type:"error"})}}async function et(e,t){const n=e.trigger?.confirmMessage;if(!(n&&!await E.confirm(D(n)))){A.value=!0;try{const s=await g.actions.execute(e.slug,t.id);if(s?.success){const o=s.stepsCompleted??0;E.toast(`${e.trigger?.label||e.title||"Action"} done (${o} step${o===1?"":"s"}).`,{type:"success"}),i()&&await V()}else E.toast(s?.results?.find(o=>!o.success)?.error||"The action failed.",{type:"error"})}catch(s){E.toast(s.message||"The action failed.",{type:"error"})}finally{A.value=!1}}}async function Ee(e){try{await navigator.clipboard.writeText(e),E.toast("Copied.",{type:"success",duration:1500})}catch{E.toast("Copy failed - your browser refused the clipboard.",{type:"error"})}}function ue(e){const t=y.peek();y.value=t.includes(e)?t.filter(n=>n!==e):[...t,e]}function ke(e,t,n){w.value=[...w.peek(),{field:e.name,label:e.label||e.name,op:t,value:n}],q.value=X}const pe=new Set;let N=null,xe=!1;function we(e,t,n){const s=e.close.bind(e);let o=!1,p=!1;e.close=async c=>{if(!o&&c!==!0&&!xe&&t()){if(p)return;p=!0;const u=await E.confirm(n);if(p=!1,!u)return}return o=!0,s()}}function te(e,t=()=>{}){pe.add(e),ut(e,()=>{pe.delete(e),t()})}function O(e,t=null){const n=d.peek(),s=re(),o=!!e,p=E.slideover({title:o?`Edit ${D(K(e))}`:"New entry",size:"md",position:"right"}),c=ce(`
|
|
8
8
|
<div class="cex-form">
|
|
9
9
|
<div class="cex-fields"></div>
|
|
10
10
|
<small class="cex-hint cex-when-line"></small>
|
|
@@ -15,7 +15,7 @@ import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}fro
|
|
|
15
15
|
<button type="button" class="btn btn-sm btn-ghost" data-act="cancel">Cancel</button>
|
|
16
16
|
<button type="button" class="btn btn-sm btn-primary" data-act="save"><span data-icon="check" data-icon-size="13"></span> <span class="cex-save-text"></span></button>
|
|
17
17
|
</div>
|
|
18
|
-
</div>`);
|
|
18
|
+
</div>`);c.querySelector(".cex-save-text").textContent=o?"Save":"Add entry";const u=c.querySelector(".cex-fields");F.create(Ct(s,n?.columns||2),e?.data||{},{layout:n?.layout||"stacked",columns:n?.columns||2,showSubmitButton:!1}).renderTo(u),s.forEach(k=>{if(k.type!=="date"||!k.name)return;const U=u.querySelector(`[name="${CSS.escape(k.name)}"]`);U&&U.type!=="date"&&(U.type="date")}),e?.data&&Fe(u,s,e.data),o&&e.meta?.createdAt&&(c.querySelector(".cex-when-line").textContent=`Created ${P(e.meta.createdAt)}${e.meta.updatedAt?`, last changed ${P(e.meta.updatedAt)}`:""}${e.meta.source?` \xB7 via ${e.meta.source}`:""}.`),p.element.appendChild(c);const f=je(le(u,s));t&&Fe(u,s,t);const b=()=>je(le(u,s))!==f,lt=c.querySelector(".cex-dirty"),me=()=>{lt.hidden=!b()};u.addEventListener("input",me),u.addEventListener("change",me),me();let he=!1;async function Ie(){if(he)return;document.activeElement?.dispatchEvent?.(new Event("change",{bubbles:!0}));const k=le(u,s);he=!0;const U=c.querySelector('[data-act="save"]');U.disabled=!0;try{if(o){const W=await g.collections.updateEntry(a,e.id,k);l.value=l.peek().map(Oe=>Oe.id===e.id?W:Oe),E.toast("Entry saved.",{type:"success"})}else{const W=await g.collections.createEntry(a,k);l.value=[W,...l.peek()],x.value=x.peek()+1,j("#/collections"),E.toast("Entry added.",{type:"success"})}try{S.remove?S.remove(B(a)):S.set(B(a),null)}catch{}p.close(!0)}catch(W){E.toast(W.message||"Not saved.",{type:"error"})}finally{he=!1,U.disabled=!1}}c.querySelector('[data-act="save"]').addEventListener("click",Ie),c.querySelector('[data-act="cancel"]').addEventListener("click",()=>p.close()),c.addEventListener("keydown",k=>{(k.ctrlKey||k.metaKey)&&(k.key==="Enter"||k.key==="s")&&(k.preventDefault(),Ie())});const De={id:e?.id??null,dirty:b,values:()=>vt(le(u,s),s)};N=De,te(p,()=>{N===De&&(N=null)}),we(p,b,"Discard the changes you have not saved?"),I.scan(c),p.open(),u.querySelector("input, select, textarea")?.focus()}function tt(e){const t=E.slideover({title:D(K(e)),size:"md",position:"right"}),n=ce(`
|
|
19
19
|
<div class="cex-form">
|
|
20
20
|
<dl class="cex-detail"></dl>
|
|
21
21
|
<div class="cex-meta"></div>
|
|
@@ -24,7 +24,7 @@ import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}fro
|
|
|
24
24
|
<span class="cex-spacer"></span>
|
|
25
25
|
<button type="button" class="btn btn-sm btn-primary" data-act="edit"><span data-icon="edit" data-icon-size="13"></span> Edit</button>
|
|
26
26
|
</div>
|
|
27
|
-
</div>`),s=n.querySelector(".cex-detail");for(const
|
|
27
|
+
</div>`),s=n.querySelector(".cex-detail");for(const c of re()){const u=Pe(ye(c,e.data?.[c.name])),f=document.createElement("dt");f.textContent=c.label||c.name;const b=document.createElement("dd");b.textContent=u===""?"-":u,s.append(f,b)}const o=n.querySelector(".cex-meta"),p=c=>{const u=document.createElement("span");u.textContent=c,o.appendChild(u)};p(`Id: ${e.id}`),e.meta?.createdAt&&p(`Created: ${P(e.meta.createdAt)}`),e.meta?.updatedAt&&p(`Changed: ${P(e.meta.updatedAt)}`),e.meta?.source&&p(`Source: ${e.meta.source}`),n.querySelector('[data-act="copy"]').addEventListener("click",()=>Ee(JSON.stringify(e,null,2))),n.querySelector('[data-act="edit"]').addEventListener("click",()=>{t.close(!0),O(e)}),t.element.appendChild(n),te(t),I.scan(n),t.open()}function nt(){const e=E.slideover({title:"Import entries",size:"md",position:"right"}),t=ce(`
|
|
28
28
|
<div class="cex-form">
|
|
29
29
|
<p class="cex-hint">A JSON array of entries, each with a "data" object of field values - what Export gives you.
|
|
30
30
|
<span data-help="Each entry is added as new, with a new id and today's date (the Data Transfer plugin from the Marketplace keeps ids and dates). Entries are checked like any other save - one that fails, or points a reference at an entry that does not exist, is skipped and reported. Values are stored as given, including fields the collection does not have."></span></p>
|
|
@@ -40,11 +40,11 @@ import{api as g,apiRequest as it}from"../api.js";import{addBannerAction as z}fro
|
|
|
40
40
|
<button type="button" class="btn btn-sm btn-ghost" data-act="cancel">Cancel</button>
|
|
41
41
|
<button type="button" class="btn btn-sm btn-primary" data-act="import"><span data-icon="upload" data-icon-size="13"></span> Import</button>
|
|
42
42
|
</div>
|
|
43
|
-
</div>`),n=t.querySelector("textarea"),s=t.querySelector(".cex-err"),o=t.querySelector(".cex-ok"),
|
|
43
|
+
</div>`),n=t.querySelector("textarea"),s=t.querySelector(".cex-err"),o=t.querySelector(".cex-ok"),p=t.querySelector('input[type="file"]');t.querySelector('[data-act="file"]').addEventListener("click",()=>p.click()),p.addEventListener("change",async()=>{const u=p.files?.[0];p.value="",u&&(n.value=await u.text())}),t.querySelector('[data-act="cancel"]').addEventListener("click",()=>e.close());let c=!1;t.querySelector('[data-act="import"]').addEventListener("click",async()=>{s.hidden=!0,o.hidden=!0;let u;try{if(u=JSON.parse(n.value),!Array.isArray(u))throw new Error("it must be a JSON array")}catch(b){s.textContent=`Not valid: ${b.message}`,s.hidden=!1;return}const f=t.querySelector('[data-act="import"]');f.disabled=!0;try{const b=await g.collections.import(a,u);o.textContent=`Imported ${b.imported}; skipped ${b.skipped}.`,o.hidden=!1,c=!0,j("#/collections"),i()&&await V(),setTimeout(()=>e.close(!0),1200)}catch(b){s.textContent=b.message||"Import failed.",s.hidden=!1}finally{f.disabled=!1}}),e.element.appendChild(t),te(e),we(e,()=>!c&&n.value.trim()!=="","Discard the entries you pasted?"),Re(t),I.scan(t),e.open(),n.focus()}function at(){const e=E.slideover({title:"Export entries",size:"sm",position:"right"}),t=ce(`
|
|
44
44
|
<div class="cex-form">
|
|
45
45
|
<p class="cex-hint">Every entry in the collection - not only the ones shown.</p>
|
|
46
46
|
<div class="cex-export">
|
|
47
47
|
<button type="button" class="btn btn-ghost" data-fmt="json"><span data-icon="download" data-icon-size="14"></span> Export as JSON</button>
|
|
48
48
|
<button type="button" class="btn btn-ghost" data-fmt="csv"><span data-icon="download" data-icon-size="14"></span> Export as CSV</button>
|
|
49
49
|
</div>
|
|
50
|
-
</div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const
|
|
50
|
+
</div>`);t.querySelectorAll("[data-fmt]").forEach(n=>n.addEventListener("click",async()=>{const s=n.dataset.fmt;try{const o=await fetch(`/api/collections/${encodeURIComponent(a)}/export?format=${s}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!o.ok)throw new Error(`${o.status}`);const p=await o.blob(),c=document.createElement("a");c.href=URL.createObjectURL(p),c.download=`${a}-entries.${s}`,document.body.appendChild(c),c.click(),c.remove(),setTimeout(()=>URL.revokeObjectURL(c.href),1e3),e.close(!0)}catch(o){E.toast(`Export failed (${o.message}).`,{type:"error"})}})),e.element.appendChild(t),te(e),I.scan(t),e.open()}function st(e,t){const n=J(e.dataset.id);if(!n)return[];const s=t?.event?.target?.closest?.("td[data-col]"),o=s?de.peek()[Number(s.dataset.col)]:null,p=ve(n.id),c=y.peek().includes(n.id),u=o?bt(o,n.data?.[o.name]):[];return[{header:K(n)},{label:"Edit\u2026",icon:"edit",action:()=>O(n)},{label:"See all of it\u2026",icon:"eye",action:()=>tt(n)},{label:"Duplicate",icon:"copy",action:()=>Qe(n)},{label:"Copy id",icon:"hash",action:()=>Ee(n.id)},{label:c?"Deselect":"Select",icon:"check",action:()=>ue(n.id)},...p.length?[{type:"divider"},...p.map(f=>({label:`${f.trigger?.label||f.title||f.slug}\u2026`,icon:f.trigger?.icon||"zap",disabled:A.peek(),action:()=>et(f,n)}))]:[],...o?[{type:"divider"},{label:`Filter by ${o.label||o.name}`,icon:"filter",submenu:[...u.map(f=>({label:f.label,action:()=>ke(o,f.op,f.value)})),{label:`${o.label||o.name} contains\u2026`,action:async()=>{const f=await E.prompt(`Show entries whose ${D(o.label||o.name)} contains`,{inputPlaceholder:"text"});f&&String(f).trim()&&ke(o,"contains",String(f).trim())}}]}]:[],...w.peek().length?[{label:`Clear filters (${w.peek().length})`,icon:"x",action:()=>{w.value=[]}}]:[],{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>ge(n)}]}const L=h.querySelector(".cex-rows"),G=h.querySelector(".cex-head"),fe=h.querySelector(".cex-chips-list");fe.innerHTML=wt;const ne=dt(h.querySelector(".dm-loading-slot"),rt.table);try{d.value=await g.collections.get(a)}catch(e){_.value=`Could not load the collection: ${e.message||e}`}if(!i()){ne.done();return}if(!d.peek()&&(await ne.done(),!_.peek())){E.toast("Collection not found.",{type:"error"}),R.navigate("/collections");return}const Se=de.peek();L.innerHTML=St(Se.length),G.replaceChildren();const ae=(e,t)=>{const n=document.createElement("th");return e&&(n.className=e),t&&n.append(t),G.appendChild(n),n},$=document.createElement("input");$.type="checkbox",$.title="Select every entry shown",$.setAttribute("aria-label","Select every entry shown"),ae("cex-check",$);const Ce=(e,t)=>{const n=document.createElement("button");return n.type="button",n.dataset.sort=e,n.append(document.createTextNode(t),Object.assign(document.createElement("span"),{className:"cex-sort"})),n};Se.forEach(e=>ae("",Ce(e.name,e.label||e.name))),ae("cex-when",Ce("created","Created")),ae("cex-tools"),r(M.effect(()=>{const e=Z.value;G.querySelectorAll("[data-sort]").forEach(t=>{t.querySelector(".cex-sort").textContent=t.dataset.sort===e.key?e.dir==="asc"?"\u25B2":"\u25BC":"",t.parentElement.setAttribute("aria-sort",t.dataset.sort===e.key?e.dir==="asc"?"ascending":"descending":"none")})})),r(M.effect(()=>{const e=T.value.map(n=>n.id),t=new Set(y.value);$.checked=e.length>0&&e.every(n=>t.has(n)),$.indeterminate=!$.checked&&e.some(n=>t.has(n))}));const $e=e=>{const t=e.target.closest("[data-sort]");if(!t)return;const n=Z.peek();Z.value={key:t.dataset.sort,dir:n.key===t.dataset.sort&&n.dir==="asc"?"desc":"asc"}};G.addEventListener("click",$e),$.addEventListener("change",()=>{const e=T.peek().map(t=>t.id);y.value=$.checked?[...new Set([...y.peek(),...e])]:y.peek().filter(t=>!e.includes(t))}),r(()=>G.removeEventListener("click",$e));const ot=M.applyBindings(Ke,h,{methods:{clearSelection:()=>{y.value=[]},deleteSelected:()=>Ye(),clearAll:()=>Ze(),clearFilters:()=>{w.value=[]},showMore:()=>{T.peek().length>q.peek()?q.value=q.peek()+X:Ge()}}});r(()=>ot.dispose()),r(Re(h,{position:"bottom"})),r(M.effect(()=>{ie.value,w.value,q.value=X}));const Ae=e=>{const t=e.target.closest("button[data-idx]");if(!t)return;const n=Number(t.dataset.idx);w.value=w.peek().filter((s,o)=>o!==n)};fe.addEventListener("click",Ae),r(()=>fe.removeEventListener("click",Ae));const ct=E.contextMenu(L,{cursor:"glyph",match:".cex-row",inherit:!1,density:"compact",items:(e,t)=>st(e,t)});r(()=>ct?.destroy?.());const Me=e=>{const t=e.target.closest(".cex-row");if(!t)return;if(e.target.closest(".cex-kebab, .cex-run")){Ue(e.target.closest(".cex-kebab, .cex-run"));return}if(e.target.closest(".cex-check"))return;const n=J(t.dataset.id);n&&O(n)},Le=e=>{if(!e.target.matches(".cex-sel"))return;const t=e.target.closest(".cex-row");t&&ue(t.dataset.id)},qe=e=>{const t=e.target.closest(".cex-row");if(!t||e.target!==t)return;const n=J(t.dataset.id);n&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Ue(t)):e.key==="Enter"?(e.preventDefault(),O(n)):e.key===" "?(e.preventDefault(),ue(n.id)):e.key==="Delete"&&(e.preventDefault(),ge(n)))};L.addEventListener("click",Me),L.addEventListener("change",Le),L.addEventListener("keydown",qe),r(()=>{L.removeEventListener("click",Me),L.removeEventListener("change",Le),L.removeEventListener("keydown",qe)});const Te=d.peek(),se=m.get(0).querySelector(".dm-plugin-banner-name")?.querySelector(".dm-plugin-banner-sep");if(se&&Te?.title){for(;se.nextSibling;)se.nextSibling.remove();se.after(document.createTextNode(Te.title))}z(m,{id:"entries-back",icon:"arrow-left",title:"All collections",onClick:()=>{location.hash="#/collections"}}),z(m,{id:"entries-fields",icon:"edit-3",title:"Edit its fields and settings",onClick:()=>{location.hash=`#/collections/edit/${encodeURIComponent(a)}`}}),Et(a)&&z(m,{id:"entries-form",icon:"layout",title:"Open its form",onClick:()=>{location.hash=`#/forms/edit/${encodeURIComponent(a)}`}}),z(m,{id:"entries-export",icon:"download",title:"Export",onClick:()=>at()}),z(m,{id:"entries-import",icon:"upload",title:"Import",onClick:()=>nt()}),z(m,{id:"entries-add",icon:"plus",title:"Add entry",onClick:()=>O(null)});const Ne=e=>{N?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",Ne),r(()=>window.removeEventListener("beforeunload",Ne)),r(()=>{if(N?.dirty())try{S.set(B(a),{id:N.id,values:N.values(),at:new Date().toISOString()})}catch{}xe=!0,[...pe].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(h),!d.peek()){Y.value=!1;return}if(await We(),!i()){ne.done();return}if(await V({pending:ne}),!i())return;let C=null;try{C=S.get(B(a))}catch{}if(C?.values){try{S.remove?S.remove(B(a)):S.set(B(a),null)}catch{}let e=C.id?J(C.id):null;if(C.id&&!e&&(e=await g.collections.getEntry(a,C.id).catch(()=>null)),!i()||C.id&&!e)return;const t=e?`your changes to \u201C${D(K(e))}\u201D`:"a new entry",n=e?.meta?.updatedAt&&C.at&&e.meta.updatedAt>C.at?" It has been changed since - check before saving.":"";await E.confirm(`You left ${t} unsaved.${n} Pick up where you left off?`)&&i()&&O(e,C.values)}}};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as C}from"../api.js";import{addBannerAction as
|
|
1
|
+
import{apiRequest as C}from"../api.js";import{addBannerAction as Pe}from"../lib/plugin-chrome.js";import{SHAPES as ge,whileLoading as be}from"../lib/loading.js";import{helpPopovers as he}from"../lib/help-popover.js";import{onceClosed as Fe,openMenuAt as ye,sidebarCount as we,viewLifetime as Ke}from"../lib/tool-kit.js";import{FILTERS as Je,arrangeCollections as He,checkNewCollection as Ye,collectionRow as ke,connectionRows as T,connectionsFromRows as b,kindCounts as _e,maskUri as ze,newConnectionRow as Ge,parseConnectionsJson as j}from"../lib/collections-arrange.js";const z=/^#\/(collections|projects\/[^/?#]+\/collections)\/?(\?.*)?$/,Ee="#/collections",A="collections.draft",G="collections.filter",Ve=`
|
|
2
2
|
<div class="clx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="builtin && 'is-builtin'">
|
|
3
3
|
<span class="clx-icon" aria-hidden="true"><span data-icon="database" data-icon-size="16"></span></span>
|
|
4
4
|
<span class="clx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
|
|
@@ -10,13 +10,13 @@ import{apiRequest as C}from"../api.js";import{addBannerAction as Ue}from"../lib/
|
|
|
10
10
|
</span>
|
|
11
11
|
<span class="clx-num" title="Entries">{{entriesText}}</span>
|
|
12
12
|
<button type="button" class="clx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
13
|
-
</div>`,
|
|
13
|
+
</div>`,We=`
|
|
14
14
|
<div class="clx-conn" data-id="{{id}}">
|
|
15
15
|
<label>Name<input type="text" class="form-input" data-f="name" value="{{name}}" placeholder="default"></label>
|
|
16
16
|
<label>URI<input type="text" class="form-input font-mono" data-f="uri" value="{{uri}}" placeholder="mongodb://localhost:27017" autocomplete="off"></label>
|
|
17
17
|
<label>Database<input type="text" class="form-input" data-f="database" value="{{database}}" placeholder="mydb"></label>
|
|
18
18
|
<button type="button" class="btn btn-sm btn-ghost" data-act="remove" title="Remove this connection" aria-label="Remove {{name}}"><span data-icon="trash" data-icon-size="14"></span></button>
|
|
19
|
-
</div>`,L=v=>String(v??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function
|
|
19
|
+
</div>`,L=v=>String(v??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");function Qe(v){const l=document.createElement("template");return l.innerHTML=v.trim(),l.content.firstElementChild}const s=Ke(z);let B=null,xe=!1;export const collectionsView={templateUrl:"/admin/js/templates/collections.html",async onMount(v){s.reset();const l=v.get(0).querySelector(".clx")||v.get(0),x=()=>z.test(location.hash)&&l.isConnected,p=M.observable([]),V=M.observable(!0),O=M.observable(""),W=location.hash.match(/^#\/projects\/([^/?#]+)\/collections/),c=M.observable(W?decodeURIComponent(W[1]):"");let Q="all";try{Q=Je.includes(S.get(G))?S.get(G):"all"}catch{}const m=M.observable(Q),X=M.observable("");s.add(M.effect(()=>{const e=m.value;try{S.set(G,e)}catch{}}));const $=M.observable([]);s.add(M.effect(()=>{$.value=He(p.value,{filter:m.value,query:X.value,project:c.value||null}).map(ke)}));const U=M.computed(()=>_e(p.value,{project:c.value||null})),Me=M.computed(()=>{const e=$.value.reduce((t,n)=>t+n.entries,0);return`${e.toLocaleString("en-GB")} entr${e===1?"y":"ies"} shown`}),Ce=M.computed(()=>U.value.all?"No collections match.":c.value?"Nothing filed under this project yet - New collection makes one here.":"No collections yet - New collection in the banner makes one."),Se=M.computed(()=>{const e=p.value.length-U.value.all;return`Showing only collections in the ${c.value} project${e?` - ${e} more elsewhere.`:"."}`}),Te=e=>p.peek().find(t=>t.slug===e);async function Z(){try{const e=await be(l.querySelector(".dm-loading-slot"),C("/collections",{method:"GET"}),ge.rows);if(!x())return;p.value=Array.isArray(e)?e:[],O.value=""}catch(e){O.value=`Could not load the collections: ${e.message||e}`}finally{V.value=!1}}const R=e=>{location.hash=e},q=e=>R(`#/collections/${encodeURIComponent(e)}/entries`);async function Le(e){const t=L(e.title||e.slug),n=e.plugin?`"${t}" belongs to the <strong>${L(e.plugin)}</strong> plugin - deleting it may stop that plugin working. Delete it and all ${Number(e.entryCount)||0} entries anyway?`:`Delete "${t}" and all ${Number(e.entryCount)||0} of its entries? This cannot be undone.`;if(await E.confirm(n))try{await C(`/collections/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast(`Deleted "${e.title||e.slug}".`,{type:"success"}),we(Ee),x()&&await Z()}catch(o){E.toast(`Not deleted: ${o.message||o}`,{type:"error"})}}async function $e(e){try{await navigator.clipboard.writeText(e.slug),E.toast(`Copied "${e.slug}".`,{type:"success",duration:2e3})}catch{E.toast(`The slug is ${e.slug}`,{type:"info"})}}const P=new Set;let h=null;function ee(e=null){const t=M.observable(e?.title??""),n=M.observable(!1),o=M.computed(()=>Ye(t.value,p.value.map(i=>i.slug))),pe={title:t,saving:n,slugText:M.computed(()=>o.value.slug?`Its slug will be "${o.value.slug}" - the address in the API and in shortcodes.`:""),error:M.computed(()=>o.value.error),cannot:M.computed(()=>n.value||!t.value.trim()||!!o.value.error),projectText:c.value?`It will be filed under the ${c.value} project.`:""},fe=()=>!!t.peek().trim()&&!D,r=E.slideover({title:"New collection",size:"sm",position:"right"}),k=Qe(`
|
|
20
20
|
<div class="clx-form">
|
|
21
21
|
<div>
|
|
22
22
|
<label class="form-label" for="clx-new-title">Title <span class="text-danger">*</span>
|
|
@@ -33,4 +33,4 @@ import{apiRequest as C}from"../api.js";import{addBannerAction as Ue}from"../lib/
|
|
|
33
33
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="create" data-bind-disabled="cannot.value">
|
|
34
34
|
<span data-icon="plus" data-icon-size="13"></span> Create</button>
|
|
35
35
|
</div>
|
|
36
|
-
</div>`);async function ve(){if(!pe.cannot.peek()){n.value=!0;try{const i={title:t.peek().trim(),...c.value&&c.value!=="core"&&{meta:{project:c.value}}},
|
|
36
|
+
</div>`);async function ve(){if(!pe.cannot.peek()){n.value=!0;try{const i={title:t.peek().trim(),...c.value&&c.value!=="core"&&{meta:{project:c.value}}},_=await C("/collections",{method:"POST",body:JSON.stringify(i)});D=!0,r.close(),we(Ee),R(`#/collections/edit/${encodeURIComponent(_.slug)}`)}catch(i){E.toast(`Not created: ${i.message||i}`,{type:"error"})}finally{n.value=!1}}}r.element.appendChild(k);const Be=he(k),Oe=M.applyBindings(pe,k,{methods:{create:ve,cancel:()=>r.close()}}),Ue=i=>{i.key==="Enter"&&i.target.id==="clx-new-title"&&(i.preventDefault(),ve())};k.addEventListener("keydown",Ue);const me={dirty:fe,values:()=>({title:t.peek()})};h=me,P.add(r),Fe(r,()=>{Oe.dispose(),Be(),P.delete(r),h===me&&(h=null)});const qe=r.close.bind(r);let D=!1,Y=!1;r.close=async i=>{if(!D&&i!==!0&&fe()){if(Y)return;Y=!0;const _=await E.confirm("Discard the collection you started?");if(Y=!1,!_)return}return D=!0,qe()},I.scan(k),r.open(),k.querySelector("#clx-new-title")?.focus()}const y=M.observable(!1),te=M.observable(!0),a=M.observable([]),g=M.observable(!1),f=M.observable("{}"),d=M.observable(""),F=M.observable(!1);let K="{}";const J=M.observable(0),H=M.computed(()=>{J.value,a.value,f.value,g.value;const e=g.peek()?j(f.peek()).config??f.peek():b(a.peek()).config;return JSON.stringify(e)!==K});async function Re(){try{const e=await be(l.querySelector(".clx-conn-loading"),C("/collections/connections",{method:"GET"}),{...ge.rows,count:2});if(!x())return;y.value=!0,K=JSON.stringify(b(T(e)).config),a.value=T(B??e),B&&(xe||E.toast("Your unsaved connection changes are back - Save to keep them.",{type:"info",duration:6e3}),B=null)}catch{y.value=!1}finally{te.value=!1}}let ne=!1;s.add(M.effect(()=>{const e=g.value;if(e!==ne){if(e)f.value=JSON.stringify(b(a.peek()).config,null,2),d.value="";else{const{error:t,config:n}=j(f.peek());if(t){d.value=`${t} Fix it before switching back.`,queueMicrotask(()=>{g.value=!0});return}a.value=T(n),d.value=""}ne=e}}));async function oe(){let e;if(g.peek()){const t=j(f.peek());if(t.error){d.value=t.error;return}e=t.config;const n=b(T(e));if(n.errors.length){d.value=n.errors.join(" ");return}}else{const t=b(a.peek());if(t.errors.length){d.value=t.errors.join(" ");return}e=t.config}F.value=!0;try{await C("/collections/connections",{method:"PUT",body:JSON.stringify(e)}),K=JSON.stringify(b(T(e)).config),J.value++,d.value="",E.toast("Connections saved. Collections already on MongoDB pick up a changed connection when the server restarts.",{type:"success",duration:6e3})}catch(t){d.value=`Not saved: ${t.message||t}`}finally{F.value=!1}}async function Ne(e){const t=a.peek().find(o=>o.id===e);if(!t)return;const n=p.peek().filter(o=>o.storage?.adapter==="mongodb"&&(o.storage.connection||"default")===t.name);n.length&&!await E.confirm(`${n.length} collection${n.length===1?" uses":"s use"} "${L(t.name)}" (${n.map(o=>L(o.slug)).join(", ")}). Remove it anyway? Nothing changes until you Save.`)||(a.value=a.peek().filter(o=>o.id!==e))}function De(e){const t=Te(e);if(!t)return[];const n=ke(t);return[{header:t.title||t.slug},{label:"Open entries",icon:"database",action:()=>q(t.slug)},{label:"Edit fields & settings",icon:"edit-3",action:()=>R(`#/collections/edit/${encodeURIComponent(t.slug)}`)},...n.hasForm?[{label:"Open its form",icon:"layout",action:()=>R(`#/forms/edit/${encodeURIComponent(t.slug)}`)}]:[],{type:"divider"},{label:"Copy slug",icon:"copy",action:()=>$e(t)},...n.canDelete?[{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>Le(t)}]:[]]}const w=l.querySelector(".clx-list");w.innerHTML=Ve;const u=l.querySelector(".clx-conns");u.innerHTML=We;const Ie=E.tabs(l.querySelector(".clx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),je=M.applyBindings({collections:p,loading:V,loadError:O,project:c,filter:m,query:X,rows:$,kinds:U,totalText:Me,emptyText:Ce,scopeText:Se,canConnect:y,connLoading:te,connRows:a,raw:g,rawText:f,connError:d,savingConn:F,connDirty:H},l,{methods:{showAll:()=>{m.value="all"},showOwn:()=>{m.value="own"},showBuiltin:()=>{m.value="builtin"},showPlugin:()=>{m.value="plugin"},addConnection:()=>{a.value=[...a.peek(),Ge(a.peek())]},saveConnections:oe}});s.add(()=>je.dispose()),s.add(he(l,{position:"bottom"})),s.add(M.effect(()=>{$.value,a.value,requestAnimationFrame(()=>{l.isConnected&&I.scan(l)})}));const ae=e=>{const t=e.target.dataset.f,n=a.peek().find(o=>o.id===e.target.closest("[data-id]")?.dataset.id);!t||!n||(n[t]=e.target.value,J.value++)},le=e=>{const t=e.target.closest('[data-act="remove"]');t&&Ne(t.closest("[data-id]")?.dataset.id)};u.addEventListener("input",ae),u.addEventListener("click",le),s.add(()=>{u.removeEventListener("input",ae),u.removeEventListener("click",le)});const se=e=>{e.target.dataset.f==="uri"&&(e.target.title="")},ie=e=>{e.target.dataset.f==="uri"&&(e.target.title=ze(e.target.value))};u.addEventListener("focusin",se),u.addEventListener("focusout",ie),s.add(()=>{u.removeEventListener("focusin",se),u.removeEventListener("focusout",ie)});const Ae=E.contextMenu(w,{cursor:"glyph",match:".clx-row",inherit:!1,density:"compact",items:e=>De(e.dataset.slug)});s.add(()=>Ae?.destroy?.());const ce=e=>{const t=e.target.closest(".clx-row");if(!t)return;const n=e.target.closest(".clx-kebab");if(n){ye(n);return}q(t.dataset.slug)},re=e=>{const t=e.target.closest(".clx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),ye(t)):e.key==="Enter"&&e.target===t&&q(t.dataset.slug))};w.addEventListener("click",ce),w.addEventListener("keydown",re),s.add(()=>{w.removeEventListener("click",ce),w.removeEventListener("keydown",re)});const de=e=>{(e.ctrlKey||e.metaKey)&&e.key==="s"&&Ie.getActive?.()===1&&y.peek()&&(e.preventDefault(),oe())};document.addEventListener("keydown",de),s.add(()=>document.removeEventListener("keydown",de)),Pe(v,{id:"collections-new",icon:"plus",title:"New collection",onClick:()=>ee()});const ue=e=>{(h?.dirty()||y.peek()&&H.peek())&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",ue),s.add(()=>window.removeEventListener("beforeunload",ue)),s.add(()=>{if(h?.dirty())try{S.set(A,{values:h.values(),project:c.peek(),at:new Date().toISOString()})}catch{}y.peek()&&H.peek()&&(B=g.peek()?j(f.peek()).config??null:b(a.peek()).config,xe=z.test(location.hash)),[...P].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await Promise.all([Z(),Re()]),!x())return;let N=null;try{N=S.get(A)}catch{}if(N?.values?.title){try{S.remove?S.remove(A):S.set(A,null)}catch{}await E.confirm(`You started a collection called "${L(N.values.title)}" and left it. Pick up where you left off?`)&&x()&&ee(N.values)}}};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import{api as
|
|
1
|
+
import{api as L,apiRequest as m}from"../api.js";import{addBannerAction as ne}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as re,viewLifetime as Ee}from"../lib/tool-kit.js";import{SHAPES as ie,showLoading as j}from"../lib/loading.js";import{BADGE_SOURCES as Se,activityRows as Ae,attentionFrom as Pe,canAny as _,chartSeries as Re,isSitePath as O,kpisFrom as Te}from"../lib/dashboard-arrange.js";const le=/^(#\/?)?(\?.*)?$/,Le=3e4,je="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js",_e={type:"custom",template:'<div class="skeleton skeleton-rect" style="flex:1;aspect-ratio:auto;height:240px"></div>'},Oe=`
|
|
2
2
|
<div class="dbx-row" data-id="{{id}}" data-href="{{href}}" data-url="{{url}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="dbx-main"><strong>{{text}}</strong><small data-bind-hidden="!meta">{{meta}}</small></span>
|
|
4
4
|
<span class="dbx-value" data-bind-hidden="!value">{{value}}</span>
|
|
5
5
|
<span class="dbx-chip" data-bind-hidden="!chip" data-bind-class="chipCls">{{chip}}</span>
|
|
6
6
|
<button type="button" class="dbx-kebab" aria-label="Actions" title="Actions" data-bind-hidden="!menu">\u22EE</button>
|
|
7
|
-
</div>`;let b=null;function
|
|
7
|
+
</div>`;let b=null;function Be(){return window.Chart?Promise.resolve(!0):(b||(b=new Promise(i=>{const o=document.createElement("script");o.src=je,o.async=!0,o.onload=()=>i(!0),o.onerror=()=>{b=null,i(!1)},document.head.appendChild(o)})),b)}const c=i=>Number(i||0).toLocaleString("en-GB"),qe=i=>`${Number(i)>=0?"+":""}${Number(i).toFixed(0)}%`,v=(i,o,d,l={})=>({id:`${i}${o}`,text:d,meta:"",href:"",url:d,value:"",chip:"",chipCls:"",rowCls:"",menu:!0,...l}),u=Ee(le);export const dashboardView={templateUrl:"/admin/js/templates/dashboard.html",async onMount(i){u.reset();const o=i.get(0).querySelector(".dbx")||i.get(0),d=()=>le.test(location.hash)&&o.isConnected,l=M.observable(null),g=M.observable({}),y=M.observable(!1),w=M.observable(""),x=M.observable(null),k=M.observable(Date.now()),p=M.observable(null),C=M.observable(!!window.Chart);let f=[],B=new Set;const ce=M.computed(()=>Te(l.value,g.value)),de=M.computed(()=>{const e=x.value;if(!e)return"";const a=Math.floor((k.value-e)/6e4);return a<1?"Updated just now":`Updated ${a} min ago`}),q=M.observable([]),F=M.observable([]),N=M.observable([]),D=M.observable([]),U=M.observable([]),H=M.observable([]),G=M.observable([]),K=M.observable([]);u.add(M.effect(()=>{const e=l.value,a=t=>t==="danger"?"is-danger":"is-warn";q.value=Pe(g.value,e?.health?.checks||[]).map(t=>({id:t.id,text:`${t.title}: ${t.text}`,meta:t.meta,href:t.href,url:"",value:"",chip:t.tone==="danger"?"Urgent":"Check",chipCls:a(t.tone),rowCls:a(t.tone)+(t.href?"":" is-static"),menu:!!t.href})),F.value=Ae(e?.activity||[],k.value).map(t=>({id:t.id,text:t.text,meta:t.meta,href:t.href,url:t.url,value:"",chip:"",chipCls:"",rowCls:"",menu:!0})),N.value=(e?.topPages||[]).map((t,r)=>v("t",r,t.url,{value:c(t.hits),chip:t.deltaPct==null?"":qe(t.deltaPct),chipCls:Number(t.deltaPct)>=0?"is-ok":"is-danger"})),D.value=(e?.spikes||[]).map((t,r)=>v("s",r,t.url,{value:c(t.hits),chip:`\xD7${Number(t.ratio)||0}`,chipCls:"is-warn"}));const s=e?.journeys;U.value=(s?.entry||[]).map((t,r)=>v("e",r,t.url,{value:c(t.count)})),H.value=(s?.exit||[]).map((t,r)=>v("x",r,t.url,{value:c(t.count)})),G.value=(s?.paths||[]).map((t,r)=>({id:`p${r}`,text:`${t.from} \u2192 ${t.to}`,meta:"",href:"",url:"",value:c(t.count),chip:"",chipCls:"",rowCls:"is-static",menu:!1}));const n={ok:["OK","is-ok"],warn:["Look","is-warn"],fail:["Failing","is-danger"]};K.value=(e?.health?.checks||[]).map((t,r)=>({id:`c${r}`,text:String(t.label||t.id),meta:Array.isArray(t.detail)?t.detail.slice(0,5).join(", "):"",href:"",url:"",value:String(t.value??""),chip:(n[t.level]||n.warn)[0],chipCls:(n[t.level]||n.warn)[1],rowCls:"is-static",menu:!1}))}));const ue=M.computed(()=>!!l.value?.journeys?.totalSessions),he=M.computed(()=>{const e=l.value?.journeys;return e?.totalSessions?`Bounce rate ${(Number(e.bounceRate)*100).toFixed(1)}%`:""}),$=M.computed(()=>Re(l.value?.traffic?.series)),S=M.computed(()=>C.value&&!$.value.empty),pe=M.computed(()=>y.value?l.value?C.value?"No page views recorded yet.":"The chart could not load (Chart.js).":"Traffic needs an administrator.":"Loading\u2026"),me=M.computed(()=>!!p.value?.enabled),fe=M.computed(()=>{const e=p.value||{},a=(e.hits||0)+(e.misses||0),s=n=>{if(!n)return"never";const t=Math.max(0,Math.floor((Date.now()-new Date(n).getTime())/1e3));return t<60?`${t}s ago`:t<3600?`${Math.floor(t/60)} min ago`:t<86400?`${Math.floor(t/3600)} h ago`:`${Math.floor(t/86400)} days ago`};return{hitRate:e.hitRate==null?"-":`${(e.hitRate*100).toFixed(1)}%`,hitsMisses:a?`${c(e.hits)} hits \xB7 ${c(e.misses)} misses`:"no visits yet",size:e.maxItems?`${c(e.size)} / ${c(e.maxItems)}`:c(e.size),driver:e.driver?`driver: ${e.driver}`:"",writes:c(e.writes),invalidations:`${c(e.invalidations)} invalidations`,cleared:s(e.lastClearedAt)}});let A=0;async function V(){const e=Se.filter(s=>_(f,s.permission)),a=await Promise.all(e.map(s=>m(s.countUrl,{method:"GET"}).catch(()=>null)));d()&&(g.value=Object.fromEntries(e.map((s,n)=>[s.key,a[n]]).filter(([,s])=>s)))}async function z({first:e=!1}={}){const a=++A,s=e?[j(o.querySelectorAll(".dbx-kpi-value"),ie.figure),j(o.querySelectorAll(".dbx-loading"),{...ie.rows,count:3}),j(o.querySelector(".dbx-chart-note"),_e)]:[],n=()=>Promise.all(s.map(t=>t.done()));try{f=(await m("/auth/permissions",{method:"GET"}).catch(()=>({})))?.permissions||[];const[r,Me,oe]=await Promise.all([L.dashboard.summary().catch(T=>({__error:T})),m("/cache/status",{method:"GET"}).catch(()=>null),_(f,"pages")?L.pages.list().catch(()=>[]):Promise.resolve([]),V()]);if(await n(),a!==A||!d())return;B=new Set((Array.isArray(oe)?oe:[]).map(T=>T.urlPath).filter(Boolean)),r?.__error?(l.value=null,w.value=/403|forbidden/i.test(String(r.__error?.message))?"":`The dashboard could not load: ${r.__error.message}`):(l.value=r,w.value=r?.warnings?.length?`Some parts could not load: ${r.warnings.join(" \xB7 ")}`:""),p.value=Me,x.value=Date.now()}finally{await n(),a===A&&(y.value=!0)}}let be=0;async function ve(){if(!(!d()||!l.peek())){k.value=Date.now();try{const e=await L.dashboard.summaryLite();if(!d())return;const a={...l.peek()};e.traffic&&(a.traffic={...a.traffic,...e.traffic}),e.realtime&&(a.realtime=e.realtime),e.spikes&&(a.spikes=e.spikes),e.health&&(a.health={...a.health,status:e.health.status}),l.value=a,x.value=Date.now()}catch{}++be%2===0&&V(),m("/cache/status",{method:"GET"}).then(e=>{d()&&(p.value=e)}).catch(()=>{})}}const ge=setInterval(ve,Le);u.add(()=>clearInterval(ge));let h=null;const P=o.querySelector(".dbx-canvas"),ye=()=>{const e=getComputedStyle(o),a=(s,n)=>e.getPropertyValue(s).trim()||n;return{line:a("--dm-primary","#5b8cff"),text:a("--dm-text-muted","#94a3b8"),grid:a("--dm-border","rgba(127,127,127,.2)")}},we=e=>/^#[0-9a-f]{6}$/i.test(e)?`${e}26`:"transparent";function J(){if(!S.peek()||!P?.isConnected||!P.offsetParent)return;const{labels:e,values:a}=$.peek(),{line:s,text:n,grid:t}=ye();if(h){h.data.labels=e,h.data.datasets[0].data=a,h.update("none");return}h=new window.Chart(P,{type:"line",data:{labels:e,datasets:[{data:a,borderColor:s,backgroundColor:we(s),fill:!0,tension:.3,pointRadius:0,borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8,color:n},grid:{color:t}},y:{beginAtZero:!0,ticks:{precision:0,color:n},grid:{color:t}}}}})}u.add(M.effect(()=>{$.value,S.value,requestAnimationFrame(J)})),u.add(()=>{try{h?.destroy()}catch{}h=null});const W=()=>[...document.body.classList].filter(e=>/^dm-(theme|custom)-/.test(e)).sort().join(" ");let Z=W();const Q=new MutationObserver(()=>{const e=W();if(e!==Z){Z=e;try{h?.destroy()}catch{}h=null,requestAnimationFrame(J)}});Q.observe(document.body,{attributes:!0,attributeFilter:["class"]}),u.add(()=>Q.disconnect());const R=e=>new URL(e,location.origin).href,X=()=>_(f,"pages"),Y=e=>O(e)&&B.has(e.split(/[?#]/)[0].replace(/\/$/,"")||"/");async function ee(e,a){try{await navigator.clipboard.writeText(e),E.toast(`${a} copied.`,{type:"success"})}catch{E.toast("Could not copy - select it and copy it by hand.",{type:"error"})}}function te(e){const{href:a,url:s}=e.dataset;if(a){location.hash=a;return}if(s&&X()&&Y(s)){location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`;return}s&&O(s)&&window.open(R(s),"_blank","noopener")}function xe(e){const{href:a,url:s}=e.dataset,n=[{header:(e.querySelector(".dbx-main strong")?.textContent||"").slice(0,60)}];return a&&n.push({label:"Open",icon:"arrow-right",action:()=>{location.hash=a}}),s&&O(s)?(n.push({label:"Open on the site",icon:"external-link",action:()=>window.open(R(s),"_blank","noopener")}),n.push({label:"Copy address",icon:"copy",action:()=>ee(R(s),"Address")}),X()&&n.push({label:"Edit the page",icon:"edit",disabled:!Y(s),action:()=>{location.hash=`#/pages/edit${s.split(/[?#]/)[0]}`}})):s&&n.push({label:"Copy address",icon:"copy",action:()=>ee(s,"Address")}),n.length>1?n:[]}for(const e of[".dbx-attention",".dbx-activity",".dbx-top-pages",".dbx-spikes",".dbx-entry",".dbx-exit",".dbx-paths",".dbx-checks"]){const a=o.querySelector(e);a&&(a.innerHTML=Oe)}const ke=M.applyBindings({summary:l,loaded:y,warning:w,asOf:de,kpi:ce,attention:q,activity:F,topPages:N,spikes:D,entry:U,exit:H,paths:G,checks:K,hasJourneys:ue,bounce:he,hasChart:S,chartNote:pe,cache:p,cacheOn:me,cacheView:fe},o,{methods:{async toggleCache(e){const a=!!e?.target?.checked;try{p.value=await m("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})}),E.toast(`The cache is ${a?"on":"off"}.`,{type:"success"})}catch(s){e?.target&&(e.target.checked=!a),E.toast(s.message||"Could not change the cache.",{type:"error"})}},async clearCache(){if(await E.confirm("Clear the whole response cache? Pages render afresh until it fills again."))try{p.value=await m("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}}}});u.add(()=>ke.dispose()),u.add($e(o,{position:"bottom"}));const Ce=E.contextMenu(o,{cursor:"glyph",match:".dbx-row",inherit:!1,density:"compact",items:e=>xe(e)});u.add(()=>Ce?.destroy?.());const ae=e=>{const a=e.target.closest(".dbx-row");if(!a||!o.contains(a))return;const s=e.target.closest(".dbx-kebab");if(s){re(s);return}te(a)},se=e=>{const a=e.target.closest(".dbx-row");!a||!o.contains(a)||(e.key==="F10"&&e.shiftKey?(e.preventDefault(),re(a)):e.key==="Enter"&&e.target===a&&te(a))};o.addEventListener("click",ae),o.addEventListener("keydown",se),u.add(()=>{o.removeEventListener("click",ae),o.removeEventListener("keydown",se)}),ne(i,{id:"dashboard-refresh",icon:"refresh-cw",title:"Refresh",onClick:()=>z()}),ne(i,{id:"dashboard-new-page",icon:"plus",title:"New page",onClick:()=>{location.hash="#/pages/new"}}),I.scan(o),Be().then(e=>{d()&&(C.value=e)}),await z({first:!0}),d()&&I.scan(o)}};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as
|
|
1
|
+
import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as z}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ke,whileLoading as Se}from"/admin/js/lib/loading.js";import{helpPopovers as re}from"/admin/js/lib/help-popover.js";import{onceClosed as $e,openMenuAt as ce,sidebarCount as G,viewLifetime as Ne}from"/admin/js/lib/tool-kit.js";import{FILTERS as Te,answerFields as Me,answerText as O,arrangeSubmissions as Ae,submissionCounts as Le,submissionRow as L,submissionText as De}from"/admin/js/lib/form-submissions-arrange.js";import{fmtDateTime as Re}from"../lib/dates.js";const le=/^#\/forms\/[^/?#]+\/submissions\/?(\?.*)?$/,X="form-submissions.filter",Ie=`
|
|
2
2
|
<div class="fsx-row" data-id="{{id}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="fsx-dot" aria-hidden="true"></span>
|
|
4
4
|
<span class="fsx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
|
|
@@ -8,4 +8,4 @@ import{apiRequest as A}from"/admin/js/api.js";import{addBannerAction as H}from"/
|
|
|
8
8
|
</span>
|
|
9
9
|
<span class="fsx-when" title="{{whenFull}}">{{when}}</span>
|
|
10
10
|
<button type="button" class="fsx-kebab" aria-label="Actions for this submission" title="Actions">\u22EE</button>
|
|
11
|
-
</div>`,Y=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"),U=(i,r,b=`${r}s`)=>`${i} ${i===1?r:b}`,Q=i=>i?De(i):"";async function me(i){try{return await navigator.clipboard.writeText(i),!0}catch{}try{const r=document.createElement("textarea");r.value=i,r.style.cssText="position:fixed;opacity:0;",document.body.appendChild(r),r.select();const b=document.execCommand("copy");return r.remove(),b}catch{return!1}}const h=$e(le);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(i){h.reset();const r=i.get(0).querySelector(".fsx")||i.get(0),b=()=>le.test(location.hash)&&r.isConnected,p=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!p){E.toast("No form selected.",{type:"error"});return}const C=encodeURIComponent(p),y=M.observable(null),c=M.observable([]),Z=M.observable(!0),q=M.observable("");let ee="inbox";try{ee=Se.includes(S.get(X))?S.get(X):"inbox"}catch{}const w=M.observable(ee),te=M.observable(""),j=M.observable(""),F=M.observable("");h.add(M.effect(()=>{const e=w.value;try{S.set(X,e)}catch{}}));const k=M.computed(()=>Ne(y.value||{})),P=M.observable([]);h.add(M.effect(()=>{const e=Date.now(),t=Te(c.value,{filter:w.value,query:te.value,from:j.value,to:F.value,now:e}),s=k.value;P.value=t.map(n=>{const a=L(n,s,e);return{...a,when:a.at?D(a.at).fromNow():"",whenFull:Q(a.at)}})}));const de=M.computed(()=>Me(c.value)),pe=M.computed(()=>{const e=P.value.length,t=c.value.length;return t?e===t?U(t,"submission"):`${e} of ${U(t,"submission")}`:""}),ue=M.computed(()=>c.value.length?w.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),fe=M.computed(()=>y.value?`What people sent through "${y.value.title||p}".`:"What people sent through this form."),R=e=>c.peek().find(t=>String(t.id)===String(e));async function he(){try{const[e,t]=await Promise.all([A(`/forms/${C}`),A(`/forms/${C}/submissions`)]);if(!b())return;y.value=e||null,c.value=Array.isArray(t)?t:[],q.value="",be(e?.title||p)}catch(e){if(!b())return;q.value=`Could not load the submissions: ${e.message||e}`}finally{Z.value=!1}}function be(e){const t=i.get(0).querySelector(".dm-plugin-banner-name"),s=t?.lastChild;s&&s.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(s.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}async function B(e,t){try{const s=await A(`/forms/${C}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return c.value=c.peek().map(n=>String(n.id)===String(e.id)?{...n,data:s?.entry?.data||{...n.data,spam:t}}:n),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),G("#/forms"),!0}catch(s){return E.toast(`Not changed: ${s.message||s}`,{type:"error"}),!1}}async function W(e){const t=L(e,k.peek()).title;if(!await E.confirm(`Delete the submission from "${Y(t)}"? This cannot be undone.`))return!1;try{return await A(`/forms/${C}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),c.value=c.peek().filter(s=>String(s.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),G("#/forms"),!0}catch(s){return E.toast(`Not deleted: ${s.message||s}`,{type:"error"}),!1}}async function ye(){const e=c.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=y.peek()?.title||p;if(await E.confirm(`Delete all ${U(e,"submission")} to "${Y(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await A(`/forms/${C}/submissions`,{method:"DELETE"}),c.value=[],E.toast("All submissions cleared.",{type:"success"}),G("#/forms")}catch(s){E.toast(`Not cleared: ${s.message||s}`,{type:"error"})}}const se=async e=>{const t=Ae(e,k.peek(),{formTitle:y.peek()?.title||p,when:Q(e.meta?.createdAt)});E.toast(await me(t)?"Copied.":"Copy failed.",{type:"info"})},we=async e=>{E.toast(await me(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ne=e=>{const t=L(e,k.peek()).email;if(!t)return;const s=`Re: ${y.peek()?.title||p}`;window.location.href=`mailto:${encodeURIComponent(t)}?subject=${encodeURIComponent(s)}`};async function Ee(e,t){try{const s=await fetch(`/api/forms/${C}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!s.ok)throw new Error(`${s.status} ${s.statusText}`);const n=await s.blob(),a=document.createElement("a");return a.href=URL.createObjectURL(n),a.download=t,document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(a.href),1e3),!0}catch(s){return E.toast(`Export failed: ${s.message||s}`,{type:"error"}),!1}}const _=new Set;function oe(e,t,s="md"){const n=E.slideover({title:Y(e),size:s,position:"right"});n.element.appendChild(t);const a=re(t);return _.add(n),ke(n,()=>{_.delete(n),a()}),I.scan(t),n.open(),n}function ve(){const e=c.peek().length,t=document.createElement("div");t.className="fsx-export";const s=document.createElement("p");s.className="fsx-note",s.textContent=e?`All ${U(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(s);let n;const a=(N,v,K,V,$)=>{const l=document.createElement("button");l.type="button",l.className="btn btn-ghost";const x=document.createElement("span");x.setAttribute("data-icon",v),l.append(x,document.createTextNode(` ${N}`));const T=document.createElement("span");T.setAttribute("data-help",$),l.addEventListener("click",async()=>{await Ee(K,`${p}-submissions.${V}`)&&n?.close()});const o=document.createElement("div");o.append(l,T),t.appendChild(o)};a("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),a("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),n=oe("Export submissions",t,"sm")}function J(e){if(!e)return;const t=k.peek(),s=L(e,t),n=document.createElement("div");n.className="fsx-detail";const a=document.createElement("div");a.className="fsx-facts";const N=o=>{const m=document.createElement("span");m.textContent=o,a.appendChild(m)};e.meta?.createdAt&&N(`Sent ${Q(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&N(`IP ${e.meta.ip}`),s.spam&&N("Marked as spam"),n.appendChild(a);const v=document.createElement("dl");v.className="fsx-answers";const K=t.filter(o=>O(e.data?.[o.name])!=="");for(const o of K){const m=document.createElement("div");m.className="fsx-answer";const f=document.createElement("dt");f.textContent=o.label;const g=document.createElement("dd");g.textContent=O(e.data[o.name]),m.append(f,g),v.appendChild(m)}const V=new Set(t.map(o=>o.name));for(const[o,m]of Object.entries(e.data||{})){if(V.has(o)||o==="spam"||O(m)==="")continue;const f=document.createElement("div");f.className="fsx-answer";const g=document.createElement("dt");g.textContent=`${o} (no longer on the form)`;const d=document.createElement("dd");d.textContent=O(m),f.append(g,d),v.appendChild(f)}if(v.children.length)n.appendChild(v);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",n.appendChild(o)}const $=document.createElement("div");$.className="fsx-actions";const l=(o,m,f,g)=>{const d=document.createElement("button");d.type="button",d.className=`btn btn-sm ${f}`;const z=document.createElement("span");return z.setAttribute("data-icon",m),z.setAttribute("data-icon-size","13"),d.append(z,document.createTextNode(` ${o}`)),d.addEventListener("click",g),$.appendChild(d),d};let x;l("Copy","copy","btn-ghost",()=>se(e)),s.email&&l("Email them","mail","btn-ghost",()=>ne(e)),l(s.spam?"Not spam":"Mark as spam",s.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await B(e,!s.spam)&&x.close()});const T=document.createElement("span");T.className="fsx-spacer",$.appendChild(T),l("Delete","trash","btn-ghost text-danger",async()=>{await W(e)&&x.close()}),l("Close","x","btn-ghost",()=>x.close()),n.appendChild($),x=oe(s.title,n)}function xe(e){const t=R(e);if(!t)return[];const s=L(t,k.peek());return[{header:s.title},{label:"Open\u2026",icon:"eye",action:()=>J(t)},{label:"Copy submission",icon:"copy",action:()=>se(t)},{type:"divider"},{label:s.email?`Copy ${s.email}`:"Copy email",icon:"at-sign",disabled:!s.email,action:()=>we(s.email)},{label:"Email them\u2026",icon:"mail",disabled:!s.email,action:()=>ne(t)},{type:"divider"},s.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>B(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>B(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>W(t)}]}const u=r.querySelector(".fsx-list");u.innerHTML=Le;const ge=M.applyBindings({loading:Z,loadError:q,filter:w,query:te,from:j,to:F,rows:P,counts:de,summary:pe,emptyText:ue,intro:fe},r,{methods:{showInbox:()=>{w.value="inbox"},showNew:()=>{w.value="new"},showSpam:()=>{w.value="spam"},clearDates:()=>{j.value="",F.value=""}}});h.add(()=>ge.dispose()),h.add(re(r,{position:"bottom"}));const Ce=E.contextMenu(u,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>xe(e.dataset.id)});h.add(()=>Ce?.destroy?.());const ae=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const s=e.target.closest(".fsx-kebab");if(s){ce(s);return}J(R(t.dataset.id))},ie=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ce(t);else if(e.key==="Enter")J(R(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const s=R(t.dataset.id);s&&W(s)}}};u.addEventListener("click",ae),u.addEventListener("keydown",ie),h.add(()=>{u.removeEventListener("click",ae),u.removeEventListener("keydown",ie)}),H(i,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),H(i,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:ve}),H(i,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:ye}),h.add(()=>{[..._].forEach(e=>{try{e.close()}catch{}})}),I.scan(r),await he(),b()&&requestAnimationFrame(()=>{u.isConnected&&I.scan(u)})}};
|
|
11
|
+
</div>`,Y=i=>String(i??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'"),U=(i,r,b=`${r}s`)=>`${i} ${i===1?r:b}`,Q=i=>i?Re(i):"";async function me(i){try{return await navigator.clipboard.writeText(i),!0}catch{}try{const r=document.createElement("textarea");r.value=i,r.style.cssText="position:fixed;opacity:0;",document.body.appendChild(r),r.select();const b=document.execCommand("copy");return r.remove(),b}catch{return!1}}const h=Ne(le);export const formSubmissionsView={templateUrl:"/admin/js/templates/form-submissions.html",async onMount(i){h.reset();const r=i.get(0).querySelector(".fsx")||i.get(0),b=()=>le.test(location.hash)&&r.isConnected,p=decodeURIComponent((location.hash.match(/^#\/forms\/([^/?#]+)\/submissions/)||[])[1]||"");if(!p){E.toast("No form selected.",{type:"error"});return}const C=encodeURIComponent(p),y=M.observable(null),c=M.observable([]),Z=M.observable(!0),q=M.observable("");let ee="inbox";try{ee=Te.includes(S.get(X))?S.get(X):"inbox"}catch{}const w=M.observable(ee),te=M.observable(""),j=M.observable(""),F=M.observable("");h.add(M.effect(()=>{const e=w.value;try{S.set(X,e)}catch{}}));const k=M.computed(()=>Me(y.value||{})),P=M.observable([]);h.add(M.effect(()=>{const e=Date.now(),t=Ae(c.value,{filter:w.value,query:te.value,from:j.value,to:F.value,now:e}),s=k.value;P.value=t.map(n=>{const a=L(n,s,e);return{...a,when:a.at?D(a.at).fromNow():"",whenFull:Q(a.at)}})}));const de=M.computed(()=>Le(c.value)),pe=M.computed(()=>{const e=P.value.length,t=c.value.length;return t?e===t?U(t,"submission"):`${e} of ${U(t,"submission")}`:""}),ue=M.computed(()=>c.value.length?w.value==="spam"?"Nothing marked as spam.":"No submissions match.":"No submissions yet - they appear here as people send the form."),fe=M.computed(()=>y.value?`What people sent through "${y.value.title||p}".`:"What people sent through this form."),R=e=>c.peek().find(t=>String(t.id)===String(e));async function he(){try{const[e,t]=await Se(r.querySelector(".dm-loading-slot"),Promise.all([A(`/forms/${C}`),A(`/forms/${C}/submissions`)]),ke.rows);if(!b())return;y.value=e||null,c.value=Array.isArray(t)?t:[],q.value="",be(e?.title||p)}catch(e){if(!b())return;q.value=`Could not load the submissions: ${e.message||e}`}finally{Z.value=!1}}function be(e){const t=i.get(0).querySelector(".dm-plugin-banner-name"),s=t?.lastChild;s&&s.nodeType===Node.TEXT_NODE&&t.querySelector(".dm-plugin-banner-sep")&&(s.textContent=`${e} submissions`),document.title=`${e} - Submissions - Domma CMS`}async function B(e,t){try{const s=await A(`/forms/${C}/submissions/${encodeURIComponent(e.id)}/spam`,{method:"POST",body:JSON.stringify({spam:t})});return c.value=c.peek().map(n=>String(n.id)===String(e.id)?{...n,data:s?.entry?.data||{...n.data,spam:t}}:n),E.toast(t?"Marked as spam - it is under Spam now.":"Back in the inbox.",{type:"success"}),G("#/forms"),!0}catch(s){return E.toast(`Not changed: ${s.message||s}`,{type:"error"}),!1}}async function W(e){const t=L(e,k.peek()).title;if(!await E.confirm(`Delete the submission from "${Y(t)}"? This cannot be undone.`))return!1;try{return await A(`/forms/${C}/submissions/${encodeURIComponent(e.id)}`,{method:"DELETE"}),c.value=c.peek().filter(s=>String(s.id)!==String(e.id)),E.toast("Submission deleted.",{type:"success"}),G("#/forms"),!0}catch(s){return E.toast(`Not deleted: ${s.message||s}`,{type:"error"}),!1}}async function ye(){const e=c.peek().length;if(!e){E.toast("There are no submissions to clear.",{type:"info"});return}const t=y.peek()?.title||p;if(await E.confirm(`Delete all ${U(e,"submission")} to "${Y(t)}", spam included? This cannot be undone - Export them first if you may need them.`))try{await A(`/forms/${C}/submissions`,{method:"DELETE"}),c.value=[],E.toast("All submissions cleared.",{type:"success"}),G("#/forms")}catch(s){E.toast(`Not cleared: ${s.message||s}`,{type:"error"})}}const se=async e=>{const t=De(e,k.peek(),{formTitle:y.peek()?.title||p,when:Q(e.meta?.createdAt)});E.toast(await me(t)?"Copied.":"Copy failed.",{type:"info"})},we=async e=>{E.toast(await me(e)?`Copied ${e}.`:"Copy failed.",{type:"info"})},ne=e=>{const t=L(e,k.peek()).email;if(!t)return;const s=`Re: ${y.peek()?.title||p}`;window.location.href=`mailto:${encodeURIComponent(t)}?subject=${encodeURIComponent(s)}`};async function Ee(e,t){try{const s=await fetch(`/api/forms/${C}/submissions/${e}`,{headers:{Authorization:`Bearer ${S.get("auth_token")||""}`}});if(!s.ok)throw new Error(`${s.status} ${s.statusText}`);const n=await s.blob(),a=document.createElement("a");return a.href=URL.createObjectURL(n),a.download=t,document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(a.href),1e3),!0}catch(s){return E.toast(`Export failed: ${s.message||s}`,{type:"error"}),!1}}const _=new Set;function oe(e,t,s="md"){const n=E.slideover({title:Y(e),size:s,position:"right"});n.element.appendChild(t);const a=re(t);return _.add(n),$e(n,()=>{_.delete(n),a()}),I.scan(t),n.open(),n}function ve(){const e=c.peek().length,t=document.createElement("div");t.className="fsx-export";const s=document.createElement("p");s.className="fsx-note",s.textContent=e?`All ${U(e,"submission")}, spam included.`:"There are no submissions yet - the file will be empty.",t.appendChild(s);let n;const a=(N,v,J,K,$)=>{const l=document.createElement("button");l.type="button",l.className="btn btn-ghost";const x=document.createElement("span");x.setAttribute("data-icon",v),l.append(x,document.createTextNode(` ${N}`));const T=document.createElement("span");T.setAttribute("data-help",$),l.addEventListener("click",async()=>{await Ee(J,`${p}-submissions.${K}`)&&n?.close()});const o=document.createElement("div");o.append(l,T),t.appendChild(o)};a("Export as CSV","file-text","export","csv","One row per submission, one column per question - opens in Excel, Numbers or Google Sheets."),a("Export as JSON","code","export/json","json","Every submission exactly as stored, with its id and dates - for importing elsewhere or keeping a full copy."),n=oe("Export submissions",t,"sm")}function H(e){if(!e)return;const t=k.peek(),s=L(e,t),n=document.createElement("div");n.className="fsx-detail";const a=document.createElement("div");a.className="fsx-facts";const N=o=>{const m=document.createElement("span");m.textContent=o,a.appendChild(m)};e.meta?.createdAt&&N(`Sent ${Q(e.meta.createdAt)} (${D(e.meta.createdAt).fromNow()})`),e.meta?.ip&&N(`IP ${e.meta.ip}`),s.spam&&N("Marked as spam"),n.appendChild(a);const v=document.createElement("dl");v.className="fsx-answers";const J=t.filter(o=>O(e.data?.[o.name])!=="");for(const o of J){const m=document.createElement("div");m.className="fsx-answer";const f=document.createElement("dt");f.textContent=o.label;const g=document.createElement("dd");g.textContent=O(e.data[o.name]),m.append(f,g),v.appendChild(m)}const K=new Set(t.map(o=>o.name));for(const[o,m]of Object.entries(e.data||{})){if(K.has(o)||o==="spam"||O(m)==="")continue;const f=document.createElement("div");f.className="fsx-answer";const g=document.createElement("dt");g.textContent=`${o} (no longer on the form)`;const d=document.createElement("dd");d.textContent=O(m),f.append(g,d),v.appendChild(f)}if(v.children.length)n.appendChild(v);else{const o=document.createElement("p");o.className="fsx-note",o.textContent="Nothing was filled in.",n.appendChild(o)}const $=document.createElement("div");$.className="fsx-actions";const l=(o,m,f,g)=>{const d=document.createElement("button");d.type="button",d.className=`btn btn-sm ${f}`;const V=document.createElement("span");return V.setAttribute("data-icon",m),V.setAttribute("data-icon-size","13"),d.append(V,document.createTextNode(` ${o}`)),d.addEventListener("click",g),$.appendChild(d),d};let x;l("Copy","copy","btn-ghost",()=>se(e)),s.email&&l("Email them","mail","btn-ghost",()=>ne(e)),l(s.spam?"Not spam":"Mark as spam",s.spam?"inbox":"alert-triangle","btn-ghost",async()=>{await B(e,!s.spam)&&x.close()});const T=document.createElement("span");T.className="fsx-spacer",$.appendChild(T),l("Delete","trash","btn-ghost text-danger",async()=>{await W(e)&&x.close()}),l("Close","x","btn-ghost",()=>x.close()),n.appendChild($),x=oe(s.title,n)}function xe(e){const t=R(e);if(!t)return[];const s=L(t,k.peek());return[{header:s.title},{label:"Open\u2026",icon:"eye",action:()=>H(t)},{label:"Copy submission",icon:"copy",action:()=>se(t)},{type:"divider"},{label:s.email?`Copy ${s.email}`:"Copy email",icon:"at-sign",disabled:!s.email,action:()=>we(s.email)},{label:"Email them\u2026",icon:"mail",disabled:!s.email,action:()=>ne(t)},{type:"divider"},s.spam?{label:"Not spam - back to the inbox",icon:"inbox",action:()=>B(t,!1)}:{label:"Mark as spam",icon:"alert-triangle",action:()=>B(t,!0)},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>W(t)}]}const u=r.querySelector(".fsx-list");u.innerHTML=Ie;const ge=M.applyBindings({loading:Z,loadError:q,filter:w,query:te,from:j,to:F,rows:P,counts:de,summary:pe,emptyText:ue,intro:fe},r,{methods:{showInbox:()=>{w.value="inbox"},showNew:()=>{w.value="new"},showSpam:()=>{w.value="spam"},clearDates:()=>{j.value="",F.value=""}}});h.add(()=>ge.dispose()),h.add(re(r,{position:"bottom"}));const Ce=E.contextMenu(u,{cursor:"glyph",match:".fsx-row",inherit:!1,density:"compact",items:e=>xe(e.dataset.id)});h.add(()=>Ce?.destroy?.());const ae=e=>{const t=e.target.closest(".fsx-row");if(!t)return;const s=e.target.closest(".fsx-kebab");if(s){ce(s);return}H(R(t.dataset.id))},ie=e=>{const t=e.target.closest(".fsx-row");if(!(!t||e.target!==t)){if(e.key==="F10"&&e.shiftKey)e.preventDefault(),ce(t);else if(e.key==="Enter")H(R(t.dataset.id));else if(e.key==="Delete"){e.preventDefault();const s=R(t.dataset.id);s&&W(s)}}};u.addEventListener("click",ae),u.addEventListener("keydown",ie),h.add(()=>{u.removeEventListener("click",ae),u.removeEventListener("keydown",ie)}),z(i,{id:"form-submissions-back",icon:"arrow-left",title:"All forms",onClick:()=>{location.hash="#/forms"}}),z(i,{id:"form-submissions-export",icon:"download",title:"Export (CSV or JSON)",onClick:ve}),z(i,{id:"form-submissions-clear",icon:"trash",title:"Clear all submissions",onClick:ye}),h.add(()=>{[..._].forEach(e=>{try{e.close()}catch{}})}),I.scan(r),await he(),b()&&requestAnimationFrame(()=>{u.isConnected&&I.scan(u)})}};
|
package/admin/js/views/forms.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/admin/js/lib/plugin-chrome.js";import{helpPopovers as J}from"/admin/js/lib/help-popover.js";import{onceClosed as
|
|
1
|
+
import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as me,whileLoading as fe}from"/admin/js/lib/loading.js";import{helpPopovers as J}from"/admin/js/lib/help-popover.js";import{onceClosed as pe,openMenuAt as Y,sidebarCount as P,viewLifetime as be}from"/admin/js/lib/tool-kit.js";import{filterByProject as ve,getProjectFromHash as ge,renderProjectFilterNotice as he}from"/admin/js/lib/project-context.js";import{FILTERS as ye,arrangeForms as we,duplicatePayload as xe,formRow as Ee,shortcodeFor as Se}from"/admin/js/lib/forms-arrange.js";const V=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,R="#/forms",w="forms.draft",A="forms.filter",ke=`
|
|
2
2
|
<div class="fmx-row" data-slug="{{slug}}" tabindex="0" data-bind-class="rowCls">
|
|
3
3
|
<span class="fmx-icon" aria-hidden="true"><span data-icon="layout" data-icon-size="16"></span></span>
|
|
4
4
|
<span class="fmx-main"><strong>{{title}}</strong><small>{{meta}}</small></span>
|
|
@@ -10,7 +10,7 @@ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/
|
|
|
10
10
|
</span>
|
|
11
11
|
<span class="fmx-count" title="Submissions">{{totalText}}</span>
|
|
12
12
|
<button type="button" class="fmx-kebab" aria-label="Actions for {{title}}" title="Actions">\u22EE</button>
|
|
13
|
-
</div>`,x=l=>String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function
|
|
13
|
+
</div>`,x=l=>String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function Me(l){const i=document.createElement("template");return i.innerHTML=l.trim(),i.content.firstElementChild}const G=l=>String(l??"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),r=be(V);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(l){r.reset();const i=l.get(0).querySelector(".fmx")||l.get(0),h=()=>V.test(location.hash)&&i.isConnected,k=ge(),u=M.observable([]),N=M.observable(!0),T=M.observable("");let q="all";try{q=ye.includes(S.get(A))?S.get(A):"all"}catch{}const p=M.observable(q),j=M.observable("");r.add(M.effect(()=>{const t=p.value;try{S.set(A,t)}catch{}}));const C=M.observable([]);r.add(M.effect(()=>{const t=Date.now();C.value=we(u.value,{filter:p.value,query:j.value}).map(e=>Ee(e,t))}));const Q=M.computed(()=>{const t=u.value;return{all:t.length,recent:t.filter(e=>Number(e.submissionsThisWeek)>0).length,empty:t.filter(e=>!Number(e.submissionCount)).length}}),X=M.computed(()=>{const t=u.value.reduce((e,s)=>e+(Number(s.submissionCount)||0),0);return u.value.length?`${t} submission${t===1?"":"s"} in all`:""}),Z=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),$=t=>u.peek().find(e=>e.slug===t);async function O(){try{let t=await fe(i.querySelector(".dm-loading-slot"),v("/forms"),me.rows);if(!h())return;if(t=Array.isArray(t)?t:[],k){const e=t.length;t=ve(t,k),he(i,{total:e,shown:t.length,slug:k,allHref:"#/forms",noun:"forms"})}u.value=t,T.value=""}catch(t){T.value=`Could not load the forms: ${t.message||t}`}finally{N.value=!1}}const g=t=>{location.hash=`#/forms/edit/${encodeURIComponent(t)}`},ee=t=>{location.hash=`#/forms/${encodeURIComponent(t)}/submissions`};async function te(t){const e=Se(t.slug);try{await navigator.clipboard.writeText(e),E.toast(`Copied ${e}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${e}`,{type:"info",duration:8e3})}}async function se(t){const e=t.plugin?`This form is managed by the <strong>${x(t.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${x(t.title||t.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(e))try{await v(`/forms/${encodeURIComponent(t.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),P(R),h()&&await O()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const L=new Set;let b=null,_=!1;function D({title:t,html:e,model:s,methods:c,dirty:d=()=>!1,cleanup:n=()=>{}}){const a=E.slideover({title:t,size:"sm",position:"right"}),o=Me(e);a.element.appendChild(o);const f=J(o),re=M.applyBindings(s,o,{methods:c});L.add(a),pe(a,()=>{n(),re.dispose(),f(),L.delete(a)});const ce=a.close.bind(a);let K=!1,F=!1;return a.close=async de=>{if(!K&&de!==!0&&!_&&d()){if(F)return;F=!0;const ue=await E.confirm("Discard what you have typed?");if(F=!1,!ue)return}return K=!0,ce()},I.scan(o),a.open(),{so:a,body:o}}function z(t=null){const e={title:M.observable(t?.title??""),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.title.value.trim()?e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),e.hint=M.computed(()=>e.slug.value&&!e.err.value?`Its address will be "${e.slug.value}" - [form name="${e.slug.value}" /].`:""),e.cannot=M.computed(()=>e.saving.value||!e.title.value.trim()||!!e.err.value);const s=()=>!!e.title.peek().trim(),c={kind:"create",dirty:s,values:()=>({title:e.title.peek()})};async function d(){if(!e.cannot.peek()){e.saving.value=!0;try{const f=await v("/forms",{method:"POST",body:JSON.stringify({title:e.title.peek().trim()})});P(R),n.close(!0),g(f.slug)}catch(f){E.toast(f.message||"Failed to create the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:n,body:a}=D({title:"New form",model:e,dirty:s,cleanup:()=>{b===c&&(b=null)},methods:{save:d,cancel:()=>n.close()},html:`
|
|
14
14
|
<div class="fmx-form">
|
|
15
15
|
<div>
|
|
16
16
|
<label class="form-label" for="fmx-new-title">Title
|
|
@@ -26,7 +26,7 @@ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/
|
|
|
26
26
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
|
|
27
27
|
<span data-icon="plus" data-icon-size="13"></span> Create</button>
|
|
28
28
|
</div>
|
|
29
|
-
</div>`});b=c;const o=a.querySelector("#fmx-new-title");o.addEventListener("keydown",f=>{f.key==="Enter"&&(f.preventDefault(),d())}),setTimeout(()=>o?.focus(),50)}function ae(t){const e={title:M.observable(`${t.title||t.slug} (Copy)`),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}".`:"":"The title needs at least one letter or number."),e.cannot=M.computed(()=>e.saving.value||!!e.err.value);async function s(){if(!e.cannot.peek()){e.saving.value=!0;try{const a=await v(`/forms/${encodeURIComponent(t.slug)}`),o=await v("/forms",{method:"POST",body:JSON.stringify(
|
|
29
|
+
</div>`});b=c;const o=a.querySelector("#fmx-new-title");o.addEventListener("keydown",f=>{f.key==="Enter"&&(f.preventDefault(),d())}),setTimeout(()=>o?.focus(),50)}function ae(t){const e={title:M.observable(`${t.title||t.slug} (Copy)`),saving:M.observable(!1)};e.slug=M.computed(()=>G(e.title.value)),e.err=M.computed(()=>e.slug.value?$(e.slug.value)?`There is already a form "${e.slug.value}".`:"":"The title needs at least one letter or number."),e.cannot=M.computed(()=>e.saving.value||!!e.err.value);async function s(){if(!e.cannot.peek()){e.saving.value=!0;try{const a=await v(`/forms/${encodeURIComponent(t.slug)}`),o=await v("/forms",{method:"POST",body:JSON.stringify(xe(a,e.title.peek()))});E.toast(`Duplicated as "${o.title}".`,{type:"success"}),P(R),c.close(!0),g(o.slug)}catch(a){E.toast(a.message||"Failed to duplicate the form.",{type:"error"})}finally{e.saving.value=!1}}}const{so:c,body:d}=D({title:`Duplicate ${x(t.title||t.slug)}`,model:e,methods:{save:s,cancel:()=>c.close()},html:`
|
|
30
30
|
<div class="fmx-form">
|
|
31
31
|
<div>
|
|
32
32
|
<label class="form-label" for="fmx-dup-title">Title of the copy</label>
|
|
@@ -40,7 +40,7 @@ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/
|
|
|
40
40
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">
|
|
41
41
|
<span data-icon="copy" data-icon-size="13"></span> Duplicate</button>
|
|
42
42
|
</div>
|
|
43
|
-
</div>`}),n=d.querySelector("#fmx-dup-title");n.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),s())}),setTimeout(()=>{n?.focus(),n?.select()},50)}function ne(){const t={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};t.cannot=M.computed(()=>t.sending.value||!/^[^\s@]+@[^\s@]+$/.test(t.to.value.trim()));async function e(){if(!t.cannot.peek()){t.sending.value=!0;try{await v("/forms/test-email",{method:"POST",body:JSON.stringify({to:t.to.peek().trim()})}),s.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{t.sending.value=!1}}}const{so:s,body:c}=
|
|
43
|
+
</div>`}),n=d.querySelector("#fmx-dup-title");n.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),s())}),setTimeout(()=>{n?.focus(),n?.select()},50)}function ne(){const t={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};t.cannot=M.computed(()=>t.sending.value||!/^[^\s@]+@[^\s@]+$/.test(t.to.value.trim()));async function e(){if(!t.cannot.peek()){t.sending.value=!0;try{await v("/forms/test-email",{method:"POST",body:JSON.stringify({to:t.to.peek().trim()})}),s.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{t.sending.value=!1}}}const{so:s,body:c}=D({title:"Send test email",model:t,methods:{send:e,cancel:()=>s.close()},html:`
|
|
44
44
|
<div class="fmx-form">
|
|
45
45
|
<div>
|
|
46
46
|
<label class="form-label" for="fmx-test-to">Send to
|
|
@@ -54,4 +54,4 @@ import{apiRequest as v}from"/admin/js/api.js";import{addBannerAction as W}from"/
|
|
|
54
54
|
<button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="cannot.value">
|
|
55
55
|
<span data-icon="mail" data-icon-size="13"></span> Send</button>
|
|
56
56
|
</div>
|
|
57
|
-
</div>`}),d=c.querySelector("#fmx-test-to");d.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),e())}),setTimeout(()=>d?.focus(),50)}function oe(t){const e=$(t);if(!e)return[];const s=Number(e.submissionCount)||0;return[{header:e.title||e.slug},{label:"Edit",icon:"edit",action:()=>g(e.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ee(e.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>te(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>ae(e)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>se(e)}]}const m=
|
|
57
|
+
</div>`}),d=c.querySelector("#fmx-test-to");d.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),e())}),setTimeout(()=>d?.focus(),50)}function oe(t){const e=$(t);if(!e)return[];const s=Number(e.submissionCount)||0;return[{header:e.title||e.slug},{label:"Edit",icon:"edit",action:()=>g(e.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ee(e.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>te(e)},{label:"Duplicate\u2026",icon:"copy",action:()=>ae(e)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>se(e)}]}const m=i.querySelector(".fmx-list");m.innerHTML=ke;const le=M.applyBindings({forms:u,loading:N,loadError:T,filter:p,query:j,rows:C,counts:Q,summary:X,emptyText:Z},i,{methods:{showAll:()=>{p.value="all"},showRecent:()=>{p.value="recent"},showEmpty:()=>{p.value="empty"}}});r.add(()=>le.dispose()),r.add(J(i,{position:"bottom"})),r.add(M.effect(()=>{C.value,requestAnimationFrame(()=>{m.isConnected&&I.scan(m)})}));const ie=E.contextMenu(m,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:t=>oe(t.dataset.slug)});r.add(()=>ie?.destroy?.());const B=t=>{const e=t.target.closest(".fmx-row");if(!e)return;const s=t.target.closest(".fmx-kebab");if(s){Y(s);return}g(e.dataset.slug)},U=t=>{const e=t.target.closest(".fmx-row");e&&(t.key==="F10"&&t.shiftKey?(t.preventDefault(),Y(e)):t.key==="Enter"&&t.target===e&&g(e.dataset.slug))};m.addEventListener("click",B),m.addEventListener("keydown",U),r.add(()=>{m.removeEventListener("click",B),m.removeEventListener("keydown",U)}),W(l,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>z()}),W(l,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ne()});const H=t=>{b?.dirty()&&(t.preventDefault(),t.returnValue="")};if(window.addEventListener("beforeunload",H),r.add(()=>window.removeEventListener("beforeunload",H)),r.add(()=>{if(b?.dirty())try{S.set(w,{values:b.values(),at:new Date().toISOString()})}catch{}_=!0,[...L].forEach(t=>{try{t.close(!0)}catch{}})}),I.scan(i),await O(),!h())return;let y=null;try{y=S.get(w)}catch{}if(y?.values?.title){try{S.remove?S.remove(w):S.set(w,null)}catch{}await E.confirm(`You started a new form, "${x(y.values.title)}", and left it. Pick up where you left off?`)&&h()&&z(y.values)}}};
|