feat(journey-editor): JourneyEditor frontend — issue #753 #792
@@ -163,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_ITEM_NOT_IN_JOURNEY`, `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -271,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_ITEM_NOT_IN_JOURNEY`, `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.list")
|
||||
Page<Document> findAll(Pageable pageable);
|
||||
|
||||
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||
@EntityGraph("Document.list")
|
||||
List<Document> findByIdIn(Collection<UUID> ids);
|
||||
|
||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
|
||||
@@ -858,7 +858,7 @@ public class DocumentService {
|
||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||
pageIds.add(ftsPage.hits().get(i).id());
|
||||
}
|
||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||
|
||||
@@ -128,6 +128,8 @@ public enum ErrorCode {
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
||||
GESCHICHTE_TYPE_MISMATCH,
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ public class GeschichteController {
|
||||
public List<GeschichteSummary> list(
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||
return geschichteService.list(
|
||||
status,
|
||||
personIds == null ? List.of() : personIds,
|
||||
documentId,
|
||||
limit);
|
||||
}
|
||||
|
||||
@@ -51,14 +53,14 @@ public class GeschichteController {
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
return geschichteService.update(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, J
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.body AS body
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
@@ -33,11 +33,15 @@ public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, J
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
AND (:documentId IS NULL OR
|
||||
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount);
|
||||
@Param("personCount") long personCount,
|
||||
@Param("documentId") UUID documentId);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ public class GeschichteService {
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*/
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, int limit) {
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
@@ -119,7 +119,7 @@ public class GeschichteService {
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount)
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -127,13 +127,18 @@ public class GeschichteService {
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||
|
||||
@Transactional
|
||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||
requireTitle(dto.getTitle());
|
||||
Geschichte g = Geschichte.builder()
|
||||
.title(dto.getTitle().trim())
|
||||
.body(sanitize(dto.getBody()))
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(dto.getType() != null ? dto.getType() : GeschichteType.STORY)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.build();
|
||||
@@ -141,11 +146,12 @@ public class GeschichteService {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
g.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||
@@ -162,7 +168,8 @@ public class GeschichteService {
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, journeyItemService.getItems(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -31,6 +31,10 @@ public interface GeschichteSummary {
|
||||
|
||||
LocalDateTime getPublishedAt();
|
||||
|
||||
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime getUpdatedAt();
|
||||
|
||||
String getBody();
|
||||
|
||||
interface AuthorSummary {
|
||||
|
||||
@@ -15,5 +15,6 @@ public class GeschichteUpdateDTO {
|
||||
private String title;
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private GeschichteType type;
|
||||
private List<UUID> personIds;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,19 @@ public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID>
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Dedup guard: true when the document is already linked to this journey.
|
||||
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||
""")
|
||||
boolean existsByGeschichteIdAndDocumentId(
|
||||
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
|
||||
@@ -69,6 +69,10 @@ public class JourneyItemService {
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||
// q + default sort + no other filters → the relevance fast path
|
||||
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||
// transaction and must still deliver an initialized tags collection.
|
||||
Person sender = savedPerson("Hans", "FtSender");
|
||||
Tag tag = savedTag("FtTag");
|
||||
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
SearchFilters textOnly = new SearchFilters(
|
||||
"Walter", null, null, null, null, null, null, null, null, false);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(1);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.tags().size()))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||
Person sender = savedPerson("Hans", "SsSender");
|
||||
|
||||
@@ -63,7 +63,7 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), anyInt()))
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
@@ -75,13 +75,13 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), eq(List.of(personId)), anyInt()))
|
||||
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), eq(List.of(personId)), anyInt());
|
||||
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,7 +89,7 @@ class GeschichteControllerTest {
|
||||
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), eq(List.of(a, b)), anyInt()))
|
||||
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten")
|
||||
@@ -97,7 +97,7 @@ class GeschichteControllerTest {
|
||||
.param("personId", b.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt());
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
@@ -150,7 +150,7 @@ class GeschichteControllerTest {
|
||||
void create_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(draft(id, "New"));
|
||||
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
@@ -178,7 +178,7 @@ class GeschichteControllerTest {
|
||||
void update_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -381,35 +381,13 @@ class GeschichteControllerTest {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body("<p>x</p>")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte draft(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
GeschichteStatus.PUBLISHED, GeschichteType.STORY,
|
||||
status, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
@@ -423,6 +401,7 @@ class GeschichteControllerTest {
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.user.UserGroup;
|
||||
import org.raddatz.familienarchiv.user.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
@@ -16,6 +18,7 @@ import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
@@ -28,6 +31,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -49,6 +53,7 @@ class GeschichteHttpTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired UserGroupRepository userGroupRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
|
||||
private RestTemplate http;
|
||||
@@ -63,6 +68,8 @@ class GeschichteHttpTest {
|
||||
baseUrl = "http://localhost:" + port;
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
|
||||
appUserRepository.save(AppUser.builder()
|
||||
.email(WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||
@@ -184,15 +191,78 @@ class GeschichteHttpTest {
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_returns_200_and_serializes_items_open_in_view_false() {
|
||||
// Canonical guard for the write path: PATCH must not 500 when the response
|
||||
// is serialized after the service transaction closed. The raw entity carries
|
||||
// a dead lazy items proxy at that point — the endpoint must answer with a
|
||||
// view assembled inside the transaction.
|
||||
AppUser writer = blogWriter();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise vor dem Umbenennen")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
journey.getItems().add(JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build());
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
|
||||
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Reise nach dem Umbenennen")
|
||||
.contains("Prolog");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
|
||||
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
|
||||
|
||||
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
|
||||
private AppUser blogWriter() {
|
||||
UserGroup group = userGroupRepository.save(UserGroup.builder()
|
||||
.name("HttpTest-BlogWriters")
|
||||
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
|
||||
.build());
|
||||
return appUserRepository.save(AppUser.builder()
|
||||
.email(BLOG_WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
|
||||
.groups(new HashSet<>(Set.of(group)))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
|
||||
private HttpHeaders csrfJsonHeaders(String sessionId) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String loginAsWriter() {
|
||||
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
|
||||
}
|
||||
|
||||
private String loginAs(String email, String password) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}";
|
||||
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
|
||||
ResponseEntity<String> resp = http.postForEntity(
|
||||
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||
return extractFaSessionCookie(resp);
|
||||
@@ -215,7 +285,8 @@ class GeschichteHttpTest {
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
|
||||
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
|
||||
@@ -4,6 +4,11 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
@@ -27,6 +32,8 @@ class GeschichteListProjectionTest {
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
|
||||
AppUser author;
|
||||
AppUser otherAuthor;
|
||||
@@ -48,18 +55,31 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(draft("Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
|
||||
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
|
||||
// projection must carry it for drafts, where publishedAt is null.
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getUpdatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -73,7 +93,7 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||
@@ -94,7 +114,7 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
@@ -108,7 +128,7 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0);
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||
@@ -122,7 +142,7 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(published("B", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
@@ -143,7 +163,7 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(withAnna);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1);
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||
@@ -164,12 +184,41 @@ class GeschichteListProjectionTest {
|
||||
geschichteRepository.save(onlyFranz);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2);
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||
}
|
||||
|
||||
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_documentId_returns_journey_containing_that_document() {
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
|
||||
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
|
||||
journeyItemRepository.save(JourneyItem.builder()
|
||||
.geschichte(withDoc).document(doc).position(1).build());
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
|
||||
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_unknown_documentId_returns_empty() {
|
||||
geschichteRepository.save(journey("Irgendeine Reise", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(String title, AppUser writer) {
|
||||
@@ -189,6 +238,16 @@ class GeschichteListProjectionTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte journey(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||
private List<UUID> sentinel() {
|
||||
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||
|
||||
@@ -80,20 +80,20 @@ class GeschichteServiceIntegrationTest {
|
||||
+ "<script>alert('xss')</script>");
|
||||
dto.setPersonIds(List.of(franz.getId()));
|
||||
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.getBody())
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.body())
|
||||
.contains("<strong>jeden Sonntag</strong>")
|
||||
.doesNotContain("<script>");
|
||||
|
||||
// Reader cannot see DRAFT in list
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), 50)).isEmpty();
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.getId();
|
||||
UUID draftId = created.id();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
@@ -101,13 +101,13 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
@@ -141,26 +141,26 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), 50))
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), 50))
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50))
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50))
|
||||
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
|
||||
.isEmpty();
|
||||
|
||||
// AND: Anna AND Bertha AND Carl → none
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50))
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -190,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
||||
dto.setBody("<p>body</p>");
|
||||
dto.setPersonIds(personIds);
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
return geschichteService.create(dto).getId();
|
||||
return geschichteService.create(dto).id();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -222,12 +223,12 @@ class GeschichteServiceTest {
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), 50);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -235,25 +236,25 @@ class GeschichteServiceTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteSummary s1 = mock(GeschichteSummary.class);
|
||||
GeschichteSummary s2 = mock(GeschichteSummary.class);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(s1, s2));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 50);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), 50);
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,21 +262,33 @@ class GeschichteServiceTest {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), 50);
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 9999);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
@@ -293,11 +306,11 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("My Story");
|
||||
dto.setBody("<p>plain text</p>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -311,9 +324,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("XSS attempt");
|
||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<p>safe</p>")
|
||||
.doesNotContain("<script>")
|
||||
.doesNotContain("onerror")
|
||||
@@ -332,9 +345,9 @@ class GeschichteServiceTest {
|
||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<h2>Heading</h2>")
|
||||
.contains("<strong>bold</strong>")
|
||||
.contains("<em>italic</em>")
|
||||
@@ -357,9 +370,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("Linked");
|
||||
dto.setPersonIds(List.of(personId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -376,6 +389,37 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_preserves_JOURNEY_type_from_dto() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Journey");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_defaults_to_STORY_when_type_is_null() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Story");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
// ─── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -391,10 +435,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.getPublishedAt()).isNotNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.publishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -410,10 +454,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.DRAFT);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -427,9 +471,9 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -258,6 +258,30 @@ class JourneyItemIntegrationTest {
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_document_persists_and_rejects_duplicate() {
|
||||
// Covers the document branch of append, including the duplicate guard —
|
||||
// the derived exists query must resolve document.id, which the transient
|
||||
// getDocumentId() getter on JourneyItem shadows for Spring Data.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(view.document()).isNotNull();
|
||||
assertThat(view.document().id()).isEqualTo(doc.getId());
|
||||
|
||||
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
|
||||
duplicate.setDocumentId(doc.getId());
|
||||
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
|
||||
.hasFieldOrPropertyWithValue("code",
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -288,6 +288,22 @@ class JourneyItemServiceTest {
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_document_already_in_journey() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
|
||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_ITEM_NOT_IN_JOURNEY`, `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`. |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -159,6 +159,9 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||
|
||||
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
|
||||
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
|
||||
|
||||
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||
|
||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||
|
||||
@@ -12,7 +12,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
@@ -25,7 +25,7 @@ Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /ap
|
||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
|
||||
@@ -500,7 +500,7 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
|
||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||
@@ -508,18 +508,18 @@
|
||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del> → <code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del> → <code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del> → <code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
||||
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del> → <code>createBlockDragDrop<JourneyItemView></code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
|
||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||
</tbody>
|
||||
@@ -720,7 +720,7 @@
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
|
||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||
@@ -779,7 +779,7 @@
|
||||
|
||||
<h3>Drag-to-Reorder</h3>
|
||||
<ul>
|
||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
||||
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop<JourneyItemView></code> (kein externes Package).</li>
|
||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||
</ul>
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
"comp_multiselect_placeholder": "Namen tippen...",
|
||||
"comp_multiselect_remove": "Entfernen",
|
||||
"comp_multiselect_loading": "Suche...",
|
||||
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
||||
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
||||
"comp_taginput_remove": "Schlagwort entfernen",
|
||||
@@ -1050,6 +1051,7 @@
|
||||
"geschichten_card_show_all": "Alle anzeigen",
|
||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||
"geschichte_sidebar_status": "Status",
|
||||
"geschichte_editor_status_draft": "ENTWURF",
|
||||
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
||||
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
||||
@@ -1169,10 +1171,40 @@
|
||||
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
|
||||
"journey_selector_next_btn": "Weiter",
|
||||
"journey_placeholder_back": "andere Auswahl",
|
||||
"journey_placeholder_heading": "Lesereise-Editor folgt in #753",
|
||||
"journey_create_submit": "Lesereise erstellen",
|
||||
"journey_item_open_aria": "Brief vom {date} öffnen",
|
||||
"journey_item_open_aria_undated": "Brief öffnen",
|
||||
"journey_empty_state": "Diese Lesereise ist noch leer.",
|
||||
"journey_interlude_aria_label": "Kuratorennotiz",
|
||||
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren."
|
||||
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
|
||||
"journey_add_document": "Brief hinzufügen",
|
||||
"journey_add_interlude": "Zwischentext hinzufügen",
|
||||
"journey_note_add": "Notiz hinzufügen",
|
||||
"journey_note_remove": "Notiz entfernen",
|
||||
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
|
||||
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
|
||||
"journey_already_added": "Bereits enthalten",
|
||||
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
|
||||
"journey_drag_aria_label": "Reihenfolge von '{title}' ändern",
|
||||
"journey_move_up": "'{title}' nach oben verschieben",
|
||||
"journey_move_down": "'{title}' nach unten verschieben",
|
||||
"journey_note_error": "Notiz konnte nicht gespeichert werden",
|
||||
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
|
||||
"journey_remove_item_aria": "Eintrag entfernen",
|
||||
"journey_remove_confirm": "Wirklich entfernen?",
|
||||
"journey_remove_confirm_yes": "Bestätigen",
|
||||
"journey_remove_confirm_cancel": "Abbrechen",
|
||||
"journey_mutation_error_reload": "Aktion fehlgeschlagen – bitte Seite neu laden.",
|
||||
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
|
||||
"journey_intro_placeholder": "Einleitung (optional)",
|
||||
"journey_interlude_placeholder": "Zwischentext eingeben…",
|
||||
"journey_add_interlude_confirm": "Hinzufügen",
|
||||
"journey_edit_title_story": "Geschichte bearbeiten",
|
||||
"journey_edit_title_journey": "Lesereise bearbeiten",
|
||||
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
|
||||
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
|
||||
"error_journey_item_not_in_journey": "Dieser Eintrag gehört nicht zu dieser Lesereise.",
|
||||
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
||||
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
||||
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
"comp_multiselect_placeholder": "Type a name...",
|
||||
"comp_multiselect_remove": "Remove",
|
||||
"comp_multiselect_loading": "Searching...",
|
||||
"comp_typeahead_error": "Search failed. Please try again.",
|
||||
"comp_taginput_placeholder_create": "Add tags...",
|
||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||
"comp_taginput_remove": "Remove tag",
|
||||
@@ -1050,6 +1051,7 @@
|
||||
"geschichten_card_show_all": "Show all",
|
||||
"geschichte_editor_title_placeholder": "Story title",
|
||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||
"geschichte_sidebar_status": "Status",
|
||||
"geschichte_editor_status_draft": "DRAFT",
|
||||
"geschichte_editor_status_published": "PUBLISHED",
|
||||
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
||||
@@ -1169,10 +1171,40 @@
|
||||
"journey_selector_journey_desc": "A curated selection of letters with notes.",
|
||||
"journey_selector_next_btn": "Continue",
|
||||
"journey_placeholder_back": "different selection",
|
||||
"journey_placeholder_heading": "Reading Journey editor coming in #753",
|
||||
"journey_create_submit": "Create reading journey",
|
||||
"journey_item_open_aria": "Open letter from {date}",
|
||||
"journey_item_open_aria_undated": "Open letter",
|
||||
"journey_empty_state": "This reading journey is still empty.",
|
||||
"journey_interlude_aria_label": "Curator's note",
|
||||
"journey_selector_aria_live_hint": "Please select a type to continue."
|
||||
"journey_selector_aria_live_hint": "Please select a type to continue.",
|
||||
"journey_add_document": "Add letter",
|
||||
"journey_add_interlude": "Add interlude",
|
||||
"journey_note_add": "Add note",
|
||||
"journey_note_remove": "Remove note",
|
||||
"journey_note_save_hint": "Saved when you leave the field.",
|
||||
"journey_intro_save_hint": "Saved when you click 'Save'.",
|
||||
"journey_already_added": "Already included",
|
||||
"journey_note_aria_label": "Curator note for {title}",
|
||||
"journey_drag_aria_label": "Change order of '{title}'",
|
||||
"journey_move_up": "Move '{title}' up",
|
||||
"journey_move_down": "Move '{title}' down",
|
||||
"journey_note_error": "Could not save note",
|
||||
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
|
||||
"journey_remove_item_aria": "Remove item",
|
||||
"journey_remove_confirm": "Really remove?",
|
||||
"journey_remove_confirm_yes": "Confirm",
|
||||
"journey_remove_confirm_cancel": "Cancel",
|
||||
"journey_mutation_error_reload": "Action failed – please reload the page.",
|
||||
"journey_published_empty_warning": "This journey will remain published without any entries.",
|
||||
"journey_intro_placeholder": "Introduction (optional)",
|
||||
"journey_interlude_placeholder": "Enter interlude text…",
|
||||
"journey_add_interlude_confirm": "Add",
|
||||
"journey_edit_title_story": "Edit story",
|
||||
"journey_edit_title_journey": "Edit reading journey",
|
||||
"journey_publish_disabled_title": "Title and at least one entry required",
|
||||
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
|
||||
"error_journey_item_not_in_journey": "This entry does not belong to this reading journey.",
|
||||
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
||||
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
||||
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
"comp_multiselect_placeholder": "Escriba un nombre...",
|
||||
"comp_multiselect_remove": "Eliminar",
|
||||
"comp_multiselect_loading": "Buscando...",
|
||||
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
|
||||
"comp_taginput_placeholder_create": "Añadir etiquetas...",
|
||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||
"comp_taginput_remove": "Eliminar etiqueta",
|
||||
@@ -1050,6 +1051,7 @@
|
||||
"geschichten_card_show_all": "Mostrar todas",
|
||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||
"geschichte_sidebar_status": "Estado",
|
||||
"geschichte_editor_status_draft": "BORRADOR",
|
||||
"geschichte_editor_status_published": "PUBLICADA",
|
||||
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
||||
@@ -1169,10 +1171,40 @@
|
||||
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
|
||||
"journey_selector_next_btn": "Continuar",
|
||||
"journey_placeholder_back": "otra selección",
|
||||
"journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753",
|
||||
"journey_create_submit": "Crear viaje de lectura",
|
||||
"journey_item_open_aria": "Abrir carta del {date}",
|
||||
"journey_item_open_aria_undated": "Abrir carta",
|
||||
"journey_empty_state": "Este viaje de lectura está vacío.",
|
||||
"journey_interlude_aria_label": "Nota del curador",
|
||||
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar."
|
||||
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
|
||||
"journey_add_document": "Añadir carta",
|
||||
"journey_add_interlude": "Añadir interludio",
|
||||
"journey_note_add": "Añadir nota",
|
||||
"journey_note_remove": "Eliminar nota",
|
||||
"journey_note_save_hint": "Se guarda al salir del campo.",
|
||||
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
|
||||
"journey_already_added": "Ya incluido",
|
||||
"journey_note_aria_label": "Nota del curador para {title}",
|
||||
"journey_drag_aria_label": "Cambiar el orden de '{title}'",
|
||||
"journey_move_up": "Subir '{title}'",
|
||||
"journey_move_down": "Bajar '{title}'",
|
||||
"journey_note_error": "No se pudo guardar la nota",
|
||||
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
|
||||
"journey_remove_item_aria": "Eliminar entrada",
|
||||
"journey_remove_confirm": "¿Realmente eliminar?",
|
||||
"journey_remove_confirm_yes": "Confirmar",
|
||||
"journey_remove_confirm_cancel": "Cancelar",
|
||||
"journey_mutation_error_reload": "Acción fallida – por favor recarga la página.",
|
||||
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
|
||||
"journey_intro_placeholder": "Introducción (opcional)",
|
||||
"journey_interlude_placeholder": "Escribe el texto del interludio…",
|
||||
"journey_add_interlude_confirm": "Añadir",
|
||||
"journey_edit_title_story": "Editar historia",
|
||||
"journey_edit_title_journey": "Editar viaje de lectura",
|
||||
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
|
||||
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
|
||||
"error_journey_item_not_in_journey": "Esta entrada no pertenece a este viaje de lectura.",
|
||||
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
||||
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
||||
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
/**
|
||||
* Exactly the fields this picker reads — id for selection/dedup, the rest for
|
||||
* the honest date label. A full `Document` and a `DocumentListItem` are both
|
||||
* structurally assignable, so the search results need no cast.
|
||||
*/
|
||||
type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
import {
|
||||
createDocumentTypeahead,
|
||||
formatDocumentOption,
|
||||
type DocumentOption
|
||||
} from './documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
selectedDocuments?: DocumentOption[];
|
||||
@@ -30,13 +20,16 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: DocumentOption[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
// Filter out already-selected documents from typeahead results.
|
||||
const filteredResults = $derived(
|
||||
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
|
||||
);
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||
if (res.ok) {
|
||||
const body: { items: DocumentListItem[] } = await res.json();
|
||||
const docs: DocumentOption[] = body.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
}));
|
||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
if (searchTerm.trim().length >= 1) {
|
||||
picker.setQuery(searchTerm);
|
||||
} else {
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
picker.close();
|
||||
}
|
||||
|
||||
function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
function formatDocLabel(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
);
|
||||
return `${doc.title} · ${label}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
|
||||
<div
|
||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||
>
|
||||
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeDocument(doc.id)}
|
||||
@@ -136,24 +94,21 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => {
|
||||
updateDropdownPosition();
|
||||
showDropdown = true;
|
||||
}}
|
||||
onfocus={() => updateDropdownPosition()}
|
||||
placeholder={placeholder}
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
{#if picker.loading}
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as doc (doc.id)}
|
||||
{#each filteredResults as doc (doc.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||
onclick={() => selectDocument(doc)}
|
||||
@@ -161,7 +116,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
98
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
98
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script module>
|
||||
let _uid = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import {
|
||||
createDocumentTypeahead,
|
||||
formatDocumentOption,
|
||||
type DocumentOption
|
||||
} from './documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
alreadyAddedIds?: Set<string>;
|
||||
placeholder?: string;
|
||||
onSelect: (doc: DocumentOption) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
alreadyAddedIds = new Set(),
|
||||
placeholder = m.journey_add_document(),
|
||||
onSelect
|
||||
}: Props = $props();
|
||||
|
||||
const listboxId = `doc-picker-listbox-${++_uid}`;
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
let inputValue = $state('');
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const q = (e.currentTarget as HTMLInputElement).value;
|
||||
inputValue = q;
|
||||
if (q.trim().length >= 1) {
|
||||
picker.setQuery(q);
|
||||
} else {
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(doc: DocumentOption) {
|
||||
if (alreadyAddedIds.has(doc.id!)) return;
|
||||
inputValue = '';
|
||||
picker.close();
|
||||
onSelect(doc);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
aria-label={placeholder}
|
||||
aria-expanded={picker.isOpen && picker.results.length > 0}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
oninput={handleInput}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
|
||||
{#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)}
|
||||
<ul
|
||||
id={listboxId}
|
||||
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
{#if picker.loading}
|
||||
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li>
|
||||
{:else if picker.error}
|
||||
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li>
|
||||
{:else}
|
||||
{#each picker.results as doc (doc.id)}
|
||||
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||
<li
|
||||
aria-disabled={disabled}
|
||||
onclick={() => handleSelect(doc)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
class={[
|
||||
'px-3 py-2 text-ink select-none',
|
||||
disabled
|
||||
? 'cursor-default opacity-50'
|
||||
: 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none'
|
||||
].join(' ')}
|
||||
>
|
||||
{formatDocumentOption(doc)}
|
||||
{#if disabled}
|
||||
<span class="sr-only">{m.journey_already_added()}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
117
frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
Normal file
117
frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
const docFactory = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY' as const,
|
||||
metaDateEnd: undefined
|
||||
});
|
||||
|
||||
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ items })
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DocumentPickerDropdown — empty query guard', () => {
|
||||
it('does not call fetch on empty query', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||
await userEvent.fill(page.getByRole('combobox'), '');
|
||||
await waitForDebounce();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentPickerDropdown — already-added indicator', () => {
|
||||
it('shows already-added document as aria-disabled with sr-only hint', async () => {
|
||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
|
||||
|
||||
render(DocumentPickerDropdown, {
|
||||
alreadyAddedIds: new Set(['d1']),
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await waitForDebounce();
|
||||
|
||||
const disabledItem = page
|
||||
.getByText(/Brief von Eugenie/i)
|
||||
.element()
|
||||
.closest('li')!;
|
||||
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
|
||||
// Screen-reader text "bereits enthalten" must be present in the item
|
||||
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentPickerDropdown — selection', () => {
|
||||
it('calls onSelect with the item when a non-disabled option is clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||
|
||||
render(DocumentPickerDropdown, { onSelect });
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await waitForDebounce();
|
||||
await userEvent.click(page.getByText(/Brief von Eugenie/i));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||
});
|
||||
|
||||
it('does not call onSelect when an aria-disabled option is clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
||||
|
||||
render(DocumentPickerDropdown, {
|
||||
alreadyAddedIds: new Set(['d1']),
|
||||
onSelect
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await waitForDebounce();
|
||||
await page.getByText(/Brief von Eugenie/i).click({ force: true });
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentPickerDropdown — search failure', () => {
|
||||
it('shows an error message when the search request fails instead of vanishing', async () => {
|
||||
// 500 from /api/documents/search — must surface, not render as "no results"
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
|
||||
})
|
||||
);
|
||||
|
||||
render(DocumentPickerDropdown, { onSelect: vi.fn() });
|
||||
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||
await waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/document/documentTypeahead.ts
Normal file
45
frontend/src/lib/document/documentTypeahead.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
export type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
|
||||
export function createDocumentTypeahead() {
|
||||
return createTypeahead<DocumentOption>({
|
||||
fetchUrl: (q) =>
|
||||
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
||||
.then((r) => {
|
||||
// Without this check a 401/500 parses as JSON without `items` and
|
||||
// renders as "no results" — errors must reach the hook's error state.
|
||||
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((b: { items: DocumentListItem[] }) =>
|
||||
b.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
}))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDocumentOption(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
);
|
||||
return `${doc.title} · ${label}`;
|
||||
}
|
||||
@@ -1,7 +1,35 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, expectTypeOf } from 'vitest';
|
||||
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/shared/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
|
||||
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
|
||||
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
|
||||
// until the function is made generic.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createBlockDragDrop — generic type guard', () => {
|
||||
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
|
||||
type SimpleItem = { id: string; position: number };
|
||||
const items: SimpleItem[] = [
|
||||
{ id: 'item-1', position: 0 },
|
||||
{ id: 'item-2', position: 1 }
|
||||
];
|
||||
const onReorder = vi.fn();
|
||||
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
|
||||
// Verify the hook is functional with the new type — state reads must work
|
||||
expect(dd.draggedBlockId).toBeNull();
|
||||
expect(dd.dragOffsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
|
||||
// If the generic constraint is wrong this line fails tsc --noEmit
|
||||
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
|
||||
// Runtime assertion so browser-mode doesn't report "no assertions"
|
||||
expect(typeof createBlockDragDrop).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { TranscriptionBlockData } from '$lib/shared/types';
|
||||
|
||||
type Options = {
|
||||
getSortedBlocks: () => TranscriptionBlockData[];
|
||||
type Options<T extends { id: string }> = {
|
||||
getSortedBlocks: () => T[];
|
||||
onReorder: (blockIds: string[]) => void;
|
||||
};
|
||||
|
||||
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
|
||||
export function createBlockDragDrop<T extends { id: string }>({
|
||||
getSortedBlocks,
|
||||
onReorder
|
||||
}: Options<T>) {
|
||||
let draggedBlockId = $state<string | null>(null);
|
||||
let dropTargetIdx = $state<number | null>(null);
|
||||
let dragOffsetY = $state(0);
|
||||
|
||||
@@ -728,6 +728,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-titles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["backfillTitles"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-file-hashes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1464,22 +1480,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getConversation"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1836,6 +1836,7 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
hasTranscription: boolean;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
PersonMention: {
|
||||
@@ -2023,25 +2024,44 @@ export interface components {
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
status?: "DRAFT" | "PUBLISHED";
|
||||
/** @enum {string} */
|
||||
type?: "STORY" | "JOURNEY";
|
||||
personIds?: string[];
|
||||
documentIds?: string[];
|
||||
};
|
||||
Geschichte: {
|
||||
AuthorView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
GeschichteView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
author?: components["schemas"]["AppUser"];
|
||||
persons?: components["schemas"]["Person"][];
|
||||
documents?: components["schemas"]["Document"][];
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
author?: components["schemas"]["AuthorView"];
|
||||
persons: components["schemas"]["PersonView"][];
|
||||
items: components["schemas"]["JourneyItemView"][];
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
};
|
||||
PersonView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
JourneyItemCreateDTO: {
|
||||
/** Format: uuid */
|
||||
documentId?: string;
|
||||
note?: string;
|
||||
};
|
||||
CreateTranscriptionBlockDTO: {
|
||||
/** Format: int32 */
|
||||
@@ -2311,6 +2331,11 @@ export interface components {
|
||||
color?: string;
|
||||
/** Format: int32 */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
|
||||
*/
|
||||
subtreeDocumentCount: number;
|
||||
children?: components["schemas"]["TagTreeNodeDTO"][];
|
||||
/**
|
||||
* Format: uuid
|
||||
@@ -2497,40 +2522,12 @@ export interface components {
|
||||
type: "STORY" | "JOURNEY";
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
author?: components["schemas"]["AuthorSummary"];
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
};
|
||||
AuthorView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
GeschichteView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
author?: components["schemas"]["AuthorView"];
|
||||
persons: components["schemas"]["PersonView"][];
|
||||
items: components["schemas"]["JourneyItemView"][];
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
PersonView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -3733,7 +3730,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Geschichte"][];
|
||||
"*/*": components["schemas"]["GeschichteSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -3757,7 +3754,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Geschichte"];
|
||||
"*/*": components["schemas"]["GeschichteView"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -4286,6 +4283,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillTitles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillFileHashes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4485,7 +4502,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Geschichte"];
|
||||
"*/*": components["schemas"]["GeschichteView"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5476,32 +5493,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
dir?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getResume: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -5,13 +5,14 @@ import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import GeschichteSidebar from '$lib/geschichte/GeschichteSidebar.svelte';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type Person = components['schemas']['Person'];
|
||||
type PersonOption = Pick<Person, 'id' | 'displayName'>;
|
||||
|
||||
interface Props {
|
||||
geschichte?: Geschichte | null;
|
||||
geschichte?: GeschichteView | null;
|
||||
initialPersons?: Person[];
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
@@ -31,8 +32,13 @@ let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Pr
|
||||
let title = $state(geschichte?.title ?? '');
|
||||
let body = $state(geschichte?.body ?? '');
|
||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||
let selectedPersons: Person[] = $state(
|
||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||
let selectedPersons: PersonOption[] = $state(
|
||||
geschichte?.persons
|
||||
? Array.from(geschichte.persons).map((p) => ({
|
||||
id: p.id,
|
||||
displayName: [p.firstName, p.lastName].filter(Boolean).join(' ')
|
||||
}))
|
||||
: initialPersons
|
||||
);
|
||||
|
||||
let dirty = $state(false);
|
||||
@@ -227,35 +233,7 @@ function exec(action: () => void) {
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex flex-col gap-6">
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
||||
<p class="mb-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||
? 'bg-muted text-ink-2'
|
||||
: 'bg-accent-bg text-ink'}"
|
||||
>
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft()
|
||||
: m.geschichte_editor_status_published()}
|
||||
</span>
|
||||
</p>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft_hint()
|
||||
: m.geschichte_editor_status_published_hint()}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
</aside>
|
||||
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
|
||||
63
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
63
frontend/src/lib/geschichte/GeschichteSidebar.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type PersonOption = Pick<Person, 'id' | 'displayName'>;
|
||||
|
||||
interface Props {
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
selectedPersons: PersonOption[];
|
||||
}
|
||||
|
||||
let { status, selectedPersons = $bindable() }: Props = $props();
|
||||
|
||||
const isDraft = $derived(status === 'DRAFT');
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col gap-6">
|
||||
<!-- Status section -->
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_sidebar_status()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_sidebar_status()}
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||
? 'bg-muted text-ink-2'
|
||||
: 'bg-accent-bg text-ink'}"
|
||||
>
|
||||
{isDraft ? m.geschichte_editor_status_draft() : m.geschichte_editor_status_published()}
|
||||
</span>
|
||||
</p>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft_hint()
|
||||
: m.geschichte_editor_status_published_hint()}
|
||||
</p>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<!-- Persons section -->
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</summary>
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
</details>
|
||||
</aside>
|
||||
104
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
104
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
alreadyAddedIds?: Set<string>;
|
||||
onAddDocument: (doc: DocumentOption) => void;
|
||||
onAddInterlude: (text: string) => void;
|
||||
}
|
||||
|
||||
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
|
||||
|
||||
let showPicker = $state(false);
|
||||
let showInterludeForm = $state(false);
|
||||
let interludeDraft = $state('');
|
||||
|
||||
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
||||
|
||||
function handleDocumentSelect(doc: DocumentOption) {
|
||||
showPicker = false;
|
||||
onAddDocument(doc);
|
||||
}
|
||||
|
||||
function handleInterludeConfirm() {
|
||||
if (!canConfirmInterlude) return;
|
||||
const text = interludeDraft.trim();
|
||||
interludeDraft = '';
|
||||
showInterludeForm = false;
|
||||
onAddInterlude(text);
|
||||
}
|
||||
|
||||
function handleInterludeCancel() {
|
||||
interludeDraft = '';
|
||||
showInterludeForm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showPicker = !showPicker;
|
||||
showInterludeForm = false;
|
||||
}}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
+ {m.journey_add_document()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showInterludeForm = !showInterludeForm;
|
||||
showPicker = false;
|
||||
}}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
+ {m.journey_add_interlude()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<DocumentPickerDropdown
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onSelect={handleDocumentSelect}
|
||||
placeholder={m.journey_add_document()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showInterludeForm}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={interludeDraft}
|
||||
placeholder={m.journey_interlude_placeholder()}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterludeConfirm}
|
||||
disabled={!canConfirmInterlude}
|
||||
class={[
|
||||
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
|
||||
canConfirmInterlude
|
||||
? 'bg-primary text-primary-fg hover:opacity-90'
|
||||
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.journey_add_interlude_confirm()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleInterludeCancel}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
55
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
55
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('JourneyAddBar — interlude flow', () => {
|
||||
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
|
||||
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true });
|
||||
await expect.element(confirmBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('confirm becomes enabled after typing text', async () => {
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
|
||||
|
||||
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true });
|
||||
await expect.element(confirmBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
it('calls onAddInterlude with text on confirm', async () => {
|
||||
const onAddInterlude = vi.fn();
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
|
||||
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
|
||||
|
||||
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyAddBar — document picker', () => {
|
||||
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||||
);
|
||||
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||
|
||||
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
341
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
341
frontend/src/lib/geschichte/JourneyEditor.svelte
Normal file
@@ -0,0 +1,341 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
||||
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
import GeschichteSidebar from './GeschichteSidebar.svelte';
|
||||
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
let { geschichte, onSubmit, submitting = false }: Props = $props();
|
||||
|
||||
const unsaved = createUnsavedWarning();
|
||||
|
||||
let title = $state(geschichte.title ?? '');
|
||||
let body = $state(geschichte.body ?? '');
|
||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
|
||||
let selectedPersons: Person[] = $state(geschichte.persons ? Array.from(geschichte.persons) : []);
|
||||
let items: JourneyItemView[] = $state(
|
||||
[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
|
||||
);
|
||||
|
||||
let titleTouched = $state(false);
|
||||
let mutationError = $state('');
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleAnnounceReset() {
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
announceTimer = setTimeout(() => {
|
||||
liveAnnounce = '';
|
||||
announceTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||
const isDraft = $derived(status === 'DRAFT');
|
||||
const alreadyAddedIds = $derived(
|
||||
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||
);
|
||||
const canPublish = $derived(items.length > 0 && !titleEmpty);
|
||||
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
|
||||
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
|
||||
const dragDrop = createBlockDragDrop<JourneyItemView>({
|
||||
getSortedBlocks: () => items,
|
||||
onReorder: handleReorder
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
dragDrop.setListElement(listEl);
|
||||
});
|
||||
|
||||
async function handleReorder(itemIds: string[]) {
|
||||
const prev = [...items];
|
||||
items = itemIds.map((id) => items.find((i) => i.id === id)!);
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemIds })
|
||||
});
|
||||
if (!res.ok) throw new Error('reorder failed');
|
||||
const updated: JourneyItemView[] = await res.json();
|
||||
items = updated.sort((a, b) => a.position - b.position);
|
||||
} catch {
|
||||
items = prev;
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDocument(doc: DocumentOption) {
|
||||
const prev = [...items];
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: doc.id })
|
||||
});
|
||||
if (!res.ok) throw new Error('add document failed');
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
} catch {
|
||||
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddInterlude(text: string) {
|
||||
const prev = [...items];
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: text })
|
||||
});
|
||||
if (!res.ok) throw new Error('add interlude failed');
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
} catch {
|
||||
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
const prev = [...items];
|
||||
items = items.filter((i) => i.id !== itemId);
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error('delete failed');
|
||||
} catch {
|
||||
items = prev;
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotePatch(itemId: string, note: string | null) {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: note })
|
||||
});
|
||||
if (!res.ok) throw new Error('note patch failed');
|
||||
const updated: JourneyItemView = await res.json();
|
||||
items = items.map((i) => (i.id === itemId ? updated : i));
|
||||
}
|
||||
|
||||
async function handleMoveUp(index: number) {
|
||||
if (index === 0) return;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||
liveAnnounce = m.journey_item_moved({
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
newPosition: index
|
||||
});
|
||||
await handleReorder(ids);
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
async function handleMoveDown(index: number) {
|
||||
if (index === items.length - 1) return;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||
liveAnnounce = m.journey_item_moved({
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
newPosition: index + 2
|
||||
});
|
||||
await handleReorder(ids);
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||
});
|
||||
unsaved.clearOnSuccess();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for move announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Editor column -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
oninput={() => unsaved.markDirty()}
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-invalid={showTitleError}
|
||||
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if showTitleError}
|
||||
<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||
{m.geschichte_editor_title_required()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Intro textarea -->
|
||||
<div>
|
||||
<textarea
|
||||
bind:value={body}
|
||||
oninput={() => unsaved.markDirty()}
|
||||
placeholder={m.journey_intro_placeholder()}
|
||||
rows={3}
|
||||
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Item list -->
|
||||
{#if showPublishedEmptyWarning}
|
||||
<p
|
||||
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800"
|
||||
>
|
||||
{m.journey_published_empty_warning()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if mutationError}
|
||||
<p
|
||||
class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{mutationError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={listEl}
|
||||
onpointermove={(e) => dragDrop.handlePointerMove(e)}
|
||||
onpointerup={() => dragDrop.handlePointerUp()}
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
{#if items.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
|
||||
{/if}
|
||||
{#each items as item, i (item.id)}
|
||||
<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-block-wrapper
|
||||
onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
|
||||
class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
|
||||
style={dragDrop.draggedBlockId === item.id
|
||||
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||
: ''}
|
||||
>
|
||||
{#if dragDrop.dropTargetIdx === i}
|
||||
<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
|
||||
{/if}
|
||||
<JourneyItemRow
|
||||
item={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
onRemove={() => handleRemove(item.id)}
|
||||
onNotePatch={(note) => handleNotePatch(item.id, note)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<JourneyAddBar
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
onAddDocument={handleAddDocument}
|
||||
onAddInterlude={handleAddInterlude}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if isDraft}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save_draft()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || !canPublish}
|
||||
title={canPublish ? undefined : m.journey_publish_disabled_title()}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_publish()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_unpublish()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
396
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
396
frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import JourneyEditor from './JourneyEditor.svelte';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const
|
||||
});
|
||||
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Briefe der Familie Raddatz',
|
||||
body: '',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'JOURNEY' as const,
|
||||
persons: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichte: makeGeschichte(),
|
||||
onSubmit: vi.fn().mockResolvedValue(undefined),
|
||||
submitting: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
function mockCsrfFetch(responseFactory: () => object) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(responseFactory())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('JourneyEditor — empty state', () => {
|
||||
it('renders title input and intro textarea', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('publish button disabled when no items', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows empty state message when items list is empty', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — items in position order', () => {
|
||||
it('renders items sorted by position', async () => {
|
||||
const items = [
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
|
||||
];
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
|
||||
const briefA = page.getByText('Brief A').element();
|
||||
const briefB = page.getByText('Brief B').element();
|
||||
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — publish disabled when title empty', () => {
|
||||
it('publish stays disabled until title is non-empty', async () => {
|
||||
render(
|
||||
JourneyEditor,
|
||||
defaultProps({
|
||||
geschichte: makeGeschichte({
|
||||
title: '',
|
||||
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
|
||||
const titleInput = page.getByPlaceholder(/Titel/);
|
||||
await userEvent.fill(titleInput, 'Meine Reise');
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — add document', () => {
|
||||
it('calls POST with documentId when document selected from picker', async () => {
|
||||
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
|
||||
mockCsrfFetch(() => newItem);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
// picker search results
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'Brief von Karl',
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY',
|
||||
originalFilename: 'brief.pdf',
|
||||
receivers: [],
|
||||
tags: [],
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// POST /items
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(newItem)
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
await new Promise((r) => setTimeout(r, 350)); // wait debounce
|
||||
await userEvent.click(page.getByText(/Brief von Karl/));
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — add interlude', () => {
|
||||
it('calls POST with note on interlude confirm', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: 'Reise nach Wien' })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — remove with rollback', () => {
|
||||
it('restores item on failed DELETE (non-ok response)', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
// Click remove (no note → direct remove)
|
||||
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Item should be restored after rollback
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Test' };
|
||||
mockCsrfFetch(() => newItem);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
// Publish should be disabled before adding item
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
|
||||
// Add interlude
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// After item add, publish becomes enabled — item was added and state is correct
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — reorder via move buttons', () => {
|
||||
it('move-up calls PUT reorder with swapped IDs', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('move-down calls PUT reorder with swapped IDs', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — live announce region', () => {
|
||||
it('clears the live announce region 500ms after a move operation', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
|
||||
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
|
||||
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — note patch body', () => {
|
||||
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
|
||||
const items = [
|
||||
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
|
||||
];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/i1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: null })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — duplicate document aria-disabled', () => {
|
||||
it('already-added document appears as aria-disabled in picker', async () => {
|
||||
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'Brief von Karl',
|
||||
documentDate: '1880-01-01',
|
||||
metaDatePrecision: 'DAY',
|
||||
originalFilename: 'brief.pdf',
|
||||
receivers: [],
|
||||
tags: [],
|
||||
completionPercentage: 0,
|
||||
contributors: [],
|
||||
matchData: {
|
||||
titleOffsets: [],
|
||||
senderMatched: false,
|
||||
matchedReceiverIds: [],
|
||||
matchedTagIds: [],
|
||||
snippetOffsets: [],
|
||||
summaryOffsets: []
|
||||
},
|
||||
status: 'UPLOADED',
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
|
||||
const option = page
|
||||
.getByText(/Brief von Karl ·/)
|
||||
.element()
|
||||
.closest('li')!;
|
||||
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
212
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
212
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
index: number;
|
||||
total: number;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
onNotePatch: (note: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
let { item, index, total, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props();
|
||||
|
||||
const isInterlude = $derived(!item.document);
|
||||
const itemTitle = $derived(item.document?.title ?? m.journey_add_interlude());
|
||||
const needsConfirmOnRemove = $derived(!!item.note);
|
||||
|
||||
let showNote = $state(!!item.note);
|
||||
let noteDraft = $state(item.note ?? '');
|
||||
let noteSaving = $state(false);
|
||||
let noteError = $state('');
|
||||
let showRemoveConfirm = $state(false);
|
||||
|
||||
async function handleNoteBlur() {
|
||||
if (noteSaving) return;
|
||||
if (noteDraft === item.note) return;
|
||||
if (isInterlude && noteDraft.trim().length === 0) return;
|
||||
|
||||
noteSaving = true;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(noteDraft.trim().length === 0 ? null : noteDraft);
|
||||
} catch {
|
||||
noteError = m.journey_note_error();
|
||||
} finally {
|
||||
noteSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteRemove() {
|
||||
const prevDraft = noteDraft;
|
||||
const prevShowNote = showNote;
|
||||
noteDraft = '';
|
||||
showNote = false;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(null);
|
||||
} catch {
|
||||
noteDraft = prevDraft;
|
||||
showNote = prevShowNote;
|
||||
noteError = m.journey_note_error();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveClick() {
|
||||
if (needsConfirmOnRemove) {
|
||||
showRemoveConfirm = true;
|
||||
} else {
|
||||
onRemove();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-block-id={item.id}
|
||||
class={[
|
||||
'flex min-w-0 flex-col rounded border transition-colors',
|
||||
isInterlude
|
||||
? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-1 px-2 py-2">
|
||||
<!-- Drag handle (desktop) -->
|
||||
<button
|
||||
type="button"
|
||||
data-drag-handle
|
||||
aria-label={m.journey_drag_aria_label({ title: itemTitle })}
|
||||
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||
style="min-height: 44px; min-width: 44px;"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
|
||||
<!-- Move up/down (mobile + always visible) -->
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
aria-label={m.journey_move_up({ title: itemTitle })}
|
||||
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
aria-label={m.journey_move_down({ title: itemTitle })}
|
||||
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1 py-1 break-words">
|
||||
{#if isInterlude}
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest uppercase"
|
||||
style="color: var(--color-interlude-label);"
|
||||
>
|
||||
{m.journey_add_interlude()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button / confirm -->
|
||||
<div class="shrink-0">
|
||||
{#if showRemoveConfirm}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_yes()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showRemoveConfirm = false)}
|
||||
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemoveClick}
|
||||
aria-label={m.journey_remove_item_aria()}
|
||||
class="-m-1 rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note section -->
|
||||
{#if showNote}
|
||||
<div class="border-t border-line/50 px-3 pt-2 pb-3">
|
||||
<textarea
|
||||
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||
bind:value={noteDraft}
|
||||
onblur={handleNoteBlur}
|
||||
maxlength={2000}
|
||||
rows={2}
|
||||
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||
{#if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteRemove}
|
||||
class="font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_remove()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if noteError}
|
||||
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isInterlude}
|
||||
<div class="px-3 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showNote = true;
|
||||
}}
|
||||
class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_add()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
146
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
146
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||
|
||||
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
|
||||
id: 'item-1',
|
||||
position: 0,
|
||||
document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const },
|
||||
...overrides
|
||||
});
|
||||
|
||||
const interludeItem = (note = 'Reise nach Wien') => ({
|
||||
id: 'item-2',
|
||||
position: 1,
|
||||
note
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
index: 0,
|
||||
total: 3,
|
||||
onMoveUp: vi.fn(),
|
||||
onMoveDown: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onNotePatch: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('JourneyItemRow — note textarea', () => {
|
||||
it('opens note textarea on "Notiz hinzufügen" click', async () => {
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||
|
||||
await userEvent.click(page.getByText('Notiz hinzufügen'));
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onNotePatch on textarea blur with non-empty value', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText('Notiz hinzufügen'));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await userEvent.fill(textarea, 'Eine neue Notiz');
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — note error state', () => {
|
||||
it('shows role=alert error message when onNotePatch rejects', async () => {
|
||||
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText('Notiz hinzufügen'));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await userEvent.fill(textarea, 'Eine Notiz');
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — note remove error state', () => {
|
||||
it('restores note and shows error when onNotePatch rejects during remove', async () => {
|
||||
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'keep me' }),
|
||||
...defaultProps({ onNotePatch })
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByText('Notiz entfernen'));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// textarea should be visible again (showNote restored)
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
// error alert should be shown
|
||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — interlude rules', () => {
|
||||
it('does not show "Notiz entfernen" for interlude items', async () => {
|
||||
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||
|
||||
// Note section should be visible (interlude always shows note)
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
// But "Notiz entfernen" must be absent
|
||||
await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks saving empty text on interlude note blur', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, {
|
||||
item: interludeItem('original text'),
|
||||
...defaultProps({ onNotePatch })
|
||||
});
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — remove confirm', () => {
|
||||
it('shows inline confirm when removing a document item that has a note', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
// Click remove (x button)
|
||||
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
|
||||
|
||||
await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('confirm cancel restores remove button without calling onRemove', async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Notiz' }),
|
||||
...defaultProps({ onRemove })
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
|
||||
await userEvent.click(page.getByRole('button', { name: 'Abbrechen' }));
|
||||
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
// The remove button should be back
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Eintrag entfernen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-te
|
||||
|
||||
## What this domain owns
|
||||
|
||||
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
|
||||
Components: `GeschichteEditor.svelte`, `GeschichteSidebar.svelte`, `JourneyEditor.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
|
||||
Utilities: `utils.ts`.
|
||||
|
||||
## What this domain does NOT own
|
||||
@@ -15,15 +15,19 @@ Utilities: `utils.ts`.
|
||||
|
||||
## Key components
|
||||
|
||||
| Component | Used in | Notes |
|
||||
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
|
||||
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
|
||||
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) |
|
||||
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
|
||||
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
|
||||
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph |
|
||||
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` |
|
||||
| Component | Used in | Notes |
|
||||
| -------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor (TipTap) for STORY type; delegates sidebar to `GeschichteSidebar` |
|
||||
| `GeschichteSidebar.svelte` | `GeschichteEditor`, `JourneyEditor` | Status badge + PersonMultiSelect sidebar; `<details>` mobile collapsibles with 44px touch targets |
|
||||
| `JourneyEditor.svelte` | `/geschichten/[id]/edit` (JOURNEY branch) | Curator editing surface: title, intro textarea, ordered item list with drag/reorder, add bar, save/publish |
|
||||
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
|
||||
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
|
||||
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
|
||||
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) |
|
||||
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
|
||||
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
|
||||
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph |
|
||||
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` |
|
||||
|
||||
## utils.ts
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
// Narrow contract: only what the chips render and dedup needs. Full Person
|
||||
// objects from /api/persons remain assignable; view projections fit too.
|
||||
type PersonOption = Pick<Person, 'id' | 'displayName'>;
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
selectedPersons?: PersonOption[];
|
||||
}
|
||||
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
interface Props {
|
||||
drafts: Geschichte[];
|
||||
drafts: GeschichteSummary[];
|
||||
}
|
||||
|
||||
const { drafts }: Props = $props();
|
||||
|
||||
@@ -5,24 +5,25 @@ import { page } from 'vitest/browser';
|
||||
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const draft1: Geschichte = {
|
||||
const draft1: GeschichteSummary = {
|
||||
id: 'g1',
|
||||
title: 'Mein erster Entwurf',
|
||||
status: 'DRAFT',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
type: 'STORY',
|
||||
updatedAt: '2025-01-02T00:00:00Z'
|
||||
};
|
||||
|
||||
const draft2: Geschichte = {
|
||||
const draft2: GeschichteSummary = {
|
||||
id: 'g2',
|
||||
title: 'Zweiter Entwurf',
|
||||
status: 'DRAFT',
|
||||
type: 'STORY',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z'
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/shared/relativeTime';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
interface Props {
|
||||
stories: Geschichte[];
|
||||
stories: GeschichteSummary[];
|
||||
}
|
||||
|
||||
const { stories }: Props = $props();
|
||||
|
||||
@@ -5,27 +5,28 @@ import { page } from 'vitest/browser';
|
||||
import ReaderRecentStories from './ReaderRecentStories.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const story1: Geschichte = {
|
||||
const story1: GeschichteSummary = {
|
||||
id: 'g1',
|
||||
title: 'Die Familie Müller',
|
||||
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
|
||||
status: 'PUBLISHED',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
type: 'STORY',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
publishedAt: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const longBodyStory: Geschichte = {
|
||||
const longBodyStory: GeschichteSummary = {
|
||||
id: 'g2',
|
||||
title: 'Sehr lange Geschichte',
|
||||
body: '<p>' + 'A'.repeat(200) + '</p>',
|
||||
status: 'PUBLISHED',
|
||||
type: 'STORY',
|
||||
createdAt: '2025-02-01T00:00:00Z',
|
||||
updatedAt: '2025-02-01T00:00:00Z',
|
||||
publishedAt: '2025-02-01T00:00:00Z'
|
||||
|
||||
@@ -47,9 +47,13 @@ export type ErrorCode =
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'GESCHICHTE_NOT_FOUND'
|
||||
| 'JOURNEY_ITEM_NOT_FOUND'
|
||||
| 'JOURNEY_ITEM_NOT_IN_JOURNEY'
|
||||
| 'JOURNEY_ITEM_POSITION_CONFLICT'
|
||||
| 'JOURNEY_AT_CAPACITY'
|
||||
| 'JOURNEY_NOTE_TOO_LONG'
|
||||
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
|
||||
| 'GESCHICHTE_TYPE_MISMATCH'
|
||||
| 'GESCHICHTE_TYPE_IMMUTABLE'
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'SESSION_EXPIRED'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
@@ -170,12 +174,20 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_geschichte_not_found();
|
||||
case 'JOURNEY_ITEM_NOT_FOUND':
|
||||
return m.error_journey_item_not_found();
|
||||
case 'JOURNEY_ITEM_NOT_IN_JOURNEY':
|
||||
return m.error_journey_item_not_in_journey();
|
||||
case 'JOURNEY_ITEM_POSITION_CONFLICT':
|
||||
return m.error_journey_item_position_conflict();
|
||||
case 'JOURNEY_AT_CAPACITY':
|
||||
return m.error_journey_at_capacity();
|
||||
case 'JOURNEY_NOTE_TOO_LONG':
|
||||
return m.error_journey_note_too_long();
|
||||
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
|
||||
return m.error_journey_document_already_added();
|
||||
case 'GESCHICHTE_TYPE_MISMATCH':
|
||||
return m.error_geschichte_type_mismatch();
|
||||
case 'GESCHICHTE_TYPE_IMMUTABLE':
|
||||
return m.error_geschichte_type_immutable();
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return m.error_invalid_credentials();
|
||||
case 'SESSION_EXPIRED':
|
||||
|
||||
@@ -78,6 +78,34 @@ describe('createTypeahead', () => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fetch error sets the error flag so callers can render a failure state', async () => {
|
||||
const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
|
||||
expect(ta.error).toBe(false);
|
||||
ta.setQuery('foo');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(ta.error).toBe(true);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('error flag clears on the next successful fetch', async () => {
|
||||
const fetchUrl = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('500'))
|
||||
.mockResolvedValueOnce([{ id: '1' }]);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
|
||||
ta.setQuery('foo');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(ta.error).toBe(true);
|
||||
ta.setQuery('foob');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(ta.error).toBe(false);
|
||||
expect(ta.results).toEqual([{ id: '1' }]);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('setActiveIndex updates activeIndex', () => {
|
||||
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
|
||||
expect(ta.activeIndex).toBe(-1);
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createTypeahead<T>(options: Options<T>) {
|
||||
let results: T[] = $state([]);
|
||||
let isOpen = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -21,11 +22,13 @@ export function createTypeahead<T>(options: Options<T>) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
loading = true;
|
||||
error = false;
|
||||
try {
|
||||
results = await fetchUrl(q);
|
||||
} catch (e) {
|
||||
console.error('typeahead fetch error', e);
|
||||
results = [];
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -65,6 +68,9 @@ export function createTypeahead<T>(options: Options<T>) {
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get activeIndex() {
|
||||
return activeIndex;
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||
|
||||
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
|
||||
@@ -57,9 +57,9 @@ export async function load({ fetch, parent }) {
|
||||
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
|
||||
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
|
||||
const recentDocs = searchData?.items ?? [];
|
||||
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
|
||||
const recentStories = settled<GeschichteSummary[]>(recentStoriesRes) ?? [];
|
||||
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
|
||||
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
|
||||
const drafts = settled<GeschichteSummary[]>(draftsRes) ?? [];
|
||||
|
||||
return {
|
||||
isReader: true as const,
|
||||
@@ -179,9 +179,9 @@ export async function load({ fetch, parent }) {
|
||||
readerStats: null,
|
||||
topPersons: [] as PersonSummaryDTO[],
|
||||
recentDocs: [] as DocumentListItem[],
|
||||
recentStories: [] as Geschichte[],
|
||||
recentStories: [] as GeschichteSummary[],
|
||||
tagTree: [] as TagTreeNodeDTO[],
|
||||
drafts: [] as Geschichte[],
|
||||
drafts: [] as GeschichteSummary[],
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ describe('SearchFilterBar – undated-only toggle (#668)', () => {
|
||||
async function openAdvanced() {
|
||||
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await filterBtn.click();
|
||||
// Wait for slide transition to finish before interacting with contents —
|
||||
// clicking during the transition triggers track_reactivity_loss in Svelte 5 async.js
|
||||
await expect.element(page.getByTestId('undated-only-toggle')).toBeVisible();
|
||||
}
|
||||
|
||||
it('renders the "Nur undatierte" toggle in the advanced row', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import JourneyEditor from '$lib/geschichte/JourneyEditor.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
@@ -12,6 +13,8 @@ let { data }: { data: PageData } = $props();
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
const isJourney = $derived(data.geschichte.type === 'JOURNEY');
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -44,7 +47,8 @@ async function handleSubmit(payload: {
|
||||
</div>
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||
{m.btn_edit()}: {data.geschichte.title}
|
||||
{isJourney ? m.journey_edit_title_journey() : m.journey_edit_title_story()}:
|
||||
{data.geschichte.title}
|
||||
</h1>
|
||||
|
||||
{#if errorMessage}
|
||||
@@ -56,5 +60,13 @@ async function handleSubmit(payload: {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||
{#if isJourney}
|
||||
<JourneyEditor geschichte={data.geschichte} onSubmit={handleSubmit} submitting={submitting} />
|
||||
{:else}
|
||||
<GeschichteEditor
|
||||
geschichte={data.geschichte}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import TypeSelector from './TypeSelector.svelte';
|
||||
import StoryCreate from './StoryCreate.svelte';
|
||||
import JourneyCreate from './JourneyCreate.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -19,12 +20,7 @@ let { data }: { data: PageData } = $props();
|
||||
{#if data.selectedType === 'STORY'}
|
||||
<StoryCreate initialPersons={data.initialPersons} />
|
||||
{:else if data.selectedType === 'JOURNEY'}
|
||||
<div data-testid="journey-placeholder">
|
||||
<p class="mb-4 font-sans text-base text-ink-2">{m.journey_placeholder_heading()}</p>
|
||||
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
|
||||
{m.journey_placeholder_back()}
|
||||
</a>
|
||||
</div>
|
||||
<JourneyCreate />
|
||||
{:else}
|
||||
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
|
||||
{/if}
|
||||
|
||||
85
frontend/src/routes/geschichten/new/JourneyCreate.svelte
Normal file
85
frontend/src/routes/geschichten/new/JourneyCreate.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
|
||||
let title = $state('');
|
||||
let titleTouched = $state(false);
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
type: 'JOURNEY',
|
||||
status: 'DRAFT',
|
||||
body: '',
|
||||
personIds: []
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}/edit`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="max-w-lg">
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-invalid={showTitleError}
|
||||
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
|
||||
? 'border-danger'
|
||||
: 'border-line'}"
|
||||
/>
|
||||
{#if showTitleError}
|
||||
<p class="mt-1 font-sans text-xs text-danger">{m.geschichte_editor_title_required()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="rounded bg-brand-navy px-4 py-2 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
|
||||
>
|
||||
{m.journey_create_submit()}
|
||||
</button>
|
||||
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
|
||||
{m.journey_placeholder_back()}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -73,14 +73,13 @@ describe('geschichten/new page', () => {
|
||||
await expect.element(page.getByRole('radiogroup')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => {
|
||||
it('shows JourneyCreate form when selectedType is JOURNEY', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||
|
||||
const placeholder = document.querySelector('[data-testid="journey-placeholder"]');
|
||||
expect(placeholder).not.toBeNull();
|
||||
await expect.element(page.getByRole('button', { name: /Lesereise erstellen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('JOURNEY placeholder offers a return-to-selection link', async () => {
|
||||
it('JOURNEY create form offers a return-to-selection link', async () => {
|
||||
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
|
||||
|
||||
const backLink = page.getByRole('link', { name: /andere Auswahl/i });
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
--color-journey: var(--c-journey-text);
|
||||
--color-journey-border: var(--c-journey-border);
|
||||
|
||||
/* Interlude row — neutral surface with left accent border; ZWISCHENTEXT label */
|
||||
--color-interlude-bg: var(--c-interlude-bg);
|
||||
--color-interlude-border: var(--c-interlude-border);
|
||||
--color-interlude-label: var(--c-interlude-label);
|
||||
|
||||
/* Static brand tokens (not themed) */
|
||||
--color-brand-navy: var(--palette-navy);
|
||||
--color-brand-mint: var(--palette-mint);
|
||||
@@ -139,6 +144,11 @@
|
||||
--c-journey-text: #7a3f0e;
|
||||
--c-journey-border: #f0c99a;
|
||||
|
||||
/* Interlude (Zwischentext) — neutral warm surface with left accent border */
|
||||
--c-interlude-bg: #f5f4f0;
|
||||
--c-interlude-border: #a1dcd8;
|
||||
--c-interlude-label: #4b5563;
|
||||
|
||||
/* Tag color tokens — decorative dot colors on tag chips */
|
||||
--c-tag-sage: #5a8a6a;
|
||||
--c-tag-sienna: #a0522d;
|
||||
@@ -263,6 +273,11 @@
|
||||
--c-journey-bg: #3a2a1a;
|
||||
--c-journey-text: #e8862a;
|
||||
--c-journey-border: #7a4a1e;
|
||||
|
||||
/* Interlude (Zwischentext) — KEEP IN SYNC with :root[data-theme='dark'] */
|
||||
--c-interlude-bg: #151c22;
|
||||
--c-interlude-border: #00c7b1;
|
||||
--c-interlude-label: #8b97a5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +358,11 @@
|
||||
--c-journey-bg: #3a2a1a;
|
||||
--c-journey-text: #e8862a;
|
||||
--c-journey-border: #7a4a1e;
|
||||
|
||||
/* Interlude (Zwischentext) — KEEP IN SYNC with the @media block above */
|
||||
--c-interlude-bg: #151c22;
|
||||
--c-interlude-border: #00c7b1;
|
||||
--c-interlude-label: #8b97a5;
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
Reference in New Issue
Block a user