Skip to content

Commit ae4593a

Browse files
committed
feat: faq sort via vuedraggable
feat: faq delete fix: faq create after edit bug
1 parent 3a75793 commit ae4593a

11 files changed

Lines changed: 209 additions & 51 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "faq_categories" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
3+
4+
-- AlterTable
5+
ALTER TABLE "faqs" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;

apps/api/prisma/schema/Faq.prisma

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
model FaqCategory {
2-
id String @id @default(uuid(7))
3-
name String
2+
id String @id @default(uuid(7))
3+
name String
4+
sortOrder Int @default(0)
45
56
unterveranstaltungId String
67
unterveranstaltung Unterveranstaltung @relation(fields: [unterveranstaltungId], references: [id])
@@ -11,9 +12,10 @@ model FaqCategory {
1112
}
1213

1314
model Faq {
14-
id String @id @default(uuid(7))
15-
question String
16-
answer String
15+
id String @id @default(uuid(7))
16+
question String
17+
answer String
18+
sortOrder Int @default(0)
1719
1820
categoryId String
1921
category FaqCategory @relation(fields: [categoryId], references: [id])

apps/api/src/services/faqs/faqCreateProcedure.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,22 @@ export const faqCreateProcedure = defineProtectedMutateProcedure({
1212
faq: faqSchema,
1313
}),
1414
handler: async ({ input: { faq, unterveranstaltungId } }) => {
15+
const [maxCategoryOrder, maxFaqOrder] = await Promise.all([
16+
prisma.faqCategory.aggregate({
17+
where: { unterveranstaltungId },
18+
_max: { sortOrder: true },
19+
}),
20+
prisma.faq.aggregate({
21+
where: { category: { unterveranstaltungId } },
22+
_max: { sortOrder: true },
23+
}),
24+
])
25+
1526
await prisma.faq.create({
1627
data: {
1728
question: faq.question,
1829
answer: faq.answer,
30+
sortOrder: (maxFaqOrder._max.sortOrder ?? -1) + 1,
1931
unterveranstaltung: {
2032
connect: {
2133
id: unterveranstaltungId,
@@ -26,6 +38,7 @@ export const faqCreateProcedure = defineProtectedMutateProcedure({
2638
create: {
2739
name: faq.category,
2840
unterveranstaltungId,
41+
sortOrder: (maxCategoryOrder._max.sortOrder ?? -1) + 1,
2942
},
3043
where: {
3144
name_unterveranstaltungId: {

apps/api/src/services/faqs/faqListProcedure.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,37 @@ export async function listFaqs(unterveranstaltungId: string) {
1313
},
1414
},
1515
},
16+
orderBy: {
17+
sortOrder: 'asc',
18+
},
1619
select: {
1720
id: true,
1821
question: true,
1922
answer: true,
23+
sortOrder: true,
2024
category: {
2125
select: {
26+
id: true,
2227
name: true,
28+
sortOrder: true,
2329
},
2430
},
2531
},
2632
})
2733

2834
const groups = groupBy(
29-
list.map((v) => ({ ...v, category: v.category.name })),
35+
list.map((v) => ({
36+
...v,
37+
category: v.category.name,
38+
categoryId: v.category.id,
39+
categorySortOrder: v.category.sortOrder,
40+
})),
3041
({ category }) => category
3142
)
3243

33-
return Object.fromEntries(Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)))
44+
return Object.fromEntries(
45+
Object.entries(groups).sort(([, a], [, b]) => (a[0]?.categorySortOrder ?? 0) - (b[0]?.categorySortOrder ?? 0))
46+
)
3447
}
3548

3649
export const faqListProcedure = defineProtectedQueryProcedure({
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import z from 'zod'
2+
3+
import prisma from '../../prisma.js'
4+
import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js'
5+
6+
export const faqReorderProcedure = defineProtectedMutateProcedure({
7+
key: 'reorder',
8+
roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'],
9+
inputSchema: z.strictObject({
10+
faqOrder: z.array(
11+
z.strictObject({
12+
id: z.string().uuid(),
13+
sortOrder: z.number().int().min(0),
14+
categoryId: z.string().uuid(),
15+
})
16+
),
17+
categoryOrder: z.array(
18+
z.strictObject({
19+
id: z.string().uuid(),
20+
sortOrder: z.number().int().min(0),
21+
})
22+
),
23+
}),
24+
handler: async ({ input: { faqOrder, categoryOrder } }) => {
25+
await prisma.$transaction([
26+
...faqOrder.map(({ id, sortOrder, categoryId }) =>
27+
prisma.faq.update({
28+
where: { id },
29+
data: { sortOrder, categoryId },
30+
})
31+
),
32+
...categoryOrder.map(({ id, sortOrder }) =>
33+
prisma.faqCategory.update({
34+
where: { id },
35+
data: { sortOrder },
36+
})
37+
),
38+
])
39+
},
40+
})

apps/api/src/services/faqs/faqs.router.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { faqCreateProcedure } from './faqCreateProcedure.js'
44
import { faqDeleteProcedure } from './faqDeleteProcecure.js'
55
import { faqCategorySearchProcedure, faqListProcedure } from './faqListProcedure.js'
66
import { faqUpdateProcedure } from './faqUpdateProcedure.js'
7+
import { faqReorderProcedure } from './faqReorderProcedure.js'
78

89
// Import Routes here - do not delete this line
910

@@ -12,6 +13,7 @@ export const faqsRouter = mergeRouters(
1213
faqCategorySearchProcedure,
1314
faqCreateProcedure,
1415
faqUpdateProcedure,
15-
faqDeleteProcedure
16+
faqDeleteProcedure,
17+
faqReorderProcedure
1618
// Add Routes here - do not delete this line
1719
)

apps/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"vaul-vue": "^0.4.1",
5252
"vue": "catalog:",
5353
"vue-router": "^4.2.5",
54-
"vue-sonner": "^1.3.0"
54+
"vue-sonner": "^1.3.0",
55+
"vuedraggable": "^4.1.0"
5556
},
5657
"devDependencies": {
5758
"@codeanker/eslint-config": "workspace:*",

apps/frontend/src/views/FAQs/FAQFormModal.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ async function onSubmit() {
7979
emit('success')
8080
}
8181
82+
async function onDelete() {
83+
if (!props.faq) return
84+
await apiClient.faq.delete.mutate(props.faq.id)
85+
modal.value?.hide()
86+
emit('success')
87+
}
88+
8289
defineExpose<ModalApi>({
8390
show() {
8491
modal.value?.show()
@@ -127,7 +134,16 @@ defineExpose<ModalApi>({
127134
/>
128135
</div>
129136

130-
<div class="flex justify-end col-span-2 mt-8">
137+
<div class="flex justify-between col-span-2 mt-8">
138+
<Button
139+
v-if="isEdit"
140+
type="button"
141+
color="danger"
142+
@click="onDelete"
143+
>
144+
Entfernen
145+
</Button>
146+
<span v-else />
131147
<Button type="submit"> Speichern </Button>
132148
</div>
133149
</ValidateForm>
Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,62 @@
11
<script setup lang="ts">
22
import { apiClient } from '@/api'
33
import Loading from '@/components/UIComponents/Loading.vue'
4+
import { Bars3Icon } from '@heroicons/vue/24/outline'
45
import { useAsyncState } from '@vueuse/core'
5-
import { computed, ref, useTemplateRef } from 'vue'
6+
import { computed, ref, useTemplateRef, watch } from 'vue'
7+
import draggable from 'vuedraggable'
68
import FAQFormModal, { type FAQ } from './FAQFormModal.vue'
9+
import { toast } from 'vue-sonner'
710
811
type Props = {
912
unterveranstaltungId: string
1013
}
1114
1215
const { unterveranstaltungId } = defineProps<Props>()
1316
14-
const { state, isLoading, execute } = useAsyncState(() => apiClient.faq.list.query({ unterveranstaltungId }), {})
15-
const hasKeys = computed(() => Object.keys(state.value).length > 0)
17+
const { state, isLoading, execute } = useAsyncState(
18+
async () => apiClient.faq.list.query({ unterveranstaltungId }),
19+
{},
20+
{ resetOnExecute: false }
21+
)
22+
23+
type Category = {
24+
name: string
25+
categoryId: string
26+
faqs: FAQ[]
27+
}
28+
const categories = ref<Category[]>([])
29+
watch(state, () => {
30+
categories.value = Object.entries(state.value).map(([name, faqs]) => ({
31+
name,
32+
categoryId: faqs[0]?.categoryId ?? '',
33+
faqs: [...faqs],
34+
}))
35+
})
36+
37+
const hasKeys = computed(() => categories.value.length > 0)
1638
1739
const formModal = useTemplateRef('formModal')
1840
const editFaq = ref<FAQ>()
1941
2042
function openFormModal(faq?: FAQ) {
21-
if (faq !== undefined) {
22-
editFaq.value = faq
23-
}
43+
editFaq.value = faq
2444
formModal.value?.show()
2545
}
2646
47+
async function saveOrder() {
48+
const faqOrder = categories.value.flatMap((cat) =>
49+
cat.faqs.map((faq, index) => ({ id: faq.id, sortOrder: index, categoryId: cat.categoryId }))
50+
)
51+
const categoryOrder = categories.value.map((cat, index) => ({
52+
id: cat.categoryId,
53+
sortOrder: index,
54+
}))
55+
await apiClient.faq.reorder.mutate({ faqOrder, categoryOrder })
56+
await execute()
57+
toast.success('FAQ-Reihenfolge gespeichert')
58+
}
59+
2760
defineExpose({
2861
openFormModal,
2962
})
@@ -37,27 +70,50 @@ defineExpose({
3770
@success="execute"
3871
/>
3972

40-
<Loading v-if="isLoading" />
73+
<Loading v-if="isLoading && !hasKeys" />
4174
<p v-else-if="!hasKeys">Hier wurden noch keine FAQs angelegt.</p>
4275

43-
<div
44-
v-for="(list, category) in state"
76+
<draggable
4577
v-else
46-
:key="category"
47-
class="space-y-4"
78+
v-model="categories"
79+
item-key="categoryId"
80+
handle=".category-handle"
81+
class="grid grid-cols-3 gap-8 items-start"
82+
@end="saveOrder"
4883
>
49-
<p class="text-gray-500 font-normal">{{ category }}</p>
50-
<div
51-
v-for="(faq, index) in list"
52-
:key="index"
53-
class="transition-all rounded-lg shadow hover:shadow-lg bg-primary-5 dark:bg-primary-950 p-2 select-none cursor-pointer"
54-
@click="() => openFormModal(faq)"
55-
>
56-
<span class="font-bold">{{ faq.question }}</span>
57-
<br />
58-
<!-- eslint-disable vue/no-v-html -->
59-
<div v-html="faq.answer" />
60-
<!-- eslint-enable vue/no-v-html -->
61-
</div>
62-
</div>
84+
<template #item="{ element: category }">
85+
<div class="space-y-4 mb-6">
86+
<div class="flex items-center gap-2">
87+
<Bars3Icon class="category-handle w-5 h-5 text-gray-400 cursor-grab active:cursor-grabbing" />
88+
<p class="text-gray-500 font-normal">{{ category.name }}</p>
89+
</div>
90+
<draggable
91+
v-model="category.faqs"
92+
item-key="id"
93+
handle=".faq-handle"
94+
group="faqs"
95+
class="space-y-4"
96+
@end="saveOrder"
97+
>
98+
<template #item="{ element: faq }">
99+
<div
100+
class="transition-all rounded-lg shadow hover:shadow-lg bg-primary-5 dark:bg-primary-950 p-2 select-none flex items-start gap-2"
101+
>
102+
<Bars3Icon class="faq-handle w-5 h-5 mt-0.5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
103+
<div
104+
class="cursor-pointer flex-1"
105+
@click="() => openFormModal(faq)"
106+
>
107+
<span class="font-bold">{{ faq.question }}</span>
108+
<br />
109+
<!-- eslint-disable vue/no-v-html -->
110+
<div v-html="faq.answer" />
111+
<!-- eslint-enable vue/no-v-html -->
112+
</div>
113+
</div>
114+
</template>
115+
</draggable>
116+
</div>
117+
</template>
118+
</draggable>
63119
</template>

apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -374,16 +374,16 @@ const anmeldeLinkCreateModal = useTemplateRef('anmeldeLinkCreateModal')
374374
</p>
375375
</div>
376376
<div class="flex items-center gap-x-4">
377-
<RouterLink
377+
<RouterLink
378378
class="text-primary-600 flex items-center gap-x-1"
379-
:to="{
380-
name: 'Unterveranstaltung Custom Field erstellen',
381-
params: { veranstaltungId: route.params.veranstaltungId },
382-
}"
383-
>
379+
:to="{
380+
name: 'Unterveranstaltung Custom Field erstellen',
381+
params: { veranstaltungId: route.params.veranstaltungId },
382+
}"
383+
>
384384
<PlusIcon class="size-4" />
385385
<span>Neues Feld</span>
386-
</RouterLink>
386+
</RouterLink>
387387
<RouterLink
388388
class="text-primary-600 flex items-center gap-x-1"
389389
:to="{
@@ -416,13 +416,11 @@ const anmeldeLinkCreateModal = useTemplateRef('anmeldeLinkCreateModal')
416416
<div class="flex-1"></div>
417417
<Button @click="() => faqList?.openFormModal()"> Frage anlegen </Button>
418418
</div>
419-
<div class="grid grid-cols-3 gap-8">
420-
<FAQList
421-
v-if="unterveranstaltung"
422-
ref="faqList"
423-
:unterveranstaltung-id="unterveranstaltung.id"
424-
/>
425-
</div>
419+
<FAQList
420+
v-if="unterveranstaltung"
421+
ref="faqList"
422+
:unterveranstaltung-id="unterveranstaltung.id"
423+
/>
426424
<hr class="my-10" />
427425
</Tab>
428426

0 commit comments

Comments
 (0)