@frigear-nu/forms 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/FormField.vue +1 -1
- 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 +37 -21
- 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,5 @@
|
|
|
1
|
-
import { LazyFormCreateSlideover } from "#components";
|
|
1
|
+
import { LazyFormCreateSlideover, LazyFormImportSlideover } from "#components";
|
|
2
|
+
import { formsApi } from "../../utils/index.js";
|
|
2
3
|
export const FORMS_LIST_KEY = "forms:admin:forms";
|
|
3
4
|
export function useFormActions() {
|
|
4
5
|
const t = useModuleT("forms");
|
|
@@ -10,6 +11,7 @@ export function useFormActions() {
|
|
|
10
11
|
const showError = useErrorToast();
|
|
11
12
|
const { busy, run } = useAction();
|
|
12
13
|
const creator = overlay.create(LazyFormCreateSlideover, { destroyOnClose: true });
|
|
14
|
+
const importer = overlay.create(LazyFormImportSlideover, { destroyOnClose: true });
|
|
13
15
|
async function create() {
|
|
14
16
|
const created = await creator.open().result;
|
|
15
17
|
if (created) await navigateTo(paths("adminForm", { id: created.id }));
|
|
@@ -17,7 +19,7 @@ export function useFormActions() {
|
|
|
17
19
|
}
|
|
18
20
|
async function clone(form) {
|
|
19
21
|
const copy = await run(
|
|
20
|
-
() => $fetch(
|
|
22
|
+
() => $fetch(formsApi.admin.clone(form.id), { method: "POST" }),
|
|
21
23
|
{ success: t("admin.cloned", "Form duplicated") }
|
|
22
24
|
);
|
|
23
25
|
if (!copy) return false;
|
|
@@ -33,25 +35,16 @@ export function useFormActions() {
|
|
|
33
35
|
});
|
|
34
36
|
if (!confirmed) return false;
|
|
35
37
|
const archived = await run(
|
|
36
|
-
|
|
38
|
+
// 204 No Content: `run` resolves with `undefined` on failure, so map success to `true`.
|
|
39
|
+
() => $fetch(formsApi.admin.form(form.id), { method: "DELETE" }).then(() => true),
|
|
37
40
|
{ success: t("admin.archived", "Form archived") }
|
|
38
41
|
);
|
|
39
42
|
if (!archived) return false;
|
|
40
43
|
await refreshNuxtData(FORMS_LIST_KEY);
|
|
41
44
|
return true;
|
|
42
45
|
}
|
|
43
|
-
async function importJson(
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
parsed = JSON.parse(text);
|
|
47
|
-
} catch {
|
|
48
|
-
toast.add({ title: t("admin.invalidJson", "The JSON is not valid."), color: "error" });
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
const result = await run(() => $fetch("/api/forms/admin/import", {
|
|
52
|
-
method: "POST",
|
|
53
|
-
body: { forms: Array.isArray(parsed) ? parsed : [parsed] }
|
|
54
|
-
}));
|
|
46
|
+
async function importJson() {
|
|
47
|
+
const result = await importer.open().result;
|
|
55
48
|
if (!result) return false;
|
|
56
49
|
toast.add({
|
|
57
50
|
title: t("admin.imported", "Imported {inserted}, skipped {skipped} existing.", { inserted: result.inserted.length, skipped: result.skipped.length }),
|
|
@@ -61,10 +54,7 @@ export function useFormActions() {
|
|
|
61
54
|
return true;
|
|
62
55
|
}
|
|
63
56
|
async function redeliver(submissionId) {
|
|
64
|
-
const result = await run(() => $fetch(
|
|
65
|
-
`/api/forms/admin/submissions/${submissionId}/redeliver`,
|
|
66
|
-
{ method: "POST" }
|
|
67
|
-
));
|
|
57
|
+
const result = await run(() => $fetch(formsApi.admin.redeliver(submissionId), { method: "POST" }));
|
|
68
58
|
if (!result) return false;
|
|
69
59
|
if (result.deliveryStatus === "failed") {
|
|
70
60
|
showError(new Error(result.deliveryError ?? ""), t("delivery.failed", "Delivery failed"));
|
|
@@ -74,7 +64,11 @@ export function useFormActions() {
|
|
|
74
64
|
return true;
|
|
75
65
|
}
|
|
76
66
|
function menu(form, options = {}) {
|
|
77
|
-
const items = [
|
|
67
|
+
const items = [];
|
|
68
|
+
if (options.open ?? true) {
|
|
69
|
+
items.push({ label: t("admin.open", "Open"), icon: "i-lucide-arrow-right", to: paths("adminForm", { id: form.id }) });
|
|
70
|
+
}
|
|
71
|
+
items.push(...options.extra ?? []);
|
|
78
72
|
if (form.storage === "db" || (form.submissionCount ?? 0) > 0) {
|
|
79
73
|
items.push({
|
|
80
74
|
label: t("admin.submissionsTitle", "Submissions"),
|
|
@@ -99,5 +93,23 @@ export function useFormActions() {
|
|
|
99
93
|
}
|
|
100
94
|
return groups;
|
|
101
95
|
}
|
|
102
|
-
|
|
96
|
+
function submissionMenu(submission, options = {}) {
|
|
97
|
+
const items = [];
|
|
98
|
+
if (options.open ?? true) {
|
|
99
|
+
items.push({
|
|
100
|
+
label: t("admin.open", "Open"),
|
|
101
|
+
icon: "i-lucide-arrow-right",
|
|
102
|
+
to: paths("adminSubmission", { id: submission.formId, submissionId: submission.id })
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
items.push({
|
|
106
|
+
label: t("admin.redeliver", "Deliver again"),
|
|
107
|
+
icon: "i-lucide-send",
|
|
108
|
+
onSelect: async () => {
|
|
109
|
+
if (await redeliver(submission.id)) await options.onRedelivered?.();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return [items];
|
|
113
|
+
}
|
|
114
|
+
return { busy, create, clone, remove, importJson, redeliver, menu, submissionMenu };
|
|
103
115
|
}
|
|
@@ -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>
|
|
@@ -117,7 +118,8 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
|
|
|
117
118
|
:items="tabs"
|
|
118
119
|
/>
|
|
119
120
|
|
|
120
|
-
<!-- The builder has its own chrome (canvas and settings panel), so it is not framed again
|
|
121
|
+
<!-- The builder has its own chrome (canvas and settings panel), so it is not framed again; settings and
|
|
122
|
+
access render their own `SectionCard`s, one per topic. -->
|
|
121
123
|
<FormBuilder
|
|
122
124
|
v-if="tab === 'builder'"
|
|
123
125
|
v-model="definition"
|
|
@@ -133,17 +135,17 @@ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage:
|
|
|
133
135
|
</template>
|
|
134
136
|
</FormBuilder>
|
|
135
137
|
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
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>
|