domma-cms 1.10.19 → 1.10.20
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/CHANGELOG.md +5 -0
- package/admin/js/lib/definition-transfer.js +2 -0
- package/admin/js/views/forms.js +5 -5
- package/docs/guides/definition-transfer.md +11 -0
- package/package.json +1 -1
- package/server/routes/api/forms.js +17 -0
- package/server/services/definition-transfer.js +32 -0
- package/server/services/form-transfer.js +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.20] - 2026-10-25
|
|
4
|
+
|
|
5
|
+
- Forms support versioned JSON definition import and export from the list banner and item actions. Imports create a fresh private submissions collection, keep existing forms and collections, and assign a new slug on conflicts.
|
|
6
|
+
- Shared transfer services support Surveys and Campaigns Pro definition bundles without copying responses, recipients, invitations, delivery history or claims.
|
|
7
|
+
|
|
3
8
|
## [1.10.19] - 2026-10-25
|
|
4
9
|
|
|
5
10
|
- **Built against domma-js 0.58.2** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.58.2).
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{apiRequest as c}from"/admin/js/api.js";const d=n=>String(n??"").replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]);export async function exportDefinition(n){try{const t=await c(n),i=URL.createObjectURL(new Blob([JSON.stringify(t,null,4)+`
|
|
2
|
+
`],{type:"application/json"})),o=document.createElement("a");o.href=i,o.download=`${t.kind}-${t.definition.slug}.json`,document.body.appendChild(o),o.click(),o.remove(),setTimeout(()=>URL.revokeObjectURL(i),1e3)}catch(t){E.toast(`Export failed: ${t.message||t}`,{type:"error"})}}export function importDefinition(n,t,i,o){if(n.querySelector("[data-definition-import]"))return;const e=document.createElement("input");e.type="file",e.accept=".json,application/json",e.hidden=!0,e.dataset.definitionImport="true",n.appendChild(e),e.addEventListener("cancel",()=>e.remove(),{once:!0}),e.addEventListener("change",async()=>{try{const r=e.files?.[0];if(!r)return;if(r.size>1e6)throw new Error("The export must be smaller than 1 MB.");const a=JSON.parse(await r.text());if(a.format!=="domma-definition"||a.version!==1||a.kind!==i||!a.definition?.title)throw new Error(`Choose a Domma ${i} JSON export.`);const l=i==="form"?"The form settings and actions will be copied. It gets a new, empty submissions collection.":i==="survey"?"Its backing form is included. The survey starts as a draft with no audience or schedule.":"The campaign starts as a draft with no audience or sending schedule.";if(!await E.confirm(`Import <strong>${d(a.definition.title)}</strong>?<br>${l}<br>Existing items are kept. Naming conflicts get a new slug.`,{confirmText:"Import"})||!n.isConnected)return;const s=await c(t,{method:"POST",body:JSON.stringify(a)});E.toast(`Imported "${s.title}" as ${s.slug}.`,{type:"success"}),await o(s)}catch(r){E.toast(`Import failed: ${r.message||r}`,{type:"error"})}finally{e.remove()}},{once:!0}),e.click()}
|
package/admin/js/views/forms.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as
|
|
1
|
+
import{exportDefinition as ve,importDefinition as he}from"/admin/js/lib/definition-transfer.js";import{apiRequest as h}from"/admin/js/api.js";import{addBannerAction as U}from"/admin/js/lib/plugin-chrome.js";import{SHAPES as ge,whileLoading as ye}from"/admin/js/lib/loading.js";import{helpPopovers as Q}from"/admin/js/lib/help-popover.js";import{onceClosed as we,openMenuAt as G,sidebarCount as x,viewLifetime as xe}from"/admin/js/lib/tool-kit.js";import{filterByProject as ke,getProjectFromHash as Ee,renderProjectFilterNotice as Se}from"/admin/js/lib/project-context.js";import{FILTERS as Te,arrangeForms as Me,duplicatePayload as Ce,formRow as $e,newFormDraftQuestion as De,shortcodeFor as Fe,slugify as X}from"/admin/js/lib/forms-arrange.js";const Z=/^#\/(projects\/[^/?#]+\/)?forms\/?(\?.*)?$/,k="#/forms",T="forms.draft",j="forms.filter",Le=`
|
|
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 h}from"/admin/js/api.js";import{addBannerAction as Y}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>`,
|
|
13
|
+
</div>`,C=d=>String(d??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");function Ie(d){const l=document.createElement("template");return l.innerHTML=d.trim(),l.content.firstElementChild}const c=xe(Z);export const formsView={templateUrl:"/admin/js/templates/forms.html",async onMount(d){c.reset();const l=d.get(0).querySelector(".fmx")||d.get(0),p=()=>Z.test(location.hash)&&l.isConnected,$=Ee(),u=M.observable([]),B=M.observable(!0),D=M.observable("");let _="all";try{_=Te.includes(S.get(j))?S.get(j):"all"}catch{}const b=M.observable(_),z=M.observable("");c.add(M.effect(()=>{const e=b.value;try{S.set(j,e)}catch{}}));const F=M.observable([]);c.add(M.effect(()=>{const e=Date.now();F.value=Me(u.value,{filter:b.value,query:z.value}).map(t=>$e(t,e))}));const ee=M.computed(()=>{const e=u.value;return{all:e.length,recent:e.filter(t=>Number(t.submissionsThisWeek)>0).length,empty:e.filter(t=>!Number(t.submissionCount)).length}}),te=M.computed(()=>{const e=u.value.reduce((t,s)=>t+(Number(s.submissionCount)||0),0);return u.value.length?`${e} submission${e===1?"":"s"} in all`:""}),se=M.computed(()=>u.value.length?"No forms match.":"No forms yet - New form in the banner makes one."),L=e=>u.peek().find(t=>t.slug===e);async function A(){try{let e=await ye(l.querySelector(".dm-loading-slot"),h("/forms"),ge.rows);if(!p())return;if(e=Array.isArray(e)?e:[],$){const t=e.length;e=ke(e,$),Se(l,{total:t,shown:e.length,slug:$,allHref:"#/forms",noun:"forms"})}u.value=e,D.value=""}catch(e){D.value=`Could not load the forms: ${e.message||e}`}finally{B.value=!1}}const g=e=>{location.hash=`#/forms/edit/${encodeURIComponent(e)}`},ae=e=>{location.hash=`#/forms/${encodeURIComponent(e)}/submissions`};async function ne(e){const t=Fe(e.slug);try{await navigator.clipboard.writeText(t),E.toast(`Copied ${t}`,{type:"success"})}catch{E.toast(`Copy this into a page: ${t}`,{type:"info",duration:8e3})}}async function oe(e){const t=e.plugin?`This form is managed by the <strong>${C(e.plugin)}</strong> plugin. Deleting it may cause the plugin to malfunction. Continue?`:`Delete the form "${C(e.title||e.slug)}" and all its submissions? This cannot be undone.`;if(await E.confirm(t))try{await h(`/forms/${encodeURIComponent(e.slug)}`,{method:"DELETE"}),E.toast("Form deleted.",{type:"success"}),x(k),p()&&await A()}catch(s){E.toast(`Failed to delete the form: ${s.message||s}`,{type:"error"})}}const R=new Set,y=new Set;let v=null,K=!1;function N({key:e,title:t,html:s,model:r,methods:n,dirty:o=()=>!1,cleanup:i=()=>{}}){if(y.has(e))return null;y.add(e);let a;try{a=E.slideover({title:t,size:"sm",position:"right"});const m=Ie(s);a.element.appendChild(m);const ue=Q(m),me=M.applyBindings(r,m,{methods:n});R.add(a),we(a,()=>{y.delete(e),i(),me.dispose(),ue(),R.delete(a)});const fe=a.close.bind(a);let Y=!1,q=!1;a.close=async O=>{if(!Y&&O!==!0&&!K&&o()){if(q)return;q=!0;const be=await E.confirm("Throw away what you have typed?",{confirmText:"Throw away",cancelText:"Keep editing"});if(q=!1,!be)return}return Y=!0,fe()};const pe=O=>{m.inert=!!O};return I.scan(m),a.open(),{so:a,body:m,lock:pe}}catch(m){y.delete(e);try{a?.destroy?.(),a?.element?.remove()}catch{}return E.toast(`Could not open the panel: ${m.message||m}`,{type:"error"}),null}}function H(e=null){const t={title:M.observable(e?.title??""),saving:M.observable(!1)};t.slug=M.computed(()=>X(t.title.value)),t.err=M.computed(()=>t.title.value.trim()?t.slug.value?L(t.slug.value)?`There is already a form "${t.slug.value}" - choose another title.`:"":"The title needs at least one letter or number.":""),t.hint=M.computed(()=>t.slug.value&&!t.err.value?`Its address will be "${t.slug.value}" - [form name="${t.slug.value}" /].`:""),t.cannot=M.computed(()=>t.saving.value||!t.title.value.trim()||!!t.err.value);const s=()=>!!t.title.peek().trim(),r={kind:"create",dirty:s,values:()=>({title:t.title.peek()})};async function n(){if(!t.cannot.peek()){t.saving.value=!0,o.lock(!0);try{const a=await h("/forms",{method:"POST",body:JSON.stringify({title:t.title.peek().trim()})});x(k),o.so.close(!0),P(),p()?g(a.slug):E.toast(`Form "${a.title||a.slug}" created.`,{type:"success"})}catch(a){o.lock(!1),E.toast(a.message||"Failed to create the form.",{type:"error"})}finally{t.saving.value=!1}}}const o=N({key:"create",title:"New form",model:t,dirty:s,cleanup:()=>{v===r&&(v=null)},methods:{save:n,cancel:()=>o.so.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 h}from"/admin/js/api.js";import{addBannerAction as Y}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>`});if(!o)return!1;v=r;const
|
|
29
|
+
</div>`});if(!o)return!1;v=r;const i=o.body.querySelector("#fmx-new-title");return i.addEventListener("keydown",a=>{a.key==="Enter"&&(a.preventDefault(),n())}),setTimeout(()=>i?.focus(),50),!0}function le(e){const t=`${e.title||e.slug} (Copy)`,s={title:M.observable(t),saving:M.observable(!1)};s.slug=M.computed(()=>X(s.title.value)),s.err=M.computed(()=>s.slug.value?L(s.slug.value)?`There is already a form "${s.slug.value}".`:"":"The title needs at least one letter or number."),s.cannot=M.computed(()=>s.saving.value||!!s.err.value);async function r(){if(!s.cannot.peek()){s.saving.value=!0,n.lock(!0);try{const i=await h(`/forms/${encodeURIComponent(e.slug)}`),a=await h("/forms",{method:"POST",body:JSON.stringify(Ce(i,s.title.peek()))});E.toast(`Duplicated as "${a.title}".`,{type:"success"}),x(k),n.so.close(!0),p()&&g(a.slug)}catch(i){n.lock(!1),E.toast(i.message||"Failed to duplicate the form.",{type:"error"})}finally{s.saving.value=!1}}}const n=N({key:`duplicate:${e.slug}`,title:`Duplicate ${C(e.title||e.slug)}`,model:s,dirty:()=>s.title.peek().trim()!==t,methods:{save:r,cancel:()=>n.so.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 h}from"/admin/js/api.js";import{addBannerAction as Y}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>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",
|
|
43
|
+
</div>`});if(!n)return;const o=n.body.querySelector("#fmx-dup-title");o.addEventListener("keydown",i=>{i.key==="Enter"&&(i.preventDefault(),r())}),setTimeout(()=>{o?.focus(),o?.select()},50)}function ie(){const e={to:M.observable(window.__CMS_SITE__?.smtp?.fromAddress||""),sending:M.observable(!1)};e.cannot=M.computed(()=>e.sending.value||!/^[^\s@]+@[^\s@]+$/.test(e.to.value.trim()));async function t(){if(!e.cannot.peek()){e.sending.value=!0,s.lock(!0);try{await h("/forms/test-email",{method:"POST",body:JSON.stringify({to:e.to.peek().trim()})}),s.so.close(!0),E.toast("Test email sent.",{type:"success"})}catch(n){s.lock(!1),E.toast(n.message||"Failed to send the test email.",{type:"error"})}finally{e.sending.value=!1}}}const s=N({key:"test-email",title:"Send test email",model:e,methods:{send:t,cancel:()=>s.so.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 h}from"/admin/js/api.js";import{addBannerAction as Y}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>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=
|
|
57
|
+
</div>`});if(!s)return;const r=s.body.querySelector("#fmx-test-to");r.addEventListener("keydown",n=>{n.key==="Enter"&&(n.preventDefault(),t())}),setTimeout(()=>r?.focus(),50)}function re(e){const t=L(e);if(!t)return[];const s=Number(t.submissionCount)||0;return[{header:t.title||t.slug},{label:"Edit",icon:"edit",action:()=>g(t.slug)},{label:`Submissions (${s})`,icon:"inbox",action:()=>ae(t.slug)},{type:"divider"},{label:"Copy shortcode",icon:"code",action:()=>ne(t)},{label:"Export JSON",icon:"download",action:()=>ve(`/forms/${encodeURIComponent(t.slug)}/export`)},{label:"Duplicate\u2026",icon:"copy",action:()=>le(t)},{type:"divider"},{label:"Delete\u2026",icon:"trash",danger:!0,action:()=>oe(t)}]}const f=l.querySelector(".fmx-list");f.innerHTML=Le;const ce=M.applyBindings({forms:u,loading:B,loadError:D,filter:b,query:z,rows:F,counts:ee,summary:te,emptyText:se},l,{methods:{showAll:()=>{b.value="all"},showRecent:()=>{b.value="recent"},showEmpty:()=>{b.value="empty"}}});c.add(()=>ce.dispose()),c.add(Q(l,{position:"bottom"})),c.add(M.effect(()=>{F.value,requestAnimationFrame(()=>{f.isConnected&&I.scan(f)})}));const de=E.contextMenu(f,{cursor:"glyph",match:".fmx-row",inherit:!1,density:"compact",items:e=>re(e.dataset.slug)});c.add(()=>de?.destroy?.());const J=e=>{const t=e.target.closest(".fmx-row");if(!t)return;const s=e.target.closest(".fmx-kebab");if(s){G(s);return}g(t.dataset.slug)},W=e=>{const t=e.target.closest(".fmx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),G(t)):e.key==="Enter"&&e.target===t&&g(t.dataset.slug))};f.addEventListener("click",J),f.addEventListener("keydown",W),c.add(()=>{f.removeEventListener("click",J),f.removeEventListener("keydown",W)}),U(d,{id:"forms-import",icon:"upload",title:"Import",onClick:()=>he(l,"/forms/import","form",async()=>{x(k),p()&&await A()})}),U(d,{id:"forms-new",icon:"plus",title:"New form",onClick:()=>H()}),U(d,{id:"forms-test-email",icon:"mail",title:"Send a test email",onClick:()=>ie()});const P=()=>{try{S.remove?S.remove(T):S.set(T,null)}catch{}},V=e=>{v?.dirty()&&(e.preventDefault(),e.returnValue="")};if(window.addEventListener("beforeunload",V),c.add(()=>window.removeEventListener("beforeunload",V)),c.add(()=>{if(v?.dirty())try{S.set(T,{values:v.values(),at:new Date().toISOString()})}catch{}K=!0,[...R].forEach(e=>{try{e.close(!0)}catch{}})}),I.scan(l),await A(),!p())return;let w=null;try{w=S.get(T)}catch{}if(w?.values?.title){const e=De(w,u.peek());if(await E.confirm(C(e),{title:"Unsaved new form",confirmText:"Throw away",cancelText:"Keep it"})){P();return}if(!p())return;H(w.values)&&P()}}};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Import and export definitions
|
|
2
|
+
|
|
3
|
+
Forms, Surveys Pro and Campaigns Pro support portable JSON definitions.
|
|
4
|
+
|
|
5
|
+
Open an item's actions menu and choose **Export JSON**. In the destination site's list, choose **Import**, select that file and review the confirmation. Imports create new items. Existing slugs receive a numeric suffix instead of being overwritten.
|
|
6
|
+
|
|
7
|
+
- Forms include fields, page breaks, conditional rules, settings and actions. An imported form gets a fresh, private submissions collection. Review email, webhook and CMS action settings for the destination site.
|
|
8
|
+
- Surveys include their backing form, including wizard steps. The imported survey points to its imported form and starts as a draft. Select its audience and dates before opening it.
|
|
9
|
+
- Campaigns include their message, links and offer settings. The imported campaign starts as a draft. Select recipients and a sending schedule before sending it.
|
|
10
|
+
|
|
11
|
+
Definitions do not include submissions, responses, audience contacts, invitations, delivery history, claims or sending credentials. Existing response exports remain available separately. Imports accept version 1 Domma JSON exports smaller than 1 MB.
|
package/package.json
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {packDefinition, unpackDefinition, sendDefinition} from '../../services/definition-transfer.js';
|
|
2
|
+
import {importFormDefinition} from '../../services/form-transfer.js';
|
|
1
3
|
/**
|
|
2
4
|
* Core Forms API Routes
|
|
3
5
|
* REST endpoints for form CRUD, public rendering, and submission handling.
|
|
@@ -499,6 +501,21 @@ export async function formsRoutes(fastify) {
|
|
|
499
501
|
const canUpdate = { preHandler: [authenticate, requirePermission('collections', 'update')] };
|
|
500
502
|
const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
|
|
501
503
|
|
|
504
|
+
fastify.get('/forms/:slug/export', canRead, async (request, reply) => {
|
|
505
|
+
let form;
|
|
506
|
+
try { form = await readForm(request.params.slug); }
|
|
507
|
+
catch { return reply.code(404).send({error: 'Form not found.'}); }
|
|
508
|
+
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
509
|
+
if (!canSeeArtefact(request.user, form)) return reply.code(403).send({error: 'Access denied for this project'});
|
|
510
|
+
return sendDefinition(reply, packDefinition('form', form));
|
|
511
|
+
});
|
|
512
|
+
fastify.post('/forms/import', canCreate, async (request, reply) => {
|
|
513
|
+
try {
|
|
514
|
+
const form = await importFormDefinition(unpackDefinition('form', request.body));
|
|
515
|
+
return reply.code(201).send(form);
|
|
516
|
+
} catch (err) { return reply.code(400).send({error: err.message}); }
|
|
517
|
+
});
|
|
518
|
+
|
|
502
519
|
// -----------------------------------------------------------------------
|
|
503
520
|
// GET /forms - list all form definitions with submission counts
|
|
504
521
|
// -----------------------------------------------------------------------
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Versioned, definition-only transfers. Runtime entries and audience IDs stay on their site. */
|
|
2
|
+
const KEYS = {
|
|
3
|
+
form: ['slug', 'title', 'description', 'fields', 'settings', 'actions'],
|
|
4
|
+
survey: ['slug', 'title', 'description', 'formSlug', 'mode', 'reminderAfterDays', 'intro', 'footer', 'thankYou', 'inviteEmail'],
|
|
5
|
+
campaign: ['slug', 'title', 'description', 'message', 'linkUrl', 'endsAt', 'offer']
|
|
6
|
+
};
|
|
7
|
+
export function definition(kind, value) {
|
|
8
|
+
if (!KEYS[kind] || !value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid definition.');
|
|
9
|
+
if (typeof value.title !== 'string' || !value.title.trim()) throw new Error('A definition needs a title.');
|
|
10
|
+
if (typeof value.slug !== 'string' || value.slug.length > 80 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.slug)) throw new Error('Invalid definition slug.');
|
|
11
|
+
if (kind === 'form') {
|
|
12
|
+
if (!Array.isArray(value.fields) || value.fields.some(f => !f || typeof f !== 'object' || Array.isArray(f) || typeof f.type !== 'string')) throw new Error('Invalid form fields.');
|
|
13
|
+
for (const key of ['settings', 'actions']) if (value[key] != null && (typeof value[key] !== 'object' || Array.isArray(value[key]))) throw new Error(`Invalid form ${key}.`);
|
|
14
|
+
}
|
|
15
|
+
return structuredClone(Object.fromEntries(KEYS[kind].filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]])));
|
|
16
|
+
}
|
|
17
|
+
export function packDefinition(kind, value, extra = {}) {
|
|
18
|
+
return {format: 'domma-definition', version: 1, kind, definition: definition(kind, value), ...extra};
|
|
19
|
+
}
|
|
20
|
+
export function unpackDefinition(kind, bundle) {
|
|
21
|
+
if (bundle?.format !== 'domma-definition' || bundle.version !== 1 || bundle.kind !== kind) throw new Error(`Choose a Domma ${kind} export (version 1).`);
|
|
22
|
+
return definition(kind, bundle.definition);
|
|
23
|
+
}
|
|
24
|
+
export function unusedSlug(slug, existing) {
|
|
25
|
+
const used = new Set(existing);
|
|
26
|
+
let next = slug;
|
|
27
|
+
for (let i = 2; used.has(next); i++) next = `${slug.slice(0, 70).replace(/-$/, '')}-${i}`;
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
export function sendDefinition(reply, bundle) {
|
|
31
|
+
return reply.header('Content-Disposition', `attachment; filename="${bundle.kind}-${bundle.definition.slug}.json"`).send(bundle);
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {createForm, deleteForm, ensureCollectionForForm, listForms} from './forms.js';
|
|
2
|
+
import {getCollection} from './collections.js';
|
|
3
|
+
import {definition, unusedSlug} from './definition-transfer.js';
|
|
4
|
+
|
|
5
|
+
/** Imported forms always get their own private submission collection. */
|
|
6
|
+
export async function importFormDefinition(input) {
|
|
7
|
+
const data = definition('form', input);
|
|
8
|
+
const used = (await listForms()).map(f => f.slug);
|
|
9
|
+
data.slug = unusedSlug(data.slug, used);
|
|
10
|
+
while (await getCollection(data.slug)) {
|
|
11
|
+
used.push(data.slug);
|
|
12
|
+
data.slug = unusedSlug(input.slug, used);
|
|
13
|
+
}
|
|
14
|
+
data.actions = {...data.actions, collection: {enabled: true, slug: data.slug}};
|
|
15
|
+
const form = await createForm(data);
|
|
16
|
+
try {
|
|
17
|
+
await ensureCollectionForForm(form);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
await deleteForm(form.slug);
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
return form;
|
|
23
|
+
}
|