diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 3149d9e2..048ceded 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -1,9 +1,8 @@
@@ -131,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
  • - -

    {g.title}

    -

    - {authorName(g)} - {#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if} -

    - {#if g.body} -

    {plainExcerpt(g.body, 150)}

    - {/if} -
    + +
  • {/each} diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts index 4763555d..652aa86c 100644 --- a/frontend/src/routes/geschichten/new/+page.server.ts +++ b/frontend/src/routes/geschichten/new/+page.server.ts @@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => { const initialPersons = personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; - return { initialPersons }; + // Validate ?type against the known union — prevents unexpected strings from reaching the API. + // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and + // only the FIRST value is returned by searchParams.get() on repeated params. + const rawType = url.searchParams.get('type'); + const selectedType: 'STORY' | 'JOURNEY' | null = + rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; + + return { initialPersons, selectedType }; }; diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index ad80d87b..0eff0a68 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -1,42 +1,12 @@
    @@ -46,18 +16,16 @@ async function handleSubmit(payload: {

    {m.geschichten_new_button()}

    - {#if errorMessage} - diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte new file mode 100644 index 00000000..5bd49fdc --- /dev/null +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte @@ -0,0 +1,50 @@ + + +{#if errorMessage} + +{/if} + + diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte b/frontend/src/routes/geschichten/new/TypeSelector.svelte new file mode 100644 index 00000000..a6cc6848 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte @@ -0,0 +1,96 @@ + + +
    +

    + {m.journey_selector_question()} +

    + +
    { + if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType); + }} + > + {#each TYPES as type (type)} + + {/each} +
    + +
    {announcement}
    + + {#if !selected} + + {/if} + + +
    diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts new file mode 100644 index 00000000..8ddaa362 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: TypeSelector } = await import('./TypeSelector.svelte'); + +afterEach(cleanup); + +describe('TypeSelector', () => { + it('renders both type cards', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible(); + }); + + it('radiogroup is correctly labelled', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const group = document.querySelector('[role="radiogroup"]'); + const labelledBy = group?.getAttribute('aria-labelledby'); + const labelEl = labelledBy ? document.getElementById(labelledBy) : null; + expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0); + }); + + it('Weiter button has aria-disabled=true when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('no card is aria-checked when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true'); + expect(anyChecked).toBe(false); + }); + + it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + expect(radios[0]?.getAttribute('tabindex')).toBe('0'); + expect(radios[1]?.getAttribute('tabindex')).toBe('-1'); + }); + + it('clicking STORY card sets aria-checked=true and enables Weiter', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); + + await expect.element(storyCard).toHaveAttribute('aria-checked', 'true'); + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('false'); + }); + + it('clicking JOURNEY card sets aria-checked=true', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await userEvent.click(journeyCard); + + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); + + it('clicking Weiter after selection calls onweiter with the selected type', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + await userEvent.click(page.getByRole('radio', { name: /Geschichte/i })); + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).toHaveBeenCalledWith('STORY'); + }); + + it('clicking Weiter without selection does NOT call onweiter', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).not.toHaveBeenCalled(); + }); + + it('instructional text is visible when no type is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible(); + }); +});