feat(#21): Replaced chat comment component with proper cursor-based commenting
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,6 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
### 2025-11-23 - Pipeline
|
||||
## 2025-12-26 - Date/Time Handling & Comment Pagination
|
||||
|
||||
### Problem
|
||||
- Comment infinite scroll pagination failed: older comments weren't loading
|
||||
- Root cause: Backend used `LocalDateTime` (no timezone info), frontend's JavaScript `Date` interpreted it as local time, then converted to UTC when sending back - causing a 1-hour timezone shift
|
||||
|
||||
### Solution
|
||||
- Migrated all date fields from `LocalDateTime` to `Instant` across all entities (Comment, ApplicationForm, Notification, User, ApplicationFormVersion)
|
||||
- `Instant` represents a point in time in UTC and is:
|
||||
- Supported by JPA's `@CreatedDate`/`@LastModifiedDate` annotations (unlike `OffsetDateTime`)
|
||||
- Serialized by Jackson as ISO-8601 with `Z` suffix (e.g., `2025-12-26T08:13:48.608921Z`)
|
||||
- Correctly parsed by JavaScript as UTC, ensuring round-trip consistency
|
||||
|
||||
## 2025-11-23 - Pipeline
|
||||
- Gitea pipeline seems not to be stable enough yet. Jobs don't get assigned runners consistently. Especially the backend job.
|
||||
- Switch to local nektos act runner also led to issues
|
||||
- metadata-action not working properly: https://github.com/docker/metadata-action/issues/542
|
||||
|
||||
@@ -646,13 +646,67 @@ paths:
|
||||
operationId: getCommentsByApplicationFormId
|
||||
tags:
|
||||
- comment
|
||||
parameters:
|
||||
- in: query
|
||||
name: formElementId
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: If provided, only comments for this form element are returned.
|
||||
- in: query
|
||||
name: cursorCreatedAt
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Cursor for pagination (createdAt of the last received comment). When omitted, returns the first page.
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
default: 10
|
||||
minimum: 1
|
||||
maximum: 50
|
||||
description: Number of comments to return.
|
||||
responses:
|
||||
"200":
|
||||
description: Get comments for application form ID
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PagedCommentDto"
|
||||
$ref: "#/components/schemas/CursorPagedCommentDto"
|
||||
"400":
|
||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||
"503":
|
||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
|
||||
|
||||
/application-forms/{applicationFormId}/comments/counts:
|
||||
parameters:
|
||||
- name: applicationFormId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
summary: Get comment counts grouped by form element for an application form
|
||||
operationId: getGroupedCommentCountByApplicationFromId
|
||||
tags:
|
||||
- comment
|
||||
responses:
|
||||
"200":
|
||||
description: Map of formElementId to comment count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApplicationFormCommentCountsDto"
|
||||
"400":
|
||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||
"401":
|
||||
@@ -1132,6 +1186,12 @@ components:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApplicationFormStatus"
|
||||
nullable: true
|
||||
commentCount:
|
||||
type: integer
|
||||
format: int64
|
||||
nullable: true
|
||||
readOnly: true
|
||||
description: Total number of comments associated with this application form.
|
||||
|
||||
PagedApplicationFormDto:
|
||||
type: object
|
||||
@@ -1571,17 +1631,36 @@ components:
|
||||
message:
|
||||
type: string
|
||||
|
||||
PagedCommentDto:
|
||||
CursorPagedCommentDto:
|
||||
type: object
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Page"
|
||||
required:
|
||||
- content
|
||||
- hasMore
|
||||
properties:
|
||||
content:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CommentDto"
|
||||
nextCursorCreatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Cursor to fetch the next page (createdAt of the last item in this page). Null when no more pages.
|
||||
hasMore:
|
||||
type: boolean
|
||||
description: Whether more comments exist after this page.
|
||||
|
||||
ApplicationFormCommentCountsDto:
|
||||
type: object
|
||||
required:
|
||||
- counts
|
||||
properties:
|
||||
counts:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Keys are formElementId (UUID), values are comment counts.
|
||||
|
||||
####### RoleDto #######
|
||||
RoleDto:
|
||||
|
||||
@@ -90,8 +90,8 @@ task generate_legalconsenthub_server(type: org.openapitools.generator.gradle.plu
|
||||
enumPropertyNaming: 'original',
|
||||
interfaceOnly : 'true',
|
||||
useSpringBoot3 : 'true']
|
||||
typeMappings = [DateTime: "LocalDateTime"]
|
||||
importMappings = [LocalDateTime: "java.time.LocalDateTime"]
|
||||
typeMappings = [DateTime: "Instant"]
|
||||
importMappings = [Instant: "java.time.Instant"]
|
||||
}
|
||||
|
||||
compileKotlin.dependsOn(tasks.generate_legalconsenthub_server)
|
||||
|
||||
@@ -18,7 +18,7 @@ import jakarta.persistence.OneToMany
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -47,8 +47,8 @@ class ApplicationForm(
|
||||
var lastModifiedBy: User,
|
||||
@CreatedDate
|
||||
@Column(nullable = false)
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
var modifiedAt: LocalDateTime? = null,
|
||||
var modifiedAt: Instant? = null,
|
||||
)
|
||||
|
||||
@@ -33,9 +33,12 @@ class ApplicationFormController(
|
||||
)
|
||||
override fun getAllApplicationForms(organizationId: String?): ResponseEntity<PagedApplicationFormDto> =
|
||||
ResponseEntity.ok(
|
||||
pagedApplicationFormMapper.toPagedApplicationFormDto(
|
||||
applicationFormService.getApplicationForms(organizationId),
|
||||
),
|
||||
applicationFormService.getApplicationFormsWithCommentCounts(organizationId).let { result ->
|
||||
pagedApplicationFormMapper.toPagedApplicationFormDto(
|
||||
result.page,
|
||||
result.commentCountByApplicationFormId,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@PreAuthorize(
|
||||
|
||||
@@ -19,8 +19,9 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSna
|
||||
import org.springframework.stereotype.Service
|
||||
import org.thymeleaf.TemplateEngine
|
||||
import org.thymeleaf.context.Context
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto
|
||||
@@ -39,7 +40,7 @@ class ApplicationFormFormatService(
|
||||
|
||||
fun generatePdf(
|
||||
snapshot: ApplicationFormSnapshotDto,
|
||||
createdAt: LocalDateTime?,
|
||||
createdAt: Instant?,
|
||||
): ByteArray {
|
||||
val latexContent = generateLatex(snapshot, createdAt)
|
||||
return pdfRenderer.render(latexContent)
|
||||
@@ -58,7 +59,7 @@ class ApplicationFormFormatService(
|
||||
|
||||
fun generateLatex(
|
||||
snapshot: ApplicationFormSnapshotDto,
|
||||
createdAt: LocalDateTime?,
|
||||
createdAt: Instant?,
|
||||
): String {
|
||||
val filteredSnapshot = filterVisibleElements(snapshot)
|
||||
val exportModel = buildLatexExportModel(filteredSnapshot, createdAt)
|
||||
@@ -88,7 +89,7 @@ class ApplicationFormFormatService(
|
||||
organizationId = LatexEscaper.escape(applicationForm.organizationId),
|
||||
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
|
||||
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
|
||||
createdAt = applicationForm.createdAt?.format(dateFormatter) ?: "",
|
||||
createdAt = applicationForm.createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
|
||||
sections =
|
||||
applicationForm.formElementSections.map { section ->
|
||||
LatexSection(
|
||||
@@ -116,7 +117,7 @@ class ApplicationFormFormatService(
|
||||
|
||||
private fun buildLatexExportModel(
|
||||
snapshot: ApplicationFormSnapshotDto,
|
||||
createdAt: LocalDateTime?,
|
||||
createdAt: Instant?,
|
||||
): LatexExportModel {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
@@ -126,7 +127,7 @@ class ApplicationFormFormatService(
|
||||
organizationId = LatexEscaper.escape(snapshot.organizationId),
|
||||
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${snapshot.organizationId}"),
|
||||
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${snapshot.organizationId}"),
|
||||
createdAt = createdAt?.format(dateFormatter) ?: "",
|
||||
createdAt = createdAt?.atZone(ZoneId.of("Europe/Berlin"))?.format(dateFormatter) ?: "",
|
||||
sections =
|
||||
snapshot.sections.map { section ->
|
||||
LatexSection(
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserMapper
|
||||
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
@@ -16,6 +16,12 @@ class ApplicationFormMapper(
|
||||
private val userService: UserService,
|
||||
) {
|
||||
fun toApplicationFormDto(applicationForm: ApplicationForm): ApplicationFormDto =
|
||||
toApplicationFormDto(applicationForm, null)
|
||||
|
||||
fun toApplicationFormDto(
|
||||
applicationForm: ApplicationForm,
|
||||
commentCount: Long?,
|
||||
): ApplicationFormDto =
|
||||
ApplicationFormDto(
|
||||
id = applicationForm.id,
|
||||
name = applicationForm.name,
|
||||
@@ -30,9 +36,10 @@ class ApplicationFormMapper(
|
||||
organizationId = applicationForm.organizationId,
|
||||
createdBy = userMapper.toUserDto(applicationForm.createdBy),
|
||||
lastModifiedBy = userMapper.toUserDto(applicationForm.lastModifiedBy),
|
||||
createdAt = applicationForm.createdAt ?: LocalDateTime.now(),
|
||||
modifiedAt = applicationForm.modifiedAt ?: LocalDateTime.now(),
|
||||
createdAt = applicationForm.createdAt ?: Instant.now(),
|
||||
modifiedAt = applicationForm.modifiedAt ?: Instant.now(),
|
||||
status = applicationForm.status,
|
||||
commentCount = commentCount,
|
||||
)
|
||||
|
||||
fun toNewApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form_version.ApplicationFormVersionService
|
||||
import com.betriebsratkanzlei.legalconsenthub.comment.CommentRepository
|
||||
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormCreatedEvent
|
||||
import com.betriebsratkanzlei.legalconsenthub.email.ApplicationFormSubmittedEvent
|
||||
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormInvalidStateException
|
||||
@@ -22,6 +23,11 @@ import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
data class ApplicationFormPageWithCommentCounts(
|
||||
val page: Page<ApplicationForm>,
|
||||
val commentCountByApplicationFormId: Map<UUID, Long>,
|
||||
)
|
||||
|
||||
@Service
|
||||
class ApplicationFormService(
|
||||
private val applicationFormRepository: ApplicationFormRepository,
|
||||
@@ -31,6 +37,7 @@ class ApplicationFormService(
|
||||
private val versionService: ApplicationFormVersionService,
|
||||
private val userService: UserService,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val commentRepository: CommentRepository,
|
||||
) {
|
||||
fun createApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
|
||||
val applicationForm = applicationFormMapper.toNewApplicationForm(applicationFormDto)
|
||||
@@ -62,10 +69,26 @@ class ApplicationFormService(
|
||||
}
|
||||
|
||||
fun getApplicationForms(organizationId: String?): Page<ApplicationForm> {
|
||||
val pageable = PageRequest.of(0, 10)
|
||||
val pageable = PageRequest.of(0, 100)
|
||||
return applicationFormRepository.findAllByIsTemplateFalseAndOrganizationId(organizationId, pageable)
|
||||
}
|
||||
|
||||
fun getApplicationFormsWithCommentCounts(organizationId: String?): ApplicationFormPageWithCommentCounts {
|
||||
val page = getApplicationForms(organizationId)
|
||||
val applicationFormIds = page.content.mapNotNull { it.id }
|
||||
|
||||
if (applicationFormIds.isEmpty()) {
|
||||
return ApplicationFormPageWithCommentCounts(page, emptyMap())
|
||||
}
|
||||
|
||||
val counts =
|
||||
commentRepository.countByApplicationFormIds(applicationFormIds).associate { projection ->
|
||||
projection.applicationFormId to projection.commentCount
|
||||
}
|
||||
|
||||
return ApplicationFormPageWithCommentCounts(page, counts)
|
||||
}
|
||||
|
||||
fun updateApplicationForm(
|
||||
id: UUID,
|
||||
applicationFormDto: ApplicationFormDto,
|
||||
|
||||
@@ -3,14 +3,25 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class PagedApplicationFormMapper(
|
||||
private val applicationFormMapper: ApplicationFormMapper,
|
||||
) {
|
||||
fun toPagedApplicationFormDto(pagedApplicationForm: Page<ApplicationForm>): PagedApplicationFormDto =
|
||||
toPagedApplicationFormDto(pagedApplicationForm, emptyMap())
|
||||
|
||||
fun toPagedApplicationFormDto(
|
||||
pagedApplicationForm: Page<ApplicationForm>,
|
||||
commentCountByApplicationFormId: Map<UUID, Long>,
|
||||
): PagedApplicationFormDto =
|
||||
PagedApplicationFormDto(
|
||||
content = pagedApplicationForm.content.map { applicationFormMapper.toApplicationFormDto(it) },
|
||||
content =
|
||||
pagedApplicationForm.content.map { applicationForm ->
|
||||
val count = applicationForm.id?.let { commentCountByApplicationFormId[it] } ?: 0L
|
||||
applicationFormMapper.toApplicationFormDto(applicationForm, count)
|
||||
},
|
||||
number = pagedApplicationForm.number,
|
||||
propertySize = pagedApplicationForm.size,
|
||||
numberOfElements = pagedApplicationForm.numberOfElements,
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.hibernate.annotations.OnDelete
|
||||
import org.hibernate.annotations.OnDeleteAction
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -45,5 +45,5 @@ class ApplicationFormVersion(
|
||||
var createdBy: User,
|
||||
@CreatedDate
|
||||
@Column(nullable = false)
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import jakarta.persistence.ManyToOne
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -22,17 +22,17 @@ class Comment(
|
||||
@Id
|
||||
@GeneratedValue
|
||||
var id: UUID? = null,
|
||||
@Column(nullable = false)
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
var message: String = "",
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "created_by_id", nullable = false)
|
||||
var createdBy: User,
|
||||
@CreatedDate
|
||||
@Column(nullable = false)
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
var modifiedAt: LocalDateTime? = null,
|
||||
var modifiedAt: Instant? = null,
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "application_form_id", nullable = false)
|
||||
var applicationForm: ApplicationForm? = null,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.comment
|
||||
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.api.CommentApi
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormCommentCountsDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CursorPagedCommentDto
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
class CommentController(
|
||||
val commentService: CommentService,
|
||||
val commentMapper: CommentMapper,
|
||||
val pagedCommentMapper: PagedCommentMapper,
|
||||
) : CommentApi {
|
||||
@PreAuthorize(
|
||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||
@@ -32,12 +33,36 @@ class CommentController(
|
||||
@PreAuthorize(
|
||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||
)
|
||||
override fun getCommentsByApplicationFormId(applicationFormId: UUID): ResponseEntity<PagedCommentDto> =
|
||||
ResponseEntity.ok(
|
||||
pagedCommentMapper.toPagedCommentDto(
|
||||
commentService.getComments(applicationFormId),
|
||||
override fun getGroupedCommentCountByApplicationFromId(
|
||||
applicationFormId: UUID,
|
||||
): ResponseEntity<ApplicationFormCommentCountsDto> {
|
||||
val counts = commentService.getGroupedCommentCountByApplicationFromId(applicationFormId)
|
||||
return ResponseEntity.ok(
|
||||
ApplicationFormCommentCountsDto(
|
||||
counts = counts.mapKeys { it.key.toString() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@PreAuthorize(
|
||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||
)
|
||||
override fun getCommentsByApplicationFormId(
|
||||
applicationFormId: UUID,
|
||||
formElementId: UUID?,
|
||||
cursorCreatedAt: Instant?,
|
||||
limit: Int,
|
||||
): ResponseEntity<CursorPagedCommentDto> {
|
||||
val page = commentService.getComments(applicationFormId, formElementId, cursorCreatedAt, limit)
|
||||
|
||||
return ResponseEntity.ok(
|
||||
CursorPagedCommentDto(
|
||||
content = page.comments.map { commentMapper.toCommentDto(it) },
|
||||
nextCursorCreatedAt = page.nextCursorCreatedAt,
|
||||
hasMore = page.hasMore,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@PreAuthorize(
|
||||
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||
@@ -48,7 +73,7 @@ class CommentController(
|
||||
): ResponseEntity<CommentDto> =
|
||||
ResponseEntity.ok(
|
||||
commentMapper.toCommentDto(
|
||||
commentService.updateComment(commentDto),
|
||||
commentService.updateComment(id, commentDto),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
@@ -23,8 +23,8 @@ class CommentMapper(
|
||||
CommentDto(
|
||||
id = comment.id ?: throw IllegalStateException("Comment ID must not be null!"),
|
||||
message = comment.message,
|
||||
createdAt = comment.createdAt ?: LocalDateTime.now(),
|
||||
modifiedAt = comment.modifiedAt ?: LocalDateTime.now(),
|
||||
createdAt = comment.createdAt ?: Instant.now(),
|
||||
modifiedAt = comment.modifiedAt ?: Instant.now(),
|
||||
createdBy = userMapper.toUserDto(comment.createdBy),
|
||||
applicationFormId =
|
||||
comment.applicationForm?.id
|
||||
@@ -56,6 +56,14 @@ class CommentMapper(
|
||||
)
|
||||
}
|
||||
|
||||
fun applyUpdate(
|
||||
existing: Comment,
|
||||
commentDto: CommentDto,
|
||||
): Comment {
|
||||
existing.message = commentDto.message
|
||||
return existing
|
||||
}
|
||||
|
||||
fun toComment(
|
||||
applicationFormId: UUID,
|
||||
formElementId: UUID,
|
||||
|
||||
@@ -4,13 +4,68 @@ import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface ApplicationFormCommentCountProjection {
|
||||
val applicationFormId: UUID
|
||||
val commentCount: Long
|
||||
}
|
||||
|
||||
interface FormElementCommentCountProjection {
|
||||
val formElementId: UUID
|
||||
val commentCount: Long
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface CommentRepository : JpaRepository<Comment, UUID> {
|
||||
fun findAllByApplicationForm(
|
||||
applicationForm: ApplicationForm,
|
||||
pageable: Pageable,
|
||||
): Page<Comment>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select c.applicationForm.id as applicationFormId, count(c.id) as commentCount
|
||||
from Comment c
|
||||
where c.applicationForm.id in :applicationFormIds
|
||||
group by c.applicationForm.id
|
||||
""",
|
||||
)
|
||||
fun countByApplicationFormIds(
|
||||
@Param("applicationFormIds") applicationFormIds: Collection<UUID>,
|
||||
): List<ApplicationFormCommentCountProjection>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select c.*
|
||||
from comment c
|
||||
where c.application_form_id = :applicationFormId
|
||||
and (cast(:formElementId as uuid) is null or c.form_element_id = :formElementId)
|
||||
and (cast(:cursorCreatedAt as timestamp) is null or c.created_at < :cursorCreatedAt)
|
||||
order by c.created_at desc, c.id desc
|
||||
""",
|
||||
nativeQuery = true,
|
||||
)
|
||||
fun findNextByApplicationFormId(
|
||||
@Param("applicationFormId") applicationFormId: UUID,
|
||||
@Param("formElementId") formElementId: UUID?,
|
||||
@Param("cursorCreatedAt") cursorCreatedAt: Instant?,
|
||||
pageable: Pageable,
|
||||
): List<Comment>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select c.formElement.id as formElementId, count(c.id) as commentCount
|
||||
from Comment c
|
||||
where c.applicationForm.id = :applicationFormId
|
||||
group by c.formElement.id
|
||||
""",
|
||||
)
|
||||
fun countByApplicationFormIdGroupByFormElementId(
|
||||
@Param("applicationFormId") applicationFormId: UUID,
|
||||
): List<FormElementCommentCountProjection>
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ import com.betriebsratkanzlei.legalconsenthub.error.CommentNotFoundException
|
||||
import com.betriebsratkanzlei.legalconsenthub.error.CommentNotUpdatedException
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CommentDto
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateCommentDto
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class CursorCommentPage(
|
||||
val comments: List<Comment>,
|
||||
val nextCursorCreatedAt: Instant?,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
@Service
|
||||
class CommentService(
|
||||
private val commentRepository: CommentRepository,
|
||||
@@ -36,25 +42,55 @@ class CommentService(
|
||||
|
||||
fun getCommentById(id: UUID): Comment = commentRepository.findById(id).orElseThrow { CommentNotFoundException(id) }
|
||||
|
||||
fun getComments(applicationFormId: UUID): Page<Comment> {
|
||||
val applicationForm =
|
||||
applicationFormRepository.findById(applicationFormId).orElse(null)
|
||||
val pageable = PageRequest.of(0, 10)
|
||||
return commentRepository.findAllByApplicationForm(applicationForm, pageable)
|
||||
fun getComments(
|
||||
applicationFormId: UUID,
|
||||
formElementId: UUID?,
|
||||
cursorCreatedAt: Instant?,
|
||||
limit: Int,
|
||||
): CursorCommentPage {
|
||||
applicationFormRepository.findById(applicationFormId).orElse(null)
|
||||
|
||||
val pageSize = limit.coerceIn(1, 50)
|
||||
val fetchSize = pageSize + 1
|
||||
|
||||
val comments =
|
||||
commentRepository.findNextByApplicationFormId(
|
||||
applicationFormId = applicationFormId,
|
||||
formElementId = formElementId,
|
||||
cursorCreatedAt = cursorCreatedAt,
|
||||
pageable = PageRequest.of(0, fetchSize),
|
||||
)
|
||||
val hasMore = comments.size > pageSize
|
||||
val pageItems = if (hasMore) comments.take(pageSize) else comments
|
||||
val nextCursor = if (hasMore) pageItems.lastOrNull()?.createdAt else null
|
||||
|
||||
return CursorCommentPage(
|
||||
comments = pageItems,
|
||||
nextCursorCreatedAt = nextCursor,
|
||||
hasMore = hasMore,
|
||||
)
|
||||
}
|
||||
|
||||
fun updateComment(commentDto: CommentDto): Comment {
|
||||
// TODO find statt mappen?
|
||||
val comment = commentMapper.toComment(commentDto)
|
||||
val updatedComment: Comment
|
||||
fun getGroupedCommentCountByApplicationFromId(applicationFormId: UUID): Map<UUID, Long> {
|
||||
applicationFormRepository.findById(applicationFormId).orElse(null)
|
||||
|
||||
try {
|
||||
updatedComment = commentRepository.save(comment)
|
||||
return commentRepository
|
||||
.countByApplicationFormIdGroupByFormElementId(applicationFormId)
|
||||
.associate { it.formElementId to it.commentCount }
|
||||
}
|
||||
|
||||
fun updateComment(
|
||||
id: UUID,
|
||||
commentDto: CommentDto,
|
||||
): Comment {
|
||||
val existing = getCommentById(id)
|
||||
val updated = commentMapper.applyUpdate(existing, commentDto)
|
||||
|
||||
return try {
|
||||
commentRepository.save(updated)
|
||||
} catch (e: Exception) {
|
||||
throw CommentNotUpdatedException(e, commentDto.id)
|
||||
throw CommentNotUpdatedException(e, id)
|
||||
}
|
||||
|
||||
return updatedComment
|
||||
}
|
||||
|
||||
fun deleteCommentByID(id: UUID) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.comment
|
||||
|
||||
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedCommentDto
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class PagedCommentMapper(
|
||||
private val commentMapper: CommentMapper,
|
||||
) {
|
||||
fun toPagedCommentDto(pagedComment: Page<Comment>): PagedCommentDto =
|
||||
PagedCommentDto(
|
||||
content = pagedComment.content.map { commentMapper.toCommentDto(it) },
|
||||
number = pagedComment.number,
|
||||
propertySize = pagedComment.size,
|
||||
numberOfElements = pagedComment.numberOfElements,
|
||||
last = pagedComment.isLast,
|
||||
totalPages = pagedComment.totalPages,
|
||||
totalElements = pagedComment.totalElements,
|
||||
empty = pagedComment.isEmpty,
|
||||
first = pagedComment.isFirst,
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.ManyToOne
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@@ -42,5 +42,5 @@ class Notification(
|
||||
var organizationId: String = "",
|
||||
@CreatedDate
|
||||
@Column(nullable = false)
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import jakarta.persistence.Table
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
@@ -29,8 +29,8 @@ class User(
|
||||
var emailOnFormSubmitted: Boolean = true,
|
||||
@CreatedDate
|
||||
@Column(nullable = false)
|
||||
var createdAt: LocalDateTime? = null,
|
||||
var createdAt: Instant? = null,
|
||||
@LastModifiedDate
|
||||
@Column(nullable = false)
|
||||
var modifiedAt: LocalDateTime? = null,
|
||||
var modifiedAt: Instant? = null,
|
||||
)
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
create table app_user
|
||||
(
|
||||
email_on_form_created boolean not null,
|
||||
email_on_form_submitted boolean not null,
|
||||
created_at timestamp(6) not null,
|
||||
modified_at timestamp(6) not null,
|
||||
email_on_form_created boolean not null,
|
||||
email_on_form_submitted boolean not null,
|
||||
created_at timestamp(6) with time zone not null,
|
||||
modified_at timestamp(6) with time zone not null,
|
||||
email varchar(255),
|
||||
keycloak_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
keycloak_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
organization_id varchar(255),
|
||||
primary key (keycloak_id)
|
||||
);
|
||||
|
||||
create table application_form
|
||||
(
|
||||
is_template boolean not null,
|
||||
created_at timestamp(6) not null,
|
||||
modified_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
last_modified_by_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
is_template boolean not null,
|
||||
created_at timestamp(6) with time zone not null,
|
||||
modified_at timestamp(6) with time zone not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
last_modified_by_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
organization_id varchar(255),
|
||||
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
|
||||
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table application_form_version
|
||||
(
|
||||
version_number integer not null,
|
||||
created_at timestamp(6) not null,
|
||||
application_form_id uuid not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
organization_id varchar(255) not null,
|
||||
snapshot_data TEXT not null,
|
||||
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
|
||||
version_number integer not null,
|
||||
created_at timestamp(6) with time zone not null,
|
||||
application_form_id uuid not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
name varchar(255) not null,
|
||||
organization_id varchar(255) not null,
|
||||
snapshot_data TEXT not null,
|
||||
status varchar(255) not null check (status in ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'SIGNED')),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
create table comment
|
||||
(
|
||||
created_at timestamp(6) not null,
|
||||
modified_at timestamp(6) not null,
|
||||
application_form_id uuid not null,
|
||||
form_element_id uuid not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
message varchar(255) not null,
|
||||
created_at timestamp(6) with time zone not null,
|
||||
modified_at timestamp(6) with time zone not null,
|
||||
application_form_id uuid not null,
|
||||
form_element_id uuid not null,
|
||||
id uuid not null,
|
||||
created_by_id varchar(255) not null,
|
||||
message TEXT not null,
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
@@ -109,16 +109,16 @@ create table form_element_sub_section
|
||||
|
||||
create table notification
|
||||
(
|
||||
is_read boolean not null,
|
||||
created_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
click_target varchar(255) not null,
|
||||
message TEXT not null,
|
||||
organization_id varchar(255) not null,
|
||||
is_read boolean not null,
|
||||
created_at timestamp(6) with time zone not null,
|
||||
id uuid not null,
|
||||
click_target varchar(255) not null,
|
||||
message TEXT not null,
|
||||
organization_id varchar(255) not null,
|
||||
recipient_id varchar(255),
|
||||
target_roles TEXT,
|
||||
title varchar(255) not null,
|
||||
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
|
||||
title varchar(255) not null,
|
||||
type varchar(255) not null check (type in ('INFO', 'WARNING', 'ERROR')),
|
||||
primary key (id)
|
||||
);
|
||||
|
||||
@@ -136,9 +136,7 @@ alter table if exists application_form_version
|
||||
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
||||
foreign key (application_form_id)
|
||||
references application_form
|
||||
on
|
||||
delete
|
||||
cascade;
|
||||
on delete cascade;
|
||||
|
||||
alter table if exists application_form_version
|
||||
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user