@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.
Files changed (69) 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/FormImportSlideover.d.vue.ts +8 -0
  9. package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
  10. package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
  11. package/dist/runtime/app/components/FormRenderer.vue +3 -3
  12. package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
  13. package/dist/runtime/app/composables/useCaptchaSecrets.js +9 -6
  14. package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
  15. package/dist/runtime/app/composables/useFormActions.js +33 -21
  16. package/dist/runtime/app/composables/useForms.d.ts +12 -5
  17. package/dist/runtime/app/composables/useForms.js +11 -5
  18. package/dist/runtime/app/pages/admin-form.vue +36 -20
  19. package/dist/runtime/app/pages/admin-submission.vue +27 -16
  20. package/dist/runtime/app/pages/admin-submissions.vue +30 -22
  21. package/dist/runtime/app/pages/admin.vue +29 -78
  22. package/dist/runtime/app/pages/form.vue +2 -1
  23. package/dist/runtime/i18n/lang/da.json +10 -2
  24. package/dist/runtime/i18n/lang/en.json +10 -2
  25. package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
  26. package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
  27. package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
  28. package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
  29. package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
  30. package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
  31. package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
  32. package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
  33. package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
  34. package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
  35. package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
  36. package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
  37. package/dist/runtime/server/routes/admin/form.delete.js +6 -4
  38. package/dist/runtime/server/routes/admin/form.get.js +4 -2
  39. package/dist/runtime/server/routes/admin/form.patch.js +3 -3
  40. package/dist/runtime/server/routes/admin/forms.get.js +37 -16
  41. package/dist/runtime/server/routes/admin/forms.post.js +3 -4
  42. package/dist/runtime/server/routes/admin/import.post.js +3 -2
  43. package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
  44. package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
  45. package/dist/runtime/server/routes/admin/submission.get.js +5 -17
  46. package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
  47. package/dist/runtime/server/routes/public/access.get.js +3 -1
  48. package/dist/runtime/server/routes/public/form.get.js +3 -1
  49. package/dist/runtime/server/routes/public/submit.post.js +2 -1
  50. package/dist/runtime/server/utils/access.js +3 -2
  51. package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
  52. package/dist/runtime/server/utils/admin-schemas.js +4 -0
  53. package/dist/runtime/server/utils/auth.d.ts +8 -4
  54. package/dist/runtime/server/utils/auth.js +6 -7
  55. package/dist/runtime/server/utils/captcha.js +3 -2
  56. package/dist/runtime/server/utils/forms.d.ts +12 -2
  57. package/dist/runtime/server/utils/forms.js +57 -10
  58. package/dist/runtime/server/utils/mcp-access.js +2 -1
  59. package/dist/runtime/server/utils/rate-limit.js +3 -2
  60. package/dist/runtime/server/utils/submit.d.ts +3 -0
  61. package/dist/runtime/server/utils/submit.js +21 -17
  62. package/dist/runtime/utils/api.d.ts +36 -0
  63. package/dist/runtime/utils/api.js +39 -0
  64. package/dist/runtime/utils/index.d.ts +1 -0
  65. package/dist/runtime/utils/index.js +1 -0
  66. package/dist/types.d.mts +1 -1
  67. package/package.json +15 -10
  68. package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
  69. /package/dist/runtime/server/routes/admin/{form-secrets.put.d.ts → form-secret.put.d.ts} +0 -0
@@ -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;
@@ -112,7 +112,6 @@
112
112
  "clearFilters": "Ryd filtre",
113
113
  "noMatches": "Ingen formularer matcher.",
114
114
  "emptyDescription": "Opret en formular for at begynde at indsamle svar.",
115
- "showing": "{shown} af {total} formularer",
116
115
  "open": "Åbn",
117
116
  "updated": "Opdateret",
118
117
  "cloned": "Formular duplikeret",
@@ -126,7 +125,14 @@
126
125
  "details": "Detaljer",
127
126
  "submissionNotFound": "Besvarelsen findes ikke.",
128
127
  "noSubmissionsHint": "Svar vises her, når nogen udfylder formularen.",
129
- "redelivered": "Leveret igen"
128
+ "redelivered": "Leveret igen",
129
+ "importEmpty": "Indsæt den JSON, der skal importeres.",
130
+ "importLabel": "Formularer som JSON",
131
+ "loadFailed": "Formularerne kunne ikke indlæses",
132
+ "tryAgain": "Prøv igen",
133
+ "formLoadFailed": "Formularen kunne ikke indlæses",
134
+ "submissionsLoadFailed": "Besvarelserne kunne ikke indlæses",
135
+ "submissionLoadFailed": "Besvarelsen kunne ikke indlæses"
130
136
  },
131
137
  "schedule": {
132
138
  "closed": "Formularen er lukket.",
@@ -138,6 +144,7 @@
138
144
  "hint": "Uden for perioden vises en lukket-besked, og indsendelser afvises. En sæson, der slutter før den starter, løber over nytår.",
139
145
  "opens": "Åbner",
140
146
  "closes": "Lukker",
147
+ "month": "Måned",
141
148
  "day": "Dag",
142
149
  "time": "Tid",
143
150
  "timezone": "Tidszone"
@@ -198,6 +205,7 @@
198
205
  "failed": "Levering fejlede",
199
206
  "title": "Levering og lagring",
200
207
  "storeTitle": "Gem besvarelser",
208
+ "storageTitle": "Lagring",
201
209
  "storeHint": "Gem besvarelser i databasen, så de kan ses under Besvarelser.",
202
210
  "filesWarning": "Formularen har filupload-felter, som kræver gemte besvarelser. Fjern dem, eller fortsæt med at gemme.",
203
211
  "lostWarning": "Besvarelser hverken gemmes eller sendes på mail. Tilføj modtagere eller fortsæt med at gemme, ellers går svarene tabt, medmindre noget håndterer forms:submitted.",
@@ -112,7 +112,6 @@
112
112
  "clearFilters": "Clear filters",
113
113
  "noMatches": "No forms match.",
114
114
  "emptyDescription": "Create a form to start collecting answers.",
115
- "showing": "{shown} of {total} forms",
116
115
  "open": "Open",
117
116
  "updated": "Updated",
118
117
  "cloned": "Form duplicated",
@@ -126,7 +125,14 @@
126
125
  "details": "Details",
127
126
  "submissionNotFound": "This submission does not exist.",
128
127
  "noSubmissionsHint": "Answers appear here once someone submits the form.",
129
- "redelivered": "Delivered again"
128
+ "redelivered": "Delivered again",
129
+ "importEmpty": "Paste the JSON to import.",
130
+ "importLabel": "Forms as JSON",
131
+ "loadFailed": "Forms could not be loaded",
132
+ "tryAgain": "Try again",
133
+ "formLoadFailed": "The form could not be loaded",
134
+ "submissionsLoadFailed": "Submissions could not be loaded",
135
+ "submissionLoadFailed": "The submission could not be loaded"
130
136
  },
131
137
  "schedule": {
132
138
  "closed": "This form is closed.",
@@ -138,6 +144,7 @@
138
144
  "hint": "Outside the period the form shows a closed notice and rejects submissions. A season whose end is before its start wraps over new year.",
139
145
  "opens": "Opens",
140
146
  "closes": "Closes",
147
+ "month": "Month",
141
148
  "day": "Day",
142
149
  "time": "Time",
143
150
  "timezone": "Timezone"
@@ -198,6 +205,7 @@
198
205
  "failed": "Delivery failed",
199
206
  "title": "Delivery & storage",
200
207
  "storeTitle": "Store submissions",
208
+ "storageTitle": "Storage",
201
209
  "storeHint": "Keep submissions in the database so they can be reviewed under Submissions.",
202
210
  "filesWarning": "This form has file upload fields, which need stored submissions. Remove them or keep storing.",
203
211
  "lostWarning": "Submissions are neither stored nor emailed. Add recipients or keep storing, or answers are lost unless something handles forms:submitted.",