Compare commits
2 Commits
2e6cc346ab
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c641d704a8 | ||
|
|
69ac183fe8 |
3
backend/api_tests/Transcription.http
Normal file
3
backend/api_tests/Transcription.http
Normal file
@@ -0,0 +1,3 @@
|
||||
### Mark all blocks as reviewed
|
||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
||||
Authorization: Basic admin admin123
|
||||
@@ -90,6 +90,15 @@ public class TranscriptionBlockController {
|
||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
||||
}
|
||||
|
||||
@PutMapping("/review-all")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||
@PathVariable UUID documentId,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/history")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||
|
||||
@@ -205,6 +205,18 @@ public class TranscriptionService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
||||
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||
for (TranscriptionBlock block : blocks) {
|
||||
if (!block.isReviewed()) {
|
||||
block.setReviewed(true);
|
||||
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
||||
}
|
||||
}
|
||||
return blockRepository.saveAll(blocks);
|
||||
}
|
||||
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||
getBlock(documentId, blockId);
|
||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||
|
||||
@@ -380,4 +380,63 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
|
||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||
|
||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 1").sortOrder(0).reviewed(true).build();
|
||||
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 2").sortOrder(1).reviewed(true).build();
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of(b1, b2));
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||
.andExpect(jsonPath("$[1].reviewed").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,4 +506,86 @@ class TranscriptionServiceTest {
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(List.of(block1, block2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
onReviewToggle: (blockId: string) => Promise<void>;
|
||||
onMarkAllReviewed?: () => Promise<void>;
|
||||
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||
canWrite?: boolean;
|
||||
trainingLabels?: string[];
|
||||
@@ -37,6 +38,7 @@ let {
|
||||
onSaveBlock,
|
||||
onDeleteBlock,
|
||||
onReviewToggle,
|
||||
onMarkAllReviewed,
|
||||
onTriggerOcr,
|
||||
canWrite = false,
|
||||
trainingLabels = [],
|
||||
@@ -46,12 +48,14 @@ let {
|
||||
let activeBlockId: string | null = $state(null);
|
||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let markingAllReviewed = $state(false);
|
||||
|
||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const hasBlocks = $derived(blocks.length > 0);
|
||||
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||
const totalCount = $derived(blocks.length);
|
||||
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
|
||||
|
||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||
$effect(() => {
|
||||
@@ -60,6 +64,16 @@ $effect(() => {
|
||||
if (block) activeBlockId = block.id;
|
||||
});
|
||||
|
||||
async function handleMarkAllReviewed() {
|
||||
if (!onMarkAllReviewed) return;
|
||||
markingAllReviewed = true;
|
||||
try {
|
||||
await onMarkAllReviewed();
|
||||
} finally {
|
||||
markingAllReviewed = false;
|
||||
}
|
||||
}
|
||||
|
||||
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||
|
||||
const dragDrop = createBlockDragDrop({
|
||||
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
|
||||
{#if hasBlocks}
|
||||
<!-- Sticky review progress header -->
|
||||
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||
<p class="font-sans text-xs text-ink-2">
|
||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-sans text-xs text-ink-2">
|
||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||
</p>
|
||||
{#if onMarkAllReviewed}
|
||||
<button
|
||||
onclick={handleMarkAllReviewed}
|
||||
disabled={allReviewed || markingAllReviewed}
|
||||
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
||||
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
||||
>
|
||||
{#if markingAllReviewed}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
Alle als fertig markieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||
|
||||
@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
|
||||
};
|
||||
}
|
||||
|
||||
const unreviewedBlock1 = { ...block1, reviewed: false };
|
||||
const unreviewedBlock2 = { ...block2, reviewed: false };
|
||||
const reviewedBlock1 = { ...block1, reviewed: true };
|
||||
const reviewedBlock2 = { ...block2, reviewed: true };
|
||||
|
||||
describe('TranscriptionEditView — rendering', () => {
|
||||
it('renders blocks in sort order', async () => {
|
||||
renderView();
|
||||
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
|
||||
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button when all blocks are already reviewed', async () => {
|
||||
renderView({
|
||||
blocks: [reviewedBlock1, reviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
|
||||
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('disables button while operation is in-flight', async () => {
|
||||
let resolveMarkAll!: () => void;
|
||||
const onMarkAllReviewed = vi
|
||||
.fn()
|
||||
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
resolveMarkAll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,18 @@ async function reviewToggle(blockId: string) {
|
||||
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
||||
}
|
||||
|
||||
async function markAllReviewed() {
|
||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
for (const b of updated) {
|
||||
const existing = transcriptionBlocks.find((x) => x.id === b.id);
|
||||
if (existing) existing.reviewed = b.reviewed;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTrainingLabel(label: string, enrolled: boolean) {
|
||||
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
|
||||
method: 'PATCH',
|
||||
@@ -501,6 +513,7 @@ onMount(() => {
|
||||
onSaveBlock={saveBlock}
|
||||
onDeleteBlock={deleteBlock}
|
||||
onReviewToggle={reviewToggle}
|
||||
onMarkAllReviewed={markAllReviewed}
|
||||
onTriggerOcr={triggerOcr}
|
||||
onToggleTrainingLabel={toggleTrainingLabel}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user