@frigear-nu/forms 0.0.0-stage → 0.0.1
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/README.md +22 -2
- package/dist/module.d.mts +281 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +102 -0
- package/dist/runtime/app/components/FormField.d.vue.ts +16 -0
- package/dist/runtime/app/components/FormField.vue +80 -0
- package/dist/runtime/app/components/FormField.vue.d.ts +16 -0
- package/dist/runtime/app/components/FormRenderer.d.vue.ts +19 -0
- package/dist/runtime/app/components/FormRenderer.vue +145 -0
- package/dist/runtime/app/components/FormRenderer.vue.d.ts +19 -0
- package/dist/runtime/app/components/FormSubmissionView.d.vue.ts +7 -0
- package/dist/runtime/app/components/FormSubmissionView.vue +77 -0
- package/dist/runtime/app/components/FormSubmissionView.vue.d.ts +7 -0
- package/dist/runtime/app/composables/index.d.ts +2 -0
- package/dist/runtime/app/composables/index.js +2 -0
- package/dist/runtime/app/composables/useFormStepper.d.ts +21 -0
- package/dist/runtime/app/composables/useFormStepper.js +33 -0
- package/dist/runtime/app/composables/useForms.d.ts +21 -0
- package/dist/runtime/app/composables/useForms.js +52 -0
- package/dist/runtime/app/pages/admin-form.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-form.vue +150 -0
- package/dist/runtime/app/pages/admin-form.vue.d.ts +3 -0
- package/dist/runtime/app/pages/admin-submission.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-submission.vue +59 -0
- package/dist/runtime/app/pages/admin-submission.vue.d.ts +3 -0
- package/dist/runtime/app/pages/admin-submissions.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-submissions.vue +71 -0
- package/dist/runtime/app/pages/admin-submissions.vue.d.ts +3 -0
- package/dist/runtime/app/pages/admin.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin.vue +194 -0
- package/dist/runtime/app/pages/admin.vue.d.ts +3 -0
- package/dist/runtime/app/pages/form.d.vue.ts +3 -0
- package/dist/runtime/app/pages/form.vue +18 -0
- package/dist/runtime/app/pages/form.vue.d.ts +3 -0
- package/dist/runtime/i18n/lang/da.json +84 -0
- package/dist/runtime/i18n/lang/en.json +84 -0
- package/dist/runtime/menus.d.ts +2 -0
- package/dist/runtime/menus.js +15 -0
- package/dist/runtime/server/db/schema.sqlite.d.ts +504 -0
- package/dist/runtime/server/db/schema.sqlite.js +46 -0
- package/dist/runtime/server/plugins/email-delivery.d.ts +2 -0
- package/dist/runtime/server/plugins/email-delivery.js +21 -0
- package/dist/runtime/server/plugins/seed.d.ts +2 -0
- package/dist/runtime/server/plugins/seed.js +11 -0
- package/dist/runtime/server/routes/admin/form.delete.d.ts +2 -0
- package/dist/runtime/server/routes/admin/form.delete.js +8 -0
- package/dist/runtime/server/routes/admin/form.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/form.get.js +4 -0
- package/dist/runtime/server/routes/admin/form.patch.d.ts +2 -0
- package/dist/runtime/server/routes/admin/form.patch.js +17 -0
- package/dist/runtime/server/routes/admin/forms.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/forms.get.js +19 -0
- package/dist/runtime/server/routes/admin/forms.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/forms.post.js +13 -0
- package/dist/runtime/server/routes/admin/import.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/import.post.js +6 -0
- package/dist/runtime/server/routes/admin/submission-file.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/submission-file.get.js +17 -0
- package/dist/runtime/server/routes/admin/submission-redeliver.post.d.ts +2 -0
- package/dist/runtime/server/routes/admin/submission-redeliver.post.js +29 -0
- package/dist/runtime/server/routes/admin/submission.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/submission.get.js +22 -0
- package/dist/runtime/server/routes/admin/submissions.get.d.ts +2 -0
- package/dist/runtime/server/routes/admin/submissions.get.js +16 -0
- package/dist/runtime/server/routes/public/form.get.d.ts +2 -0
- package/dist/runtime/server/routes/public/form.get.js +3 -0
- package/dist/runtime/server/routes/public/submit.post.d.ts +2 -0
- package/dist/runtime/server/routes/public/submit.post.js +7 -0
- package/dist/runtime/server/utils/admin-schemas.d.ts +291 -0
- package/dist/runtime/server/utils/admin-schemas.js +14 -0
- package/dist/runtime/server/utils/auth.d.ts +11 -0
- package/dist/runtime/server/utils/auth.js +7 -0
- package/dist/runtime/server/utils/forms.d.ts +9 -0
- package/dist/runtime/server/utils/forms.js +30 -0
- package/dist/runtime/server/utils/rate-limit.d.ts +6 -0
- package/dist/runtime/server/utils/rate-limit.js +14 -0
- package/dist/runtime/server/utils/render.d.ts +11 -0
- package/dist/runtime/server/utils/render.js +26 -0
- package/dist/runtime/server/utils/seed.d.ts +9 -0
- package/dist/runtime/server/utils/seed.js +22 -0
- package/dist/runtime/server/utils/submit.d.ts +9 -0
- package/dist/runtime/server/utils/submit.js +120 -0
- package/dist/runtime/utils/conditions.d.ts +19 -0
- package/dist/runtime/utils/conditions.js +36 -0
- package/dist/runtime/utils/definition.d.ts +91 -0
- package/dist/runtime/utils/definition.js +103 -0
- package/dist/runtime/utils/index.d.ts +13 -0
- package/dist/runtime/utils/index.js +8 -0
- package/dist/runtime/utils/markdown.d.ts +5 -0
- package/dist/runtime/utils/markdown.js +5 -0
- package/dist/runtime/utils/schema.d.ts +27 -0
- package/dist/runtime/utils/schema.js +95 -0
- package/dist/runtime/utils/text.d.ts +12 -0
- package/dist/runtime/utils/text.js +12 -0
- package/dist/types.d.mts +3 -0
- package/package.json +86 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { translate } from "../../utils";
|
|
3
|
+
import { emptyAnswers, submitAnswers, useFormsLocale } from "../composables/useForms";
|
|
4
|
+
import { useFormStepper } from "../composables/useFormStepper";
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
slug: { type: String, required: false },
|
|
7
|
+
form: { type: Object, required: false },
|
|
8
|
+
preview: { type: Boolean, required: false }
|
|
9
|
+
});
|
|
10
|
+
const emit = defineEmits(["submitted"]);
|
|
11
|
+
const t = useModuleT("forms");
|
|
12
|
+
const locale = useFormsLocale();
|
|
13
|
+
const toast = useToast();
|
|
14
|
+
const loaded = props.slug ? await useForm(props.slug) : void 0;
|
|
15
|
+
const form = computed(() => props.form ?? loaded?.data.value ?? void 0);
|
|
16
|
+
const definition = computed(() => form.value?.definition);
|
|
17
|
+
const state = reactive(form.value ? emptyAnswers(form.value.definition) : {});
|
|
18
|
+
watch(() => form.value?.definition, (next) => {
|
|
19
|
+
if (!next) return;
|
|
20
|
+
for (const [key, value] of Object.entries(emptyAnswers(next))) if (!(key in state)) state[key] = value;
|
|
21
|
+
});
|
|
22
|
+
const stepper = useFormStepper(definition, state, { t: () => t, locale });
|
|
23
|
+
const formRef = useTemplateRef("formRef");
|
|
24
|
+
const submitting = ref(false);
|
|
25
|
+
const done = ref(false);
|
|
26
|
+
const submitSettings = computed(() => definition.value?.submit);
|
|
27
|
+
const title = (value) => translate(value, locale.value);
|
|
28
|
+
async function onSubmit() {
|
|
29
|
+
if (!stepper.isLast.value) return stepper.next();
|
|
30
|
+
if (props.preview) {
|
|
31
|
+
toast.add({ title: t("preview.notSent", "Preview: nothing was sent."), color: "info" });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
submitting.value = true;
|
|
35
|
+
try {
|
|
36
|
+
const result = await submitAnswers(form.value.slug, definition.value, state, locale.value);
|
|
37
|
+
done.value = true;
|
|
38
|
+
emit("submitted", result.id);
|
|
39
|
+
const redirect = submitSettings.value?.redirect;
|
|
40
|
+
if (redirect) await navigateTo(redirect);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const issues = error.data?.data?.issues;
|
|
43
|
+
if (issues?.length) {
|
|
44
|
+
formRef.value?.setErrors(issues.map((issue) => ({ name: String(issue.path[0] ?? ""), message: issue.message })));
|
|
45
|
+
toast.add({ title: t("error.invalid", "Please check the highlighted fields."), color: "error" });
|
|
46
|
+
} else {
|
|
47
|
+
toast.add({ title: t("error.generic", "Something went wrong. Please try again."), color: "error" });
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
submitting.value = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function sendAnother() {
|
|
54
|
+
const keep = new Set(submitSettings.value?.allowResubmit?.keep ?? []);
|
|
55
|
+
const fresh = emptyAnswers(definition.value);
|
|
56
|
+
for (const key of Object.keys(state)) state[key] = keep.has(key) ? state[key] : fresh[key];
|
|
57
|
+
stepper.reset();
|
|
58
|
+
done.value = false;
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<UAlert
|
|
64
|
+
v-if="!form"
|
|
65
|
+
color="error"
|
|
66
|
+
variant="subtle"
|
|
67
|
+
:title="t('error.notFound', 'This form could not be found.')"
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<UEmpty
|
|
71
|
+
v-else-if="done"
|
|
72
|
+
icon="i-lucide-circle-check"
|
|
73
|
+
:title="title(submitSettings?.successMessage) || t('success.title', 'Thank you!')"
|
|
74
|
+
:description="title(submitSettings?.successMessage) ? void 0 : t('success.description', 'Your answers have been sent.')"
|
|
75
|
+
:actions="submitSettings?.allowResubmit ? [{ label: t('actions.sendAnother', 'Send another'), onClick: sendAnother }] : []"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<UForm
|
|
79
|
+
v-else
|
|
80
|
+
ref="formRef"
|
|
81
|
+
:schema="stepper.schema.value"
|
|
82
|
+
:state="state"
|
|
83
|
+
class="space-y-6"
|
|
84
|
+
@submit="onSubmit"
|
|
85
|
+
>
|
|
86
|
+
<template v-if="stepper.current.value">
|
|
87
|
+
<div
|
|
88
|
+
v-if="stepper.stepped.value && stepper.steps.value.length > 1"
|
|
89
|
+
class="space-y-2"
|
|
90
|
+
>
|
|
91
|
+
<p class="text-sm text-muted">
|
|
92
|
+
{{ t("step.progress", "Step {current} of {total}", { current: stepper.index.value + 1, total: stepper.steps.value.length }) }}
|
|
93
|
+
</p>
|
|
94
|
+
<UProgress
|
|
95
|
+
:model-value="stepper.index.value + 1"
|
|
96
|
+
:max="stepper.steps.value.length"
|
|
97
|
+
size="sm"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
v-if="stepper.current.value.step.title || stepper.current.value.step.description"
|
|
103
|
+
class="space-y-1"
|
|
104
|
+
>
|
|
105
|
+
<h2
|
|
106
|
+
v-if="stepper.current.value.step.title"
|
|
107
|
+
class="text-xl font-semibold"
|
|
108
|
+
>
|
|
109
|
+
{{ title(stepper.current.value.step.title) }}
|
|
110
|
+
</h2>
|
|
111
|
+
<p
|
|
112
|
+
v-if="stepper.current.value.step.description"
|
|
113
|
+
class="text-muted"
|
|
114
|
+
>
|
|
115
|
+
{{ title(stepper.current.value.step.description) }}
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<FormField
|
|
120
|
+
v-for="field in stepper.current.value.fields"
|
|
121
|
+
:key="field.key"
|
|
122
|
+
v-model="state[field.key]"
|
|
123
|
+
:field="field"
|
|
124
|
+
:locale="locale"
|
|
125
|
+
/>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<div class="flex justify-between gap-2">
|
|
129
|
+
<UButton
|
|
130
|
+
v-if="stepper.stepped.value && !stepper.isFirst.value"
|
|
131
|
+
type="button"
|
|
132
|
+
color="neutral"
|
|
133
|
+
variant="outline"
|
|
134
|
+
:label="t('actions.back', 'Back')"
|
|
135
|
+
@click="stepper.prev()"
|
|
136
|
+
/>
|
|
137
|
+
<span v-else />
|
|
138
|
+
<UButton
|
|
139
|
+
type="submit"
|
|
140
|
+
:loading="submitting"
|
|
141
|
+
:label="stepper.isLast.value ? title(submitSettings?.label) || t('actions.submit', 'Submit') : t('actions.next', 'Next')"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</UForm>
|
|
145
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FormDefinition, PublicFormDto } from '../../../types.js';
|
|
2
|
+
type FormShape = Pick<PublicFormDto, 'slug' | 'definition'> & {
|
|
3
|
+
definition: Omit<FormDefinition, 'delivery'>;
|
|
4
|
+
};
|
|
5
|
+
type __VLS_Props = {
|
|
6
|
+
/** Slug of a published form (loaded from the API). */
|
|
7
|
+
slug?: string;
|
|
8
|
+
/** A definition to render directly (admin preview, tests). */
|
|
9
|
+
form?: FormShape;
|
|
10
|
+
/** Validates and steps like normal, but never posts. */
|
|
11
|
+
preview?: boolean;
|
|
12
|
+
};
|
|
13
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
14
|
+
submitted: (id: string | null) => any;
|
|
15
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
16
|
+
onSubmitted?: ((id: string | null) => any) | undefined;
|
|
17
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
18
|
+
declare const _default: typeof __VLS_export;
|
|
19
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SubmissionDto } from '../../../types.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
submission: SubmissionDto;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { isInputField, resolveVisibility, translate } from "../../utils";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
submission: { type: Object, required: true }
|
|
5
|
+
});
|
|
6
|
+
const t = useModuleT("forms");
|
|
7
|
+
const locale = useFormsLocale();
|
|
8
|
+
const sections = computed(() => resolveVisibility(props.submission.definition, props.submission.answers).map(({ step, fields }) => ({
|
|
9
|
+
id: step.id,
|
|
10
|
+
title: translate(step.title, locale.value),
|
|
11
|
+
rows: fields.filter(isInputField).map((field) => {
|
|
12
|
+
const value = props.submission.answers[field.key];
|
|
13
|
+
const option = (v) => translate(field.options?.find((o) => o.value === v)?.label, locale.value) || v;
|
|
14
|
+
return {
|
|
15
|
+
key: field.key,
|
|
16
|
+
label: translate(field.label, locale.value) || field.key,
|
|
17
|
+
type: field.type,
|
|
18
|
+
value: field.type === "checkbox" ? value ? t("value.yes", "Yes") : t("value.no", "No") : (field.type === "select" || field.type === "radio") && typeof value === "string" ? option(value) : value === void 0 || value === null || value === "" ? "" : String(value),
|
|
19
|
+
files: props.submission.files.filter((file) => file.fieldKey === field.key)
|
|
20
|
+
};
|
|
21
|
+
})
|
|
22
|
+
})));
|
|
23
|
+
const fileUrl = (fileId) => `/api/forms/admin/submissions/${props.submission.id}/files/${fileId}`;
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="space-y-6">
|
|
28
|
+
<section
|
|
29
|
+
v-for="section in sections"
|
|
30
|
+
:key="section.id"
|
|
31
|
+
class="space-y-3"
|
|
32
|
+
>
|
|
33
|
+
<h3
|
|
34
|
+
v-if="section.title"
|
|
35
|
+
class="font-semibold"
|
|
36
|
+
>
|
|
37
|
+
{{ section.title }}
|
|
38
|
+
</h3>
|
|
39
|
+
<dl class="grid gap-x-6 gap-y-3 sm:grid-cols-[minmax(0,14rem)_1fr]">
|
|
40
|
+
<template
|
|
41
|
+
v-for="row in section.rows"
|
|
42
|
+
:key="row.key"
|
|
43
|
+
>
|
|
44
|
+
<dt class="text-sm text-muted">
|
|
45
|
+
{{ row.label }}
|
|
46
|
+
</dt>
|
|
47
|
+
<dd class="whitespace-pre-wrap break-words">
|
|
48
|
+
<template v-if="row.type === 'file'">
|
|
49
|
+
<ul
|
|
50
|
+
v-if="row.files.length"
|
|
51
|
+
class="space-y-1"
|
|
52
|
+
>
|
|
53
|
+
<li
|
|
54
|
+
v-for="file in row.files"
|
|
55
|
+
:key="file.id"
|
|
56
|
+
>
|
|
57
|
+
<a
|
|
58
|
+
:href="fileUrl(file.id)"
|
|
59
|
+
class="underline"
|
|
60
|
+
download
|
|
61
|
+
>{{ file.filename }}</a>
|
|
62
|
+
</li>
|
|
63
|
+
</ul>
|
|
64
|
+
<span
|
|
65
|
+
v-else
|
|
66
|
+
class="text-muted"
|
|
67
|
+
>-</span>
|
|
68
|
+
</template>
|
|
69
|
+
<template v-else>
|
|
70
|
+
{{ row.value || "-" }}
|
|
71
|
+
</template>
|
|
72
|
+
</dd>
|
|
73
|
+
</template>
|
|
74
|
+
</dl>
|
|
75
|
+
</section>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SubmissionDto } from '../../../types.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
submission: SubmissionDto;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FormDefinition } from '../../../types.js';
|
|
2
|
+
import type { Answers, MessageTranslator } from '../../utils/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* State machine for single page and stepped forms. Steps and fields that are hidden by `showIf`
|
|
5
|
+
* are skipped, so `current` always refers to a visible step.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useFormStepper(definition: MaybeRefOrGetter<FormDefinition>, answers: MaybeRefOrGetter<Answers>, options?: {
|
|
8
|
+
t?: MaybeRefOrGetter<MessageTranslator | undefined>;
|
|
9
|
+
locale?: MaybeRefOrGetter<string>;
|
|
10
|
+
}): {
|
|
11
|
+
steps: any;
|
|
12
|
+
index: any;
|
|
13
|
+
current: any;
|
|
14
|
+
isFirst: any;
|
|
15
|
+
isLast: any;
|
|
16
|
+
stepped: any;
|
|
17
|
+
schema: any;
|
|
18
|
+
next: () => void;
|
|
19
|
+
prev: () => void;
|
|
20
|
+
reset: (to?: number) => void;
|
|
21
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { buildFormSchema, resolveVisibility } from "../../utils/index.js";
|
|
2
|
+
export function useFormStepper(definition, answers, options = {}) {
|
|
3
|
+
const position = ref(0);
|
|
4
|
+
const steps = computed(() => resolveVisibility(toValue(definition), toValue(answers)));
|
|
5
|
+
const index = computed(() => Math.min(position.value, Math.max(steps.value.length - 1, 0)));
|
|
6
|
+
const current = computed(() => steps.value[index.value]);
|
|
7
|
+
const isFirst = computed(() => index.value === 0);
|
|
8
|
+
const isLast = computed(() => index.value >= steps.value.length - 1);
|
|
9
|
+
const stepped = computed(() => toValue(definition).mode === "stepped");
|
|
10
|
+
const schema = computed(() => buildFormSchema(toValue(definition), toValue(answers), {
|
|
11
|
+
stepIndex: current.value?.index,
|
|
12
|
+
t: toValue(options.t),
|
|
13
|
+
locale: toValue(options.locale)
|
|
14
|
+
}));
|
|
15
|
+
return {
|
|
16
|
+
steps,
|
|
17
|
+
index,
|
|
18
|
+
current,
|
|
19
|
+
isFirst,
|
|
20
|
+
isLast,
|
|
21
|
+
stepped,
|
|
22
|
+
schema,
|
|
23
|
+
next: () => {
|
|
24
|
+
if (!isLast.value) position.value = index.value + 1;
|
|
25
|
+
},
|
|
26
|
+
prev: () => {
|
|
27
|
+
if (!isFirst.value) position.value = index.value - 1;
|
|
28
|
+
},
|
|
29
|
+
reset: (to = 0) => {
|
|
30
|
+
position.value = to;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FormDefinition, FormField } from '../../../types.js';
|
|
2
|
+
/** Locale for translated form content: `@nuxtjs/i18n`'s when installed, otherwise `en`. */
|
|
3
|
+
export declare function useFormsLocale(): any;
|
|
4
|
+
/** Loads a published form definition by slug. */
|
|
5
|
+
export declare function useForm(slug: MaybeRefOrGetter<string>): any;
|
|
6
|
+
/** Initial answers: empty arrays for files, `false` for checkboxes, everything else unset. */
|
|
7
|
+
export declare function emptyAnswers(definition: Pick<FormDefinition, 'steps'>): Record<string, unknown>;
|
|
8
|
+
/** Posts the answers: JSON for forms without files, multipart (answers + `file:<key>` parts) otherwise. */
|
|
9
|
+
export declare function submitAnswers(slug: string, definition: Pick<FormDefinition, 'steps'>, answers: Record<string, unknown>, locale: string): Promise<any>;
|
|
10
|
+
declare const DEFAULT_PATHS: {
|
|
11
|
+
readonly form: "/forms/:slug";
|
|
12
|
+
readonly admin: "/admin/forms";
|
|
13
|
+
readonly adminForm: "/admin/forms/:id";
|
|
14
|
+
readonly adminSubmissions: "/admin/forms/:id/submissions";
|
|
15
|
+
readonly adminSubmission: "/admin/forms/:id/submissions/:submissionId";
|
|
16
|
+
};
|
|
17
|
+
/** Resolves the (configurable) page paths, filling in params. */
|
|
18
|
+
export declare function useFormsPaths(): (name: keyof typeof DEFAULT_PATHS, params?: Record<string, string>) => string;
|
|
19
|
+
/** Label of a field in the given locale, falling back to its key. */
|
|
20
|
+
export declare const fieldLabel: (field: FormField, locale: string) => string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolveVisibility, translate } from "../../utils/index.js";
|
|
2
|
+
export function useFormsLocale() {
|
|
3
|
+
const nuxtApp = useNuxtApp();
|
|
4
|
+
return computed(() => nuxtApp.$i18n?.locale?.value ?? "en");
|
|
5
|
+
}
|
|
6
|
+
export function useForm(slug) {
|
|
7
|
+
return useFetch(() => `/api/forms/${toValue(slug)}`, { key: `forms:${toValue(slug)}` });
|
|
8
|
+
}
|
|
9
|
+
export function emptyAnswers(definition) {
|
|
10
|
+
const state = {};
|
|
11
|
+
for (const step of definition.steps) {
|
|
12
|
+
for (const field of step.fields) {
|
|
13
|
+
if (field.type === "file") state[field.key] = [];
|
|
14
|
+
else if (field.type === "checkbox") state[field.key] = false;
|
|
15
|
+
else if (field.type !== "markdown") state[field.key] = void 0;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
export async function submitAnswers(slug, definition, answers, locale) {
|
|
21
|
+
const files = [];
|
|
22
|
+
const plain = {};
|
|
23
|
+
const visible = new Set(resolveVisibility(definition, answers).flatMap((step) => step.fields.map((field) => field.key)));
|
|
24
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
25
|
+
if (!visible.has(key)) continue;
|
|
26
|
+
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
|
|
27
|
+
files.push(...value.map((file) => ({ key, file })));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
plain[key] = value;
|
|
31
|
+
}
|
|
32
|
+
if (!files.length) {
|
|
33
|
+
return $fetch(`/api/forms/${slug}/submit`, { method: "POST", body: { answers: plain, locale } });
|
|
34
|
+
}
|
|
35
|
+
const body = new FormData();
|
|
36
|
+
body.append("answers", JSON.stringify(plain));
|
|
37
|
+
body.append("locale", locale);
|
|
38
|
+
for (const { key, file } of files) body.append(`file:${key}`, file, file.name);
|
|
39
|
+
return $fetch(`/api/forms/${slug}/submit`, { method: "POST", body });
|
|
40
|
+
}
|
|
41
|
+
const DEFAULT_PATHS = {
|
|
42
|
+
form: "/forms/:slug",
|
|
43
|
+
admin: "/admin/forms",
|
|
44
|
+
adminForm: "/admin/forms/:id",
|
|
45
|
+
adminSubmissions: "/admin/forms/:id/submissions",
|
|
46
|
+
adminSubmission: "/admin/forms/:id/submissions/:submissionId"
|
|
47
|
+
};
|
|
48
|
+
export function useFormsPaths() {
|
|
49
|
+
const pages = useRuntimeConfig().public.forms?.pages ?? {};
|
|
50
|
+
return (name, params = {}) => Object.entries(params).reduce((path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)), pages[name] ?? DEFAULT_PATHS[name]);
|
|
51
|
+
}
|
|
52
|
+
export const fieldLabel = (field, locale) => translate(field.label, locale) || field.key;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { formDefinitionSchema } from "../../utils";
|
|
3
|
+
const t = useModuleT("forms");
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
const paths = useFormsPaths();
|
|
6
|
+
const toast = useToast();
|
|
7
|
+
const id = computed(() => String(route.params.id));
|
|
8
|
+
const { data: form, error, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}`);
|
|
9
|
+
const draft = reactive({ name: form.value?.name ?? "", slug: form.value?.slug ?? "", status: form.value?.status ?? "draft" });
|
|
10
|
+
const json = ref(form.value ? JSON.stringify(form.value.definition, null, 2) : "");
|
|
11
|
+
const checked = computed(() => {
|
|
12
|
+
try {
|
|
13
|
+
const result = formDefinitionSchema.safeParse(JSON.parse(json.value));
|
|
14
|
+
return result.success ? { definition: result.data, issues: [] } : { definition: void 0, issues: result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) };
|
|
15
|
+
} catch {
|
|
16
|
+
return { definition: void 0, issues: [t("admin.invalidJson", "The JSON is not valid.")] };
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const previewKey = ref(0);
|
|
20
|
+
const saving = ref(false);
|
|
21
|
+
const statuses = ["draft", "published", "archived"];
|
|
22
|
+
async function save() {
|
|
23
|
+
if (!checked.value.definition) return;
|
|
24
|
+
saving.value = true;
|
|
25
|
+
try {
|
|
26
|
+
await $fetch(`/api/forms/admin/forms/${id.value}`, { method: "PATCH", body: { ...draft, definition: checked.value.definition } });
|
|
27
|
+
await refresh();
|
|
28
|
+
previewKey.value++;
|
|
29
|
+
toast.add({ title: t("admin.saved", "Saved."), color: "success" });
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
toast.add({ title: cause.statusMessage ?? t("error.generic", "Something went wrong. Please try again."), color: "error" });
|
|
32
|
+
} finally {
|
|
33
|
+
saving.value = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<UContainer class="space-y-6 py-10">
|
|
40
|
+
<UAlert
|
|
41
|
+
v-if="error || !form"
|
|
42
|
+
color="error"
|
|
43
|
+
variant="subtle"
|
|
44
|
+
:title="t('admin.forbidden', 'You are not allowed to manage forms.')"
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
<template v-else>
|
|
48
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
49
|
+
<div>
|
|
50
|
+
<NuxtLink
|
|
51
|
+
:to="paths('admin')"
|
|
52
|
+
class="text-sm text-muted hover:underline"
|
|
53
|
+
>
|
|
54
|
+
← {{ t("admin.title", "Forms") }}
|
|
55
|
+
</NuxtLink>
|
|
56
|
+
<h1 class="text-3xl font-bold">
|
|
57
|
+
{{ form.name }} <span class="text-base font-normal text-muted">v{{ form.version }}</span>
|
|
58
|
+
</h1>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="flex gap-2">
|
|
61
|
+
<UButton
|
|
62
|
+
v-if="form.definition.storage === 'db'"
|
|
63
|
+
color="neutral"
|
|
64
|
+
variant="outline"
|
|
65
|
+
:to="paths('adminSubmissions', { id: form.id })"
|
|
66
|
+
:label="t('admin.submissionsTitle', 'Submissions')"
|
|
67
|
+
/>
|
|
68
|
+
<UButton
|
|
69
|
+
:loading="saving"
|
|
70
|
+
:disabled="!checked.definition"
|
|
71
|
+
:label="t('admin.save', 'Save')"
|
|
72
|
+
@click="save"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="grid gap-4 sm:grid-cols-3">
|
|
78
|
+
<UFormField :label="t('admin.name', 'Name')">
|
|
79
|
+
<UInput
|
|
80
|
+
v-model="draft.name"
|
|
81
|
+
class="w-full"
|
|
82
|
+
/>
|
|
83
|
+
</UFormField>
|
|
84
|
+
<UFormField :label="t('admin.slug', 'Slug')">
|
|
85
|
+
<UInput
|
|
86
|
+
v-model="draft.slug"
|
|
87
|
+
class="w-full"
|
|
88
|
+
/>
|
|
89
|
+
</UFormField>
|
|
90
|
+
<UFormField :label="t('admin.status', 'Status')">
|
|
91
|
+
<USelect
|
|
92
|
+
v-model="draft.status"
|
|
93
|
+
:items="statuses.map((value) => ({ value, label: t(`status.${value}`, value) }))"
|
|
94
|
+
class="w-full"
|
|
95
|
+
/>
|
|
96
|
+
</UFormField>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="grid gap-6 lg:grid-cols-2">
|
|
100
|
+
<div class="space-y-3">
|
|
101
|
+
<h2 class="font-semibold">
|
|
102
|
+
{{ t("admin.definition", "Definition (JSON)") }}
|
|
103
|
+
</h2>
|
|
104
|
+
<UTextarea
|
|
105
|
+
v-model="json"
|
|
106
|
+
:rows="30"
|
|
107
|
+
class="w-full font-mono text-xs"
|
|
108
|
+
/>
|
|
109
|
+
<UAlert
|
|
110
|
+
v-if="checked.issues.length"
|
|
111
|
+
color="error"
|
|
112
|
+
variant="subtle"
|
|
113
|
+
:title="t('admin.invalidDefinition', 'The definition has problems')"
|
|
114
|
+
>
|
|
115
|
+
<template #description>
|
|
116
|
+
<ul class="list-disc pl-4">
|
|
117
|
+
<li
|
|
118
|
+
v-for="issue in checked.issues"
|
|
119
|
+
:key="issue"
|
|
120
|
+
>
|
|
121
|
+
{{ issue }}
|
|
122
|
+
</li>
|
|
123
|
+
</ul>
|
|
124
|
+
</template>
|
|
125
|
+
</UAlert>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div class="space-y-3">
|
|
129
|
+
<h2 class="font-semibold">
|
|
130
|
+
{{ t("admin.preview", "Preview") }}
|
|
131
|
+
</h2>
|
|
132
|
+
<div class="rounded-lg border border-default p-6">
|
|
133
|
+
<FormRenderer
|
|
134
|
+
v-if="checked.definition"
|
|
135
|
+
:key="previewKey + json.length"
|
|
136
|
+
:form="{ slug: draft.slug, definition: checked.definition }"
|
|
137
|
+
preview
|
|
138
|
+
/>
|
|
139
|
+
<p
|
|
140
|
+
v-else
|
|
141
|
+
class="text-muted"
|
|
142
|
+
>
|
|
143
|
+
{{ t("admin.previewUnavailable", "Fix the definition to see a preview.") }}
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</UContainer>
|
|
150
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const t = useModuleT("forms");
|
|
3
|
+
const route = useRoute();
|
|
4
|
+
const paths = useFormsPaths();
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
const id = computed(() => String(route.params.id));
|
|
7
|
+
const submissionId = computed(() => String(route.params.submissionId));
|
|
8
|
+
const { data: submission, error, refresh } = await useFetch(() => `/api/forms/admin/submissions/${submissionId.value}`);
|
|
9
|
+
const redelivering = ref(false);
|
|
10
|
+
async function redeliver() {
|
|
11
|
+
redelivering.value = true;
|
|
12
|
+
try {
|
|
13
|
+
await $fetch(`/api/forms/admin/submissions/${submissionId.value}/redeliver`, { method: "POST" });
|
|
14
|
+
await refresh();
|
|
15
|
+
} catch (cause) {
|
|
16
|
+
toast.add({ title: cause.statusMessage ?? t("error.generic", "Something went wrong. Please try again."), color: "error" });
|
|
17
|
+
} finally {
|
|
18
|
+
redelivering.value = false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UContainer class="max-w-3xl space-y-6 py-10">
|
|
25
|
+
<UAlert
|
|
26
|
+
v-if="error || !submission"
|
|
27
|
+
color="error"
|
|
28
|
+
variant="subtle"
|
|
29
|
+
:title="t('admin.forbidden', 'You are not allowed to manage forms.')"
|
|
30
|
+
/>
|
|
31
|
+
|
|
32
|
+
<template v-else>
|
|
33
|
+
<div>
|
|
34
|
+
<NuxtLink
|
|
35
|
+
:to="paths('adminSubmissions', { id })"
|
|
36
|
+
class="text-sm text-muted hover:underline"
|
|
37
|
+
>
|
|
38
|
+
← {{ t("admin.submissionsTitle", "Submissions") }}
|
|
39
|
+
</NuxtLink>
|
|
40
|
+
<h1 class="text-3xl font-bold">
|
|
41
|
+
{{ new Date(submission.createdAt).toLocaleString() }}
|
|
42
|
+
</h1>
|
|
43
|
+
<p class="text-sm text-muted">
|
|
44
|
+
{{ submission.formSlug }} · v{{ submission.formVersion }} · {{ submission.locale }}
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<UAlert
|
|
49
|
+
:color="submission.deliveryStatus === 'failed' ? 'error' : 'neutral'"
|
|
50
|
+
variant="subtle"
|
|
51
|
+
:title="t(`delivery.${submission.deliveryStatus}`, submission.deliveryStatus)"
|
|
52
|
+
:description="submission.deliveryError ?? void 0"
|
|
53
|
+
:actions="[{ label: t('admin.redeliver', 'Deliver again'), loading: redelivering, onClick: redeliver }]"
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
<FormSubmissionView :submission="submission" />
|
|
57
|
+
</template>
|
|
58
|
+
</UContainer>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|