@frigear-nu/forms 0.0.9 → 0.1.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.
@@ -1,278 +1,197 @@
1
1
  <script setup>
2
- import { defineForm } from "../../utils";
3
2
  const t = useModuleT("forms");
4
3
  const paths = useFormsPaths();
5
- const toast = useToast();
6
- const link = useFormLink();
7
- const { data: forms, error, refresh } = await useFetch("/api/forms/admin/forms");
8
- const starter = defineForm({
9
- mode: "single",
10
- storage: "db",
11
- steps: [{
12
- id: "main",
13
- fields: [
14
- { key: "name", type: "text", label: "Name", required: true },
15
- { key: "message", type: "textarea", label: "Message", required: true }
16
- ]
17
- }]
18
- });
19
- const creating = ref(false);
20
- const draft = reactive({ name: "", slug: "" });
21
- const importing = ref(false);
22
- const importJson = ref("");
23
- const slugify = (value) => value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
24
- watch(() => draft.name, (name, previous) => {
25
- if (draft.slug === slugify(previous)) draft.slug = slugify(name);
26
- });
27
- async function create() {
28
- try {
29
- const created = await $fetch("/api/forms/admin/forms", { method: "POST", body: { ...draft, definition: starter } });
30
- await navigateTo(paths("adminForm", { id: created.id }));
31
- } catch (cause) {
32
- toast.add({ title: cause.statusMessage ?? t("error.generic", "Something went wrong. Please try again."), color: "error" });
33
- }
34
- }
35
- async function clone(form) {
36
- try {
37
- const copy = await $fetch(`/api/forms/admin/forms/${form.id}/clone`, { method: "POST" });
38
- await navigateTo(paths("adminForm", { id: copy.id }));
39
- } catch (cause) {
40
- toast.add({ title: cause.statusMessage ?? t("error.generic", "Something went wrong. Please try again."), color: "error" });
41
- }
42
- }
43
- async function runImport() {
44
- try {
45
- const parsed = JSON.parse(importJson.value);
46
- const result = await $fetch("/api/forms/admin/import", {
47
- method: "POST",
48
- body: { forms: Array.isArray(parsed) ? parsed : [parsed] }
49
- });
50
- toast.add({ title: t("admin.imported", "Imported {inserted}, skipped {skipped} existing.", { inserted: result.inserted.length, skipped: result.skipped.length }), color: "success" });
51
- importing.value = false;
52
- importJson.value = "";
53
- await refresh();
54
- } catch (cause) {
55
- toast.add({ title: cause.statusMessage ?? t("admin.invalidJson", "The JSON is not valid."), color: "error" });
56
- }
57
- }
58
- const columns = [
59
- { accessorKey: "name", header: t("admin.name", "Name") },
60
- { accessorKey: "status", header: t("admin.status", "Status") },
61
- { id: "actions", header: "" }
62
- ];
63
- const statusColor = { draft: "neutral", published: "success", archived: "warning" };
64
- const statuses = ["published", "draft", "archived"];
65
- const tab = ref("published");
4
+ const format = useFormat();
5
+ const actions = useFormActions();
6
+ const { data: forms, error, status } = await useFetch("/api/forms/admin/forms", { key: FORMS_LIST_KEY });
66
7
  const search = ref("");
67
- const tabs = computed(() => statuses.map((status) => ({
68
- value: status,
69
- label: t(`status.${status}`, status),
70
- badge: (forms.value ?? []).filter((form) => form.status === status).length
71
- })));
8
+ const statusFilter = ref("all");
9
+ const statusItems = computed(() => [
10
+ { value: "all", label: t("admin.allStatuses", "All statuses") },
11
+ ...["published", "draft", "archived"].map((value) => ({ value, label: t(`status.${value}`, value) }))
12
+ ]);
72
13
  const visibleForms = computed(() => {
73
14
  const query = search.value.trim().toLowerCase();
74
15
  return (forms.value ?? []).filter(
75
- (form) => form.status === tab.value && (!query || form.name.toLowerCase().includes(query) || form.slug.toLowerCase().includes(query))
16
+ (form) => (statusFilter.value === "all" || form.status === statusFilter.value) && (!query || form.name.toLowerCase().includes(query) || form.slug.toLowerCase().includes(query))
76
17
  );
77
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 });
25
+ const columns = computed(() => [
26
+ { accessorKey: "name", header: header("name", t("admin.name", "Name")) },
27
+ { accessorKey: "status", header: header("status", t("admin.status", "Status")) },
28
+ { accessorKey: "submissionCount", header: header("submissionCount", t("admin.submissionsTitle", "Submissions")), meta: numericColumn },
29
+ { accessorKey: "updatedAt", header: header("updatedAt", t("admin.updated", "Updated")) },
30
+ { id: "actions", header: "", meta: { class: { th: "w-0", td: "w-0" } } }
31
+ ]);
32
+ const formDescription = (form) => [
33
+ `/${form.slug}`,
34
+ form.mode === "stepped" ? t("admin.stepped", "Stepped") : t("admin.single", "Single page"),
35
+ `v${form.version}`,
36
+ ...form.storage === "none" ? [t("admin.notStored", "Not stored")] : []
37
+ ].join(" \xB7 ");
38
+ 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
+ }
78
56
  </script>
79
57
 
80
58
  <template>
81
- <AdminPage>
82
- <AdminPageHeader :title="t('admin.title', 'Forms')">
59
+ <div class="flex flex-col gap-4">
60
+ <PageHeading :title="t('admin.title', 'Forms')">
83
61
  <template #actions>
84
62
  <UButton
85
63
  color="neutral"
86
64
  variant="outline"
87
65
  icon="i-lucide-upload"
88
- :label="t('admin.import', 'Import')"
66
+ :label="t('admin.import', 'Import JSON')"
89
67
  @click="importing = true"
90
68
  />
91
69
  <UButton
92
70
  icon="i-lucide-plus"
93
71
  :label="t('admin.new', 'New form')"
94
- @click="creating = true"
72
+ @click="actions.create()"
95
73
  />
96
74
  </template>
97
- </AdminPageHeader>
75
+ </PageHeading>
98
76
 
99
- <UAlert
77
+ <UEmpty
100
78
  v-if="error"
101
- color="error"
102
- variant="subtle"
79
+ icon="i-lucide-lock"
103
80
  :title="t('admin.forbidden', 'You are not allowed to manage forms.')"
104
81
  />
105
82
 
106
- <div
107
- v-else
108
- class="flex flex-wrap items-center justify-between gap-3"
109
- >
110
- <UTabs
111
- v-model="tab"
112
- :items="tabs"
113
- :content="false"
114
- class="w-auto"
115
- />
116
- <UInput
117
- v-model="search"
118
- class="w-full sm:w-72"
119
- icon="i-lucide-search"
120
- :placeholder="t('admin.search', 'Search forms')"
121
- :aria-label="t('admin.search', 'Search forms')"
122
- />
123
- </div>
124
-
125
- <AdminTable
126
- v-if="!error"
127
- :data="visibleForms"
128
- :columns="columns"
129
- @select="(_, row2) => navigateTo(paths('adminForm', { id: row2.original.id }))"
130
- >
131
- <template #name-cell="{ row }">
132
- <div class="space-y-0.5">
133
- <p class="font-medium">
134
- {{ row.original.name }}
135
- </p>
136
- <p class="text-sm text-muted">
137
- /{{ row.original.slug }} · {{ row.original.mode === "stepped" ? t("admin.stepped", "Stepped") : t("admin.single", "Single page") }}
138
- · v{{ row.original.version }}
139
- <template v-if="row.original.storage === 'none'">
140
- · {{ t("admin.notStored", "Not stored") }}
141
- </template>
142
- </p>
143
- </div>
144
- </template>
145
- <template #status-cell="{ row }">
146
- <UBadge
147
- :color="statusColor[row.original.status]"
148
- variant="subtle"
149
- :label="t(`status.${row.original.status}`, row.original.status)"
83
+ <template v-else>
84
+ <PageToolbar>
85
+ <UInput
86
+ v-model="search"
87
+ class="w-full sm:w-72"
88
+ icon="i-lucide-search"
89
+ :placeholder="t('admin.search', 'Search forms')"
90
+ :aria-label="t('admin.search', 'Search forms')"
150
91
  />
151
- </template>
152
- <template #actions-cell="{ row }">
153
- <div class="flex items-center justify-end gap-2">
154
- <UButton
155
- v-if="row.original.storage === 'db' || row.original.submissionCount > 0"
156
- color="neutral"
157
- variant="outline"
158
- size="sm"
159
- :to="paths('adminSubmissions', { id: row.original.id })"
160
- :label="t('admin.submissions', 'Submissions ({count})', { count: row.original.submissionCount })"
161
- @click.stop
162
- />
163
- <UButton
164
- v-if="row.original.status === 'published' && link.enabled.value"
165
- color="neutral"
166
- variant="ghost"
167
- size="sm"
168
- icon="i-lucide-link"
169
- :aria-label="t('admin.copyLink', 'Copy link')"
170
- @click.stop="link.copy(row.original.slug)"
171
- />
172
- <UButton
173
- color="neutral"
174
- variant="ghost"
175
- size="sm"
176
- icon="i-lucide-copy"
177
- :aria-label="t('admin.clone', 'Duplicate')"
178
- @click="clone(form)"
179
- />
180
- <UButton
181
- color="neutral"
182
- variant="ghost"
183
- size="sm"
184
- icon="i-lucide-pencil"
185
- :to="paths('adminForm', { id: row.original.id })"
186
- :aria-label="t('admin.edit', 'Edit')"
187
- @click.stop
188
- />
189
- </div>
190
- </template>
191
- <template #empty>
192
- <AdminEmpty
193
- icon="i-lucide-clipboard-list"
194
- :title="forms?.length ? t('admin.noMatches', 'No forms match.') : t('admin.empty', 'No forms yet.')"
92
+ <USelect
93
+ v-model="statusFilter"
94
+ class="w-full sm:w-44"
95
+ :items="statusItems"
96
+ :aria-label="t('admin.statusFilter', 'Filter by status')"
195
97
  />
196
- </template>
197
- </AdminTable>
98
+ </PageToolbar>
198
99
 
199
- <UModal
200
- v-model:open="creating"
201
- :title="t('admin.new', 'New form')"
202
- >
203
- <template #body>
204
- <form
205
- id="admin-form-create"
206
- class="space-y-4"
207
- @submit.prevent="create"
100
+ <TableCard
101
+ :loading="status === 'pending' && !forms"
102
+ :refreshing="status === 'pending' && !!forms"
103
+ >
104
+ <UTable
105
+ v-model:sorting="sorting"
106
+ :data="visibleForms"
107
+ :columns="columns"
108
+ :ui="tableUi()"
109
+ @select="(_, row2) => navigateTo(editPath(row2.original))"
208
110
  >
209
- <UFormField :label="t('admin.name', 'Name')">
210
- <UInput
211
- v-model="draft.name"
212
- class="w-full"
213
- autofocus
111
+ <template #name-cell="{ row }">
112
+ <TableIdentity
113
+ :title="row.original.name"
114
+ :description="formDescription(row.original)"
214
115
  />
215
- </UFormField>
216
- <UFormField
217
- :label="t('admin.slug', 'Slug')"
218
- :description="t('admin.slugHint', 'Used in the public address.')"
219
- >
220
- <UInput
221
- v-model="draft.slug"
222
- class="w-full"
116
+ </template>
117
+ <template #status-cell="{ row }">
118
+ <UBadge
119
+ :color="statusColor(row.original.status, { archived: 'warning' })"
120
+ variant="subtle"
121
+ :label="t(`status.${row.original.status}`, row.original.status)"
223
122
  />
224
- </UFormField>
225
- </form>
226
- </template>
227
- <template #footer>
228
- <div class="flex w-full justify-end gap-2">
229
- <UButton
230
- variant="subtle"
231
- color="neutral"
232
- :label="t('admin.cancel', 'Cancel')"
233
- @click="creating = false"
234
- />
235
- <UButton
236
- type="submit"
237
- form="admin-form-create"
238
- :label="t('admin.create', 'Create')"
239
- :disabled="!draft.name || !draft.slug"
240
- />
241
- </div>
242
- </template>
243
- </UModal>
123
+ </template>
124
+ <template #updatedAt-cell="{ row }">
125
+ <span class="tabular-nums">{{ format.date(row.original.updatedAt) }}</span>
126
+ </template>
127
+ <template #actions-cell="{ row }">
128
+ <RowMenu
129
+ :items="menu(row.original)"
130
+ :subject="row.original.name"
131
+ />
132
+ </template>
133
+ <template #empty>
134
+ <UEmpty
135
+ variant="naked"
136
+ icon="i-lucide-clipboard-list"
137
+ :title="filtered ? t('admin.noMatches', 'No forms match.') : t('admin.empty', 'No forms yet')"
138
+ :description="filtered ? void 0 : t('admin.emptyDescription', 'Create a form to start collecting answers.')"
139
+ >
140
+ <template #actions>
141
+ <UButton
142
+ v-if="filtered"
143
+ color="neutral"
144
+ variant="outline"
145
+ :label="t('admin.clearFilters', 'Clear filters')"
146
+ @click="clearFilters"
147
+ />
148
+ <UButton
149
+ v-else
150
+ icon="i-lucide-plus"
151
+ :label="t('admin.new', 'New form')"
152
+ @click="actions.create()"
153
+ />
154
+ </template>
155
+ </UEmpty>
156
+ </template>
157
+ </UTable>
158
+ <template
159
+ v-if="forms?.length"
160
+ #footer
161
+ >
162
+ <span>{{ t("admin.showing", "{shown} of {total} forms", { shown: visibleForms.length, total: forms.length }) }}</span>
163
+ </template>
164
+ </TableCard>
165
+ </template>
244
166
 
245
167
  <UModal
246
168
  v-model:open="importing"
247
- :title="t('admin.import', 'Import')"
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' }"
248
172
  >
249
173
  <template #body>
250
- <div class="space-y-4">
251
- <p class="text-sm text-muted">
252
- {{ t("admin.importHint", "Paste one form or a list of forms (each with slug, name and definition). Forms whose slug already exists are skipped.") }}
253
- </p>
254
- <UTextarea
255
- v-model="importJson"
256
- :rows="12"
257
- class="w-full font-mono text-xs"
258
- />
259
- </div>
174
+ <UTextarea
175
+ v-model="importText"
176
+ :rows="12"
177
+ class="w-full font-mono text-xs"
178
+ />
260
179
  </template>
261
180
  <template #footer>
262
- <div class="flex w-full justify-end gap-2">
263
- <UButton
264
- variant="subtle"
265
- color="neutral"
266
- :label="t('admin.cancel', 'Cancel')"
267
- @click="importing = false"
268
- />
269
- <UButton
270
- :label="t('admin.import', 'Import')"
271
- :disabled="!importJson.trim()"
272
- @click="runImport"
273
- />
274
- </div>
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
+ />
275
194
  </template>
276
195
  </UModal>
277
- </AdminPage>
196
+ </div>
278
197
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  const route = useRoute();
3
3
  const t = useModuleT("forms");
4
+ const format = useFormat();
4
5
  const slug = computed(() => String(route.params.slug));
5
6
  const { data: access } = await useFormAccess(slug);
6
7
  if (access.value && (access.value.access.availability === "api" || access.value.access.auth === "token")) {
@@ -32,53 +33,54 @@ useHead({ title: computed(() => access.value?.name ?? "") });
32
33
  </script>
33
34
 
34
35
  <template>
35
- <UContainer class="max-w-2xl py-10">
36
- <h1
37
- v-if="access"
38
- class="mb-6 text-3xl font-bold"
39
- >
40
- {{ access.name }}
41
- </h1>
36
+ <UContainer>
37
+ <UPage>
38
+ <UPageHeader
39
+ v-if="access"
40
+ :title="access.name"
41
+ />
42
+ <UPageBody class="max-w-2xl">
43
+ <UAlert
44
+ v-if="access && !access.open"
45
+ icon="i-lucide-calendar-x"
46
+ color="neutral"
47
+ variant="subtle"
48
+ :title="t('schedule.closed', 'This form is closed.')"
49
+ :description="access.opensAt ? t('schedule.opensAt', 'It opens again {date}.', { date: format.dateTime(access.opensAt) }) : void 0"
50
+ />
42
51
 
43
- <UAlert
44
- v-if="access && !access.open"
45
- icon="i-lucide-calendar-x"
46
- color="neutral"
47
- variant="subtle"
48
- :title="t('schedule.closed', 'This form is closed.')"
49
- :description="access.opensAt ? t('schedule.opensAt', 'It opens again {date}.', { date: new Date(access.opensAt).toLocaleString() }) : void 0"
50
- />
52
+ <SectionCard v-else-if="gated && !unlocked">
53
+ <form
54
+ class="space-y-4"
55
+ @submit.prevent="unlock"
56
+ >
57
+ <UFormField
58
+ :label="t('access.passwordLabel', 'Password')"
59
+ :description="t('access.passwordHint', 'This form is protected. Enter the password you were given.')"
60
+ :error="wrong ? t('access.passwordWrong', 'Wrong password.') : void 0"
61
+ >
62
+ <UInput
63
+ v-model="password"
64
+ type="password"
65
+ autocomplete="off"
66
+ class="w-full"
67
+ />
68
+ </UFormField>
69
+ <UButton
70
+ type="submit"
71
+ :loading="checking"
72
+ :disabled="!password"
73
+ :label="t('access.unlock', 'Continue')"
74
+ />
75
+ </form>
76
+ </SectionCard>
51
77
 
52
- <UCard v-else-if="gated && !unlocked">
53
- <form
54
- class="space-y-4"
55
- @submit.prevent="unlock"
56
- >
57
- <UFormField
58
- :label="t('access.passwordLabel', 'Password')"
59
- :description="t('access.passwordHint', 'This form is protected. Enter the password you were given.')"
60
- :error="wrong ? t('access.passwordWrong', 'Wrong password.') : void 0"
61
- >
62
- <UInput
63
- v-model="password"
64
- type="password"
65
- autocomplete="off"
66
- class="w-full"
67
- />
68
- </UFormField>
69
- <UButton
70
- type="submit"
71
- :loading="checking"
72
- :disabled="!password"
73
- :label="t('access.unlock', 'Continue')"
78
+ <FormRenderer
79
+ v-else-if="!needsSignIn"
80
+ :slug="slug"
81
+ :password="unlocked || void 0"
74
82
  />
75
- </form>
76
- </UCard>
77
-
78
- <FormRenderer
79
- v-else-if="!needsSignIn"
80
- :slug="slug"
81
- :password="unlocked || void 0"
82
- />
83
+ </UPageBody>
84
+ </UPage>
83
85
  </UContainer>
84
86
  </template>
@@ -48,12 +48,6 @@
48
48
  "published": "Udgivet",
49
49
  "archived": "Arkiveret"
50
50
  },
51
- "delivery": {
52
- "none": "Ikke leveret",
53
- "pending": "Afventer",
54
- "delivered": "Leveret",
55
- "failed": "Levering fejlede"
56
- },
57
51
  "validation": {
58
52
  "required": "Feltet er påkrævet.",
59
53
  "email": "Indtast en gyldig e-mailadresse.",
@@ -74,19 +68,19 @@
74
68
  "admin": {
75
69
  "title": "Formularer",
76
70
  "new": "Ny formular",
77
- "import": "Importér",
71
+ "import": "Importér JSON",
78
72
  "edit": "Rediger",
79
73
  "create": "Opret",
80
74
  "copyLink": "Kopiér link",
81
75
  "linkCopied": "Link kopieret.",
82
76
  "settings": "Levering og lagring",
83
- "save": "Gem",
77
+ "save": "Gem formular",
84
78
  "saved": "Gemt.",
85
79
  "name": "Navn",
86
80
  "slug": "Slug",
87
81
  "slugHint": "Bruges i den offentlige adresse.",
88
82
  "status": "Status",
89
- "empty": "Ingen formularer endnu.",
83
+ "empty": "Ingen formularer endnu",
90
84
  "stepped": "Flere trin",
91
85
  "single": "Én side",
92
86
  "notStored": "Gemmes ikke",
@@ -109,7 +103,30 @@
109
103
  "builder": "Opbygning",
110
104
  "json": "JSON",
111
105
  "access": "Adgang",
112
- "clone": "Duplikér"
106
+ "clone": "Duplikér formular",
107
+ "createForm": "Opret formular",
108
+ "importSubmit": "Importér formularer",
109
+ "search": "Søg i formularer",
110
+ "statusFilter": "Filtrér efter status",
111
+ "allStatuses": "Alle statusser",
112
+ "clearFilters": "Ryd filtre",
113
+ "noMatches": "Ingen formularer matcher.",
114
+ "emptyDescription": "Opret en formular for at begynde at indsamle svar.",
115
+ "showing": "{shown} af {total} formularer",
116
+ "open": "Åbn",
117
+ "updated": "Opdateret",
118
+ "cloned": "Formular duplikeret",
119
+ "created": "Formular oprettet",
120
+ "archive": "Arkivér formular",
121
+ "archiveTitle": "Arkivér {name}?",
122
+ "archiveBody": "Den modtager ikke længere besvarelser og forsvinder fra det offentlige site. Besvarelserne beholdes.",
123
+ "archived": "Formular arkiveret",
124
+ "allForms": "Alle formularer",
125
+ "notFound": "Formularen findes ikke.",
126
+ "details": "Detaljer",
127
+ "submissionNotFound": "Besvarelsen findes ikke.",
128
+ "noSubmissionsHint": "Svar vises her, når nogen udfylder formularen.",
129
+ "redelivered": "Leveret igen"
113
130
  },
114
131
  "schedule": {
115
132
  "closed": "Formularen er lukket.",
@@ -175,6 +192,10 @@
175
192
  }
176
193
  },
177
194
  "delivery": {
195
+ "none": "Ikke leveret",
196
+ "pending": "Afventer",
197
+ "delivered": "Leveret",
198
+ "failed": "Levering fejlede",
178
199
  "title": "Levering og lagring",
179
200
  "storeTitle": "Gem besvarelser",
180
201
  "storeHint": "Gem besvarelser i databasen, så de kan ses under Besvarelser.",