feat(transcription): let read-only users read transcriptions (read tab only, no edit) (#697) #700
@@ -177,6 +177,13 @@ public class Document {
|
||||
@Builder.Default
|
||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||
|
||||
// Not persisted — computed per detail fetch so read-only users can tell at first
|
||||
// paint whether there is a transcription to read (DocumentService.getDocumentById).
|
||||
@Transient
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private boolean hasTranscription = false;
|
||||
|
||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||
|
||||
@@ -138,7 +138,7 @@ public class DocumentController {
|
||||
// --- METADATA ---
|
||||
@GetMapping("/{id}")
|
||||
public Document getDocument(@PathVariable UUID id) {
|
||||
return documentService.getDocumentById(id);
|
||||
return documentService.getDocumentDetail(id);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
|
||||
@@ -946,6 +946,19 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
* existence query only runs for the single-document detail endpoint, not for the
|
||||
* many internal callers that never read the flag.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Document getDocumentDetail(UUID id) {
|
||||
Document doc = getDocumentById(id);
|
||||
doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
|
||||
return doc;
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||
return documentRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
|
||||
|
||||
private final TranscriptionBlockRepository blockRepository;
|
||||
|
||||
public boolean hasBlocks(UUID documentId) {
|
||||
return blockRepository.existsByDocumentId(documentId);
|
||||
}
|
||||
|
||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, Integer> result = new HashMap<>();
|
||||
|
||||
@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
|
||||
boolean existsByDocumentId(UUID documentId);
|
||||
|
||||
@Query("""
|
||||
SELECT b FROM TranscriptionBlock b
|
||||
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
||||
|
||||
@@ -118,6 +118,37 @@ class DocumentServiceTest {
|
||||
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentById_doesNotQueryTranscription() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Test").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
documentService.getDocumentById(id);
|
||||
|
||||
verifyNoInteractions(transcriptionBlockQueryService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentDetail_setsHasTranscriptionTrue_whenBlocksExist() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Test").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true);
|
||||
|
||||
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDocumentDetail_setsHasTranscriptionFalse_whenNoBlocksExist() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Test").build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false);
|
||||
|
||||
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isFalse();
|
||||
}
|
||||
|
||||
// ─── updateDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -83,6 +83,15 @@ class AnnotationControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void createAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(ANNOTATION_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void patchAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(PATCH_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
||||
|
||||
@@ -94,6 +94,15 @@ class CommentControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void postBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
@@ -142,6 +151,16 @@ class CommentControllerTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void replyToBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||
@@ -181,6 +200,14 @@ class CommentControllerTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void editComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
|
||||
@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void createBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(CREATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
||||
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(UPDATE_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
||||
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderBlocks_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REORDER_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reviewBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||
DOC_ID, BLOCK_ID).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||
|
||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.raddatz.familienarchiv.document.transcription;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TranscriptionBlockQueryServiceTest {
|
||||
|
||||
@Mock TranscriptionBlockRepository blockRepository;
|
||||
@InjectMocks TranscriptionBlockQueryService queryService;
|
||||
|
||||
@Test
|
||||
void hasBlocks_returns_true_when_a_block_exists() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(blockRepository.existsByDocumentId(documentId)).thenReturn(true);
|
||||
|
||||
assertThat(queryService.hasBlocks(documentId)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasBlocks_returns_false_when_no_block_exists() {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(blockRepository.existsByDocumentId(documentId)).thenReturn(false);
|
||||
|
||||
assertThat(queryService.hasBlocks(documentId)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -102,4 +102,22 @@ class TranscriptionBlockRepositoryIntegrationTest {
|
||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)"
|
||||
})
|
||||
void existsByDocumentId_returns_true_when_document_has_a_block() {
|
||||
assertThat(repository.existsByDocumentId(DOC_A)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(statements = {
|
||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
|
||||
})
|
||||
void existsByDocumentId_returns_false_when_document_has_no_blocks() {
|
||||
assertThat(repository.existsByDocumentId(DOC_A)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
98
frontend/e2e/transcription-read-only.spec.ts
Normal file
98
frontend/e2e/transcription-read-only.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
|
||||
/**
|
||||
* E2E for issue #697 — read-only users can read an existing transcription.
|
||||
*
|
||||
* Setup runs as admin (default storage state): a PDF document with one
|
||||
* transcription block, so hasTranscription is true. The assertions run in a
|
||||
* fresh context logged in as the seeded READ_ALL-only "reader": they can open
|
||||
* the read view but see no edit tab and no per-block edit controls, and the
|
||||
* panel cannot be switched to edit.
|
||||
*/
|
||||
|
||||
let docId: string;
|
||||
let docHref: string;
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Read-only user reads an existing transcription', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
const uniqueSuffix = Date.now();
|
||||
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: {
|
||||
title: `E2E Read-only Transcription ${uniqueSuffix}`,
|
||||
documentDate: '1945-05-08'
|
||||
}
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
docId = (await docRes.json()).id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: `E2E Read-only Transcription ${uniqueSuffix}`,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||
});
|
||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.1,
|
||||
text: 'Liebe Mutter, viele Grüße vom Mai 1945',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('reader opens the read view with no edit tab or edit controls', async ({ browser }) => {
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await login(page, 'reader', 'reader123');
|
||||
await page.goto(docHref);
|
||||
|
||||
// Reader entry control is the read label, not "Transkribieren".
|
||||
const readButton = page.getByRole('button', { name: /Transkription lesen/i });
|
||||
await expect(readButton).toBeVisible({ timeout: 5000 });
|
||||
await readButton.click();
|
||||
|
||||
// Read view shows the transcription text.
|
||||
await expect(page.getByText(/Mai 1945/)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Header is a plain "Transkription" label, not a Lesen/Bearbeiten toggle.
|
||||
await expect(page.getByRole('heading', { name: /^Transkription$/i })).toBeVisible();
|
||||
await expect(page.getByTestId('mode-edit')).toHaveCount(0);
|
||||
await expect(page.getByTestId('mode-read')).toHaveCount(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -603,6 +603,8 @@
|
||||
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
|
||||
"doc_details_more_receivers": "+{count} weitere",
|
||||
"transcription_mode_label": "Transkribieren",
|
||||
"transcription_read_label": "Transkription lesen",
|
||||
"transcription_panel_title": "Transkription",
|
||||
"transcription_mode_stop": "Fertig",
|
||||
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
||||
"transcription_block_save_saving": "Speichere...",
|
||||
|
||||
@@ -603,6 +603,8 @@
|
||||
"doc_details_no_tags": "No tags assigned",
|
||||
"doc_details_more_receivers": "+{count} more",
|
||||
"transcription_mode_label": "Transcribe",
|
||||
"transcription_read_label": "Read transcription",
|
||||
"transcription_panel_title": "Transcription",
|
||||
"transcription_mode_stop": "Done",
|
||||
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
||||
"transcription_block_save_saving": "Saving...",
|
||||
|
||||
@@ -603,6 +603,8 @@
|
||||
"doc_details_no_tags": "No hay etiquetas asignadas",
|
||||
"doc_details_more_receivers": "+{count} más",
|
||||
"transcription_mode_label": "Transcribir",
|
||||
"transcription_read_label": "Leer transcripción",
|
||||
"transcription_panel_title": "Transcripción",
|
||||
"transcription_mode_stop": "Listo",
|
||||
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
||||
"transcription_block_save_saving": "Guardando...",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
type Props = {
|
||||
canWrite: boolean;
|
||||
isPdf: boolean;
|
||||
hasTranscription: boolean;
|
||||
transcribeMode: boolean;
|
||||
filePath?: string | null;
|
||||
originalFilename?: string | null;
|
||||
@@ -14,12 +15,18 @@ type Props = {
|
||||
let {
|
||||
canWrite,
|
||||
isPdf,
|
||||
hasTranscription,
|
||||
transcribeMode = $bindable(),
|
||||
filePath = null,
|
||||
originalFilename = null,
|
||||
fileUrl
|
||||
}: Props = $props();
|
||||
|
||||
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
|
||||
const transcriptionLabel = $derived(
|
||||
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
|
||||
);
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
function startTranscribe() {
|
||||
@@ -50,10 +57,10 @@ function startTranscribe() {
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{#if canOpenTranscription && !transcribeMode}
|
||||
<button
|
||||
onclick={startTranscribe}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-label={transcriptionLabel}
|
||||
aria-pressed={false}
|
||||
class="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
@@ -70,7 +77,7 @@ function startTranscribe() {
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
{transcriptionLabel}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ afterEach(cleanup);
|
||||
const baseProps = {
|
||||
canWrite: false,
|
||||
isPdf: false,
|
||||
hasTranscription: false,
|
||||
transcribeMode: false,
|
||||
filePath: null as string | null,
|
||||
originalFilename: 'brief.pdf' as string | null,
|
||||
@@ -49,6 +50,43 @@ describe('DocumentMobileMenu', () => {
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows no transcription action for a read-only user when no transcription exists', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: false,
|
||||
isPdf: true,
|
||||
hasTranscription: false,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkription lesen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the read-transcription action for a read-only user when a transcription exists', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: false,
|
||||
isPdf: true,
|
||||
hasTranscription: true,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the transcribe action when already in transcribeMode', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: {
|
||||
|
||||
@@ -28,6 +28,7 @@ type Doc = {
|
||||
location?: string | null;
|
||||
status?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
hasTranscription?: boolean;
|
||||
};
|
||||
|
||||
type GeschichteSummary = {
|
||||
@@ -132,6 +133,7 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
documentId={doc.id}
|
||||
canWrite={canWrite}
|
||||
isPdf={!!isPdf}
|
||||
hasTranscription={!!doc.hasTranscription}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
filePath={doc.filePath}
|
||||
originalFilename={doc.originalFilename}
|
||||
@@ -143,6 +145,7 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
<DocumentMobileMenu
|
||||
canWrite={canWrite}
|
||||
isPdf={!!isPdf}
|
||||
hasTranscription={!!doc.hasTranscription}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
filePath={doc.filePath}
|
||||
originalFilename={doc.originalFilename}
|
||||
|
||||
@@ -5,6 +5,7 @@ type Props = {
|
||||
documentId: string;
|
||||
canWrite: boolean;
|
||||
isPdf: boolean;
|
||||
hasTranscription: boolean;
|
||||
transcribeMode: boolean;
|
||||
filePath?: string | null;
|
||||
originalFilename?: string | null;
|
||||
@@ -15,17 +16,23 @@ let {
|
||||
documentId,
|
||||
canWrite,
|
||||
isPdf,
|
||||
hasTranscription,
|
||||
transcribeMode = $bindable(),
|
||||
filePath = null,
|
||||
originalFilename = null,
|
||||
fileUrl
|
||||
}: Props = $props();
|
||||
|
||||
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
|
||||
const transcriptionLabel = $derived(
|
||||
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{#if canOpenTranscription && !transcribeMode}
|
||||
<button
|
||||
onclick={() => (transcribeMode = true)}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-label={transcriptionLabel}
|
||||
aria-pressed={false}
|
||||
class="hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex"
|
||||
>
|
||||
@@ -42,7 +49,7 @@ let {
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
{transcriptionLabel}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const baseProps = {
|
||||
documentId: 'd1',
|
||||
canWrite: false,
|
||||
isPdf: false,
|
||||
hasTranscription: false,
|
||||
transcribeMode: false,
|
||||
filePath: null as string | null,
|
||||
originalFilename: 'brief.pdf' as string | null,
|
||||
@@ -34,6 +35,56 @@ describe('DocumentTopBarActions', () => {
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows no transcription control for a read-only user when no transcription exists', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: false,
|
||||
isPdf: true,
|
||||
hasTranscription: false,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkription lesen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the read-transcription button for a read-only user when a transcription exists', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: false,
|
||||
isPdf: true,
|
||||
hasTranscription: true,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /^transkribieren$/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the transcribe (edit) label for a writer regardless of hasTranscription', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: true,
|
||||
isPdf: true,
|
||||
hasTranscription: false,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the transcribe button when not a PDF', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
transcribeMode?: boolean;
|
||||
canAnnotate?: boolean;
|
||||
blockNumbers?: Record<string, number>;
|
||||
annotationReloadKey?: number;
|
||||
activeAnnotationId: string | null;
|
||||
@@ -33,6 +34,7 @@ let {
|
||||
isLoading,
|
||||
error,
|
||||
transcribeMode = false,
|
||||
canAnnotate = false,
|
||||
blockNumbers = {},
|
||||
annotationReloadKey = 0,
|
||||
activeAnnotationId = $bindable(),
|
||||
@@ -93,6 +95,7 @@ let {
|
||||
url={fileUrl}
|
||||
documentId={doc.id}
|
||||
transcribeMode={transcribeMode}
|
||||
canAnnotate={canAnnotate}
|
||||
blockNumbers={blockNumbers}
|
||||
annotationReloadKey={annotationReloadKey}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
|
||||
@@ -8,11 +8,20 @@ type Props = {
|
||||
hasBlocks: boolean;
|
||||
blockCount: number;
|
||||
lastEditedAt: string | null;
|
||||
canEdit?: boolean;
|
||||
onModeChange: (mode: 'read' | 'edit') => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props();
|
||||
let {
|
||||
mode,
|
||||
hasBlocks,
|
||||
blockCount,
|
||||
lastEditedAt,
|
||||
canEdit = true,
|
||||
onModeChange,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
const formattedDate = $derived(
|
||||
lastEditedAt
|
||||
@@ -34,37 +43,41 @@ function handleReadClick() {
|
||||
<div
|
||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||
>
|
||||
<!-- Segmented toggle + help chip -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
<!-- Segmented toggle + help chip for editors; plain title for read-only users -->
|
||||
{#if canEdit}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="text-[16px] font-semibold text-ink">{m.transcription_panel_title()}</h2>
|
||||
{/if}
|
||||
|
||||
<!-- Status line (hidden on mobile to save space) -->
|
||||
<p class="hidden text-xs text-ink-2 md:block">
|
||||
|
||||
@@ -22,6 +22,27 @@ describe('TranscriptionPanelHeader', () => {
|
||||
await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders both tabs when canEdit is true', async () => {
|
||||
render(TranscriptionPanelHeader, { ...baseProps, canEdit: true });
|
||||
|
||||
await expect.element(page.getByTestId('mode-read')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('mode-edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the edit tab and shows a plain title when canEdit is false', async () => {
|
||||
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false });
|
||||
|
||||
await expect.element(page.getByTestId('mode-edit')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('mode-read')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('heading', { name: /^transkription$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the section status line visible for readers (canEdit false)', async () => {
|
||||
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false, blockCount: 3 });
|
||||
|
||||
await expect.element(page.getByText('3 Abschnitte')).toBeVisible();
|
||||
});
|
||||
|
||||
it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
...baseProps,
|
||||
|
||||
@@ -14,6 +14,7 @@ let {
|
||||
url,
|
||||
documentId = '',
|
||||
transcribeMode = false,
|
||||
canAnnotate = false,
|
||||
blockNumbers = {},
|
||||
annotationReloadKey = 0,
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
@@ -28,6 +29,7 @@ let {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
transcribeMode?: boolean;
|
||||
canAnnotate?: boolean;
|
||||
blockNumbers?: Record<string, number>;
|
||||
annotationReloadKey?: number;
|
||||
activeAnnotationId?: string | null;
|
||||
@@ -262,7 +264,7 @@ function handleAnnotationClick(id: string) {
|
||||
annotations={visibleAnnotations.filter(
|
||||
(a) => a.pageNumber === renderer.currentPage
|
||||
)}
|
||||
canDraw={transcribeMode}
|
||||
canDraw={transcribeMode && canAnnotate}
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
blockNumbers={blockNumbers}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
|
||||
@@ -86,6 +86,38 @@ describe('PdfViewer — loaded state', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('makes the annotation surface drawable (crosshair) when transcribeMode and canAnnotate', async () => {
|
||||
render(PdfViewer, {
|
||||
url: '/api/documents/test/file',
|
||||
documentId: 'test',
|
||||
transcribeMode: true,
|
||||
canAnnotate: true,
|
||||
libLoader: makeFakeLibLoader()
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const surface = document.querySelector('[role="presentation"]');
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface?.getAttribute('style') ?? '').toContain('crosshair');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not make the annotation surface drawable when canAnnotate is false (read-only user)', async () => {
|
||||
render(PdfViewer, {
|
||||
url: '/api/documents/test/file',
|
||||
documentId: 'test',
|
||||
transcribeMode: true,
|
||||
canAnnotate: false,
|
||||
libLoader: makeFakeLibLoader()
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('.bg-pdf-bg')).not.toBeNull();
|
||||
});
|
||||
const surface = document.querySelector('[role="presentation"]');
|
||||
expect(surface?.getAttribute('style') ?? '').not.toContain('crosshair');
|
||||
});
|
||||
|
||||
it('renders the canvas region when documentFileHash is provided', async () => {
|
||||
render(PdfViewer, {
|
||||
url: '/api/documents/test/file',
|
||||
|
||||
@@ -1758,6 +1758,7 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
hasTranscription: boolean;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
PersonMention: {
|
||||
|
||||
@@ -71,7 +71,7 @@ const ocrJob = createOcrJob({
|
||||
onJobFinished: async () => {
|
||||
await transcription.load();
|
||||
transcription.bumpAnnotationReloadKey();
|
||||
panelMode = transcription.hasBlocks ? 'read' : 'edit';
|
||||
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,10 +148,10 @@ $effect(() => {
|
||||
if (skipInitialPanelMode) {
|
||||
skipInitialPanelMode = false;
|
||||
} else {
|
||||
panelMode = transcription.hasBlocks ? 'read' : 'edit';
|
||||
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
|
||||
}
|
||||
});
|
||||
ocrJob.checkStatus();
|
||||
if (canWrite) ocrJob.checkStatus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -252,6 +252,7 @@ onMount(() => {
|
||||
isLoading={fileLoader.isLoading}
|
||||
error={fileLoader.fileError}
|
||||
transcribeMode={transcribeMode && !ocrJob.running}
|
||||
canAnnotate={canWrite}
|
||||
blockNumbers={transcription.blockNumbers}
|
||||
annotationReloadKey={transcription.annotationReloadKey}
|
||||
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
||||
@@ -293,7 +294,10 @@ onMount(() => {
|
||||
hasBlocks={transcription.hasBlocks}
|
||||
blockCount={transcription.blocks.length}
|
||||
lastEditedAt={transcription.lastEditedAt}
|
||||
onModeChange={(newMode) => (panelMode = newMode)}
|
||||
canEdit={canWrite}
|
||||
onModeChange={(newMode) => {
|
||||
if (canWrite) panelMode = newMode;
|
||||
}}
|
||||
onClose={() => (transcribeMode = false)}
|
||||
/>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('documents/[id] page', () => {
|
||||
try {
|
||||
mockPage.url = new URL('http://localhost/documents/d-ocr-fail?task=transcribe');
|
||||
render(DocumentDetailPage, {
|
||||
props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-fail' } }) }
|
||||
props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-fail' } }) }
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="panel-close"]')).not.toBeNull();
|
||||
@@ -391,7 +391,7 @@ describe('documents/[id] page', () => {
|
||||
try {
|
||||
mockPage.url = new URL('http://localhost/documents/d-ocr-run?task=transcribe');
|
||||
render(DocumentDetailPage, {
|
||||
props: { data: baseData({ document: { ...baseDoc, id: 'd-ocr-run' } }) }
|
||||
props: { data: baseData({ canWrite: true, document: { ...baseDoc, id: 'd-ocr-run' } }) }
|
||||
});
|
||||
await expect.element(browserPage.getByText('OCR läuft')).toBeVisible();
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user