Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
4f3020ffab feat(geschichten): make Geschichte panel rows fully clickable
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m20s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m16s
CI / Unit & Component Tests (pull_request) Failing after 3m52s
CI / Backend Unit Tests (pull_request) Failing after 3m11s
CI / OCR Service Tests (pull_request) Successful in 48s
The story rows on the person detail page now match the
PersonDocumentList pattern: the entire row is a single anchor with a
hover background, and the title gets group-hover:underline. Author,
date, and body excerpt are all part of the same clickable area, so
the touch target matches the visual rhythm of the document panels
above.
2026-05-03 08:45:04 +02:00
Marcel
34ab8a0a2c test(geschichten): cover multi-person AND filter end-to-end
Adds a Playwright flow that picks two persons through the typeahead,
asserts both ?personId= params end up in the URL with two chips on
screen, then removes the first chip and verifies only the second
person id remains.

Also extends .prettierignore so a stale root-owned test-results
directory left over from running tests inside Docker doesn't break
the pre-commit lint hook.
2026-05-03 08:41:11 +02:00
Marcel
96d023a7cb feat(geschichten): chip-row UI for multi-person AND filter
The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
2026-05-03 08:37:28 +02:00
Marcel
0802889ea9 feat(geschichten): filter by multiple persons with AND semantics
GET /api/geschichten now accepts repeated personId query params and
returns only stories that mention every person supplied. Refactors the
list path to a JPA Specification chain (one EXISTS subquery per id,
mirroring DocumentSpecifications.hasTags) and embeds the
COALESCE(publishedAt, updatedAt) DESC ordering inside the spec so a
single repository.findAll covers all filter combinations.
2026-05-02 19:17:39 +02:00
18 changed files with 487 additions and 97 deletions

View File

@@ -32,10 +32,14 @@ public class GeschichteController {
@GetMapping
public List<Geschichte> list(
@RequestParam(required = false) GeschichteStatus status,
@RequestParam(required = false) UUID personId,
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(status, personId, documentId, limit);
return geschichteService.list(
status,
personIds == null ? List.of() : personIds,
documentId,
limit);
}
@GetMapping("/{id}")

View File

@@ -1,30 +1,12 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID;
@Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
@Query("""
SELECT g FROM Geschichte g
WHERE (:status IS NULL OR g.status = :status)
AND (:personId IS NULL OR :personId IN (SELECT p.id FROM g.persons p))
AND (:documentId IS NULL OR :documentId IN (SELECT d.id FROM g.documents d))
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""")
List<Geschichte> search(
@Param("status") GeschichteStatus status,
@Param("personId") UUID personId,
@Param("documentId") UUID documentId,
Pageable pageable);
}

View File

@@ -0,0 +1,91 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.data.jpa.domain.Specification;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public final class GeschichteSpecifications {
private GeschichteSpecifications() {}
public static Specification<Geschichte> hasStatus(GeschichteStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
/**
* Adds {@code ORDER BY COALESCE(publishedAt, updatedAt) DESC} to the query without contributing
* a predicate. Combined into the spec chain via {@code .and(...)}; the {@code conjunction}
* acts as a no-op WHERE clause.
*/
public static Specification<Geschichte> orderByDisplayDateDesc() {
return (root, query, cb) -> {
// Skip ordering on count queries — JPA forbids orderBy on COUNT projections.
if (query != null
&& Long.class != query.getResultType()
&& long.class != query.getResultType()) {
query.orderBy(cb.desc(cb.coalesce(root.get("publishedAt"), root.get("updatedAt"))));
}
return cb.conjunction();
};
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
/**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
*
* <p>Implemented as one EXISTS subquery per id (canonical Criteria-API idiom for AND across a
* many-to-many join). Mirrors {@link DocumentSpecifications#hasTags} which uses the same shape.
* Empty / null input returns {@code null} (i.e. no constraint added).
*/
public static Specification<Geschichte> hasAllPersons(Collection<UUID> personIds) {
return (root, query, cb) -> {
if (personIds == null || personIds.isEmpty()) return null;
List<Predicate> predicates = new ArrayList<>(personIds.size());
for (UUID id : personIds) {
predicates.add(cb.exists(personSubquery(root, query, cb, id)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
private static Subquery<UUID> personSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID personId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Person> persons = subRoot.join("persons");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(persons.get("id"), personId));
return sub;
}
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
}

View File

@@ -13,9 +13,10 @@ import org.raddatz.familienarchiv.model.Geschichte;
import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.repository.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@@ -64,11 +65,25 @@ public class GeschichteService {
return g;
}
public List<Geschichte> list(GeschichteStatus status, UUID personId, UUID documentId, int limit) {
/**
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*/
public List<Geschichte> 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);
Pageable pageable = PageRequest.of(0, safeLimit);
return geschichteRepository.search(effective, personId, documentId, pageable);
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()
);
return geschichteRepository.findAll(spec, Sort.unsorted())
.stream()
.limit(safeLimit)
.toList();
}
// ─── Write API ───────────────────────────────────────────────────────────

View File

@@ -73,15 +73,31 @@ class GeschichteControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesPersonIdFilterToService() throws Exception {
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(personId), any(), 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(personId), any(), anyInt());
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten")
.param("personId", a.toString())
.param("personId", b.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────

View File

@@ -86,7 +86,7 @@ class GeschichteServiceIntegrationTest {
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 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();
@@ -102,8 +102,8 @@ class GeschichteServiceIntegrationTest {
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, null, null, 50)).hasSize(1);
assertThat(geschichteService.list(null, franz.getId(), null, 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);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
@@ -117,6 +117,57 @@ class GeschichteServiceIntegrationTest {
assertThat(personRepository.findById(franz.getId())).isPresent();
}
@Test
void list_filters_with_AND_semantics_when_multiple_personIds_given() {
// Three published stories, persons overlap so we can prove AND-not-OR:
// story_AB: about A and B
// story_AC: about A and C
// story_A: about A only
authenticateAs(writer, Permission.BLOG_WRITE);
Person a = personRepository.save(Person.builder().firstName("Anna").lastName("A").build());
Person b = personRepository.save(Person.builder().firstName("Bertha").lastName("B").build());
Person c = personRepository.save(Person.builder().firstName("Carl").lastName("C").build());
UUID storyAB = publishedStoryWithPersons("Anna & Bertha", List.of(a.getId(), b.getId()));
UUID storyAC = publishedStoryWithPersons("Anna & Carl", List.of(a.getId(), c.getId()));
UUID storyA = publishedStoryWithPersons("Anna alone", List.of(a.getId()));
authenticateAs(reader, Permission.READ_ALL);
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(Geschichte::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()), null, 50))
.extracting(Geschichte::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
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()), null, 50))
.isEmpty();
}
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(title);
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -18,7 +17,8 @@ import org.raddatz.familienarchiv.model.GeschichteStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -36,7 +36,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -126,47 +125,76 @@ class GeschichteServiceTest {
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.search(eq(GeschichteStatus.PUBLISHED), any(), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
geschichteService.list(/*status*/ null, /*personId*/ null, /*documentId*/ null, /*limit*/ 50);
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), any(), any(), any());
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.search(any(), any(), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
geschichteService.list(null, null, null, 50);
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).search(eq(null), any(), any(), any());
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_filters_by_personId() {
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.search(any(), eq(personId), any(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, personId, null, 50);
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(personId), eq(null), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_filters_by_documentId() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.search(any(), any(), eq(documentId), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, null, documentId, 50);
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(null), eq(documentId), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
// ─── create ──────────────────────────────────────────────────────────────

View File

@@ -23,5 +23,6 @@ bun.lockb
# Test artifacts
/test-results/
/test-results.locked/
/e2e/.auth/
/coverage/

View File

@@ -62,6 +62,51 @@ test.describe('Geschichten — writer + reader journey', () => {
await expect(page.locator('article')).toBeVisible();
});
test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({
page
}) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// We need two persons to filter by. Open the picker and pick one whose name
// the dev seed reliably contains. Then open the picker again and pick a
// second one. Picking is via the typeahead — we type, wait for the listbox,
// click the first option.
async function pickPerson(query: string) {
await page.getByRole('button', { name: /Person wählen/ }).click();
const input = page.getByRole('combobox', { name: /Person wählen/ });
await input.fill(query);
// Wait for at least one option in the listbox, then click it
const firstOption = page.getByRole('option').first();
await expect(firstOption).toBeVisible();
await firstOption.click();
}
await pickPerson('a');
await page.waitForURL(/personId=/);
const firstUrl = new URL(page.url());
const firstIds = firstUrl.searchParams.getAll('personId');
expect(firstIds).toHaveLength(1);
await pickPerson('b');
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
const secondUrl = new URL(page.url());
const secondIds = secondUrl.searchParams.getAll('personId');
expect(secondIds).toHaveLength(2);
expect(secondIds[0]).toBe(firstIds[0]); // first one persists
expect(secondIds[1]).not.toBe(firstIds[0]); // second is different
// Two chips visible — find them by their remove-aria-label pattern
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
await expect(chipButtons).toHaveCount(2);
// Remove the first chip — URL drops to one param
await chipButtons.first().click();
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
const finalIds = new URL(page.url()).searchParams.getAll('personId');
expect(finalIds).toEqual([secondIds[1]]);
});
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "Alle",
"geschichten_filter_choose_person": "Person wählen",
"geschichten_filter_aria_label": "Person filtern",
"geschichten_filter_remove_chip": "{name} aus Filter entfernen",
"geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.",
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "All",
"geschichten_filter_choose_person": "Choose person",
"geschichten_filter_aria_label": "Filter by person",
"geschichten_filter_remove_chip": "Remove {name} from filter",
"geschichten_filter_and_hint": "Only stories that include every selected person are shown.",
"geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_for_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}",

View File

@@ -929,7 +929,10 @@
"geschichten_filter_all_pill": "Todas",
"geschichten_filter_choose_person": "Elegir persona",
"geschichten_filter_aria_label": "Filtrar por persona",
"geschichten_filter_remove_chip": "Quitar {name} del filtro",
"geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.",
"geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_for_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}",

View File

@@ -53,22 +53,24 @@ function authorName(g: Geschichte): string {
{/if}
</header>
<ul class="flex flex-col gap-4">
<ul class="-mx-2">
{#each visible as g (g.id)}
<li class="flex flex-col gap-1 border-b border-line pb-3 last:border-0 last:pb-0">
<li>
<a
href="/geschichten/{g.id}"
class="font-serif text-base font-bold text-ink hover:underline"
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
>
{g.title}
<span class="font-serif text-base font-bold text-ink group-hover:underline">
{g.title}
</span>
<span class="font-sans text-xs text-ink-3">
{authorName(g)}
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
</span>
{#if g.body}
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
{/if}
</a>
<p class="font-sans text-xs text-ink-3">
{authorName(g)}
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
</p>
{#if g.body}
<p class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</p>
{/if}
</li>
{/each}
</ul>

View File

@@ -51,9 +51,29 @@ describe('GeschichtenCard', () => {
canWrite: false
});
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
await expect
.element(page.getByRole('link', { name: 'Erinnerung an Franz' }))
.toBeInTheDocument();
// The whole row is one link to the story; matching on the title text via
// a partial regex tolerates trailing author/date metadata in the
// accessible name.
const link = await page
.getByRole('link', { name: /Erinnerung an Franz/ })
.first()
.element();
expect(link.getAttribute('href')).toBe('/geschichten/g1');
});
it('makes the entire story row a single clickable link', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// The body-excerpt text is inside the same <a> as the title.
const links = await page.getByRole('link', { name: /A title/ }).all();
expect(links.length).toBeGreaterThan(0);
const linkEl = await links[0].element();
expect(linkEl.tagName).toBe('A');
expect(linkEl.textContent).toContain('Some body excerpt text');
});
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {

View File

@@ -2140,9 +2140,9 @@ export interface components {
deathYear?: number;
familyMember?: boolean;
notes?: string;
alias?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
};
InferredRelationshipWithPersonDTO: {
person: components["schemas"]["PersonNodeDTO"];
@@ -3339,7 +3339,7 @@ export interface operations {
parameters: {
query?: {
status?: "DRAFT" | "PUBLISHED";
personId?: string;
personId?: string[];
documentId?: string;
limit?: number;
};

View File

@@ -1,26 +1,27 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import type { components } from '$lib/generated/api';
import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person'];
export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId') ?? undefined;
const personIds = url.searchParams.getAll('personId');
const documentId = url.searchParams.get('documentId') ?? undefined;
const [listResult, personResult] = await Promise.all([
const [listResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', {
params: {
query: {
status: 'PUBLISHED',
personId,
personId: personIds.length ? personIds : undefined,
documentId
}
}
}),
personId
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null)
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
]);
if (!listResult.response.ok) {
@@ -28,9 +29,13 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
throw error(listResult.response.status, getErrorMessage(code));
}
const personFilters = personResults
.filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[];
return {
geschichten: listResult.data ?? [],
personFilter: personResult && personResult.response.ok ? personResult.data! : null,
personFilters,
documentFilter: documentId ?? null
};
};

View File

@@ -10,22 +10,32 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false);
const filterName = $derived(data.personFilter?.displayName ?? '');
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
function clearFilter() {
function rebuildUrl(personIds: string[]) {
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
goto(url.pathname + url.search, { replaceState: true });
for (const id of personIds) url.searchParams.append('personId', id);
return url.pathname + url.search;
}
function pickPerson(personId: string) {
if (!personId) return;
const url = new URL(window.location.href);
url.searchParams.set('personId', personId);
url.searchParams.delete('documentId');
function clearAll() {
goto(rebuildUrl([]), { replaceState: true });
}
function addPerson(personId: string) {
if (!personId || selectedPersonIds.includes(personId)) {
showPersonPicker = false;
return;
}
showPersonPicker = false;
goto(url.pathname + url.search);
goto(rebuildUrl([...selectedPersonIds, personId]));
}
function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
@@ -58,33 +68,34 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
type="button"
aria-pressed={!data.personFilter}
onclick={clearFilter}
aria-pressed={!hasFilters}
onclick={clearAll}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
>
{m.geschichten_filter_all_pill()}
</button>
{#if data.personFilter}
{#each data.personFilters as p (p.id)}
<button
type="button"
aria-pressed="true"
onclick={clearFilter}
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
onclick={() => removePerson(p.id!)}
class="inline-flex h-9 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
>
{filterName}
{p.displayName}
<span aria-hidden="true">×</span>
</button>
{:else}
<button
type="button"
aria-label={m.geschichten_filter_aria_label()}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
{/if}
{/each}
<button
type="button"
aria-expanded={showPersonPicker}
onclick={() => (showPersonPicker = !showPersonPicker)}
class="inline-flex h-9 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
>
+ {m.geschichten_filter_choose_person()}
</button>
</div>
{#if showPersonPicker}
@@ -93,16 +104,24 @@ function publishedAt(g: { publishedAt?: string }): string | null {
name="filter-person"
label={m.geschichten_filter_choose_person()}
compact
onchange={pickPerson}
autofocus
onchange={addPerson}
/>
{#if selectedPersonIds.length > 1}
<p class="mt-1 font-sans text-xs text-ink-3">
{m.geschichten_filter_and_hint()}
</p>
{/if}
</div>
{/if}
<!-- Card list -->
{#if data.geschichten.length === 0}
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
{#if data.personFilter}
{m.geschichten_empty_for_person({ name: filterName })}
{#if data.personFilters.length > 0}
{m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
})}
{:else}
{m.geschichten_empty_no_filter()}
{/if}

View File

@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/state', () => ({ navigating: { to: null } }));
import Page from './+page.svelte';
import type { PageData } from './$types';
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function person(id: string, displayName: string) {
return {
id,
firstName: displayName.split(' ')[0] ?? displayName,
lastName: displayName.split(' ').slice(1).join(' ') || 'X',
displayName,
personType: 'PERSON'
};
}
function makeData(overrides: Partial<PageData> = {}): PageData {
return {
geschichten: [],
personFilters: [],
documentFilter: null,
canBlogWrite: false,
...overrides
} as unknown as PageData;
}
describe('geschichten page — multi-person filter chips', () => {
it('renders one chip per person in personFilters', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
})
});
await expect
.element(page.getByRole('button', { name: /Anna A aus Filter entfernen/ }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: /Bertha B aus Filter entfernen/ }))
.toBeVisible();
});
it('renders the "All" pill in pressed state when no filters are active', async () => {
render(Page, { data: makeData() });
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'true');
});
it('renders the "All" pill in unpressed state when at least one filter is active', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'false');
});
it('clicking × on a chip removes only that person from the URL', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
// Seed window.location so the chip-removal logic builds the new URL deterministically.
const originalHref = window.location.href;
window.history.replaceState({}, '', '/geschichten?personId=a&personId=b');
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A'), person('b', 'Bertha B')] as PageData['personFilters']
})
});
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click();
expect(goto).toHaveBeenCalledOnce();
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=b');
expect(url).not.toContain('personId=a');
window.history.replaceState({}, '', originalHref);
});
it('shows the "+ Person wählen" button even when filters are already active', async () => {
render(Page, {
data: makeData({
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
});
});