@frigear-nu/forms 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +7 -5
  3. package/dist/runtime/app/components/FormBuilderAccess.vue +6 -13
  4. package/dist/runtime/app/components/FormBuilderFieldRow.d.vue.ts +2 -2
  5. package/dist/runtime/app/components/FormBuilderFieldRow.vue.d.ts +2 -2
  6. package/dist/runtime/app/components/FormBuilderStepHeader.d.vue.ts +2 -2
  7. package/dist/runtime/app/components/FormBuilderStepHeader.vue.d.ts +2 -2
  8. package/dist/runtime/app/components/FormCreateSlideover.d.vue.ts +8 -0
  9. package/dist/runtime/app/components/FormCreateSlideover.vue +73 -0
  10. package/dist/runtime/app/components/FormCreateSlideover.vue.d.ts +8 -0
  11. package/dist/runtime/app/components/FormPreview.vue +2 -8
  12. package/dist/runtime/app/composables/index.d.ts +1 -0
  13. package/dist/runtime/app/composables/index.js +1 -0
  14. package/dist/runtime/app/composables/useFormActions.d.ts +26 -0
  15. package/dist/runtime/app/composables/useFormActions.js +103 -0
  16. package/dist/runtime/app/composables/useForms.d.ts +1 -1
  17. package/dist/runtime/app/composables/useForms.js +2 -9
  18. package/dist/runtime/app/pages/admin-form.vue +84 -82
  19. package/dist/runtime/app/pages/admin-submission.vue +60 -29
  20. package/dist/runtime/app/pages/admin-submissions.vue +81 -47
  21. package/dist/runtime/app/pages/admin.vue +152 -233
  22. package/dist/runtime/app/pages/form.vue +47 -45
  23. package/dist/runtime/i18n/lang/da.json +31 -10
  24. package/dist/runtime/i18n/lang/en.json +31 -10
  25. package/dist/runtime/menus.js +0 -11
  26. package/dist/runtime/server/db/schema.postgresql.d.ts +741 -0
  27. package/dist/runtime/server/db/schema.postgresql.js +78 -0
  28. package/package.json +17 -17
@@ -3,10 +3,13 @@ import { formDefinitionSchema } from "../../utils";
3
3
  const t = useModuleT("forms");
4
4
  const route = useRoute();
5
5
  const paths = useFormsPaths();
6
- const toast = useToast();
7
- const link = useFormLink();
6
+ const actions = useFormActions();
7
+ const { busy: saving, run } = useAction();
8
8
  const id = computed(() => String(route.params.id));
9
- const { data: form, error, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}`);
9
+ const { data: form, error, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, {
10
+ key: `forms:admin:form:${id.value}`
11
+ });
12
+ const notFound = computed(() => error.value?.statusCode === 404);
10
13
  const draft = reactive({ name: form.value?.name ?? "", slug: form.value?.slug ?? "", status: form.value?.status ?? "draft" });
11
14
  const definition = ref(structuredClone(toRaw(form.value?.definition ?? { mode: "single", storage: "db", steps: [] })));
12
15
  const tab = ref("builder");
@@ -40,95 +43,81 @@ const tabs = computed(() => [
40
43
  { value: "json", label: t("admin.json", "JSON"), icon: "i-lucide-braces" }
41
44
  ]);
42
45
  const previewKey = ref(0);
43
- const saving = ref(false);
44
46
  const statuses = ["draft", "published", "archived"];
45
47
  async function save() {
46
48
  if (!checked.value.definition) return;
47
- saving.value = true;
48
- try {
49
+ await run(async () => {
49
50
  await $fetch(`/api/forms/admin/forms/${id.value}`, { method: "PATCH", body: { ...draft, definition: checked.value.definition } });
50
51
  await refresh();
51
52
  previewKey.value++;
52
- toast.add({ title: t("admin.saved", "Saved."), color: "success" });
53
- } catch (cause) {
54
- toast.add({ title: cause.statusMessage ?? t("error.generic", "Something went wrong. Please try again."), color: "error" });
55
- } finally {
56
- saving.value = false;
57
- }
53
+ }, { success: t("admin.saved", "Saved.") });
58
54
  }
55
+ const listPath = paths("admin");
56
+ const menu = computed(() => form.value ? actions.menu({ ...form.value, storage: form.value.definition.storage }, { onRemoved: () => navigateTo(listPath) }) : []);
59
57
  </script>
60
58
 
61
59
  <template>
62
- <AdminPage>
63
- <UAlert
64
- v-if="error || !form"
65
- color="error"
66
- variant="subtle"
67
- :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
68
- />
69
-
70
- <template v-else>
71
- <AdminPageHeader
60
+ <div class="flex flex-col gap-4">
61
+ <template v-if="form">
62
+ <PageHeading
63
+ :breadcrumb="[{ label: t('admin.title', 'Forms'), to: listPath }, { label: form.name }]"
72
64
  :title="form.name"
73
- :back="{ label: t('admin.title', 'Forms'), to: paths('admin') }"
74
65
  >
75
66
  <template #title>
76
- {{ form.name }} <span class="text-base font-normal text-muted">v{{ form.version }}</span>
67
+ {{ form.name }} <span class="text-muted text-base font-normal">v{{ form.version }}</span>
77
68
  </template>
78
- <template #actions>
79
- <UButton
80
- v-if="form.status === 'published' && link.enabled.value"
81
- color="neutral"
82
- variant="outline"
83
- icon="i-lucide-link"
84
- :label="t('admin.copyLink', 'Copy link')"
85
- @click="link.copy(form.slug)"
86
- />
87
- <UButton
88
- v-if="form.definition.storage === 'db' || (form.submissionCount ?? 0) > 0"
89
- color="neutral"
90
- variant="outline"
91
- :to="paths('adminSubmissions', { id: form.id })"
92
- :label="t('admin.submissionsTitle', 'Submissions')"
69
+ <template #badge>
70
+ <UBadge
71
+ :color="statusColor(form.status, { archived: 'warning' })"
72
+ variant="subtle"
73
+ :label="t(`status.${form.status}`, form.status)"
93
74
  />
75
+ </template>
76
+ <template #actions>
94
77
  <UButton
95
78
  :loading="saving"
96
79
  :disabled="!checked.definition"
97
- :label="t('admin.save', 'Save')"
80
+ :label="t('admin.save', 'Save form')"
98
81
  @click="save"
99
82
  />
83
+ <RowMenu
84
+ :items="menu"
85
+ :subject="form.name"
86
+ variant="outline"
87
+ />
100
88
  </template>
101
- </AdminPageHeader>
89
+ </PageHeading>
102
90
 
103
- <div class="grid gap-4 sm:grid-cols-3">
104
- <UFormField :label="t('admin.name', 'Name')">
105
- <UInput
106
- v-model="draft.name"
107
- class="w-full"
108
- />
109
- </UFormField>
110
- <UFormField :label="t('admin.slug', 'Slug')">
111
- <UInput
112
- v-model="draft.slug"
113
- class="w-full"
114
- />
115
- </UFormField>
116
- <UFormField :label="t('admin.status', 'Status')">
117
- <USelect
118
- v-model="draft.status"
119
- :items="statuses.map((value) => ({ value, label: t(`status.${value}`, value) }))"
120
- class="w-full"
121
- />
122
- </UFormField>
123
- </div>
91
+ <SectionCard :title="t('admin.details', 'Details')">
92
+ <div class="grid gap-4 sm:grid-cols-3">
93
+ <UFormField :label="t('admin.name', 'Name')">
94
+ <UInput
95
+ v-model="draft.name"
96
+ class="w-full"
97
+ />
98
+ </UFormField>
99
+ <UFormField :label="t('admin.slug', 'Slug')">
100
+ <UInput
101
+ v-model="draft.slug"
102
+ class="w-full"
103
+ />
104
+ </UFormField>
105
+ <UFormField :label="t('admin.status', 'Status')">
106
+ <USelect
107
+ v-model="draft.status"
108
+ :items="statuses.map((value) => ({ value, label: t(`status.${value}`, value) }))"
109
+ class="w-full"
110
+ />
111
+ </UFormField>
112
+ </div>
113
+ </SectionCard>
124
114
 
125
- <UTabs
115
+ <PageTabs
126
116
  v-model="tab"
127
117
  :items="tabs"
128
- :content="false"
129
- class="w-full"
130
118
  />
131
119
 
120
+ <!-- The builder has its own chrome (canvas and settings panel), so it is not framed again. -->
132
121
  <FormBuilder
133
122
  v-if="tab === 'builder'"
134
123
  v-model="definition"
@@ -144,32 +133,29 @@ async function save() {
144
133
  </template>
145
134
  </FormBuilder>
146
135
 
147
- <FormBuilderSettings
148
- v-else-if="tab === 'settings'"
149
- v-model="definition"
150
- />
136
+ <SectionCard v-else-if="tab === 'settings'">
137
+ <FormBuilderSettings v-model="definition" />
138
+ </SectionCard>
151
139
 
152
- <FormBuilderAccess
153
- v-else-if="tab === 'access'"
154
- v-model="definition"
155
- :form-id="form.id"
156
- :slug="form.slug"
157
- />
140
+ <SectionCard v-else-if="tab === 'access'">
141
+ <FormBuilderAccess
142
+ v-model="definition"
143
+ :form-id="form.id"
144
+ :slug="form.slug"
145
+ />
146
+ </SectionCard>
158
147
 
159
- <div
148
+ <SectionCard
160
149
  v-else-if="tab === 'json'"
161
- class="space-y-3"
150
+ :title="t('admin.definition', 'Definition (JSON)')"
162
151
  >
163
- <h2 class="font-semibold">
164
- {{ t("admin.definition", "Definition (JSON)") }}
165
- </h2>
166
152
  <UTextarea
167
153
  :model-value="json"
168
154
  :rows="30"
169
155
  class="w-full font-mono text-xs"
170
156
  @update:model-value="onJson(String($event))"
171
157
  />
172
- </div>
158
+ </SectionCard>
173
159
 
174
160
  <UAlert
175
161
  v-if="checked.issues.length"
@@ -189,5 +175,21 @@ async function save() {
189
175
  </template>
190
176
  </UAlert>
191
177
  </template>
192
- </AdminPage>
178
+
179
+ <UEmpty
180
+ v-else
181
+ :icon="notFound ? 'i-lucide-clipboard-x' : 'i-lucide-lock'"
182
+ :title="notFound ? t('admin.notFound', 'This form does not exist.') : t('admin.forbidden', 'You are not allowed to manage forms.')"
183
+ >
184
+ <template #actions>
185
+ <UButton
186
+ color="neutral"
187
+ variant="outline"
188
+ icon="i-lucide-arrow-left"
189
+ :to="listPath"
190
+ :label="t('admin.allForms', 'All forms')"
191
+ />
192
+ </template>
193
+ </UEmpty>
194
+ </div>
193
195
  </template>
@@ -2,49 +2,80 @@
2
2
  const t = useModuleT("forms");
3
3
  const route = useRoute();
4
4
  const paths = useFormsPaths();
5
- const toast = useToast();
5
+ const format = useFormat();
6
+ const actions = useFormActions();
6
7
  const id = computed(() => String(route.params.id));
7
8
  const submissionId = computed(() => String(route.params.submissionId));
9
+ const { data: form } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, { key: `forms:admin:form:${id.value}` });
8
10
  const { data: submission, error, refresh } = await useFetch(() => `/api/forms/admin/submissions/${submissionId.value}`);
9
- const redelivering = ref(false);
10
11
  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
- }
12
+ if (await actions.redeliver(submissionId.value)) await refresh();
20
13
  }
14
+ const breadcrumb = computed(() => [
15
+ { label: t("admin.title", "Forms"), to: paths("admin") },
16
+ { label: form.value?.name ?? id.value, to: paths("adminForm", { id: id.value }) },
17
+ { label: t("admin.submissionsTitle", "Submissions"), to: paths("adminSubmissions", { id: id.value }) },
18
+ { label: `#${submissionId.value}` }
19
+ ]);
20
+ const menu = computed(() => [[{
21
+ label: t("admin.redeliver", "Deliver again"),
22
+ icon: "i-lucide-send",
23
+ onSelect: redeliver
24
+ }]]);
25
+ const notFound = computed(() => error.value?.statusCode === 404);
21
26
  </script>
22
27
 
23
28
  <template>
24
- <AdminPage>
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
- <AdminPageHeader
34
- :title="new Date(submission.createdAt).toLocaleString()"
29
+ <div class="flex flex-col gap-4">
30
+ <template v-if="submission">
31
+ <PageHeading
32
+ :title="format.dateTime(submission.createdAt)"
35
33
  :description="`${submission.formSlug} \xB7 v${submission.formVersion} \xB7 ${submission.locale}`"
36
- :back="{ label: t('admin.submissionsTitle', 'Submissions'), to: paths('adminSubmissions', { id }) }"
37
- />
34
+ :breadcrumb="breadcrumb"
35
+ >
36
+ <template #badge>
37
+ <UBadge
38
+ :color="statusColor(submission.deliveryStatus)"
39
+ variant="subtle"
40
+ :label="t(`delivery.${submission.deliveryStatus}`, submission.deliveryStatus)"
41
+ />
42
+ </template>
43
+ <template #actions>
44
+ <RowMenu
45
+ :items="menu"
46
+ :subject="format.dateTime(submission.createdAt)"
47
+ variant="outline"
48
+ />
49
+ </template>
50
+ </PageHeading>
38
51
 
39
52
  <UAlert
40
- :color="submission.deliveryStatus === 'failed' ? 'error' : 'neutral'"
53
+ v-if="submission.deliveryError"
54
+ color="error"
41
55
  variant="subtle"
42
56
  :title="t(`delivery.${submission.deliveryStatus}`, submission.deliveryStatus)"
43
- :description="submission.deliveryError ?? void 0"
44
- :actions="[{ label: t('admin.redeliver', 'Deliver again'), loading: redelivering, onClick: redeliver }]"
57
+ :description="submission.deliveryError"
45
58
  />
46
59
 
47
- <FormSubmissionView :submission="submission" />
60
+ <SectionCard :title="t('admin.answers', 'Answers')">
61
+ <FormSubmissionView :submission="submission" />
62
+ </SectionCard>
48
63
  </template>
49
- </AdminPage>
64
+
65
+ <UEmpty
66
+ v-else
67
+ :icon="notFound ? 'i-lucide-inbox' : 'i-lucide-lock'"
68
+ :title="notFound ? t('admin.submissionNotFound', 'This submission does not exist.') : t('admin.forbidden', 'You are not allowed to manage forms.')"
69
+ >
70
+ <template #actions>
71
+ <UButton
72
+ color="neutral"
73
+ variant="outline"
74
+ icon="i-lucide-arrow-left"
75
+ :to="paths('adminSubmissions', { id })"
76
+ :label="t('admin.submissionsTitle', 'Submissions')"
77
+ />
78
+ </template>
79
+ </UEmpty>
80
+ </div>
50
81
  </template>
@@ -2,65 +2,99 @@
2
2
  const t = useModuleT("forms");
3
3
  const route = useRoute();
4
4
  const paths = useFormsPaths();
5
+ const format = useFormat();
6
+ const actions = useFormActions();
5
7
  const id = computed(() => String(route.params.id));
6
- const { data: submissions, error } = await useFetch(() => `/api/forms/admin/forms/${id.value}/submissions`);
7
- const color = { none: "neutral", pending: "warning", delivered: "success", failed: "error" };
8
- const columns = [
9
- { accessorKey: "createdAt", header: t("admin.submittedAt", "Submitted") },
8
+ const { data: form } = await useFetch(() => `/api/forms/admin/forms/${id.value}`, { key: `forms:admin:form:${id.value}` });
9
+ const { data: submissions, error, status, refresh } = await useFetch(() => `/api/forms/admin/forms/${id.value}/submissions`);
10
+ const { sorting, header } = useTableSort({ id: "createdAt", desc: true });
11
+ const columns = computed(() => [
12
+ { accessorKey: "createdAt", header: header("createdAt", t("admin.submittedAt", "Submitted")) },
10
13
  { id: "preview", header: t("admin.answers", "Answers") },
11
- { accessorKey: "deliveryStatus", header: t("admin.delivery", "Delivery") }
12
- ];
13
- const format = (iso) => new Date(iso).toLocaleString();
14
+ { accessorKey: "deliveryStatus", header: header("deliveryStatus", t("admin.delivery", "Delivery")) },
15
+ { id: "actions", header: "", meta: { class: { th: "w-0", td: "w-0" } } }
16
+ ]);
17
+ const submissionPath = (submission) => paths("adminSubmission", { id: id.value, submissionId: submission.id });
18
+ const menu = (submission) => [[
19
+ { label: t("admin.open", "Open"), icon: "i-lucide-arrow-right", to: submissionPath(submission) },
20
+ {
21
+ label: t("admin.redeliver", "Deliver again"),
22
+ icon: "i-lucide-send",
23
+ onSelect: async () => {
24
+ if (await actions.redeliver(submission.id)) await refresh();
25
+ }
26
+ }
27
+ ]];
28
+ const breadcrumb = computed(() => [
29
+ { label: t("admin.title", "Forms"), to: paths("admin") },
30
+ { label: form.value?.name ?? id.value, to: paths("adminForm", { id: id.value }) },
31
+ { label: t("admin.submissionsTitle", "Submissions") }
32
+ ]);
14
33
  </script>
15
34
 
16
35
  <template>
17
- <AdminPage>
18
- <AdminPageHeader
36
+ <div class="flex flex-col gap-4">
37
+ <PageHeading
19
38
  :title="t('admin.submissionsTitle', 'Submissions')"
20
- :back="{ label: t('admin.edit', 'Edit'), to: paths('adminForm', { id }) }"
39
+ :breadcrumb="breadcrumb"
21
40
  />
22
41
 
23
- <UAlert
42
+ <UEmpty
24
43
  v-if="error"
25
- color="error"
26
- variant="subtle"
44
+ icon="i-lucide-lock"
27
45
  :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
28
46
  />
29
- <AdminTable
47
+
48
+ <TableCard
30
49
  v-else
31
- :data="submissions ?? []"
32
- :columns="columns"
33
- @select="(_, row2) => navigateTo(paths('adminSubmission', { id, submissionId: row2.original.id }))"
50
+ :loading="status === 'pending' && !submissions"
51
+ :refreshing="status === 'pending' && !!submissions"
34
52
  >
35
- <template #createdAt-cell="{ row }">
36
- <span class="text-muted">{{ format(row.original.createdAt) }}</span>
37
- </template>
38
- <template #preview-cell="{ row }">
39
- <p class="max-w-md truncate">
40
- <span
41
- v-for="(answer, index) in row.original.preview"
42
- :key="answer.label"
43
- >
53
+ <UTable
54
+ v-model:sorting="sorting"
55
+ :data="submissions ?? []"
56
+ :columns="columns"
57
+ :ui="tableUi()"
58
+ @select="(_, row2) => navigateTo(submissionPath(row2.original))"
59
+ >
60
+ <template #createdAt-cell="{ row }">
61
+ <span class="tabular-nums">{{ format.dateTime(row.original.createdAt) }}</span>
62
+ </template>
63
+ <template #preview-cell="{ row }">
64
+ <p class="max-w-md truncate">
44
65
  <span
45
- v-if="index"
46
- class="text-muted"
47
- > · </span>{{ answer.value }}
48
- </span>
49
- </p>
50
- </template>
51
- <template #deliveryStatus-cell="{ row }">
52
- <UBadge
53
- :color="color[row.original.deliveryStatus]"
54
- variant="subtle"
55
- :label="t(`delivery.${row.original.deliveryStatus}`, row.original.deliveryStatus)"
56
- />
57
- </template>
58
- <template #empty>
59
- <AdminEmpty
60
- icon="i-lucide-inbox"
61
- :title="t('admin.noSubmissions', 'No submissions yet.')"
62
- />
63
- </template>
64
- </AdminTable>
65
- </AdminPage>
66
+ v-for="(answer, index) in row.original.preview"
67
+ :key="answer.label"
68
+ >
69
+ <span
70
+ v-if="index"
71
+ class="text-muted"
72
+ > · </span>{{ answer.value }}
73
+ </span>
74
+ </p>
75
+ </template>
76
+ <template #deliveryStatus-cell="{ row }">
77
+ <UBadge
78
+ :color="statusColor(row.original.deliveryStatus)"
79
+ variant="subtle"
80
+ :label="t(`delivery.${row.original.deliveryStatus}`, row.original.deliveryStatus)"
81
+ />
82
+ </template>
83
+ <template #actions-cell="{ row }">
84
+ <RowMenu
85
+ :items="menu(row.original)"
86
+ :subject="format.dateTime(row.original.createdAt)"
87
+ />
88
+ </template>
89
+ <template #empty>
90
+ <UEmpty
91
+ variant="naked"
92
+ icon="i-lucide-inbox"
93
+ :title="t('admin.noSubmissions', 'No submissions yet.')"
94
+ :description="t('admin.noSubmissionsHint', 'Answers appear here once someone submits the form.')"
95
+ />
96
+ </template>
97
+ </UTable>
98
+ </TableCard>
99
+ </div>
66
100
  </template>