@frigear-nu/forms 0.1.2 → 0.1.4

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.
Files changed (70) hide show
  1. package/dist/module.d.mts +18 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +29 -26
  4. package/dist/runtime/app/components/FormBuilderAccess.vue +255 -262
  5. package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
  6. package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
  7. package/dist/runtime/app/components/FormEmbed.vue +2 -2
  8. package/dist/runtime/app/components/FormField.vue +1 -1
  9. package/dist/runtime/app/components/FormImportSlideover.d.vue.ts +8 -0
  10. package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
  11. package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
  12. package/dist/runtime/app/components/FormRenderer.vue +3 -3
  13. package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
  14. package/dist/runtime/app/composables/useCaptchaSecrets.js +9 -6
  15. package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
  16. package/dist/runtime/app/composables/useFormActions.js +33 -21
  17. package/dist/runtime/app/composables/useForms.d.ts +12 -5
  18. package/dist/runtime/app/composables/useForms.js +11 -5
  19. package/dist/runtime/app/pages/admin-form.vue +37 -21
  20. package/dist/runtime/app/pages/admin-submission.vue +27 -16
  21. package/dist/runtime/app/pages/admin-submissions.vue +30 -22
  22. package/dist/runtime/app/pages/admin.vue +29 -78
  23. package/dist/runtime/app/pages/form.vue +2 -1
  24. package/dist/runtime/i18n/lang/da.json +10 -2
  25. package/dist/runtime/i18n/lang/en.json +10 -2
  26. package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
  27. package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
  28. package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
  29. package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
  30. package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
  31. package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
  32. package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
  33. package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
  34. package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
  35. package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
  36. package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
  37. package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
  38. package/dist/runtime/server/routes/admin/form.delete.js +6 -4
  39. package/dist/runtime/server/routes/admin/form.get.js +4 -2
  40. package/dist/runtime/server/routes/admin/form.patch.js +3 -3
  41. package/dist/runtime/server/routes/admin/forms.get.js +37 -16
  42. package/dist/runtime/server/routes/admin/forms.post.js +3 -4
  43. package/dist/runtime/server/routes/admin/import.post.js +3 -2
  44. package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
  45. package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
  46. package/dist/runtime/server/routes/admin/submission.get.js +5 -17
  47. package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
  48. package/dist/runtime/server/routes/public/access.get.js +3 -1
  49. package/dist/runtime/server/routes/public/form.get.js +3 -1
  50. package/dist/runtime/server/routes/public/submit.post.js +2 -1
  51. package/dist/runtime/server/utils/access.js +3 -2
  52. package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
  53. package/dist/runtime/server/utils/admin-schemas.js +4 -0
  54. package/dist/runtime/server/utils/auth.d.ts +8 -4
  55. package/dist/runtime/server/utils/auth.js +6 -7
  56. package/dist/runtime/server/utils/captcha.js +3 -2
  57. package/dist/runtime/server/utils/forms.d.ts +12 -2
  58. package/dist/runtime/server/utils/forms.js +57 -10
  59. package/dist/runtime/server/utils/mcp-access.js +2 -1
  60. package/dist/runtime/server/utils/rate-limit.js +3 -2
  61. package/dist/runtime/server/utils/submit.d.ts +3 -0
  62. package/dist/runtime/server/utils/submit.js +21 -17
  63. package/dist/runtime/utils/api.d.ts +36 -0
  64. package/dist/runtime/utils/api.js +39 -0
  65. package/dist/runtime/utils/index.d.ts +1 -0
  66. package/dist/runtime/utils/index.js +1 -0
  67. package/dist/types.d.mts +1 -1
  68. package/package.json +15 -10
  69. package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
  70. /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="max-w-2xl space-y-8">
71
- <section class="space-y-3">
72
- <h2 class="font-semibold">
73
- {{ t("delivery.storeTitle", "Store submissions") }}
74
- </h2>
75
- <USwitch
76
- :model-value="definition.storage === 'db'"
77
- :label="t('delivery.storeTitle', 'Store submissions')"
78
- :description="t('delivery.storeHint', 'Keep submissions in the database so they can be reviewed under Submissions.')"
79
- @update:model-value="definition = setStorage(definition, $event ? 'db' : 'none')"
80
- />
81
- <UAlert
82
- v-if="warnings.files"
83
- color="warning"
84
- variant="subtle"
85
- :title="t('delivery.filesWarning', 'This form has file upload fields, which need stored submissions. Remove them or keep storing.')"
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
- </UFormField>
110
-
111
- <p
112
- v-if="!email"
113
- class="text-sm text-muted"
114
- >
115
- {{ t("delivery.needRecipients", "Add recipients first to set the subject, reply-to and routing.") }}
116
- </p>
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
- <template v-else>
119
- <UFormField
120
- :label="t('delivery.subject', 'Subject')"
121
- :description="t('delivery.subjectHint', 'Variables from the list below work here too.')"
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
- <FormBuilderText
124
- :model-value="email.subject"
125
- @update:model-value="definition = patchEmailDelivery(definition, { subject: $event })"
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
- </UFormField>
109
+ </FormRow>
128
110
 
129
- <UFormField
130
- :label="t('delivery.body', 'Email body')"
131
- :description="t('delivery.bodyHint', 'Optional. Leave empty to email the list of all answers. Variables are replaced with the submitted values.')"
111
+ <p
112
+ v-if="!email"
113
+ class="pt-4 text-sm text-muted"
132
114
  >
133
- <div
134
- ref="bodyEditor"
135
- class="space-y-2"
136
- @focusin="rememberTextarea"
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.body"
140
- multiline
141
- :preview="false"
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
- <div class="space-y-1">
145
- <p class="text-xs text-muted">
146
- {{ t("delivery.bodyVariables", "Click a variable to insert it at the cursor. The full list of answers is") }}
147
- <code>{{ answersExample }}</code>.
148
- </p>
149
- <div class="flex max-h-28 flex-wrap gap-1 overflow-y-auto">
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="subtle"
181
+ variant="link"
156
182
  size="xs"
157
- class="font-mono"
158
- :label="variableLabel(name)"
159
- @click="insertVariable(name)"
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
- <UAlert
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
- <UFormField
204
- v-if="routable.length"
205
- :label="t('delivery.routing', 'Route by answer')"
206
- :description="t('delivery.routingHint', 'Send to other recipients depending on the answer. Others get the default recipients.')"
207
- >
208
- <USelect
209
- :model-value="email.routes?.field ?? NONE"
210
- :items="routeFieldItems"
211
- class="w-full"
212
- @update:model-value="definition = setRouteField(definition, $event === NONE ? void 0 : String($event))"
213
- />
214
- </UFormField>
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
- <template v-if="routeField">
217
- <UFormField
218
- v-for="option in routeField.options"
219
- :key="option.value"
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
- <UInputTags
223
- :model-value="email.routes?.to[option.value] ?? []"
224
- type="email"
225
- class="w-full"
226
- @update:model-value="definition = setRoute(definition, option.value, $event)"
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
- </UFormField>
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
- </template>
231
- </section>
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("/api/forms/admin/forms", { method: "POST", body: { ...state, definition: starter } }),
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(`/api/forms/${props.slug}`, { headers: { [FORM_PASSWORD_HEADER]: password.value } });
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;
@@ -25,7 +25,6 @@ const isCheckbox = computed(() => props.field.type === "checkbox");
25
25
  class="prose prose-sm max-w-none text-muted"
26
26
  v-html="html"
27
27
  />
28
- <!-- eslint-enable vue/no-v-html -->
29
28
 
30
29
  <UFormField
31
30
  v-else-if="field.type === 'captcha'"
@@ -109,4 +108,5 @@ const isCheckbox = computed(() => props.field.type === "checkbox");
109
108
  class="w-full"
110
109
  />
111
110
  </UFormField>
111
+ <!-- eslint-enable vue/no-v-html -->
112
112
  </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;
@@ -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.data?.data?.issues;
49
- if (issues?.length) {
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) => `/api/forms/admin/submissions/${props.submission.id}/files/${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 url = computed(() => toValue(formId) ? `/api/forms/admin/forms/${toValue(formId)}/secrets` : void 0);
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 (!url.value || !captcha.value || !value.value.trim()) return;
41
+ if (!id.value || !captcha.value || !value.value.trim()) return;
40
42
  saving.value = true;
41
43
  try {
42
- secrets.value = await $fetch(url.value, { method: "PUT", body: { key: `captcha.${captcha.value.provider}.secretKey`, value: value.value.trim() } });
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 (!url.value || !captcha.value) return;
54
+ if (!id.value || !captcha.value) return;
53
55
  try {
54
- secrets.value = await $fetch(`${url.value}/captcha.${captcha.value.provider}.secretKey`, { method: "DELETE" });
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: (text: string) => Promise<boolean>;
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
  };