Compare commits
12 Commits
b690c74ddf
...
f13f635161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13f635161 | ||
|
|
6d3489d035 | ||
|
|
fa5dc43864 | ||
|
|
d4f32ed5d4 | ||
|
|
27e3d290e7 | ||
|
|
25446c9a5c | ||
|
|
660e34e016 | ||
|
|
b662117e55 | ||
|
|
d251806e72 | ||
|
|
f0da033ec9 | ||
|
|
a59feec81a | ||
|
|
779ffaab55 |
@@ -18,7 +18,12 @@ import jakarta.validation.constraints.Min;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
@@ -237,6 +242,70 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
// --- BULK EDIT ---
|
||||
|
||||
private static final int BULK_EDIT_MAX_IDS = 500;
|
||||
|
||||
@PatchMapping("/bulk")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public BulkEditResult patchBulk(
|
||||
@RequestBody DocumentBulkEditDTO dto,
|
||||
Authentication authentication) {
|
||||
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
||||
}
|
||||
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
||||
}
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
int updated = 0;
|
||||
List<BulkEditError> errors = new ArrayList<>();
|
||||
|
||||
for (UUID id : dto.getDocumentIds()) {
|
||||
try {
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
updated++;
|
||||
} catch (DomainException e) {
|
||||
errors.add(new BulkEditError(id, e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
errors.add(new BulkEditError(id, "Internal error"));
|
||||
log.warn("Bulk edit failed for document {}: {}", id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("bulkEdit actor={} documentIds={} updated={} errors={}",
|
||||
actorId, dto.getDocumentIds().size(), updated, errors.size());
|
||||
|
||||
return new BulkEditResult(updated, errors);
|
||||
}
|
||||
|
||||
@GetMapping("/ids")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<UUID> getDocumentIds(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@RequestParam(required = false) DocumentStatus status,
|
||||
@RequestParam(required = false) String tagOp) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
return documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<DocumentBatchSummary> batchMetadata(@RequestBody BatchMetadataRequest request) {
|
||||
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
||||
}
|
||||
return documentService.batchMetadata(request.ids());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BatchMetadataRequest(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditError(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record DocumentBatchSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DocumentBulkEditDTO {
|
||||
private List<UUID> documentIds;
|
||||
private List<String> tagNames;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private String documentLocation;
|
||||
private String archiveBox;
|
||||
private String archiveFolder;
|
||||
}
|
||||
@@ -111,6 +111,8 @@ public enum ErrorCode {
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||
BULK_EDIT_TOO_MANY_IDS,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -350,6 +350,97 @@ public class DocumentService {
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all document IDs matching the given filter parameters, ignoring
|
||||
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
|
||||
* frontend can replace the selection with every match across pages in one
|
||||
* round-trip.
|
||||
*/
|
||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return List.of();
|
||||
}
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
|
||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||
Specification<Document> spec = Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lightweight summaries (id, title, server PDF URL) for the given
|
||||
* document IDs. Unknown IDs are silently dropped — the consumer is the
|
||||
* bulk-edit page's left strip, where missing previews would already be
|
||||
* obvious; surfacing them as errors here adds no value.
|
||||
*/
|
||||
public List<org.raddatz.familienarchiv.dto.DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return documentRepository.findAllById(ids).stream()
|
||||
.map(d -> new org.raddatz.familienarchiv.dto.DocumentBatchSummary(
|
||||
d.getId(),
|
||||
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
|
||||
"/api/documents/" + d.getId() + "/file"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a bulk-edit DTO to a single document atomically.
|
||||
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||
* three location fields are replace-on-non-blank (null/blank means "no change").
|
||||
* Wrapped in its own transaction so a failure on one document never partially
|
||||
* mutates another in the batch loop.
|
||||
*/
|
||||
@Transactional
|
||||
public Document applyBulkEditToDocument(UUID id, org.raddatz.familienarchiv.dto.DocumentBulkEditDTO dto) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
|
||||
Set<Tag> merged = new HashSet<>(doc.getTags());
|
||||
for (String name : dto.getTagNames()) {
|
||||
String clean = name.trim();
|
||||
if (!clean.isEmpty()) {
|
||||
merged.add(tagService.findOrCreate(clean));
|
||||
}
|
||||
}
|
||||
doc.setTags(merged);
|
||||
}
|
||||
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
Set<Person> merged = new HashSet<>(doc.getReceivers());
|
||||
merged.addAll(personService.getAllById(dto.getReceiverIds()));
|
||||
doc.setReceivers(merged);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(dto.getDocumentLocation())) {
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveBox())) {
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveFolder())) {
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
}
|
||||
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsmethode: Erstellt Platzhalter (wird später vom Excel-Service genutzt)
|
||||
*/
|
||||
|
||||
@@ -929,4 +929,180 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
||||
|
||||
private static String bulkBody(String... uuids) {
|
||||
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
||||
for (int i = 0; i < uuids.length; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append("\"").append(uuids[i]).append("\"");
|
||||
}
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"documentIds\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
String[] ids = new String[501];
|
||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id1.toString(), id2.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(2))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
|
||||
verify(documentService).applyBulkEditToDocument(eq(id1), any());
|
||||
verify(documentService).applyBulkEditToDocument(eq(id2), any());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/ids ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of(id));
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0]").value(id.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID okId = UUID.randomUUID();
|
||||
UUID badId = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(okId), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(okId).build());
|
||||
when(documentService.applyBulkEditToDocument(eq(badId), any()))
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(okId.toString(), badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(1))
|
||||
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1917,4 +1917,276 @@ class DocumentServiceTest {
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
|
||||
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
||||
|
||||
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
||||
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of("Kurrent"));
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto());
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(oldSender)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getById(senderId)).thenReturn(newSender);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(senderId);
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(newSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(existing)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto());
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(existing);
|
||||
verify(personService, never()).getById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID newReceiverId = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of(newReceiverId));
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactly(existing);
|
||||
verify(personService, never()).getAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("OldBox")
|
||||
.archiveFolder("OldFolder")
|
||||
.documentLocation("OldLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox("NewBox");
|
||||
dto.setArchiveFolder("NewFolder");
|
||||
dto.setDocumentLocation("NewLocation");
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||
}
|
||||
|
||||
// ─── findIdsForFilter ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
|
||||
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
|
||||
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(d1, d2));
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
"xyz", null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
}
|
||||
|
||||
// ─── batchMetadata ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
||||
assertThat(documentService.batchMetadata(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
||||
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
||||
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
||||
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id1, id2));
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result.get(0).id()).isEqualTo(id1);
|
||||
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
||||
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_silentlyDropsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
Document d = Document.builder().id(known).title("Found").build();
|
||||
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(known, missing));
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).id()).isEqualTo(known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
||||
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id));
|
||||
|
||||
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("KeepBox")
|
||||
.archiveFolder("KeepFolder")
|
||||
.documentLocation("KeepLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox(" ");
|
||||
dto.setArchiveFolder("");
|
||||
// documentLocation left null
|
||||
documentService.applyBulkEditToDocument(id, dto);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
||||
}
|
||||
}
|
||||
|
||||
75
frontend/e2e/bulk-edit.spec.ts
Normal file
75
frontend/e2e/bulk-edit.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E coverage for the bulk metadata edit feature (issue #225).
|
||||
*
|
||||
* Assumptions:
|
||||
* - Auth setup has run as the admin user (WRITE_ALL).
|
||||
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
|
||||
* - At least two documents exist in the search list at /documents.
|
||||
*/
|
||||
|
||||
test.describe('Bulk metadata edit', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
});
|
||||
|
||||
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await expect(checkboxes.first()).toBeVisible();
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
|
||||
const bar = page.getByTestId('bulk-selection-bar');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
|
||||
});
|
||||
|
||||
test('Alles aufheben hides the bar', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
|
||||
await page.waitForURL('**/documents/bulk-edit');
|
||||
// Onboarding callout is the surest indicator the edit-mode layout rendered.
|
||||
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
|
||||
page
|
||||
}) => {
|
||||
// Navigate directly without checking anything first.
|
||||
await page.goto('/documents/bulk-edit');
|
||||
await page.waitForURL('**/documents');
|
||||
expect(page.url()).toMatch(/\/documents(\?|$)/);
|
||||
});
|
||||
|
||||
test('the same selection bar drives the /enrich page', async ({ page }) => {
|
||||
await page.goto('/enrich');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// /enrich may legitimately be empty if every doc has metadata. In that
|
||||
// case there's nothing to bulk-select; skip.
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
const count = await checkboxes.count();
|
||||
test.skip(count === 0, 'No incomplete documents available on /enrich');
|
||||
|
||||
await checkboxes.first().check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -873,5 +873,19 @@
|
||||
"bulk_drop_zone_label": "Dateien ablegen",
|
||||
"bulk_remove_file": "Entfernen",
|
||||
"bulk_title_single": "Neues Dokument",
|
||||
"bulk_title_multi": "Neue Dokumente"
|
||||
"bulk_title_multi": "Neue Dokumente",
|
||||
"bulk_edit_button": "Massenbearbeitung",
|
||||
"bulk_edit_n_selected": "{count} Dokumente ausgewählt",
|
||||
"bulk_edit_clear_all": "Alles aufheben",
|
||||
"bulk_edit_all_x": "Alle {count} editieren",
|
||||
"bulk_edit_select_document": "Dokument {title} auswählen",
|
||||
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
|
||||
"bulk_edit_badge_additive": "+ wird hinzugefügt",
|
||||
"bulk_edit_badge_replace": "wird ersetzt",
|
||||
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
|
||||
"bulk_edit_save_partial": "{done} von {total} gespeichert",
|
||||
"bulk_edit_retry": "Erneut versuchen",
|
||||
"bulk_edit_title": "Massenbearbeitung",
|
||||
"bulk_edit_save_button": "Anwenden",
|
||||
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage."
|
||||
}
|
||||
|
||||
@@ -873,5 +873,19 @@
|
||||
"bulk_drop_zone_label": "Drop files here",
|
||||
"bulk_remove_file": "Remove",
|
||||
"bulk_title_single": "New Document",
|
||||
"bulk_title_multi": "New Documents"
|
||||
"bulk_title_multi": "New Documents",
|
||||
"bulk_edit_button": "Bulk edit",
|
||||
"bulk_edit_n_selected": "{count} documents selected",
|
||||
"bulk_edit_clear_all": "Clear all",
|
||||
"bulk_edit_all_x": "Edit all {count}",
|
||||
"bulk_edit_select_document": "Select document {title}",
|
||||
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
|
||||
"bulk_edit_badge_additive": "+ added",
|
||||
"bulk_edit_badge_replace": "replaced",
|
||||
"bulk_edit_save_progress": "Batch {done} of {total} processed",
|
||||
"bulk_edit_save_partial": "{done} of {total} saved",
|
||||
"bulk_edit_retry": "Retry",
|
||||
"bulk_edit_title": "Bulk edit",
|
||||
"bulk_edit_save_button": "Apply",
|
||||
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request."
|
||||
}
|
||||
|
||||
@@ -873,5 +873,19 @@
|
||||
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||
"bulk_remove_file": "Eliminar",
|
||||
"bulk_title_single": "Nuevo Documento",
|
||||
"bulk_title_multi": "Nuevos Documentos"
|
||||
"bulk_title_multi": "Nuevos Documentos",
|
||||
"bulk_edit_button": "Edición masiva",
|
||||
"bulk_edit_n_selected": "{count} documentos seleccionados",
|
||||
"bulk_edit_clear_all": "Limpiar todo",
|
||||
"bulk_edit_all_x": "Editar los {count}",
|
||||
"bulk_edit_select_document": "Seleccionar documento {title}",
|
||||
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
|
||||
"bulk_edit_badge_additive": "+ se añade",
|
||||
"bulk_edit_badge_replace": "se reemplaza",
|
||||
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
|
||||
"bulk_edit_save_partial": "{done} de {total} guardado",
|
||||
"bulk_edit_retry": "Reintentar",
|
||||
"bulk_edit_title": "Edición masiva",
|
||||
"bulk_edit_save_button": "Aplicar",
|
||||
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud."
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let { item }: { item: DocumentSearchItem } = $props();
|
||||
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||
|
||||
const doc = $derived(item.document);
|
||||
const titleText = $derived(doc.title || doc.originalFilename);
|
||||
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-3 sm:gap-5">
|
||||
<!-- Bulk-selection checkbox -->
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} size="lg" />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import DocumentRow from './DocumentRow.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
@@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – bulk selection checkbox', () => {
|
||||
it('does not render the checkbox when canWrite is false', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the checkbox when canWrite is true', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checkbox aria-label includes the document title', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect
|
||||
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||
|
||||
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
|
||||
|
||||
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
|
||||
});
|
||||
|
||||
it('checked state mirrors the store', async () => {
|
||||
bulkSelectionStore.add('doc-99');
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – progress ring and contributors', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
@@ -34,6 +36,7 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
>{label}{#if required}*{/if}</label
|
||||
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||
>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { onDestroy, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
@@ -19,6 +20,12 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
export type BulkEditEntry = {
|
||||
documentId: string;
|
||||
title: string;
|
||||
pdfUrl: string;
|
||||
};
|
||||
|
||||
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||
let _confirmService: ConfirmService | null;
|
||||
try {
|
||||
@@ -28,13 +35,17 @@ try {
|
||||
}
|
||||
|
||||
let {
|
||||
mode = 'upload',
|
||||
initialSenderId = '',
|
||||
initialSenderName = '',
|
||||
initialReceivers = []
|
||||
initialReceivers = [],
|
||||
initialEditEntries = []
|
||||
}: {
|
||||
mode?: 'upload' | 'edit';
|
||||
initialSenderId?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceivers?: Person[];
|
||||
initialEditEntries?: BulkEditEntry[];
|
||||
} = $props();
|
||||
|
||||
// --- File state ---
|
||||
@@ -42,12 +53,35 @@ let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
// Partial-failure surface: when set, the last save aborted at chunk N of M.
|
||||
let partialSaved = $state<{ done: number; total: number } | null>(null);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||
let dateIso = $state('');
|
||||
let tags = $state<Tag[]>([]);
|
||||
// Bulk-edit only — replace-on-non-blank semantics.
|
||||
let documentLocation = $state('');
|
||||
let archiveBox = $state('');
|
||||
let archiveFolder = $state('');
|
||||
|
||||
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
|
||||
// fetch upstream in the route — by the time this layout mounts, the metadata
|
||||
// has already been resolved into `initialEditEntries`.
|
||||
if (mode === 'edit') {
|
||||
for (const entry of untrack(() => initialEditEntries)) {
|
||||
const id = entry.documentId; // reuse documentId as the local FileEntry key
|
||||
files.set(id, {
|
||||
id,
|
||||
documentId: entry.documentId,
|
||||
title: entry.title,
|
||||
status: 'idle',
|
||||
previewUrl: entry.pdfUrl
|
||||
});
|
||||
if (!activeId) activeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Derived ---
|
||||
const isMulti = $derived(files.size >= 2);
|
||||
@@ -105,10 +139,8 @@ onDestroy(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Save ---
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
// --- Save (upload mode) ---
|
||||
async function saveUpload() {
|
||||
const entries = Array.from(files.values());
|
||||
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||
const chunkSize = 10;
|
||||
@@ -122,7 +154,7 @@ async function save() {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const formData = new FormData();
|
||||
chunk.forEach((entry) => formData.append('files', entry.file));
|
||||
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
|
||||
const metadata = {
|
||||
titles: chunk.map((e) => e.title),
|
||||
senderId: senderId || null,
|
||||
@@ -143,8 +175,8 @@ async function save() {
|
||||
if (!res.ok || errorFilenames.size > 0) {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
// When backend names specific files, mark only those; otherwise mark all.
|
||||
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
|
||||
const filename = entry.file?.name;
|
||||
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
|
||||
if (isError) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
@@ -160,9 +192,97 @@ async function save() {
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
saving = false;
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
|
||||
// --- Save (edit mode) ---
|
||||
async function saveBulkEdit() {
|
||||
const entries = Array.from(files.values());
|
||||
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
|
||||
|
||||
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
|
||||
// failure so the user sees a deterministic "X of N saved" outcome.
|
||||
const chunkSize = 500;
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||
chunks.push(ids.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
partialSaved = null;
|
||||
|
||||
const dto = {
|
||||
tagNames: tags.map((t) => t.name),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
documentLocation: documentLocation || null,
|
||||
archiveBox: archiveBox || null,
|
||||
archiveFolder: archiveFolder || null
|
||||
};
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
const res = await fetch('/api/documents/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...dto, documentIds: chunk })
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Network/server failure: the chunk did not apply. Mark its entries
|
||||
// as errored, surface partial-save state, and stop.
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
updated: number;
|
||||
errors: { id: string; message: string }[];
|
||||
} | null;
|
||||
if (body && body.errors && body.errors.length > 0) {
|
||||
for (const err of body.errors) {
|
||||
const e = files.get(err.id);
|
||||
if (e) files.set(err.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
|
||||
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
|
||||
if (!stillErrored) {
|
||||
bulkSelectionStore.clear();
|
||||
goto('/documents');
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
if (mode === 'edit') {
|
||||
await saveBulkEdit();
|
||||
} else {
|
||||
await saveUpload();
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySave() {
|
||||
partialSaved = null;
|
||||
await save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||
@@ -213,11 +333,11 @@ async function save() {
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview / drop zone (55%) -->
|
||||
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||
{#if files.size === 0}
|
||||
<!-- N=0: centred drop-zone box fills the panel -->
|
||||
{#if mode === 'upload' && files.size === 0}
|
||||
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
||||
<BulkDropZone onFilesAdded={addFiles} />
|
||||
{:else}
|
||||
<!-- N≥1: real PDF preview via local blob URL -->
|
||||
{:else if files.size > 0}
|
||||
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if activeFile}
|
||||
<PdfViewer url={activeFile.previewUrl} />
|
||||
@@ -243,22 +363,44 @@ async function save() {
|
||||
class:opacity-60={files.size === 0}
|
||||
class:pointer-events-none={files.size === 0}
|
||||
>
|
||||
{#if mode === 'edit'}
|
||||
<!-- Onboarding callout: tells the user that empty fields are skipped
|
||||
and that tags/receivers are added rather than replaced. -->
|
||||
<div
|
||||
role="note"
|
||||
aria-label="Hinweis zur Massenbearbeitung"
|
||||
data-testid="bulk-edit-callout"
|
||||
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
|
||||
>
|
||||
{m.bulk_edit_hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isMulti}
|
||||
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||
<ScopeCard variant="per-file">
|
||||
{#if activeFile}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{#if mode === 'edit'}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
</ScopeCard>
|
||||
|
||||
@@ -268,33 +410,51 @@ async function save() {
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:documentLocation={documentLocation}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle />
|
||||
</ScopeCard>
|
||||
{:else}
|
||||
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
{#if activeFile}
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="—"
|
||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
{#if mode === 'edit' && activeFile}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
{#if activeFile}
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="—"
|
||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WhoWhenSection
|
||||
@@ -302,8 +462,39 @@ async function save() {
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle />
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:documentLocation={documentLocation}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if partialSaved}
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="bulk-edit-partial-failure"
|
||||
class="rounded-sm border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{m.bulk_edit_save_partial({
|
||||
done: partialSaved.done,
|
||||
total: partialSaved.total
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={retrySave}
|
||||
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.bulk_edit_retry()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -312,3 +312,190 @@ describe('BulkDocumentEditLayout', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mode="edit" ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BulkDocumentEditLayout — mode="edit"', () => {
|
||||
const editEntry = (i: number) => ({
|
||||
documentId: `doc-${i}`,
|
||||
title: `Brief ${i}`,
|
||||
pdfUrl: `/api/documents/doc-${i}/file`
|
||||
});
|
||||
|
||||
it('does not render the BulkDropZone in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the onboarding callout with role=note in edit mode', async () => {
|
||||
render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const callout = page.getByTestId('bulk-edit-callout');
|
||||
await expect.element(callout).toBeInTheDocument();
|
||||
await expect.element(callout).toHaveAttribute('role', 'note');
|
||||
});
|
||||
|
||||
it('renders read-only title display (no input) in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
|
||||
// Per-file ScopeCard absent at N=1 — title rendered in the single card
|
||||
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
|
||||
expect(titleInput).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the date field via WhoWhenSection hideDate prop', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows additive badge next to tags label', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows replace badges next to sender and archive fields', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||
// sender + documentLocation + archiveBox + archiveFolder = 4
|
||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 2, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/documents/bulk');
|
||||
expect(init.method).toBe('PATCH');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
|
||||
});
|
||||
|
||||
it('chunks IDs into 500-sized PATCH requests', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 500, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
|
||||
});
|
||||
|
||||
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
|
||||
expect(alert).not.toBeNull();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Should have called twice — chunks 0 and 1 — but not the third.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks per-document error chips when service returns errors[]', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
updated: 1,
|
||||
errors: [{ id: 'doc-2', message: 'Sender not found' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChip = container.querySelector(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
|
||||
);
|
||||
expect(errorChip).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
46
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
46
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { canWrite }: { canWrite: boolean } = $props();
|
||||
|
||||
const count = $derived(bulkSelectionStore.size);
|
||||
|
||||
function openBulkEdit() {
|
||||
goto('/documents/bulk-edit');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bulkSelectionStore.clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if canWrite && count > 0}
|
||||
<div
|
||||
data-testid="bulk-selection-bar"
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
|
||||
>
|
||||
<span class="font-sans text-sm font-medium text-ink" data-testid="bulk-selection-count">
|
||||
{m.bulk_edit_n_selected({ count })}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
data-testid="bulk-clear-all"
|
||||
>
|
||||
{m.bulk_edit_clear_all()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openBulkEdit}
|
||||
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
data-testid="bulk-edit-open"
|
||||
>
|
||||
{m.bulk_edit_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import BulkSelectionBar from './BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
describe('BulkSelectionBar', () => {
|
||||
it('does not render when canWrite is false', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when selection is empty', async () => {
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the current selection count', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('clear button empties the store', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
tags = $bindable<Tag[]>([]),
|
||||
currentTitle = $bindable(''),
|
||||
documentLocation = $bindable(''),
|
||||
archiveBox = $bindable(''),
|
||||
archiveFolder = $bindable(''),
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false,
|
||||
suggestedTitle = '',
|
||||
hideTitle = false
|
||||
hideTitle = false,
|
||||
editMode = false
|
||||
}: {
|
||||
tags?: Tag[];
|
||||
currentTitle?: string;
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
hideTitle?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
let titleDirty = $state(false);
|
||||
currentTitle = untrack(() => initialTitle);
|
||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||
|
||||
// Initialize controlled location field once from the legacy initial-* props so
|
||||
// callers that haven't switched to the bindable form keep their existing
|
||||
// pre-fill behaviour.
|
||||
documentLocation = untrack(() => documentLocation || initialDocumentLocation);
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -67,40 +81,78 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
||||
|
||||
<!-- Schlagworte (optional) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_tags()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<TagInput bind:tags={tags} />
|
||||
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||
</div>
|
||||
|
||||
<!-- Inhalt (optional) -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Inhalt (optional) — not bulk-editable. -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aufbewahrungsort (optional) -->
|
||||
<div>
|
||||
<div data-testid="description-document-location">
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_archive_location()}</label
|
||||
>
|
||||
>{m.form_label_archive_location()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="documentLocation"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
bind:value={documentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
|
||||
{#if editMode}
|
||||
<!-- Karton (only in editMode — bulk-editable replace) -->
|
||||
<div data-testid="description-archive-box">
|
||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>Karton
|
||||
<FieldLabelBadge variant="replace" />
|
||||
</label>
|
||||
<input
|
||||
id="archiveBox"
|
||||
type="text"
|
||||
name="archiveBox"
|
||||
bind:value={archiveBox}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
||||
<div data-testid="description-archive-folder">
|
||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>Mappe
|
||||
<FieldLabelBadge variant="replace" />
|
||||
</label>
|
||||
<input
|
||||
id="archiveFolder"
|
||||
type="text"
|
||||
name="archiveFolder"
|
||||
bind:value={archiveFolder}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { variant }: { variant: 'additive' | 'replace' } = $props();
|
||||
|
||||
const text = $derived(
|
||||
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
|
||||
);
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-testid="field-label-badge-{variant}"
|
||||
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-gray-600"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('FieldLabelBadge', () => {
|
||||
it('renders the additive variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'additive' });
|
||||
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-additive'))
|
||||
.toHaveTextContent('+ wird hinzugefügt');
|
||||
});
|
||||
|
||||
it('renders the replace variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-replace'))
|
||||
.toHaveTextContent('wird ersetzt');
|
||||
});
|
||||
|
||||
it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-replace'))
|
||||
.toHaveClass(/text-gray-600/);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
/** Present in upload mode only. Edit mode entries reference an existing
|
||||
* document by `documentId` and have no local file blob. */
|
||||
file?: File;
|
||||
/** Present in edit mode only — the server-side document UUID being edited. */
|
||||
documentId?: string;
|
||||
title: string;
|
||||
status: 'idle' | 'error';
|
||||
previewUrl: string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -16,7 +17,9 @@ let {
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
suggestedDateIso = '',
|
||||
suggestedSenderName = ''
|
||||
suggestedSenderName = '',
|
||||
hideDate = false,
|
||||
editMode = false
|
||||
}: {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
@@ -26,6 +29,8 @@ let {
|
||||
initialSenderName?: string;
|
||||
suggestedDateIso?: string;
|
||||
suggestedSenderName?: string;
|
||||
hideDate?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||
@@ -56,60 +61,72 @@ $effect(() => {
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div>
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !hideDate}
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div data-testid="who-when-date">
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required — row 1, col 2) -->
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
required={true}
|
||||
required={!editMode}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
badge={editMode ? 'replace' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger (optional — row 2, col 1) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_receivers()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
|
||||
<!-- Ort (optional — row 2, col 2) -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
|
||||
NOT bulk-editable per the issue spec; the three editable location
|
||||
fields live in DescriptionSection. -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ErrorCode =
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
@@ -142,6 +143,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
return m.error_batch_too_large();
|
||||
case 'BULK_EDIT_TOO_MANY_IDS':
|
||||
return m.error_bulk_edit_too_many_ids();
|
||||
default:
|
||||
return m.error_internal_error();
|
||||
}
|
||||
|
||||
@@ -484,6 +484,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/batch-metadata": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["batchMetadata"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -676,6 +692,22 @@ export interface paths {
|
||||
patch: operations["updateAnnotation"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/bulk": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["patchBulk"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1156,6 +1188,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/ids": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentIds"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1707,6 +1755,15 @@ export interface components {
|
||||
filename?: string;
|
||||
code?: string;
|
||||
};
|
||||
BatchMetadataRequest: {
|
||||
ids: string[];
|
||||
};
|
||||
DocumentBatchSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
pdfUrl: string;
|
||||
};
|
||||
ResetPasswordRequest: {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
@@ -1782,6 +1839,26 @@ export interface components {
|
||||
/** Format: double */
|
||||
height?: number;
|
||||
};
|
||||
DocumentBulkEditDTO: {
|
||||
documentIds?: string[];
|
||||
tagNames?: string[];
|
||||
/** Format: uuid */
|
||||
senderId?: string;
|
||||
receiverIds?: string[];
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
};
|
||||
BulkEditError: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
message: string;
|
||||
};
|
||||
BulkEditResult: {
|
||||
/** Format: int32 */
|
||||
updated: number;
|
||||
errors: components["schemas"]["BulkEditError"][];
|
||||
};
|
||||
TranscriptionWeeklyStatsDTO: {
|
||||
/** Format: int64 */
|
||||
segmentationCount: number;
|
||||
@@ -1833,7 +1910,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
personType?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
@@ -1844,6 +1920,7 @@ export interface components {
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
@@ -3242,6 +3319,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
batchMetadata: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BatchMetadataRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentBatchSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resetPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3578,6 +3679,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
patchBulk: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentBulkEditDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BulkEditResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -4244,6 +4369,36 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentIds: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tag?: string[];
|
||||
tagQ?: string;
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
tagOp?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
53
frontend/src/lib/stores/bulkSelection.svelte.spec.ts
Normal file
53
frontend/src/lib/stores/bulkSelection.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { bulkSelectionStore } from './bulkSelection.svelte';
|
||||
|
||||
describe('bulkSelectionStore', () => {
|
||||
afterEach(() => bulkSelectionStore.clear());
|
||||
|
||||
it('starts empty', () => {
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('toggle adds an id when absent', () => {
|
||||
bulkSelectionStore.toggle('a');
|
||||
expect(bulkSelectionStore.has('a')).toBe(true);
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
});
|
||||
|
||||
it('toggle removes an id when present', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.toggle('a');
|
||||
expect(bulkSelectionStore.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('add and remove update size', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
expect(bulkSelectionStore.size).toBe(2);
|
||||
bulkSelectionStore.remove('a');
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
expect(bulkSelectionStore.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('add is idempotent', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('a');
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
});
|
||||
|
||||
it('setAll replaces the selection', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
bulkSelectionStore.setAll(['c', 'd', 'e']);
|
||||
expect(bulkSelectionStore.size).toBe(3);
|
||||
expect(bulkSelectionStore.has('a')).toBe(false);
|
||||
expect(bulkSelectionStore.has('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear empties the selection', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
bulkSelectionStore.clear();
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
});
|
||||
36
frontend/src/lib/stores/bulkSelection.svelte.ts
Normal file
36
frontend/src/lib/stores/bulkSelection.svelte.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
// Live accumulator. Selection persists across pagination and route changes
|
||||
// within /documents and /enrich. Cleared on successful bulk save or via
|
||||
// "Alles aufheben". The store is module-singleton — there is only ever one
|
||||
// bulk-edit selection per browser session.
|
||||
const selectedIds = new SvelteSet<string>();
|
||||
|
||||
export const bulkSelectionStore = {
|
||||
get ids(): SvelteSet<string> {
|
||||
return selectedIds;
|
||||
},
|
||||
get size(): number {
|
||||
return selectedIds.size;
|
||||
},
|
||||
has(id: string): boolean {
|
||||
return selectedIds.has(id);
|
||||
},
|
||||
toggle(id: string): void {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||||
else selectedIds.add(id);
|
||||
},
|
||||
add(id: string): void {
|
||||
selectedIds.add(id);
|
||||
},
|
||||
remove(id: string): void {
|
||||
selectedIds.delete(id);
|
||||
},
|
||||
setAll(ids: Iterable<string>): void {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
clear(): void {
|
||||
selectedIds.clear();
|
||||
}
|
||||
};
|
||||
@@ -119,7 +119,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||
</div>
|
||||
<ul class="divide-y divide-line">
|
||||
{#each group.items as item (group.label + '-' + item.document.id)}
|
||||
<DocumentRow item={item} />
|
||||
<DocumentRow item={item} canWrite={canWrite} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/components/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -138,6 +140,45 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
let editingAll = $state(false);
|
||||
|
||||
/**
|
||||
* Fast path: replace the current selection with every document matching the
|
||||
* active filter (across all pages) and jump to the bulk-edit screen. The
|
||||
* /api/documents/ids endpoint is uncapped — chunking happens at PATCH time
|
||||
* inside the bulk-edit page's save handler.
|
||||
*/
|
||||
async function editAllMatching() {
|
||||
if (editingAll) return;
|
||||
editingAll = true;
|
||||
try {
|
||||
const params = buildSearchParams({
|
||||
q: data.q || '',
|
||||
from: data.from || '',
|
||||
to: data.to || '',
|
||||
senderId: data.senderId || '',
|
||||
receiverId: data.receiverId || '',
|
||||
tags: data.tags || [],
|
||||
sort: '',
|
||||
dir: '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
});
|
||||
params.delete('sort');
|
||||
params.delete('dir');
|
||||
const res = await fetch(`/api/documents/ids?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
editingAll = false;
|
||||
return;
|
||||
}
|
||||
const ids: string[] = await res.json();
|
||||
bulkSelectionStore.setAll(ids);
|
||||
await goto('/documents/bulk-edit');
|
||||
} finally {
|
||||
editingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep local filter state in sync with server data after navigation completes.
|
||||
// Guard q: skip overwrite while the user is actively typing.
|
||||
$effect(() => {
|
||||
@@ -181,6 +222,20 @@ $effect(() => {
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if data.canWrite && data.totalElements > 0}
|
||||
<div class="mb-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.totalElements}
|
||||
@@ -192,3 +247,5 @@ $effect(() => {
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
10
frontend/src/routes/documents/bulk-edit/+page.server.ts
Normal file
10
frontend/src/routes/documents/bulk-edit/+page.server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw redirect(303, '/documents');
|
||||
return { canWrite };
|
||||
}
|
||||
53
frontend/src/routes/documents/bulk-edit/+page.svelte
Normal file
53
frontend/src/routes/documents/bulk-edit/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import BulkDocumentEditLayout, {
|
||||
type BulkEditEntry
|
||||
} from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let entries = $state<BulkEditEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const ids = Array.from(bulkSelectionStore.ids);
|
||||
if (ids.length === 0) {
|
||||
await goto('/documents');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/documents/batch-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
if (!res.ok) {
|
||||
error = m.error_internal_error();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const summaries = (await res.json()) as BulkEditEntry[];
|
||||
entries = summaries;
|
||||
} catch {
|
||||
error = m.error_internal_error();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.bulk_edit_title()} – Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex h-full items-center justify-center p-12 text-sm text-ink-2">…</div>
|
||||
{:else if error}
|
||||
<div class="m-6 rounded-sm border border-red-300 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{:else if entries.length > 0}
|
||||
<BulkDocumentEditLayout mode="edit" initialEditEntries={entries} />
|
||||
{/if}
|
||||
46
frontend/src/routes/documents/bulk-edit/page.server.spec.ts
Normal file
46
frontend/src/routes/documents/bulk-edit/page.server.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
|
||||
describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
it('redirects to /documents when user lacks WRITE_ALL', async () => {
|
||||
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect to be thrown');
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; location?: string };
|
||||
expect(err.status).toBe(303);
|
||||
expect(err.location).toBe('/documents');
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects when user has no groups', async () => {
|
||||
const locals = { user: { groups: [] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects when no user is logged in', async () => {
|
||||
const locals = {};
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns canWrite=true for a WRITE_ALL user', async () => {
|
||||
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
const result = await load({ locals });
|
||||
expect(result).toEqual({ canWrite: true });
|
||||
});
|
||||
});
|
||||
@@ -19,5 +19,5 @@ export async function load({
|
||||
|
||||
const documents = result.response.ok ? (result.data ?? []) : [];
|
||||
|
||||
return { documents };
|
||||
return { documents, canWrite };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const documents = $derived(data.documents);
|
||||
const count = $derived(documents.length);
|
||||
const canWrite = $derived(data.canWrite);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
@@ -61,8 +63,24 @@ const count = $derived(documents.length);
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
<ul class="divide-y divide-line-2">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="group transition-colors duration-200 hover:bg-muted">
|
||||
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
||||
<li class="group relative transition-colors duration-200 hover:bg-muted">
|
||||
<a href="/enrich/{doc.id}" class="absolute inset-0 z-0 block" aria-label={doc.title}
|
||||
></a>
|
||||
<div class="pointer-events-none relative z-10 flex items-center justify-between p-6">
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto mr-4 flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-center"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: doc.title })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
|
||||
{doc.title}
|
||||
@@ -74,10 +92,12 @@ const count = $derived(documents.length);
|
||||
aria-hidden="true"
|
||||
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkSelectionBar canWrite={canWrite} />
|
||||
|
||||
Reference in New Issue
Block a user