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

@@ -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
}
})