@frigear-nu/forms 0.1.2 → 0.1.3
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/dist/module.d.mts +18 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +29 -26
- package/dist/runtime/app/components/FormBuilderAccess.vue +255 -262
- package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
- package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
- package/dist/runtime/app/components/FormEmbed.vue +2 -2
- package/dist/runtime/app/components/FormImportSlideover.d.vue.ts +8 -0
- package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
- package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
- package/dist/runtime/app/components/FormRenderer.vue +3 -3
- package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
- package/dist/runtime/app/composables/useCaptchaSecrets.js +9 -6
- package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
- package/dist/runtime/app/composables/useFormActions.js +33 -21
- package/dist/runtime/app/composables/useForms.d.ts +12 -5
- package/dist/runtime/app/composables/useForms.js +11 -5
- package/dist/runtime/app/pages/admin-form.vue +36 -20
- package/dist/runtime/app/pages/admin-submission.vue +27 -16
- package/dist/runtime/app/pages/admin-submissions.vue +30 -22
- package/dist/runtime/app/pages/admin.vue +29 -78
- package/dist/runtime/app/pages/form.vue +2 -1
- package/dist/runtime/i18n/lang/da.json +10 -2
- package/dist/runtime/i18n/lang/en.json +10 -2
- package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
- package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
- package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
- package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
- package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
- package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
- package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
- package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
- package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
- package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
- package/dist/runtime/server/routes/admin/form.delete.js +6 -4
- package/dist/runtime/server/routes/admin/form.get.js +4 -2
- package/dist/runtime/server/routes/admin/form.patch.js +3 -3
- package/dist/runtime/server/routes/admin/forms.get.js +37 -16
- package/dist/runtime/server/routes/admin/forms.post.js +3 -4
- package/dist/runtime/server/routes/admin/import.post.js +3 -2
- package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
- package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
- package/dist/runtime/server/routes/admin/submission.get.js +5 -17
- package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
- package/dist/runtime/server/routes/public/access.get.js +3 -1
- package/dist/runtime/server/routes/public/form.get.js +3 -1
- package/dist/runtime/server/routes/public/submit.post.js +2 -1
- package/dist/runtime/server/utils/access.js +3 -2
- package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
- package/dist/runtime/server/utils/admin-schemas.js +4 -0
- package/dist/runtime/server/utils/auth.d.ts +8 -4
- package/dist/runtime/server/utils/auth.js +6 -7
- package/dist/runtime/server/utils/captcha.js +3 -2
- package/dist/runtime/server/utils/forms.d.ts +12 -2
- package/dist/runtime/server/utils/forms.js +57 -10
- package/dist/runtime/server/utils/mcp-access.js +2 -1
- package/dist/runtime/server/utils/rate-limit.js +3 -2
- package/dist/runtime/server/utils/submit.d.ts +3 -0
- package/dist/runtime/server/utils/submit.js +21 -17
- package/dist/runtime/utils/api.d.ts +36 -0
- package/dist/runtime/utils/api.js +39 -0
- package/dist/runtime/utils/index.d.ts +1 -0
- package/dist/runtime/utils/index.js +1 -0
- package/dist/types.d.mts +1 -1
- package/package.json +15 -10
- package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
- /package/dist/runtime/server/routes/admin/{form-secrets.put.d.ts → form-secret.put.d.ts} +0 -0
|
@@ -67,167 +67,169 @@ const routeFieldItems = computed(() => [
|
|
|
67
67
|
</script>
|
|
68
68
|
|
|
69
69
|
<template>
|
|
70
|
-
<div class="
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<UAlert
|
|
88
|
-
v-if="warnings.lost"
|
|
89
|
-
color="warning"
|
|
90
|
-
variant="subtle"
|
|
91
|
-
:title="t('delivery.lostWarning', 'Submissions are neither stored nor emailed. Add recipients or keep storing, or answers are lost unless something handles forms:submitted.')"
|
|
92
|
-
/>
|
|
93
|
-
</section>
|
|
94
|
-
|
|
95
|
-
<section class="space-y-4">
|
|
96
|
-
<h2 class="font-semibold">
|
|
97
|
-
{{ t("delivery.emailTitle", "Email notification") }}
|
|
98
|
-
</h2>
|
|
99
|
-
<UFormField
|
|
100
|
-
:label="t('delivery.recipients', 'Recipients')"
|
|
101
|
-
:description="t('delivery.recipientsHint', 'Press Enter after each address. Leave empty to turn email off.')"
|
|
102
|
-
>
|
|
103
|
-
<UInputTags
|
|
104
|
-
:model-value="email?.to ?? []"
|
|
105
|
-
type="email"
|
|
106
|
-
class="w-full"
|
|
107
|
-
@update:model-value="definition = setEmailRecipients(definition, $event)"
|
|
70
|
+
<div class="flex flex-col gap-4">
|
|
71
|
+
<SectionCard :title="t('delivery.storageTitle', 'Storage')">
|
|
72
|
+
<div class="flex flex-col gap-4">
|
|
73
|
+
<FormRow
|
|
74
|
+
:label="t('delivery.storeTitle', 'Store submissions')"
|
|
75
|
+
:description="t('delivery.storeHint', 'Keep submissions in the database so they can be reviewed under Submissions.')"
|
|
76
|
+
>
|
|
77
|
+
<USwitch
|
|
78
|
+
:model-value="definition.storage === 'db'"
|
|
79
|
+
@update:model-value="definition = setStorage(definition, $event ? 'db' : 'none')"
|
|
80
|
+
/>
|
|
81
|
+
</FormRow>
|
|
82
|
+
<UAlert
|
|
83
|
+
v-if="warnings.files"
|
|
84
|
+
color="warning"
|
|
85
|
+
variant="subtle"
|
|
86
|
+
:title="t('delivery.filesWarning', 'This form has file upload fields, which need stored submissions. Remove them or keep storing.')"
|
|
108
87
|
/>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
88
|
+
<UAlert
|
|
89
|
+
v-if="warnings.lost"
|
|
90
|
+
color="warning"
|
|
91
|
+
variant="subtle"
|
|
92
|
+
:title="t('delivery.lostWarning', 'Submissions are neither stored nor emailed. Add recipients or keep storing, or answers are lost unless something handles forms:submitted.')"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</SectionCard>
|
|
117
96
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
:
|
|
97
|
+
<SectionCard :title="t('delivery.emailTitle', 'Email notification')">
|
|
98
|
+
<div class="flex flex-col divide-y divide-default">
|
|
99
|
+
<FormRow
|
|
100
|
+
:label="t('delivery.recipients', 'Recipients')"
|
|
101
|
+
:description="t('delivery.recipientsHint', 'Press Enter after each address. Leave empty to turn email off.')"
|
|
122
102
|
>
|
|
123
|
-
<
|
|
124
|
-
:model-value="email
|
|
125
|
-
|
|
103
|
+
<UInputTags
|
|
104
|
+
:model-value="email?.to ?? []"
|
|
105
|
+
type="email"
|
|
106
|
+
class="w-full sm:max-w-sm"
|
|
107
|
+
@update:model-value="definition = setEmailRecipients(definition, $event)"
|
|
126
108
|
/>
|
|
127
|
-
</
|
|
109
|
+
</FormRow>
|
|
128
110
|
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
111
|
+
<p
|
|
112
|
+
v-if="!email"
|
|
113
|
+
class="pt-4 text-sm text-muted"
|
|
132
114
|
>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
115
|
+
{{ t("delivery.needRecipients", "Add recipients first to set the subject, reply-to and routing.") }}
|
|
116
|
+
</p>
|
|
117
|
+
|
|
118
|
+
<template v-else>
|
|
119
|
+
<FormRow
|
|
120
|
+
:label="t('delivery.subject', 'Subject')"
|
|
121
|
+
:description="t('delivery.subjectHint', 'Variables from the list below work here too.')"
|
|
137
122
|
>
|
|
138
123
|
<FormBuilderText
|
|
139
|
-
:model-value="email.
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
@update:model-value="definition = patchEmailDelivery(definition, { body: $event })"
|
|
124
|
+
:model-value="email.subject"
|
|
125
|
+
class="sm:max-w-sm"
|
|
126
|
+
@update:model-value="definition = patchEmailDelivery(definition, { subject: $event })"
|
|
143
127
|
/>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
128
|
+
</FormRow>
|
|
129
|
+
|
|
130
|
+
<FormRow
|
|
131
|
+
:label="t('delivery.body', 'Email body')"
|
|
132
|
+
:description="t('delivery.bodyHint', 'Optional. Leave empty to email the list of all answers. Variables are replaced with the submitted values.')"
|
|
133
|
+
>
|
|
134
|
+
<div
|
|
135
|
+
ref="bodyEditor"
|
|
136
|
+
class="space-y-2"
|
|
137
|
+
@focusin="rememberTextarea"
|
|
138
|
+
>
|
|
139
|
+
<FormBuilderText
|
|
140
|
+
:model-value="email.body"
|
|
141
|
+
multiline
|
|
142
|
+
:preview="false"
|
|
143
|
+
@update:model-value="definition = patchEmailDelivery(definition, { body: $event })"
|
|
144
|
+
/>
|
|
145
|
+
<div class="space-y-1">
|
|
146
|
+
<p class="text-xs text-muted">
|
|
147
|
+
{{ t("delivery.bodyVariables", "Click a variable to insert it at the cursor. The full list of answers is") }}
|
|
148
|
+
<code>{{ answersExample }}</code>.
|
|
149
|
+
</p>
|
|
150
|
+
<div class="flex max-h-28 flex-wrap gap-1 overflow-y-auto">
|
|
151
|
+
<UButton
|
|
152
|
+
v-for="name in variables"
|
|
153
|
+
:key="name"
|
|
154
|
+
type="button"
|
|
155
|
+
color="neutral"
|
|
156
|
+
variant="subtle"
|
|
157
|
+
size="xs"
|
|
158
|
+
class="font-mono"
|
|
159
|
+
:label="variableLabel(name)"
|
|
160
|
+
@click="insertVariable(name)"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<UAlert
|
|
165
|
+
v-if="unknownVariables.length"
|
|
166
|
+
color="warning"
|
|
167
|
+
variant="subtle"
|
|
168
|
+
:title="t('delivery.bodyUnknown', 'Unknown variables are left empty: {names}', { names: unknownVariables.map(variableLabel).join(', ') })"
|
|
169
|
+
/>
|
|
170
|
+
<div
|
|
171
|
+
v-if="email.body"
|
|
172
|
+
class="space-y-1"
|
|
173
|
+
>
|
|
174
|
+
<p class="text-xs font-medium text-muted">
|
|
175
|
+
{{ t("delivery.bodyPreview", "Preview with sample answers") }}
|
|
176
|
+
</p>
|
|
177
|
+
<pre class="max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-default bg-elevated/50 p-2 text-xs">{{ preview }}</pre>
|
|
150
178
|
<UButton
|
|
151
|
-
v-for="name in variables"
|
|
152
|
-
:key="name"
|
|
153
179
|
type="button"
|
|
154
180
|
color="neutral"
|
|
155
|
-
variant="
|
|
181
|
+
variant="link"
|
|
156
182
|
size="xs"
|
|
157
|
-
|
|
158
|
-
:label="
|
|
159
|
-
@click="
|
|
183
|
+
icon="i-lucide-rotate-ccw"
|
|
184
|
+
:label="t('delivery.bodyReset', 'Reset to the default body')"
|
|
185
|
+
@click="definition = patchEmailDelivery(definition, { body: void 0 })"
|
|
160
186
|
/>
|
|
161
187
|
</div>
|
|
162
188
|
</div>
|
|
163
|
-
|
|
164
|
-
v-if="unknownVariables.length"
|
|
165
|
-
color="warning"
|
|
166
|
-
variant="subtle"
|
|
167
|
-
:title="t('delivery.bodyUnknown', 'Unknown variables are left empty: {names}', { names: unknownVariables.map(variableLabel).join(', ') })"
|
|
168
|
-
/>
|
|
169
|
-
<div
|
|
170
|
-
v-if="email.body"
|
|
171
|
-
class="space-y-1"
|
|
172
|
-
>
|
|
173
|
-
<p class="text-xs font-medium text-muted">
|
|
174
|
-
{{ t("delivery.bodyPreview", "Preview with sample answers") }}
|
|
175
|
-
</p>
|
|
176
|
-
<pre class="max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-default bg-elevated/50 p-2 text-xs">{{ preview }}</pre>
|
|
177
|
-
<UButton
|
|
178
|
-
type="button"
|
|
179
|
-
color="neutral"
|
|
180
|
-
variant="link"
|
|
181
|
-
size="xs"
|
|
182
|
-
icon="i-lucide-rotate-ccw"
|
|
183
|
-
:label="t('delivery.bodyReset', 'Reset to the default body')"
|
|
184
|
-
@click="definition = patchEmailDelivery(definition, { body: void 0 })"
|
|
185
|
-
/>
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
</UFormField>
|
|
189
|
-
|
|
190
|
-
<UFormField
|
|
191
|
-
:label="t('delivery.replyTo', 'Reply to')"
|
|
192
|
-
:description="t('delivery.replyToHint', 'The answer of this email field becomes the Reply-To.')"
|
|
193
|
-
>
|
|
194
|
-
<USelect
|
|
195
|
-
:model-value="email.replyToField ?? NONE"
|
|
196
|
-
:items="replyToItems"
|
|
197
|
-
:disabled="!replyToFields.length"
|
|
198
|
-
class="w-full"
|
|
199
|
-
@update:model-value="definition = patchEmailDelivery(definition, { replyToField: $event === NONE ? void 0 : String($event) })"
|
|
200
|
-
/>
|
|
201
|
-
</UFormField>
|
|
189
|
+
</FormRow>
|
|
202
190
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
191
|
+
<FormRow
|
|
192
|
+
:label="t('delivery.replyTo', 'Reply to')"
|
|
193
|
+
:description="t('delivery.replyToHint', 'The answer of this email field becomes the Reply-To.')"
|
|
194
|
+
>
|
|
195
|
+
<USelect
|
|
196
|
+
:model-value="email.replyToField ?? NONE"
|
|
197
|
+
:items="replyToItems"
|
|
198
|
+
:disabled="!replyToFields.length"
|
|
199
|
+
class="w-full sm:max-w-sm"
|
|
200
|
+
@update:model-value="definition = patchEmailDelivery(definition, { replyToField: $event === NONE ? void 0 : String($event) })"
|
|
201
|
+
/>
|
|
202
|
+
</FormRow>
|
|
215
203
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
:
|
|
220
|
-
:label="t('delivery.routeTo', 'Recipients for \u201C{value}\u201D', { value: translate(option.label, locale) || option.value })"
|
|
204
|
+
<FormRow
|
|
205
|
+
v-if="routable.length"
|
|
206
|
+
:label="t('delivery.routing', 'Route by answer')"
|
|
207
|
+
:description="t('delivery.routingHint', 'Send to other recipients depending on the answer. Others get the default recipients.')"
|
|
221
208
|
>
|
|
222
|
-
<
|
|
223
|
-
:model-value="email.routes?.
|
|
224
|
-
|
|
225
|
-
class="w-full"
|
|
226
|
-
@update:model-value="definition =
|
|
209
|
+
<USelect
|
|
210
|
+
:model-value="email.routes?.field ?? NONE"
|
|
211
|
+
:items="routeFieldItems"
|
|
212
|
+
class="w-full sm:max-w-sm"
|
|
213
|
+
@update:model-value="definition = setRouteField(definition, $event === NONE ? void 0 : String($event))"
|
|
227
214
|
/>
|
|
228
|
-
</
|
|
215
|
+
</FormRow>
|
|
216
|
+
|
|
217
|
+
<template v-if="routeField">
|
|
218
|
+
<FormRow
|
|
219
|
+
v-for="option in routeField.options"
|
|
220
|
+
:key="option.value"
|
|
221
|
+
:label="t('delivery.routeTo', 'Recipients for \u201C{value}\u201D', { value: translate(option.label, locale) || option.value })"
|
|
222
|
+
>
|
|
223
|
+
<UInputTags
|
|
224
|
+
:model-value="email.routes?.to[option.value] ?? []"
|
|
225
|
+
type="email"
|
|
226
|
+
class="w-full sm:max-w-sm"
|
|
227
|
+
@update:model-value="definition = setRoute(definition, option.value, $event)"
|
|
228
|
+
/>
|
|
229
|
+
</FormRow>
|
|
230
|
+
</template>
|
|
229
231
|
</template>
|
|
230
|
-
</
|
|
231
|
-
</
|
|
232
|
+
</div>
|
|
233
|
+
</SectionCard>
|
|
232
234
|
</div>
|
|
233
235
|
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { defineForm } from "../../utils";
|
|
3
|
+
import { defineForm, formsApi } from "../../utils";
|
|
4
4
|
const emit = defineEmits(["close"]);
|
|
5
5
|
const t = useModuleT("forms");
|
|
6
6
|
const { busy, run } = useAction();
|
|
@@ -26,7 +26,7 @@ watch(() => state.name, (name, previous) => {
|
|
|
26
26
|
});
|
|
27
27
|
async function save() {
|
|
28
28
|
const created = await run(
|
|
29
|
-
() => $fetch(
|
|
29
|
+
() => $fetch(formsApi.admin.forms, { method: "POST", body: { ...state, definition: starter } }),
|
|
30
30
|
{ success: t("admin.created", "Form created") }
|
|
31
31
|
);
|
|
32
32
|
if (created) emit("close", created);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { embedMode } from "../../utils";
|
|
2
|
+
import { embedMode, formsApi } from "../../utils";
|
|
3
3
|
const props = defineProps({
|
|
4
4
|
slug: { type: String, required: true },
|
|
5
5
|
title: { type: Boolean, required: false, default: false },
|
|
@@ -25,7 +25,7 @@ async function unlock() {
|
|
|
25
25
|
checking.value = true;
|
|
26
26
|
wrong.value = false;
|
|
27
27
|
try {
|
|
28
|
-
await $fetch(
|
|
28
|
+
await $fetch(formsApi.form(props.slug), { headers: { [FORM_PASSWORD_HEADER]: password.value } });
|
|
29
29
|
unlocked.value = password.value;
|
|
30
30
|
} catch {
|
|
31
31
|
wrong.value = true;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FormImportResultDto } from '../../../types.js';
|
|
2
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
3
|
+
close: (value: false | FormImportResultDto) => any;
|
|
4
|
+
}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
|
|
5
|
+
onClose?: ((value: false | FormImportResultDto) => any) | undefined;
|
|
6
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
7
|
+
declare const _default: typeof __VLS_export;
|
|
8
|
+
export default _default;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { formsApi } from "../../utils";
|
|
4
|
+
const emit = defineEmits(["close"]);
|
|
5
|
+
const t = useModuleT("forms");
|
|
6
|
+
const { busy, run } = useAction();
|
|
7
|
+
function parse(text) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(text);
|
|
10
|
+
} catch {
|
|
11
|
+
return void 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const state = reactive({ text: "" });
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
text: z.string().trim().min(1, t("admin.importEmpty", "Paste the JSON to import.")).refine((text) => parse(text) !== void 0, t("admin.invalidJson", "The JSON is not valid."))
|
|
17
|
+
});
|
|
18
|
+
async function save() {
|
|
19
|
+
const parsed = parse(state.text);
|
|
20
|
+
const result = await run(() => $fetch(formsApi.admin.import, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
body: { forms: Array.isArray(parsed) ? parsed : [parsed] }
|
|
23
|
+
}));
|
|
24
|
+
if (result) emit("close", result);
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<FormSlideover
|
|
30
|
+
form-id="forms-import"
|
|
31
|
+
:title="t('admin.import', 'Import JSON')"
|
|
32
|
+
:description="t('admin.importHint', 'Paste one form or a list of forms (each with slug, name and definition). Forms whose slug already exists are skipped.')"
|
|
33
|
+
:submit-label="t('admin.importSubmit', 'Import forms')"
|
|
34
|
+
:loading="busy"
|
|
35
|
+
@close="emit('close', false)"
|
|
36
|
+
>
|
|
37
|
+
<UForm
|
|
38
|
+
id="forms-import"
|
|
39
|
+
:state="state"
|
|
40
|
+
:schema="schema"
|
|
41
|
+
class="flex flex-col gap-4"
|
|
42
|
+
@submit="save"
|
|
43
|
+
>
|
|
44
|
+
<UFormField
|
|
45
|
+
name="text"
|
|
46
|
+
:label="t('admin.importLabel', 'Forms as JSON')"
|
|
47
|
+
>
|
|
48
|
+
<UTextarea
|
|
49
|
+
v-model="state.text"
|
|
50
|
+
:rows="16"
|
|
51
|
+
class="w-full font-mono text-xs"
|
|
52
|
+
autofocus
|
|
53
|
+
/>
|
|
54
|
+
</UFormField>
|
|
55
|
+
</UForm>
|
|
56
|
+
</FormSlideover>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FormImportResultDto } from '../../../types.js';
|
|
2
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
3
|
+
close: (value: false | FormImportResultDto) => any;
|
|
4
|
+
}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
|
|
5
|
+
onClose?: ((value: false | FormImportResultDto) => any) | undefined;
|
|
6
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
7
|
+
declare const _default: typeof __VLS_export;
|
|
8
|
+
export default _default;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { previewPayload, translate } from "../../utils";
|
|
3
|
-
import { emptyAnswers, submitAnswers, useFormsLocale } from "../composables/useForms";
|
|
3
|
+
import { emptyAnswers, submissionIssues, submitAnswers, useFormsLocale } from "../composables/useForms";
|
|
4
4
|
import { useFormStepper } from "../composables/useFormStepper";
|
|
5
5
|
const props = defineProps({
|
|
6
6
|
slug: { type: String, required: false },
|
|
@@ -45,8 +45,8 @@ async function onSubmit() {
|
|
|
45
45
|
const redirect = submitSettings.value?.redirect;
|
|
46
46
|
if (redirect) await navigateTo(redirect);
|
|
47
47
|
} catch (error) {
|
|
48
|
-
const issues = error
|
|
49
|
-
if (issues
|
|
48
|
+
const issues = submissionIssues(error);
|
|
49
|
+
if (issues.length) {
|
|
50
50
|
formRef.value?.setErrors(issues.map((issue) => ({ name: String(issue.path[0] ?? ""), message: issue.message })));
|
|
51
51
|
toast.add({ title: t("error.invalid", "Please check the highlighted fields."), color: "error" });
|
|
52
52
|
} else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { isAnswerField, resolveVisibility, translate } from "../../utils";
|
|
2
|
+
import { formsApi, isAnswerField, resolveVisibility, translate } from "../../utils";
|
|
3
3
|
const props = defineProps({
|
|
4
4
|
submission: { type: Object, required: true }
|
|
5
5
|
});
|
|
@@ -20,7 +20,7 @@ const sections = computed(() => resolveVisibility(props.submission.definition, p
|
|
|
20
20
|
};
|
|
21
21
|
})
|
|
22
22
|
})));
|
|
23
|
-
const fileUrl = (fileId) =>
|
|
23
|
+
const fileUrl = (fileId) => formsApi.admin.submissionFile(props.submission.id, fileId);
|
|
24
24
|
</script>
|
|
25
25
|
|
|
26
26
|
<template>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveCaptcha } from "../../utils/index.js";
|
|
1
|
+
import { formsApi, resolveCaptcha } from "../../utils/index.js";
|
|
2
2
|
import { captchaDefaultProvider } from "./useCaptcha.js";
|
|
3
3
|
export function useCaptchaSecrets(formId, field) {
|
|
4
4
|
const t = useModuleT("forms");
|
|
@@ -8,7 +8,9 @@ export function useCaptchaSecrets(formId, field) {
|
|
|
8
8
|
return selected?.type === "captcha" ? resolveCaptcha(selected, captchaDefaultProvider()) : void 0;
|
|
9
9
|
});
|
|
10
10
|
const secrets = ref();
|
|
11
|
-
const
|
|
11
|
+
const id = computed(() => toValue(formId));
|
|
12
|
+
const url = computed(() => id.value ? formsApi.admin.secrets(id.value) : void 0);
|
|
13
|
+
const secretKey = () => `captcha.${captcha.value.provider}.secretKey`;
|
|
12
14
|
const value = ref("");
|
|
13
15
|
const saving = ref(false);
|
|
14
16
|
const loading = ref(Boolean(toValue(formId)));
|
|
@@ -36,10 +38,10 @@ export function useCaptchaSecrets(formId, field) {
|
|
|
36
38
|
value.value = "";
|
|
37
39
|
});
|
|
38
40
|
async function save() {
|
|
39
|
-
if (!
|
|
41
|
+
if (!id.value || !captcha.value || !value.value.trim()) return;
|
|
40
42
|
saving.value = true;
|
|
41
43
|
try {
|
|
42
|
-
secrets.value = await $fetch(
|
|
44
|
+
secrets.value = await $fetch(formsApi.admin.secret(id.value, secretKey()), { method: "PUT", body: { value: value.value.trim() } });
|
|
43
45
|
value.value = "";
|
|
44
46
|
toast.add({ title: t("captcha.secretSaved", "Secret key saved."), color: "success" });
|
|
45
47
|
} catch {
|
|
@@ -49,9 +51,10 @@ export function useCaptchaSecrets(formId, field) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
async function remove() {
|
|
52
|
-
if (!
|
|
54
|
+
if (!id.value || !captcha.value) return;
|
|
53
55
|
try {
|
|
54
|
-
|
|
56
|
+
await $fetch(formsApi.admin.secret(id.value, secretKey()), { method: "DELETE" });
|
|
57
|
+
await load();
|
|
55
58
|
toast.add({ title: t("captcha.secretRemoved", "Secret key removed."), color: "success" });
|
|
56
59
|
} catch {
|
|
57
60
|
toast.add({ title: t("captcha.secretFailed", "The secret key could not be saved."), color: "error" });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DropdownMenuItem } from '@nuxt/ui';
|
|
2
|
-
import type { FormAdminDto, FormStatus, FormStorage } from '../../../types.js';
|
|
2
|
+
import type { FormAdminDto, FormImportResultDto, FormStatus, FormStorage } from '../../../types.js';
|
|
3
3
|
/** What the actions need to know about a form: a list row or the loaded form fits. */
|
|
4
4
|
export interface FormActionTarget {
|
|
5
5
|
id: string;
|
|
@@ -9,6 +9,13 @@ export interface FormActionTarget {
|
|
|
9
9
|
storage?: FormStorage;
|
|
10
10
|
submissionCount?: number;
|
|
11
11
|
}
|
|
12
|
+
/** What the submission actions need: the id and the form it belongs to (a list row has no `formId`, so pages add it). */
|
|
13
|
+
export interface SubmissionActionTarget {
|
|
14
|
+
id: string;
|
|
15
|
+
formId: string;
|
|
16
|
+
}
|
|
17
|
+
/** What importing reports back: slugs that were created and slugs that already existed. */
|
|
18
|
+
export type FormImportResult = FormImportResultDto;
|
|
12
19
|
/** The cache key of the admin form list, so every action can refresh it. */
|
|
13
20
|
export declare const FORMS_LIST_KEY = "forms:admin:forms";
|
|
14
21
|
/** Create, duplicate, archive, import and redeliver for forms, plus the `⋯` menu every list and detail page uses. */
|
|
@@ -17,10 +24,15 @@ export declare function useFormActions(): {
|
|
|
17
24
|
create: () => Promise<FormAdminDto | false>;
|
|
18
25
|
clone: (form: Pick<FormActionTarget, "id">) => Promise<boolean>;
|
|
19
26
|
remove: (form: Pick<FormActionTarget, "id" | "name">) => Promise<boolean>;
|
|
20
|
-
importJson: (
|
|
27
|
+
importJson: () => Promise<boolean>;
|
|
21
28
|
redeliver: (submissionId: string) => Promise<boolean>;
|
|
22
29
|
menu: (form: FormActionTarget, options?: {
|
|
30
|
+
open?: boolean;
|
|
23
31
|
extra?: DropdownMenuItem[];
|
|
24
32
|
onRemoved?: () => void | Promise<void>;
|
|
25
33
|
}) => DropdownMenuItem[][];
|
|
34
|
+
submissionMenu: (submission: SubmissionActionTarget, options?: {
|
|
35
|
+
open?: boolean;
|
|
36
|
+
onRedelivered?: () => void | Promise<void>;
|
|
37
|
+
}) => DropdownMenuItem[][];
|
|
26
38
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { LazyFormCreateSlideover } from "#components";
|
|
1
|
+
import { LazyFormCreateSlideover, LazyFormImportSlideover } from "#components";
|
|
2
|
+
import { formsApi } from "../../utils/index.js";
|
|
2
3
|
export const FORMS_LIST_KEY = "forms:admin:forms";
|
|
3
4
|
export function useFormActions() {
|
|
4
5
|
const t = useModuleT("forms");
|
|
@@ -10,6 +11,7 @@ export function useFormActions() {
|
|
|
10
11
|
const showError = useErrorToast();
|
|
11
12
|
const { busy, run } = useAction();
|
|
12
13
|
const creator = overlay.create(LazyFormCreateSlideover, { destroyOnClose: true });
|
|
14
|
+
const importer = overlay.create(LazyFormImportSlideover, { destroyOnClose: true });
|
|
13
15
|
async function create() {
|
|
14
16
|
const created = await creator.open().result;
|
|
15
17
|
if (created) await navigateTo(paths("adminForm", { id: created.id }));
|
|
@@ -17,7 +19,7 @@ export function useFormActions() {
|
|
|
17
19
|
}
|
|
18
20
|
async function clone(form) {
|
|
19
21
|
const copy = await run(
|
|
20
|
-
() => $fetch(
|
|
22
|
+
() => $fetch(formsApi.admin.clone(form.id), { method: "POST" }),
|
|
21
23
|
{ success: t("admin.cloned", "Form duplicated") }
|
|
22
24
|
);
|
|
23
25
|
if (!copy) return false;
|
|
@@ -33,25 +35,16 @@ export function useFormActions() {
|
|
|
33
35
|
});
|
|
34
36
|
if (!confirmed) return false;
|
|
35
37
|
const archived = await run(
|
|
36
|
-
|
|
38
|
+
// 204 No Content: `run` resolves with `undefined` on failure, so map success to `true`.
|
|
39
|
+
() => $fetch(formsApi.admin.form(form.id), { method: "DELETE" }).then(() => true),
|
|
37
40
|
{ success: t("admin.archived", "Form archived") }
|
|
38
41
|
);
|
|
39
42
|
if (!archived) return false;
|
|
40
43
|
await refreshNuxtData(FORMS_LIST_KEY);
|
|
41
44
|
return true;
|
|
42
45
|
}
|
|
43
|
-
async function importJson(
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
parsed = JSON.parse(text);
|
|
47
|
-
} catch {
|
|
48
|
-
toast.add({ title: t("admin.invalidJson", "The JSON is not valid."), color: "error" });
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
const result = await run(() => $fetch("/api/forms/admin/import", {
|
|
52
|
-
method: "POST",
|
|
53
|
-
body: { forms: Array.isArray(parsed) ? parsed : [parsed] }
|
|
54
|
-
}));
|
|
46
|
+
async function importJson() {
|
|
47
|
+
const result = await importer.open().result;
|
|
55
48
|
if (!result) return false;
|
|
56
49
|
toast.add({
|
|
57
50
|
title: t("admin.imported", "Imported {inserted}, skipped {skipped} existing.", { inserted: result.inserted.length, skipped: result.skipped.length }),
|
|
@@ -61,10 +54,7 @@ export function useFormActions() {
|
|
|
61
54
|
return true;
|
|
62
55
|
}
|
|
63
56
|
async function redeliver(submissionId) {
|
|
64
|
-
const result = await run(() => $fetch(
|
|
65
|
-
`/api/forms/admin/submissions/${submissionId}/redeliver`,
|
|
66
|
-
{ method: "POST" }
|
|
67
|
-
));
|
|
57
|
+
const result = await run(() => $fetch(formsApi.admin.redeliver(submissionId), { method: "POST" }));
|
|
68
58
|
if (!result) return false;
|
|
69
59
|
if (result.deliveryStatus === "failed") {
|
|
70
60
|
showError(new Error(result.deliveryError ?? ""), t("delivery.failed", "Delivery failed"));
|
|
@@ -74,7 +64,11 @@ export function useFormActions() {
|
|
|
74
64
|
return true;
|
|
75
65
|
}
|
|
76
66
|
function menu(form, options = {}) {
|
|
77
|
-
const items = [
|
|
67
|
+
const items = [];
|
|
68
|
+
if (options.open ?? true) {
|
|
69
|
+
items.push({ label: t("admin.open", "Open"), icon: "i-lucide-arrow-right", to: paths("adminForm", { id: form.id }) });
|
|
70
|
+
}
|
|
71
|
+
items.push(...options.extra ?? []);
|
|
78
72
|
if (form.storage === "db" || (form.submissionCount ?? 0) > 0) {
|
|
79
73
|
items.push({
|
|
80
74
|
label: t("admin.submissionsTitle", "Submissions"),
|
|
@@ -99,5 +93,23 @@ export function useFormActions() {
|
|
|
99
93
|
}
|
|
100
94
|
return groups;
|
|
101
95
|
}
|
|
102
|
-
|
|
96
|
+
function submissionMenu(submission, options = {}) {
|
|
97
|
+
const items = [];
|
|
98
|
+
if (options.open ?? true) {
|
|
99
|
+
items.push({
|
|
100
|
+
label: t("admin.open", "Open"),
|
|
101
|
+
icon: "i-lucide-arrow-right",
|
|
102
|
+
to: paths("adminSubmission", { id: submission.formId, submissionId: submission.id })
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
items.push({
|
|
106
|
+
label: t("admin.redeliver", "Deliver again"),
|
|
107
|
+
icon: "i-lucide-send",
|
|
108
|
+
onSelect: async () => {
|
|
109
|
+
if (await redeliver(submission.id)) await options.onRedelivered?.();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return [items];
|
|
113
|
+
}
|
|
114
|
+
return { busy, create, clone, remove, importJson, redeliver, menu, submissionMenu };
|
|
103
115
|
}
|