@frigear-nu/forms 0.1.1 → 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.
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/FormBuilderCaptchaSettings.vue +8 -1
  6. package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
  7. package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
  8. package/dist/runtime/app/components/FormEmbed.vue +2 -2
  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 +17 -7
  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 +36 -20
  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 +17 -12
  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
@@ -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(`/api/forms/admin/forms/${form.id}/clone`, { method: "POST" }),
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
- () => $fetch(`/api/forms/admin/forms/${form.id}`, { method: "DELETE" }),
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(text) {
44
- let parsed;
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 = [...options.extra ?? []];
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
- return { busy, create, clone, remove, importJson, redeliver, menu };
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
  }
@@ -1,4 +1,4 @@
1
- import type { FormDefinition, FormField } from '../../../types.js';
1
+ import type { FormDefinition, FormField, SubmitResponse } from '../../../types.js';
2
2
  /** Locale for translated form content: `@nuxtjs/i18n`'s when installed, otherwise `en`. */
3
3
  export declare function useFormsLocale(): any;
4
4
  /** Locales content can be translated into: `@nuxtjs/i18n`'s configured locales when installed, otherwise just `en`. */
@@ -11,11 +11,18 @@ export declare function useForm(slug: MaybeRefOrGetter<string>, password?: Maybe
11
11
  export declare function useFormAccess(slug: MaybeRefOrGetter<string>): any;
12
12
  /** Initial answers: empty arrays for files, `false` for checkboxes, everything else unset. */
13
13
  export declare function emptyAnswers(definition: Pick<FormDefinition, 'steps'>): Record<string, unknown>;
14
+ /** One problem with a submission, as the submit route reports it. */
15
+ export type SubmissionIssue = {
16
+ path: (string | number)[];
17
+ message: string;
18
+ };
19
+ /**
20
+ * The issues of a failed submit: the route answers 400 `validation_failed` with `{ path, message }` issues in
21
+ * `data.issues`, for a malformed body as well as for answers the definition rejects. Empty for any other error.
22
+ */
23
+ export declare function submissionIssues(error: unknown): SubmissionIssue[];
14
24
  /** Posts the answers: JSON for forms without files, multipart (answers + `file:<key>` parts) otherwise. */
15
- export declare function submitAnswers(slug: string, definition: Pick<FormDefinition, 'steps'>, answers: Record<string, unknown>, locale: string, password?: string): Promise<{
16
- ok: true;
17
- id: string | null;
18
- }>;
25
+ export declare function submitAnswers(slug: string, definition: Pick<FormDefinition, 'steps'>, answers: Record<string, unknown>, locale: string, password?: string): Promise<SubmitResponse>;
19
26
  declare const DEFAULT_PATHS: {
20
27
  readonly form: "/forms/:slug";
21
28
  readonly admin: "/admin/forms";
@@ -1,4 +1,4 @@
1
- import { resolveVisibility, translate } from "../../utils/index.js";
1
+ import { formsApi, resolveVisibility, translate } from "../../utils/index.js";
2
2
  export function useFormsLocale() {
3
3
  const nuxtApp = useNuxtApp();
4
4
  return computed(() => nuxtApp.$i18n?.locale?.value ?? "en");
@@ -12,13 +12,13 @@ export function useFormsLocales() {
12
12
  }
13
13
  export const FORM_PASSWORD_HEADER = "x-form-password";
14
14
  export function useForm(slug, password) {
15
- return useFetch(() => `/api/forms/${toValue(slug)}`, {
15
+ return useFetch(() => formsApi.form(toValue(slug)), {
16
16
  key: `forms:${toValue(slug)}${toValue(password) ? ":pw" : ""}`,
17
17
  headers: computed(() => toValue(password) ? { [FORM_PASSWORD_HEADER]: toValue(password) } : void 0)
18
18
  });
19
19
  }
20
20
  export function useFormAccess(slug) {
21
- return useFetch(() => `/api/forms/${toValue(slug)}/access`, { key: `forms:access:${toValue(slug)}` });
21
+ return useFetch(() => formsApi.access(toValue(slug)), { key: `forms:access:${toValue(slug)}` });
22
22
  }
23
23
  export function emptyAnswers(definition) {
24
24
  const state = {};
@@ -31,6 +31,12 @@ export function emptyAnswers(definition) {
31
31
  }
32
32
  return state;
33
33
  }
34
+ export function submissionIssues(error) {
35
+ const body = error?.data;
36
+ if (body?.statusCode !== void 0 && body.statusCode !== 400) return [];
37
+ const issues = body?.data?.issues;
38
+ return Array.isArray(issues) ? issues.filter((issue) => typeof issue?.message === "string" && Array.isArray(issue?.path)) : [];
39
+ }
34
40
  export async function submitAnswers(slug, definition, answers, locale, password) {
35
41
  const headers = password ? { [FORM_PASSWORD_HEADER]: password } : void 0;
36
42
  const files = [];
@@ -45,13 +51,13 @@ export async function submitAnswers(slug, definition, answers, locale, password)
45
51
  plain[key] = value;
46
52
  }
47
53
  if (!files.length) {
48
- return $fetch(`/api/forms/${slug}/submit`, { method: "POST", body: { answers: plain, locale }, headers });
54
+ return $fetch(formsApi.submit(slug), { method: "POST", body: { answers: plain, locale }, headers });
49
55
  }
50
56
  const body = new FormData();
51
57
  body.append("answers", JSON.stringify(plain));
52
58
  body.append("locale", locale);
53
59
  for (const { key, file } of files) body.append(`file:${key}`, file, file.name);
54
- return $fetch(`/api/forms/${slug}/submit`, { method: "POST", body, headers });
60
+ return $fetch(formsApi.submit(slug), { method: "POST", body, headers });
55
61
  }
56
62
  const DEFAULT_PATHS = {
57
63
  form: "/forms/:slug",
@@ -1,15 +1,16 @@
1
1
  <script setup>
2
- import { formDefinitionSchema } from "../../utils";
2
+ import { formDefinitionSchema, formsApi } from "../../utils";
3
3
  const t = useModuleT("forms");
4
4
  const route = useRoute();
5
5
  const paths = useFormsPaths();
6
6
  const actions = useFormActions();
7
7
  const { busy: saving, run } = useAction();
8
8
  const id = computed(() => String(route.params.id));
9
- const { data: form, error, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, {
9
+ const { data: form, error, refresh } = await useFetch(() => formsApi.admin.form(id.value), {
10
10
  key: `forms:admin:form:${id.value}`
11
11
  });
12
12
  const notFound = computed(() => error.value?.statusCode === 404);
13
+ const forbidden = computed(() => [401, 403].includes(error.value?.statusCode ?? 0));
13
14
  const draft = reactive({ name: form.value?.name ?? "", slug: form.value?.slug ?? "", status: form.value?.status ?? "draft" });
14
15
  const definition = ref(structuredClone(toRaw(form.value?.definition ?? { mode: "single", storage: "db", steps: [] })));
15
16
  const tab = ref("builder");
@@ -47,13 +48,13 @@ const statuses = ["draft", "published", "archived"];
47
48
  async function save() {
48
49
  if (!checked.value.definition) return;
49
50
  await run(async () => {
50
- await $fetch(`/api/forms/admin/forms/${id.value}`, { method: "PATCH", body: { ...draft, definition: checked.value.definition } });
51
+ await $fetch(formsApi.admin.form(id.value), { method: "PATCH", body: { ...draft, definition: checked.value.definition } });
51
52
  await refresh();
52
53
  previewKey.value++;
53
54
  }, { success: t("admin.saved", "Saved.") });
54
55
  }
55
56
  const listPath = paths("admin");
56
- const menu = computed(() => form.value ? actions.menu({ ...form.value, storage: form.value.definition.storage }, { onRemoved: () => navigateTo(listPath) }) : []);
57
+ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage: form.value.definition.storage }, { open: false, onRemoved: () => navigateTo(listPath) }) : []);
57
58
  </script>
58
59
 
59
60
  <template>
@@ -67,10 +68,10 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
67
68
  {{ form.name }} <span class="text-muted text-base font-normal">v{{ form.version }}</span>
68
69
  </template>
69
70
  <template #badge>
70
- <UBadge
71
- :color="statusColor(form.status, { archived: 'warning' })"
72
- variant="subtle"
71
+ <StatusBadge
72
+ :status="form.status"
73
73
  :label="t(`status.${form.status}`, form.status)"
74
+ :colors="{ archived: 'warning' }"
74
75
  />
75
76
  </template>
76
77
  <template #actions>
@@ -133,17 +134,18 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
133
134
  </template>
134
135
  </FormBuilder>
135
136
 
136
- <SectionCard v-else-if="tab === 'settings'">
137
- <FormBuilderSettings v-model="definition" />
138
- </SectionCard>
137
+ <!-- Settings and access render their own `SectionCard`s, one per topic. -->
138
+ <FormBuilderSettings
139
+ v-else-if="tab === 'settings'"
140
+ v-model="definition"
141
+ />
139
142
 
140
- <SectionCard v-else-if="tab === 'access'">
141
- <FormBuilderAccess
142
- v-model="definition"
143
- :form-id="form.id"
144
- :slug="form.slug"
145
- />
146
- </SectionCard>
143
+ <FormBuilderAccess
144
+ v-else-if="tab === 'access'"
145
+ v-model="definition"
146
+ :form-id="form.id"
147
+ :slug="form.slug"
148
+ />
147
149
 
148
150
  <SectionCard
149
151
  v-else-if="tab === 'json'"
@@ -177,9 +179,9 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
177
179
  </template>
178
180
 
179
181
  <UEmpty
180
- v-else
181
- :icon="notFound ? 'i-lucide-clipboard-x' : 'i-lucide-lock'"
182
- :title="notFound ? t('admin.notFound', 'This form does not exist.') : t('admin.forbidden', 'You are not allowed to manage forms.')"
182
+ v-else-if="notFound"
183
+ icon="i-lucide-clipboard-x"
184
+ :title="t('admin.notFound', 'This form does not exist.')"
183
185
  >
184
186
  <template #actions>
185
187
  <UButton
@@ -191,5 +193,19 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
191
193
  />
192
194
  </template>
193
195
  </UEmpty>
196
+
197
+ <UEmpty
198
+ v-else-if="forbidden"
199
+ icon="i-lucide-lock"
200
+ :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
201
+ />
202
+
203
+ <UEmpty
204
+ v-else
205
+ icon="i-lucide-circle-alert"
206
+ :title="t('admin.formLoadFailed', 'The form could not be loaded')"
207
+ :description="errorMessage(error, '')"
208
+ :actions="[{ label: t('admin.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
209
+ />
194
210
  </div>
195
211
  </template>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { formsApi } from "../../utils";
2
3
  const t = useModuleT("forms");
3
4
  const route = useRoute();
4
5
  const paths = useFormsPaths();
@@ -6,22 +7,19 @@ const format = useFormat();
6
7
  const actions = useFormActions();
7
8
  const id = computed(() => String(route.params.id));
8
9
  const submissionId = computed(() => String(route.params.submissionId));
9
- const { data: form } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, { key: `forms:admin:form:${id.value}` });
10
- const { data: submission, error, refresh } = await useFetch(() => `/api/forms/admin/submissions/${submissionId.value}`);
11
- async function redeliver() {
12
- if (await actions.redeliver(submissionId.value)) await refresh();
13
- }
10
+ const { data: form } = await useFetch(() => formsApi.admin.form(id.value), { key: `forms:admin:form:${id.value}` });
11
+ const { data: submission, error, refresh } = await useFetch(() => formsApi.admin.submission(submissionId.value));
14
12
  const breadcrumb = computed(() => [
15
13
  { label: t("admin.title", "Forms"), to: paths("admin") },
16
14
  { label: form.value?.name ?? id.value, to: paths("adminForm", { id: id.value }) },
17
15
  { label: t("admin.submissionsTitle", "Submissions"), to: paths("adminSubmissions", { id: id.value }) },
18
16
  { label: `#${submissionId.value}` }
19
17
  ]);
20
- const menu = computed(() => [[{
21
- label: t("admin.redeliver", "Deliver again"),
22
- icon: "i-lucide-send",
23
- onSelect: redeliver
24
- }]]);
18
+ const menu = computed(() => actions.submissionMenu(
19
+ { id: submissionId.value, formId: id.value },
20
+ { open: false, onRedelivered: () => refresh() }
21
+ ));
22
+ const forbidden = computed(() => [401, 403].includes(error.value?.statusCode ?? 0));
25
23
  const notFound = computed(() => error.value?.statusCode === 404);
26
24
  </script>
27
25
 
@@ -34,9 +32,8 @@ const notFound = computed(() => error.value?.statusCode === 404);
34
32
  :breadcrumb="breadcrumb"
35
33
  >
36
34
  <template #badge>
37
- <UBadge
38
- :color="statusColor(submission.deliveryStatus)"
39
- variant="subtle"
35
+ <StatusBadge
36
+ :status="submission.deliveryStatus"
40
37
  :label="t(`delivery.${submission.deliveryStatus}`, submission.deliveryStatus)"
41
38
  />
42
39
  </template>
@@ -63,9 +60,9 @@ const notFound = computed(() => error.value?.statusCode === 404);
63
60
  </template>
64
61
 
65
62
  <UEmpty
66
- v-else
67
- :icon="notFound ? 'i-lucide-inbox' : 'i-lucide-lock'"
68
- :title="notFound ? t('admin.submissionNotFound', 'This submission does not exist.') : t('admin.forbidden', 'You are not allowed to manage forms.')"
63
+ v-else-if="notFound"
64
+ icon="i-lucide-inbox"
65
+ :title="t('admin.submissionNotFound', 'This submission does not exist.')"
69
66
  >
70
67
  <template #actions>
71
68
  <UButton
@@ -77,5 +74,19 @@ const notFound = computed(() => error.value?.statusCode === 404);
77
74
  />
78
75
  </template>
79
76
  </UEmpty>
77
+
78
+ <UEmpty
79
+ v-else-if="forbidden"
80
+ icon="i-lucide-lock"
81
+ :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
82
+ />
83
+
84
+ <UEmpty
85
+ v-else
86
+ icon="i-lucide-circle-alert"
87
+ :title="t('admin.submissionLoadFailed', 'The submission could not be loaded')"
88
+ :description="errorMessage(error, '')"
89
+ :actions="[{ label: t('admin.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
90
+ />
80
91
  </div>
81
92
  </template>
@@ -1,13 +1,17 @@
1
1
  <script setup>
2
+ import { formsApi } from "../../utils";
2
3
  const t = useModuleT("forms");
3
4
  const route = useRoute();
4
5
  const paths = useFormsPaths();
5
6
  const format = useFormat();
6
7
  const actions = useFormActions();
7
8
  const id = computed(() => String(route.params.id));
8
- const { data: form } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, { key: `forms:admin:form:${id.value}` });
9
- const { data: submissions, error, status, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}/submissions`);
10
- const { sorting, header } = useTableSort({ id: "createdAt", desc: true });
9
+ const { data: form } = await useFetch(() => formsApi.admin.form(id.value), { key: `forms:admin:form:${id.value}` });
10
+ const list = useTableQuery({ sort: { id: "createdAt", desc: true } });
11
+ const { page, pageSize } = list;
12
+ const { data, error, status, refresh } = await useFetch(() => formsApi.admin.submissions(id.value), { query: list.query });
13
+ const submissions = computed(() => data.value?.items ?? []);
14
+ const { sorting, header } = useTableSort(null, list.sort);
11
15
  const columns = computed(() => [
12
16
  { accessorKey: "createdAt", header: header("createdAt", t("admin.submittedAt", "Submitted")) },
13
17
  { id: "preview", header: t("admin.answers", "Answers") },
@@ -15,16 +19,11 @@ const columns = computed(() => [
15
19
  { id: "actions", header: "", meta: { class: { th: "w-0", td: "w-0" } } }
16
20
  ]);
17
21
  const submissionPath = (submission) => paths("adminSubmission", { id: id.value, submissionId: submission.id });
18
- const menu = (submission) => [[
19
- { label: t("admin.open", "Open"), icon: "i-lucide-arrow-right", to: submissionPath(submission) },
20
- {
21
- label: t("admin.redeliver", "Deliver again"),
22
- icon: "i-lucide-send",
23
- onSelect: async () => {
24
- if (await actions.redeliver(submission.id)) await refresh();
25
- }
26
- }
27
- ]];
22
+ const menu = (submission) => actions.submissionMenu(
23
+ { id: submission.id, formId: id.value },
24
+ { onRedelivered: () => refresh() }
25
+ );
26
+ const forbidden = computed(() => [401, 403].includes(error.value?.statusCode ?? 0));
28
27
  const breadcrumb = computed(() => [
29
28
  { label: t("admin.title", "Forms"), to: paths("admin") },
30
29
  { label: form.value?.name ?? id.value, to: paths("adminForm", { id: id.value }) },
@@ -40,20 +39,23 @@ const breadcrumb = computed(() => [
40
39
  />
41
40
 
42
41
  <UEmpty
43
- v-if="error"
44
- icon="i-lucide-lock"
45
- :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
42
+ v-if="error && !data"
43
+ :icon="forbidden ? 'i-lucide-lock' : 'i-lucide-circle-alert'"
44
+ :title="forbidden ? t('admin.forbidden', 'You are not allowed to manage forms.') : t('admin.submissionsLoadFailed', 'Submissions could not be loaded')"
45
+ :description="forbidden ? void 0 : errorMessage(error, '')"
46
+ :actions="forbidden ? void 0 : [{ label: t('admin.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
46
47
  />
47
48
 
48
49
  <TableCard
49
50
  v-else
50
- :loading="status === 'pending' && !submissions"
51
- :refreshing="status === 'pending' && !!submissions"
51
+ :loading="status === 'pending' && !data"
52
+ :refreshing="status === 'pending' && !!data"
52
53
  >
53
54
  <UTable
54
55
  v-model:sorting="sorting"
55
- :data="submissions ?? []"
56
+ :data="submissions"
56
57
  :columns="columns"
58
+ :sorting-options="{ manualSorting: true }"
57
59
  :ui="tableUi()"
58
60
  @select="(_, row2) => navigateTo(submissionPath(row2.original))"
59
61
  >
@@ -74,9 +76,8 @@ const breadcrumb = computed(() => [
74
76
  </p>
75
77
  </template>
76
78
  <template #deliveryStatus-cell="{ row }">
77
- <UBadge
78
- :color="statusColor(row.original.deliveryStatus)"
79
- variant="subtle"
79
+ <StatusBadge
80
+ :status="row.original.deliveryStatus"
80
81
  :label="t(`delivery.${row.original.deliveryStatus}`, row.original.deliveryStatus)"
81
82
  />
82
83
  </template>
@@ -95,6 +96,13 @@ const breadcrumb = computed(() => [
95
96
  />
96
97
  </template>
97
98
  </UTable>
99
+ <template #footer>
100
+ <TablePagination
101
+ v-model:page="page"
102
+ v-model:page-size="pageSize"
103
+ :total="data?.total ?? 0"
104
+ />
105
+ </template>
98
106
  </TableCard>
99
107
  </div>
100
108
  </template>
@@ -1,27 +1,19 @@
1
1
  <script setup>
2
+ import { formsApi } from "../../utils";
2
3
  const t = useModuleT("forms");
3
4
  const paths = useFormsPaths();
4
5
  const format = useFormat();
5
6
  const actions = useFormActions();
6
- const { data: forms, error, status } = await useFetch("/api/forms/admin/forms", { key: FORMS_LIST_KEY });
7
- const search = ref("");
8
- const statusFilter = ref("all");
7
+ const list = useTableQuery({ sort: { id: "updatedAt", desc: true }, filters: { status: "all" } });
8
+ const { page, pageSize, search, filters, filtered } = list;
9
+ const { data, error, status, refresh } = await useFetch(formsApi.admin.forms, { key: FORMS_LIST_KEY, query: list.query });
10
+ const forms = computed(() => data.value?.items ?? []);
9
11
  const statusItems = computed(() => [
10
12
  { value: "all", label: t("admin.allStatuses", "All statuses") },
11
13
  ...["published", "draft", "archived"].map((value) => ({ value, label: t(`status.${value}`, value) }))
12
14
  ]);
13
- const visibleForms = computed(() => {
14
- const query = search.value.trim().toLowerCase();
15
- return (forms.value ?? []).filter(
16
- (form) => (statusFilter.value === "all" || form.status === statusFilter.value) && (!query || form.name.toLowerCase().includes(query) || form.slug.toLowerCase().includes(query))
17
- );
18
- });
19
- const filtered = computed(() => !!search.value.trim() || statusFilter.value !== "all");
20
- function clearFilters() {
21
- search.value = "";
22
- statusFilter.value = "all";
23
- }
24
- const { sorting, header } = useTableSort({ id: "updatedAt", desc: true });
15
+ const clearFilters = () => list.reset();
16
+ const { sorting, header } = useTableSort(null, list.sort);
25
17
  const columns = computed(() => [
26
18
  { accessorKey: "name", header: header("name", t("admin.name", "Name")) },
27
19
  { accessorKey: "status", header: header("status", t("admin.status", "Status")) },
@@ -35,24 +27,9 @@ const formDescription = (form) => [
35
27
  `v${form.version}`,
36
28
  ...form.storage === "none" ? [t("admin.notStored", "Not stored")] : []
37
29
  ].join(" \xB7 ");
30
+ const forbidden = computed(() => [401, 403].includes(error.value?.statusCode ?? 0));
38
31
  const editPath = (form) => paths("adminForm", { id: form.id });
39
- const menu = (form) => actions.menu(form, {
40
- extra: [{ label: t("admin.open", "Open"), icon: "i-lucide-arrow-right", to: editPath(form) }]
41
- });
42
- const importing = ref(false);
43
- const importText = ref("");
44
- const importBusy = ref(false);
45
- async function runImport() {
46
- importBusy.value = true;
47
- try {
48
- if (await actions.importJson(importText.value)) {
49
- importing.value = false;
50
- importText.value = "";
51
- }
52
- } finally {
53
- importBusy.value = false;
54
- }
55
- }
32
+ const menu = (form) => actions.menu(form);
56
33
  </script>
57
34
 
58
35
  <template>
@@ -64,7 +41,7 @@ async function runImport() {
64
41
  variant="outline"
65
42
  icon="i-lucide-upload"
66
43
  :label="t('admin.import', 'Import JSON')"
67
- @click="importing = true"
44
+ @click="actions.importJson()"
68
45
  />
69
46
  <UButton
70
47
  icon="i-lucide-plus"
@@ -75,9 +52,11 @@ async function runImport() {
75
52
  </PageHeading>
76
53
 
77
54
  <UEmpty
78
- v-if="error"
79
- icon="i-lucide-lock"
80
- :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
55
+ v-if="error && !data"
56
+ :icon="forbidden ? 'i-lucide-lock' : 'i-lucide-circle-alert'"
57
+ :title="forbidden ? t('admin.forbidden', 'You are not allowed to manage forms.') : t('admin.loadFailed', 'Forms could not be loaded')"
58
+ :description="forbidden ? void 0 : errorMessage(error, '')"
59
+ :actions="forbidden ? void 0 : [{ label: t('admin.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
81
60
  />
82
61
 
83
62
  <template v-else>
@@ -90,7 +69,7 @@ async function runImport() {
90
69
  :aria-label="t('admin.search', 'Search forms')"
91
70
  />
92
71
  <USelect
93
- v-model="statusFilter"
72
+ v-model="filters.status"
94
73
  class="w-full sm:w-44"
95
74
  :items="statusItems"
96
75
  :aria-label="t('admin.statusFilter', 'Filter by status')"
@@ -98,13 +77,14 @@ async function runImport() {
98
77
  </PageToolbar>
99
78
 
100
79
  <TableCard
101
- :loading="status === 'pending' && !forms"
102
- :refreshing="status === 'pending' && !!forms"
80
+ :loading="status === 'pending' && !data"
81
+ :refreshing="status === 'pending' && !!data"
103
82
  >
104
83
  <UTable
105
84
  v-model:sorting="sorting"
106
- :data="visibleForms"
85
+ :data="forms"
107
86
  :columns="columns"
87
+ :sorting-options="{ manualSorting: true }"
108
88
  :ui="tableUi()"
109
89
  @select="(_, row2) => navigateTo(editPath(row2.original))"
110
90
  >
@@ -115,10 +95,10 @@ async function runImport() {
115
95
  />
116
96
  </template>
117
97
  <template #status-cell="{ row }">
118
- <UBadge
119
- :color="statusColor(row.original.status, { archived: 'warning' })"
120
- variant="subtle"
98
+ <StatusBadge
99
+ :status="row.original.status"
121
100
  :label="t(`status.${row.original.status}`, row.original.status)"
101
+ :colors="{ archived: 'warning' }"
122
102
  />
123
103
  </template>
124
104
  <template #updatedAt-cell="{ row }">
@@ -155,43 +135,14 @@ async function runImport() {
155
135
  </UEmpty>
156
136
  </template>
157
137
  </UTable>
158
- <template
159
- v-if="forms?.length"
160
- #footer
161
- >
162
- <span>{{ t("admin.showing", "{shown} of {total} forms", { shown: visibleForms.length, total: forms.length }) }}</span>
138
+ <template #footer>
139
+ <TablePagination
140
+ v-model:page="page"
141
+ v-model:page-size="pageSize"
142
+ :total="data?.total ?? 0"
143
+ />
163
144
  </template>
164
145
  </TableCard>
165
146
  </template>
166
-
167
- <UModal
168
- v-model:open="importing"
169
- :title="t('admin.import', 'Import JSON')"
170
- :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.')"
171
- :ui="{ footer: 'justify-end gap-2' }"
172
- >
173
- <template #body>
174
- <UTextarea
175
- v-model="importText"
176
- :rows="12"
177
- class="w-full font-mono text-xs"
178
- />
179
- </template>
180
- <template #footer>
181
- <UButton
182
- color="neutral"
183
- variant="outline"
184
- :label="t('admin.cancel', 'Cancel')"
185
- :disabled="importBusy"
186
- @click="importing = false"
187
- />
188
- <UButton
189
- :label="t('admin.importSubmit', 'Import forms')"
190
- :loading="importBusy"
191
- :disabled="!importText.trim()"
192
- @click="runImport"
193
- />
194
- </template>
195
- </UModal>
196
147
  </div>
197
148
  </template>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { formsApi } from "../../utils";
2
3
  const route = useRoute();
3
4
  const t = useModuleT("forms");
4
5
  const format = useFormat();
@@ -21,7 +22,7 @@ async function unlock() {
21
22
  checking.value = true;
22
23
  wrong.value = false;
23
24
  try {
24
- await $fetch(`/api/forms/${slug.value}`, { headers: { [FORM_PASSWORD_HEADER]: password.value } });
25
+ await $fetch(formsApi.form(slug.value), { headers: { [FORM_PASSWORD_HEADER]: password.value } });
25
26
  unlocked.value = password.value;
26
27
  } catch {
27
28
  wrong.value = true;