feat(#21): Replaced chat comment component with proper cursor-based commenting

This commit is contained in:
2025-12-25 18:03:31 +01:00
parent 7f7852a66a
commit e472a5715d
27 changed files with 968 additions and 244 deletions

View File

@@ -32,35 +32,58 @@
:form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/>
</div>
<div
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
]"
>
<UDropdownMenu
:items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
<div class="flex items-start gap-1">
<div class="min-w-9">
<UButton
v-if="
applicationFormId &&
formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
"
color="neutral"
variant="soft"
size="xs"
icon="i-lucide-message-square"
class="w-full justify-center"
@click="toggleComments(formElementItem.formElement.id)"
>
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
</UButton>
</div>
<div
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
<UDropdownMenu
:items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div>
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
@@ -89,14 +112,18 @@ const emit = defineEmits<{
}>()
const commentStore = useCommentStore()
const { load: loadComments } = commentStore
const { comments } = storeToRefs(commentStore)
const logger = useLogger().withTag('FormEngine')
const { loadInitial: loadCommentsInitial } = commentStore
const { commentsByApplicationFormId, countsByApplicationFormId } = storeToRefs(commentStore)
if (props.applicationFormId) {
logger.debug('Loading comments for application form:', props.applicationFormId)
await loadComments(props.applicationFormId)
}
const comments = computed(() => {
if (!props.applicationFormId) return {}
return commentsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const commentCounts = computed(() => {
if (!props.applicationFormId) return {}
return countsByApplicationFormId.value[props.applicationFormId] ?? {}
})
const route = useRoute()
const activeFormElement = ref('')
@@ -189,12 +216,15 @@ function updateFormOptions(formOptions: FormOptionDto[], target: VisibleFormElem
emit('update:modelValue', updatedModelValue)
}
function toggleComments(formElementId: string) {
async function toggleComments(formElementId: string) {
if (activeFormElement.value === formElementId) {
activeFormElement.value = ''
return
}
activeFormElement.value = formElementId
if (props.applicationFormId) {
await loadCommentsInitial(props.applicationFormId, formElementId)
}
emit('click:comments', formElementId)
}

View File

@@ -1,67 +1,356 @@
<template>
<template v-if="comments && comments.length > 0">
<UChatMessages :auto-scroll="false" :should-scroll-to-bottom="false">
<UChatMessage
v-for="comment in comments"
:id="comment.id"
:key="comment.id"
:avatar="{ icon: 'i-lucide-bot' }"
:content="comment.message"
role="user"
:parts="[{ type: 'text', text: comment.message }]"
:side="isCommentByUser(comment) ? 'right' : 'left'"
variant="subtle"
:actions="createChatMessageActions(comment)"
>
<template>
<div class="flex flex-col">
<UAvatar icon="i-lucide-bot" />
<p class="text-sm">{{ comment.createdBy.name }}</p>
<div class="mt-4 lg:mt-6">
<UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
<template #header>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-highlighted">
{{ $t('comments.title') }}
</p>
<p class="text-xs text-muted">
{{ $t('comments.count', { count: commentCount }) }}
</p>
</div>
</template>
</UChatMessage>
</UChatMessages>
</template>
<UTextarea v-model="commentTextAreaValue" class="w-full" />
<UButton v-if="!isEditingComment" class="my-3 lg:my-4" @click="submitComment(formElementId)">
{{ $t('comments.submit') }}
</UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="updateEditComment"> {{ $t('comments.edit') }} </UButton>
<UButton v-if="isEditingComment" class="my-3 lg:my-4" @click="cancelEditComment"> {{ $t('common.cancel') }} </UButton>
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
</div>
</template>
<div v-if="comments && comments.length > 0" class="relative">
<UProgress
v-if="isLoadingMore"
indeterminate
size="xs"
class="absolute top-0 inset-x-0 z-10"
:ui="{ base: 'bg-default' }"
/>
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<UAvatar icon="i-lucide-user" size="2xs" />
<div class="min-w-0">
<p class="text-sm text-highlighted truncate">
{{ comment.createdBy.name }}
</p>
<p class="text-xs text-muted">
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UDropdownMenu
v-if="isCommentByUser(comment)"
:items="[createCommentActions(comment)]"
:content="{ align: 'end' }"
>
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
</UDropdownMenu>
</div>
</div>
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<!-- Edit comment form -->
<template v-if="editingCommentId === comment.id">
<UEditor
v-slot="{ editor }"
v-model="editingCommentEditorContent"
content-type="json"
:editable="true"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
/>
</UEditor>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
<UButton color="neutral" variant="outline" @click="cancelEditComment">
{{ $t('common.cancel') }}
</UButton>
<UButton @click="saveEditedComment(comment)">
{{ $t('comments.saveChanges') }}
</UButton>
</div>
</template>
<!-- Display comment content when not editing -->
<UEditor
v-else
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
:model-value="getCommentEditorModelValue(comment)"
content-type="json"
:editable="false"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'p-3 bg-transparent'
}"
/>
</div>
</div>
</UScrollArea>
</div>
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
<template #footer>
<div class="space-y-3">
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
<UEditor
v-slot="{ editor }"
v-model="newCommentEditorContent"
content-type="json"
:editable="true"
:placeholder="$t('comments.placeholder')"
:ui="{
root: 'bg-transparent',
content: 'bg-transparent',
base: 'min-h-[120px] p-3 bg-transparent'
}"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
/>
</UEditor>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
<UButton @click="submitComment(formElementId)">
{{ $t('comments.submit') }}
</UButton>
</div>
</div>
</template>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { CommentDto } from '~~/.api-client'
import { useCommentTextarea } from '~/composables/comment/useCommentTextarea'
import type { JSONContent } from '@tiptap/vue-3'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useInfiniteScroll } from '@vueuse/core'
import { useCommentStore } from '~~/stores/useCommentStore'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{
formElementId: string
applicationFormId: string
comments?: CommentDto[]
totalCount?: number
}>()
const commentActions = useCommentTextarea(props.applicationFormId)
const {
submitComment,
updateEditComment,
cancelEditComment,
editComment,
isEditingComment,
isCommentByUser,
commentTextAreaValue
} = commentActions
defineEmits<{
(e: 'close'): void
}>()
function createChatMessageActions(comment: CommentDto) {
const { t: $t } = useI18n()
const chatMessageActions = []
const commentStore = useCommentStore()
const { loadMore } = commentStore
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const toast = useToast()
const { t: $t } = useI18n()
if (isCommentByUser(comment)) {
chatMessageActions.push({
const scrollAreaRef = useTemplateRef('scrollAreaRef')
const scrollContainerEl = ref<HTMLElement | null>(null)
const commentCount = computed(() => props.totalCount ?? props.comments?.length ?? 0)
const commentCursorSate = computed(
() => commentStore.nextCursorByApplicationFormId[props.applicationFormId]?.[props.formElementId]
)
const canLoadMore = computed(() => commentCursorSate.value?.hasMore === true)
const isLoadingMore = computed(() => commentCursorSate.value?.isLoading === true)
const newCommentValue = ref<string>('')
const newCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(newCommentValue.value),
set: (newValue) => {
newCommentValue.value = JSON.stringify(newValue)
}
})
const editingCommentId = ref<string | null>(null)
const editingCommentValue = ref<string>('')
const editingCommentEditorContent = computed<JSONContent>({
get: () => toEditorJson(editingCommentValue.value),
set: (newValue) => {
editingCommentValue.value = JSON.stringify(newValue)
}
})
watch(
() => scrollAreaRef.value,
async (scrollAreaComponent) => {
if (!scrollAreaComponent) {
scrollContainerEl.value = null
return
}
await nextTick()
const rootEl = scrollAreaComponent.$el as HTMLElement | undefined
if (!rootEl) return
scrollContainerEl.value = rootEl
// Wait another tick for content to be measured, then scroll to bottom
await nextTick()
// Use requestAnimationFrame to ensure layout is complete
requestAnimationFrame(() => {
rootEl.scrollTop = rootEl.scrollHeight
})
},
{ immediate: true }
)
useInfiniteScroll(
scrollContainerEl,
async () => {
const scrollEl = scrollContainerEl.value
if (!scrollEl) return
const previousScrollHeight = scrollEl.scrollHeight
await loadMore(props.applicationFormId, props.formElementId)
// Maintain scroll position after prepending older comments
await nextTick()
const newScrollHeight = scrollEl.scrollHeight
scrollEl.scrollTop = newScrollHeight - previousScrollHeight + scrollEl.scrollTop
},
{
direction: 'top',
distance: 100,
canLoadMore: () => canLoadMore.value && !isLoadingMore.value
}
)
async function submitComment(formElementId: string) {
if (!newCommentValue.value.trim()) {
return
}
try {
await commentStore.createComment(props.applicationFormId, formElementId, { message: newCommentValue.value })
newCommentValue.value = ''
toast.add({ title: $t('comments.created'), color: 'success' })
} catch {
toast.add({ title: $t('comments.createError'), color: 'error' })
}
}
function startEditComment(comment: CommentDto) {
editingCommentId.value = comment.id
editingCommentValue.value = comment.message || ''
}
function cancelEditComment() {
editingCommentId.value = null
editingCommentValue.value = ''
}
async function saveEditedComment(comment: CommentDto) {
try {
const updatedComment: CommentDto = {
...comment,
message: editingCommentValue.value,
modifiedAt: new Date()
}
await commentStore.updateComment(comment.id, updatedComment)
cancelEditComment()
toast.add({ title: $t('comments.updated'), color: 'success' })
} catch {
toast.add({ title: $t('comments.updateError'), color: 'error' })
}
}
function isCommentByUser(comment: CommentDto) {
return comment.createdBy.keycloakId === user.value?.keycloakId
}
function createCommentActions(comment: CommentDto): DropdownMenuItem[] {
return [
{
label: $t('comments.editAction'),
icon: 'i-lucide-pencil',
onClick: () => editComment(comment)
})
}
return chatMessageActions
onClick: () => startEditComment(comment)
}
]
}
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
return toEditorJson(comment.message)
}
function toEditorJson(rawValue: string | undefined): JSONContent {
const raw = (rawValue ?? '').trim()
if (raw) {
try {
if (raw.startsWith('{')) {
return JSON.parse(raw) as JSONContent
}
} catch {
// fall through to plain text
}
return wrapPlainTextAsDoc(raw)
}
return wrapPlainTextAsDoc('')
}
function wrapPlainTextAsDoc(text: string): JSONContent {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: text
? [
{
type: 'text',
text
}
]
: []
}
]
}
}
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[
{ kind: 'blockquote', icon: 'i-lucide-quote' },
{ kind: 'codeBlock', icon: 'i-lucide-code' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script>

View File

@@ -1,4 +1,11 @@
import { CommentApi, Configuration, type CommentDto, type CreateCommentDto, type PagedCommentDto } from '~~/.api-client'
import {
CommentApi,
Configuration,
type ApplicationFormCommentCountsDto,
type CommentDto,
type CreateCommentDto,
type CursorPagedCommentDto
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
@@ -22,8 +29,24 @@ export function useCommentApi() {
return commentApiClient.createComment({ applicationFormId, formElementId, createCommentDto })
}
async function getCommentsByApplicationFormId(applicationFormId: string): Promise<PagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({ applicationFormId })
async function getCommentsByApplicationFormId(
applicationFormId: string,
formElementId?: string,
cursorCreatedAt?: Date,
limit: number = 10
): Promise<CursorPagedCommentDto> {
return commentApiClient.getCommentsByApplicationFormId({
applicationFormId,
formElementId,
cursorCreatedAt,
limit
})
}
async function getGroupedCommentCountByApplicationFromId(
applicationFormId: string
): Promise<ApplicationFormCommentCountsDto> {
return commentApiClient.getGroupedCommentCountByApplicationFromId({ applicationFormId })
}
async function updateComment(id: string, commentDto: CommentDto): Promise<CommentDto> {
@@ -37,6 +60,7 @@ export function useCommentApi() {
return {
createComment,
getCommentsByApplicationFormId,
getGroupedCommentCountByApplicationFromId,
updateComment,
deleteCommentById
}

View File

@@ -57,10 +57,12 @@ import {
import type { FormElementId } from '~~/types/formElement'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import { useUserStore } from '~~/stores/useUserStore'
import { useCommentStore } from '~~/stores/useCommentStore'
const route = useRoute()
const toast = useToast()
const { t: $t } = useI18n()
const commentStore = useCommentStore()
definePageMeta({
// Prevent whole page from re-rendering when navigating between sections to keep state
@@ -74,6 +76,10 @@ const {
updateApplicationForm
} = await useApplicationFormNavigation(applicationFormId!)
if (applicationFormId) {
await commentStore.loadCounts(applicationFormId)
}
const { updateApplicationForm: updateForm, submitApplicationForm } = useApplicationForm()
const { validateFormElements, getHighestComplianceStatus } = useApplicationFormValidator()
const { evaluateVisibility } = useFormElementVisibility()

View File

@@ -62,6 +62,18 @@
<p class="text-xs text-muted mt-1">#{{ index }}</p>
</div>
<div class="flex items-center gap-2">
<UTooltip
v-if="(applicationFormElem.commentCount ?? 0) > 0"
:text="$t('comments.count', { count: applicationFormElem.commentCount ?? 0 })"
>
<UBadge
:label="applicationFormElem.commentCount ?? 0"
color="neutral"
variant="subtle"
icon="i-lucide-message-square"
size="sm"
/>
</UTooltip>
<UBadge
v-if="applicationFormElem.status"
:label="applicationFormElem.status"

View File

@@ -91,9 +91,13 @@
"comments": {
"title": "Kommentare",
"empty": "Keine Kommentare vorhanden",
"count": "{count} Kommentare",
"placeholder": "Kommentar hinzufügen...",
"loadMore": "Mehr laden",
"submit": "Absenden",
"edit": "Kommentar bearbeiten",
"editAction": "Bearbeiten",
"saveChanges": "Änderungen speichern",
"created": "Kommentar erfolgreich erstellt",
"createError": "Fehler beim Erstellen des Kommentars",
"updated": "Kommentar erfolgreich aktualisiert",

View File

@@ -91,9 +91,13 @@
"comments": {
"title": "Comments",
"empty": "No comments available",
"count": "{count} comments",
"placeholder": "Add a comment...",
"loadMore": "Load more",
"submit": "Submit",
"edit": "Edit Comment",
"editAction": "Edit",
"saveChanges": "Save changes",
"created": "Comment created successfully",
"createError": "Error creating comment",
"updated": "Comment updated successfully",

View File

@@ -1,47 +1,80 @@
import { defineStore } from 'pinia'
import { type CreateCommentDto, type CommentDto, ResponseError } from '~~/.api-client'
import { type CreateCommentDto, type CommentDto, ResponseError, type CursorPagedCommentDto } from '~~/.api-client'
import { useCommentApi } from '~/composables/comment/useCommentApi'
import { useLogger } from '../app/composables/useLogger'
type ApplicationFormId = string
type FormElementId = string
export const useCommentStore = defineStore('Comment', () => {
type FormElementId = string
const nextCursorByApplicationFormId = ref<
Record<
ApplicationFormId,
Record<FormElementId, { nextCursorCreatedAt: Date | null; hasMore: boolean; isLoading: boolean }>
>
>({})
const commentsByApplicationFormId = ref<Record<ApplicationFormId, Record<FormElementId, CommentDto[]>>>({})
const countsByApplicationFormId = ref<Record<ApplicationFormId, Record<FormElementId, number>>>({})
const commentApi = useCommentApi()
const comments = ref<Record<FormElementId, CommentDto[]>>({})
const loadedForms = ref(new Set<string>())
const loadedFormElementComments = ref(new Set<string>())
const logger = useLogger().withTag('commentStore')
async function load(applicationFormId: string) {
if (loadedForms.value.has(applicationFormId)) return
const { data, error } = await useAsyncData(`comments:${applicationFormId}`, () =>
commentApi.getCommentsByApplicationFormId(applicationFormId)
)
if (error.value) {
logger.error('Failed loading comments:', error.value)
async function loadInitial(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
initializeCursorState(applicationFormId, formElementId)
const cacheKey = `${applicationFormId}:${formElementId}`
if (loadedFormElementComments.value.has(cacheKey)) return
nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = true
try {
const page = await commentApi.getCommentsByApplicationFormId(applicationFormId, formElementId, undefined, 10)
upsertComments(applicationFormId, formElementId, page, { prepend: false })
} catch (e) {
nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!.isLoading = false
logger.error('Failed loading initial comments:', e)
return
}
comments.value =
data.value?.content.reduce((acc: Record<FormElementId, CommentDto[]>, comment: CommentDto) => {
const formElementId = comment.formElementId
if (!acc[formElementId]) {
acc[formElementId] = []
}
acc[formElementId].push(comment)
return acc
}, {}) || {}
loadedForms.value.add(applicationFormId)
loadedFormElementComments.value.add(cacheKey)
}
async function loadMore(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
initializeCursorState(applicationFormId, formElementId)
const state = nextCursorByApplicationFormId.value[applicationFormId]![formElementId]!
if (state.isLoading || !state.hasMore) return
state.isLoading = true
const cursor = state.nextCursorCreatedAt ?? undefined
try {
const page = await commentApi.getCommentsByApplicationFormId(applicationFormId, formElementId, cursor, 10)
// Prepend older comments when loading more (scroll up)
upsertComments(applicationFormId, formElementId, page, { prepend: true })
} catch (e) {
state.isLoading = false
logger.error('Failed loading more comments:', e)
return Promise.reject(e)
}
}
async function createComment(
applicationFormId: string,
applicationFormId: ApplicationFormId,
formElementId: string,
createCommentDto: CreateCommentDto
): Promise<CommentDto> {
try {
const newComment = await commentApi.createComment(applicationFormId, formElementId, createCommentDto)
if (!comments.value[formElementId]) {
comments.value[formElementId] = []
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId] ?? {}
if (!commentsByFormElement[formElementId]) {
commentsByFormElement[formElementId] = []
}
comments.value[formElementId].push(newComment)
commentsByFormElement[formElementId].push(newComment)
commentsByApplicationFormId.value[applicationFormId] = commentsByFormElement
const currentCounts = countsByApplicationFormId.value[applicationFormId] ?? {}
currentCounts[formElementId] = (currentCounts[formElementId] ?? 0) + 1
countsByApplicationFormId.value[applicationFormId] = currentCounts
return newComment
} catch (e: unknown) {
if (e instanceof ResponseError) {
@@ -60,14 +93,18 @@ export const useCommentStore = defineStore('Comment', () => {
try {
const updatedComment = await commentApi.updateComment(id, commentDto)
const formElementId = updatedComment.formElementId
const formElementComments = comments.value?.[formElementId]
const commentsByFormElement = commentsByApplicationFormId.value[updatedComment.applicationFormId]
const formElementComments = commentsByFormElement?.[updatedComment.formElementId]
// Update the comment in the store
if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1 && formElementComments[index]) {
formElementComments[index] = updatedComment
}
}
return updatedComment
} catch (e: unknown) {
if (e instanceof ResponseError) {
@@ -82,13 +119,33 @@ export const useCommentStore = defineStore('Comment', () => {
async function deleteCommentById(id: string): Promise<void> {
try {
await commentApi.deleteCommentById(id)
for (const formElementId in comments.value) {
const formElementComments = comments.value[formElementId]
if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1) {
formElementComments.splice(index, 1)
break
// Remove the comment from the store
for (const applicationFormId in commentsByApplicationFormId.value) {
const commentsByFormElement = commentsByApplicationFormId.value[applicationFormId]
if (!commentsByFormElement) continue
for (const formElementId in commentsByFormElement) {
const formElementComments = commentsByFormElement[formElementId]
if (formElementComments) {
const index = formElementComments.findIndex((comment) => comment.id === id)
if (index !== -1) {
// Remove the comment from the array
formElementComments.splice(index, 1)
const commentCountsByFormElement = countsByApplicationFormId.value[applicationFormId]
// Decrement the comment count for the form element
if (commentCountsByFormElement && commentCountsByFormElement[formElementId] != null) {
commentCountsByFormElement[formElementId] = Math.max(
0,
(commentCountsByFormElement[formElementId] ?? 0) - 1
)
}
return
}
}
}
}
@@ -102,5 +159,67 @@ export const useCommentStore = defineStore('Comment', () => {
}
}
return { load, createComment, updateComment, deleteCommentById, comments }
function upsertComments(
applicationFormId: ApplicationFormId,
formElementId: FormElementId,
page: CursorPagedCommentDto,
options: { prepend: boolean }
) {
const applicationFormComments = commentsByApplicationFormId.value[applicationFormId] ?? {}
const formElementComments = applicationFormComments[formElementId] ?? (applicationFormComments[formElementId] = [])
const newComments = page.content.filter((newComment) => !formElementComments.some((c) => c.id === newComment.id))
if (options.prepend) {
// Prepend older comments at the beginning (they come in DESC order, so reverse to maintain chronological order)
formElementComments.unshift(...newComments.reverse())
} else {
// Initial load: comments come in DESC order (newest first), reverse for display (oldest at top, newest at bottom)
formElementComments.push(...newComments.reverse())
}
commentsByApplicationFormId.value[applicationFormId] = applicationFormComments
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: page.nextCursorCreatedAt ?? null,
hasMore: page.hasMore,
isLoading: false
}
}
function initializeCursorState(applicationFormId: ApplicationFormId, formElementId: FormElementId) {
if (!nextCursorByApplicationFormId.value[applicationFormId]) {
nextCursorByApplicationFormId.value[applicationFormId] = {}
}
if (!nextCursorByApplicationFormId.value[applicationFormId]![formElementId]) {
nextCursorByApplicationFormId.value[applicationFormId]![formElementId] = {
nextCursorCreatedAt: null,
hasMore: true,
isLoading: false
}
}
}
async function loadCounts(applicationFormId: ApplicationFormId) {
try {
const result = await commentApi.getGroupedCommentCountByApplicationFromId(applicationFormId)
countsByApplicationFormId.value[applicationFormId] = Object.fromEntries(
Object.entries(result.counts ?? {}).map(([formElementId, count]) => [formElementId, Number(count)])
)
} catch (e) {
logger.error('Failed loading comment counts:', e)
}
}
return {
loadCounts,
loadInitial,
loadMore,
createComment,
updateComment,
deleteCommentById,
commentsByApplicationFormId,
nextCursorByApplicationFormId,
countsByApplicationFormId
}
})