@frigear-nu/forms 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.d.mts +18 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +29 -26
- package/dist/runtime/app/components/FormBuilderAccess.vue +255 -262
- package/dist/runtime/app/components/FormBuilderSettings.vue +143 -141
- package/dist/runtime/app/components/FormCreateSlideover.vue +2 -2
- package/dist/runtime/app/components/FormEmbed.vue +2 -2
- package/dist/runtime/app/components/FormImportSlideover.d.vue.ts +8 -0
- package/dist/runtime/app/components/FormImportSlideover.vue +57 -0
- package/dist/runtime/app/components/FormImportSlideover.vue.d.ts +8 -0
- package/dist/runtime/app/components/FormRenderer.vue +3 -3
- package/dist/runtime/app/components/FormSubmissionView.vue +2 -2
- package/dist/runtime/app/composables/useCaptchaSecrets.js +9 -6
- package/dist/runtime/app/composables/useFormActions.d.ts +14 -2
- package/dist/runtime/app/composables/useFormActions.js +33 -21
- package/dist/runtime/app/composables/useForms.d.ts +12 -5
- package/dist/runtime/app/composables/useForms.js +11 -5
- package/dist/runtime/app/pages/admin-form.vue +36 -20
- package/dist/runtime/app/pages/admin-submission.vue +27 -16
- package/dist/runtime/app/pages/admin-submissions.vue +30 -22
- package/dist/runtime/app/pages/admin.vue +29 -78
- package/dist/runtime/app/pages/form.vue +2 -1
- package/dist/runtime/i18n/lang/da.json +10 -2
- package/dist/runtime/i18n/lang/en.json +10 -2
- package/dist/runtime/server/mcp/tools/admin/get-submission.js +4 -4
- package/dist/runtime/server/mcp/tools/submit-form.js +2 -1
- package/dist/runtime/server/routes/admin/form-clone.post.js +6 -6
- package/dist/runtime/server/routes/admin/form-password.delete.js +5 -3
- package/dist/runtime/server/routes/admin/form-password.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-password.put.js +4 -2
- package/dist/runtime/server/routes/admin/form-secret.delete.js +6 -5
- package/dist/runtime/server/routes/admin/form-secret.put.js +11 -0
- package/dist/runtime/server/routes/admin/form-secrets.get.js +4 -2
- package/dist/runtime/server/routes/admin/form-token.delete.js +9 -6
- package/dist/runtime/server/routes/admin/form-tokens.get.js +5 -3
- package/dist/runtime/server/routes/admin/form-tokens.post.js +5 -4
- package/dist/runtime/server/routes/admin/form.delete.js +6 -4
- package/dist/runtime/server/routes/admin/form.get.js +4 -2
- package/dist/runtime/server/routes/admin/form.patch.js +3 -3
- package/dist/runtime/server/routes/admin/forms.get.js +37 -16
- package/dist/runtime/server/routes/admin/forms.post.js +3 -4
- package/dist/runtime/server/routes/admin/import.post.js +3 -2
- package/dist/runtime/server/routes/admin/submission-file.get.js +8 -5
- package/dist/runtime/server/routes/admin/submission-redeliver.post.js +4 -3
- package/dist/runtime/server/routes/admin/submission.get.js +5 -17
- package/dist/runtime/server/routes/admin/submissions.get.js +29 -13
- package/dist/runtime/server/routes/public/access.get.js +3 -1
- package/dist/runtime/server/routes/public/form.get.js +3 -1
- package/dist/runtime/server/routes/public/submit.post.js +2 -1
- package/dist/runtime/server/utils/access.js +3 -2
- package/dist/runtime/server/utils/admin-schemas.d.ts +9 -0
- package/dist/runtime/server/utils/admin-schemas.js +4 -0
- package/dist/runtime/server/utils/auth.d.ts +8 -4
- package/dist/runtime/server/utils/auth.js +6 -7
- package/dist/runtime/server/utils/captcha.js +3 -2
- package/dist/runtime/server/utils/forms.d.ts +12 -2
- package/dist/runtime/server/utils/forms.js +57 -10
- package/dist/runtime/server/utils/mcp-access.js +2 -1
- package/dist/runtime/server/utils/rate-limit.js +3 -2
- package/dist/runtime/server/utils/submit.d.ts +3 -0
- package/dist/runtime/server/utils/submit.js +21 -17
- package/dist/runtime/utils/api.d.ts +36 -0
- package/dist/runtime/utils/api.js +39 -0
- package/dist/runtime/utils/index.d.ts +1 -0
- package/dist/runtime/utils/index.js +1 -0
- package/dist/types.d.mts +1 -1
- package/package.json +15 -10
- package/dist/runtime/server/routes/admin/form-secrets.put.js +0 -12
- /package/dist/runtime/server/routes/admin/{form-secrets.put.d.ts → form-secret.put.d.ts} +0 -0
|
@@ -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(() =>
|
|
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(() =>
|
|
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(
|
|
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(
|
|
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(() =>
|
|
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(
|
|
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
|
-
<
|
|
71
|
-
:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
182
|
-
:title="
|
|
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(() =>
|
|
10
|
-
const { data: submission, error, refresh } = await useFetch(() =>
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
<
|
|
38
|
-
:
|
|
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
|
-
|
|
68
|
-
:title="
|
|
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(() =>
|
|
9
|
-
const {
|
|
10
|
-
const {
|
|
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
|
-
{
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
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' && !
|
|
51
|
-
:refreshing="status === 'pending' && !!
|
|
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
|
-
<
|
|
78
|
-
:
|
|
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 {
|
|
7
|
-
const search =
|
|
8
|
-
const
|
|
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
|
|
14
|
-
|
|
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="
|
|
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="
|
|
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' && !
|
|
102
|
-
:refreshing="status === 'pending' && !!
|
|
80
|
+
:loading="status === 'pending' && !data"
|
|
81
|
+
:refreshing="status === 'pending' && !!data"
|
|
103
82
|
>
|
|
104
83
|
<UTable
|
|
105
84
|
v-model:sorting="sorting"
|
|
106
|
-
:data="
|
|
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
|
-
<
|
|
119
|
-
:
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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(
|
|
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.",
|