feat: add PDF annotation feature (#40)
Backend: - Add ANNOTATE_ALL permission - Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes - V10 migration: document_annotations table with page/rect/color/owner - DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO - AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete - AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL) - 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green Frontend: - AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons - PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API - Disabled annotate button with tooltip for users without ANNOTATE_ALL - canAnnotate exposed from layout server, passed to PdfViewer - errors.ts + de/en/es translations for new error codes - 3 new unit tests for AnnotationLayer — TDD red/green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/documents/{documentId}/annotations")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AnnotationController {
|
||||
|
||||
private final AnnotationService annotationService;
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping
|
||||
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
|
||||
return annotationService.listAnnotations(documentId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public DocumentAnnotation createAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateAnnotationDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
return annotationService.createAnnotation(documentId, dto, userId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{annotationId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||
public void deleteAnnotation(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID annotationId,
|
||||
Authentication authentication) {
|
||||
UUID userId = resolveUserId(authentication);
|
||||
annotationService.deleteAnnotation(documentId, annotationId, userId);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private UUID resolveUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||
try {
|
||||
AppUser user = userService.findByUsername(authentication.getName());
|
||||
return user != null ? user.getId() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CreateAnnotationDTO {
|
||||
private int pageNumber;
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
private double height;
|
||||
private String color;
|
||||
}
|
||||
@@ -38,6 +38,12 @@ public enum ErrorCode {
|
||||
/** The password-reset token is missing, expired, or already used. 400 */
|
||||
INVALID_RESET_TOKEN,
|
||||
|
||||
// --- Annotations ---
|
||||
/** The annotation with the given ID does not exist. 404 */
|
||||
ANNOTATION_NOT_FOUND,
|
||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||
ANNOTATION_OVERLAP,
|
||||
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "document_annotations")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DocumentAnnotation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "document_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID documentId;
|
||||
|
||||
@Column(name = "page_number", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int pageNumber;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double x;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double y;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double width;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private double height;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String color;
|
||||
|
||||
@Column(name = "created_by")
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
|
||||
|
||||
List<DocumentAnnotation> findByDocumentId(UUID documentId);
|
||||
|
||||
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||
|
||||
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
|
||||
public enum Permission {
|
||||
READ_ALL,
|
||||
WRITE_ALL,
|
||||
ANNOTATE_ALL,
|
||||
ADMIN,
|
||||
ADMIN_USER,
|
||||
ADMIN_TAG,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnotationService {
|
||||
|
||||
private final AnnotationRepository annotationRepository;
|
||||
|
||||
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
||||
return annotationRepository.findByDocumentId(documentId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
|
||||
List<DocumentAnnotation> existing =
|
||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||
|
||||
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
|
||||
if (overlaps) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
|
||||
}
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.documentId(documentId)
|
||||
.pageNumber(dto.getPageNumber())
|
||||
.x(dto.getX())
|
||||
.y(dto.getY())
|
||||
.width(dto.getWidth())
|
||||
.height(dto.getHeight())
|
||||
.color(dto.getColor())
|
||||
.createdBy(userId)
|
||||
.build();
|
||||
|
||||
return annotationRepository.save(annotation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
||||
DocumentAnnotation annotation = annotationRepository
|
||||
.findByIdAndDocumentId(annotationId, documentId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
|
||||
|
||||
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
|
||||
throw DomainException.forbidden("Only the annotation author can delete it");
|
||||
}
|
||||
|
||||
annotationRepository.delete(annotation);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||
double ex2 = existing.getX() + existing.getWidth();
|
||||
double ey2 = existing.getY() + existing.getHeight();
|
||||
double dx2 = dto.getX() + dto.getWidth();
|
||||
double dy2 = dto.getY() + dto.getHeight();
|
||||
return existing.getX() < dx2 && ex2 > dto.getX()
|
||||
&& existing.getY() < dy2 && ey2 > dto.getY();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE document_annotations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
x DOUBLE PRECISION NOT NULL,
|
||||
y DOUBLE PRECISION NOT NULL,
|
||||
width DOUBLE PRECISION NOT NULL,
|
||||
height DOUBLE PRECISION NOT NULL,
|
||||
color VARCHAR(20) NOT NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON document_annotations (document_id, page_number);
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AnnotationController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AnnotationControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean AnnotationService annotationService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final String ANNOTATION_JSON =
|
||||
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
|
||||
|
||||
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void listAnnotations_returns200_whenAuthenticated() throws Exception {
|
||||
when(annotationService.listAnnotations(any())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.pageNumber").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||
when(annotationService.createAnnotation(any(), any(), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AnnotationServiceTest {
|
||||
|
||||
@Mock AnnotationRepository annotationRepository;
|
||||
@InjectMocks AnnotationService annotationService;
|
||||
|
||||
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
|
||||
|
||||
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||
.thenReturn(List.of(existing));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||
|
||||
verify(annotationRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAnnotation_savesAndReturns_whenNoOverlap() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||
|
||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||
when(annotationRepository.save(any())).thenReturn(saved);
|
||||
|
||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(annotationRepository).save(any());
|
||||
}
|
||||
|
||||
// ─── deleteAnnotation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsNotFound_whenMissing() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_throwsForbidden_whenNotOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||
|
||||
verify(annotationRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAnnotation_succeeds_whenOwner() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID annotId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
|
||||
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||
.thenReturn(Optional.of(annotation));
|
||||
|
||||
annotationService.deleteAnnotation(docId, annotId, ownerId);
|
||||
|
||||
verify(annotationRepository).delete(annotation);
|
||||
}
|
||||
|
||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listAnnotations_returnsAllForDocument() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).build();
|
||||
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
|
||||
|
||||
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Annotation not found.",
|
||||
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||
"error_document_not_found": "Document not found.",
|
||||
"error_document_no_file": "No file is associated with this document.",
|
||||
"error_file_not_found": "The file could not be found in storage.",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"error_annotation_not_found": "Anotación no encontrada.",
|
||||
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||
"error_document_not_found": "Documento no encontrado.",
|
||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||
|
||||
165
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
165
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type DrawRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
let {
|
||||
annotations = [],
|
||||
canAnnotate,
|
||||
color,
|
||||
onDraw,
|
||||
onDelete
|
||||
}: {
|
||||
annotations: Annotation[];
|
||||
canAnnotate: boolean;
|
||||
color: string;
|
||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||
let drawRect = $state<DrawRect | null>(null);
|
||||
|
||||
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - rect.left) / rect.width,
|
||||
y: (event.clientY - rect.top) / rect.height
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!canAnnotate) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.dataset.annotation !== undefined) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
container.setPointerCapture(event.pointerId);
|
||||
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
drawStart = coords;
|
||||
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
drawRect = { x, y, width, height };
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart || !drawRect) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
if (width > 0.01 && height > 0.01) {
|
||||
onDraw({ x, y, width, height });
|
||||
}
|
||||
|
||||
drawStart = null;
|
||||
drawRect = null;
|
||||
}
|
||||
|
||||
const containerStyle = $derived(
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={containerStyle}
|
||||
role="presentation"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each annotations as annotation (annotation.id)}
|
||||
<div
|
||||
data-testid="annotation-{annotation.id}"
|
||||
data-annotation
|
||||
style="
|
||||
position: absolute;
|
||||
left: {annotation.x * 100}%;
|
||||
top: {annotation.y * 100}%;
|
||||
width: {annotation.width * 100}%;
|
||||
height: {annotation.height * 100}%;
|
||||
background-color: {annotation.color};
|
||||
opacity: 0.3;
|
||||
pointer-events: {canAnnotate ? 'auto' : 'none'};
|
||||
"
|
||||
>
|
||||
{#if canAnnotate}
|
||||
<button
|
||||
aria-label="Annotation löschen"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(annotation.id);
|
||||
}}
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
">×</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if drawRect && drawRect.width > 0}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
left: {drawRect.x * 100}%;
|
||||
top: {drawRect.y * 100}%;
|
||||
width: {drawRect.width * 100}%;
|
||||
height: {drawRect.height * 100}%;
|
||||
border: 2px dashed {color};
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color: '#ff0000',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe('AnnotationLayer', () => {
|
||||
it('renders a colored element for each annotation', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: true,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotation löschen/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show delete buttons when canAnnotate is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
canAnnotate = false
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
canAnnotate?: boolean;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
@@ -24,6 +33,22 @@ let textLayerInstance: { cancel: () => void } | null = null;
|
||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let annotateMode = $state(false);
|
||||
let annotateColor = $state('#ffff00');
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||
const [lib, { default: workerUrl }] = await Promise.all([
|
||||
@@ -134,6 +159,54 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||
if (res.ok) annotations = await res.json();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pageNumber: currentPage,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: annotateColor
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Annotation = await res.json();
|
||||
annotations = [...annotations, created];
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDelete(annotationId: string) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
annotations = annotations.filter((a) => a.id !== annotationId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (pdfjsReady && url) {
|
||||
loadDocument(url);
|
||||
@@ -151,6 +224,12 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
@@ -274,6 +353,37 @@ function zoomOut() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Annotate controls -->
|
||||
{#if canAnnotate}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
|
||||
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
|
||||
>
|
||||
{annotateMode ? 'Fertig' : 'Annotieren'}
|
||||
</button>
|
||||
{#if annotateMode}
|
||||
<input
|
||||
type="color"
|
||||
bind:value={annotateColor}
|
||||
aria-label="Farbe wählen"
|
||||
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
title="Farbe wählen"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
disabled
|
||||
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
|
||||
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
|
||||
aria-label="Annotieren (keine Berechtigung)"
|
||||
>
|
||||
Annotieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
@@ -297,6 +407,13 @@ function zoomOut() {
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={annotateMode}
|
||||
color={annotateColor}
|
||||
onDraw={handleAnnotationDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ErrorCode =
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_OVERLAP'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
@@ -61,6 +63,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_import_already_running();
|
||||
case 'INVALID_RESET_TOKEN':
|
||||
return m.error_invalid_reset_token();
|
||||
case 'ANNOTATION_NOT_FOUND':
|
||||
return m.error_annotation_not_found();
|
||||
case 'ANNOTATION_OVERLAP':
|
||||
return m.error_annotation_overlap();
|
||||
case 'UNAUTHORIZED':
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const groups: { permissions: string[] }[] = locals.user?.groups ?? [];
|
||||
return {
|
||||
user: locals.user,
|
||||
canWrite:
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false
|
||||
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
users: [makeUser()],
|
||||
groups: [makeGroup()],
|
||||
tags: []
|
||||
|
||||
@@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({
|
||||
...overrides
|
||||
});
|
||||
|
||||
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
editUser: makeUser(),
|
||||
groups
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const groups = [
|
||||
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
||||
];
|
||||
|
||||
const baseData = { user: undefined, canWrite: true, groups };
|
||||
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ afterEach(cleanup);
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||
|
||||
@@ -875,7 +875,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||
</div>
|
||||
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||
<PdfViewer url={fileUrl} />
|
||||
<PdfViewer url={fileUrl} documentId={doc.id} canAnnotate={data.canAnnotate} />
|
||||
{:else if fileUrl}
|
||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||
<img
|
||||
|
||||
@@ -10,6 +10,7 @@ afterEach(cleanup);
|
||||
const baseData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
persons: [],
|
||||
initialSenderId: '',
|
||||
initialSenderName: '',
|
||||
|
||||
@@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({
|
||||
createdAt: ''
|
||||
},
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ afterEach(cleanup);
|
||||
const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
|
||||
@@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({
|
||||
...overrides
|
||||
});
|
||||
|
||||
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
|
||||
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
|
||||
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
Reference in New Issue
Block a user