diff --git a/app/routes/EditPage.tsx b/app/routes/EditPage.tsx index bd2abc4..36e8db6 100644 --- a/app/routes/EditPage.tsx +++ b/app/routes/EditPage.tsx @@ -1,22 +1,102 @@ -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useLoaderData } from 'react-router' import { useTranslation } from 'react-i18next' +import { getAdvisory } from '~/auth/backend' +import { getAppConfig } from '~/auth/appConfig' import type { Route } from './+types/EditPage' -export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { - return { id: params.advisoryId } +export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { + const [detail, appConfig] = await Promise.all([ + getAdvisory(params.advisoryId), + getAppConfig(), + ]) + if (!detail.ok) { + throw new Response(detail.error.message, { status: detail.error.status }) + } + return { + advisoryId: params.advisoryId, + revision: detail.data.revision, + csaf: detail.data.csaf, + validatorUrl: appConfig.validatorUrl, + } +} + +type EditorElement = HTMLElement & { + doc?: unknown + locale?: string | undefined + validatorUrl?: string | undefined +} + +/** Property writes on an undefined custom element are lost, so wait for its definition. */ +async function whenEditorReady( + ref: React.RefObject, + signal: AbortSignal, +): Promise { + await customElements.whenDefined('secvisogram-editor') + return signal.aborted ? undefined : (ref.current ?? undefined) } const EditPage: React.FC = () => { const data = useLoaderData() - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const editorRef = useRef(null) + const [doc, setDoc] = useState(data.csaf) + const [valid, setValid] = useState(true) + + useEffect(() => { + const controller = new AbortController() + whenEditorReady(editorRef, controller.signal).then((editor) => { + if (editor) editor.locale = i18n.language + }) + return () => controller.abort() + }, [i18n.language]) + + useEffect(() => { + const controller = new AbortController() + whenEditorReady(editorRef, controller.signal).then((editor) => { + if (!editor) return + editor.validatorUrl = data.validatorUrl + editor.doc = data.csaf + setDoc(data.csaf) + }) + return () => controller.abort() + }, [data.csaf, data.validatorUrl]) + + useEffect(() => { + const editor = editorRef.current + if (!editor) return + const controller = new AbortController() + const { signal } = controller + editor.addEventListener( + 'csaf-change', + (event) => { + setDoc((event as CustomEvent<{ doc: unknown }>).detail.doc) + }, + { signal }, + ) + editor.addEventListener( + 'csaf-validate', + (event) => { + setValid((event as CustomEvent<{ valid: boolean }>).detail.valid) + }, + { signal }, + ) + return () => controller.abort() + }, []) return (

- {t('editPage.heading')} {data.id} + {t('editPage.heading')} {data.advisoryId}

- + + {/* Test-only seam for the tracked doc/validity state; ticket 18 reads this for Save. */} + +
) } diff --git a/tests/e2e/AdvisoryDashboard.spec.ts b/tests/e2e/AdvisoryDashboard.spec.ts index 9c11b88..62daea0 100644 --- a/tests/e2e/AdvisoryDashboard.spec.ts +++ b/tests/e2e/AdvisoryDashboard.spec.ts @@ -45,6 +45,15 @@ test.beforeEach(async ({ page }) => { await page.route('**/api/v1/advisories', (route) => route.fulfill({ json: advisories }), ) + await page.route('**/api/v1/advisories/advisory-1', (route) => + route.fulfill({ + json: { + ...advisories[0], + revision: 'rev-1', + csaf: { document: { csaf_version: '2.1' } }, + }, + }), + ) }) test('renders advisory rows with the expected columns, each linking to its edit page', async ({ diff --git a/tests/e2e/EditPage.spec.ts b/tests/e2e/EditPage.spec.ts index 00e196b..ef5b19e 100644 --- a/tests/e2e/EditPage.spec.ts +++ b/tests/e2e/EditPage.spec.ts @@ -1,8 +1,13 @@ import { expect, test } from '@playwright/test' -test('passes locale="en" to the embedded secvisogram-editor element', async ({ - page, -}) => { +const csaf = { + document: { csaf_version: '2.1', title: 'Example advisory' }, +} + +async function mockBackend( + page: import('@playwright/test').Page, + overrides?: { validatorUrl?: string }, +) { await page.route( '**/.well-known/appspecific/de.bsi.secvisogram.json', (route) => @@ -11,17 +16,64 @@ test('passes locale="en" to the embedded secvisogram-editor element', async ({ loginUrl: '/oauth2/sign_in', logoutUrl: '/oauth2/sign_out', userInfoUrl: '/oauth2/userinfo', + validatorUrl: overrides?.validatorUrl, }, }), ) await page.route('**/oauth2/userinfo', (route) => route.fulfill({ json: { groups: ['editor'] } }), ) + await page.route('**/api/v1/advisories/some-advisory-id', (route) => + route.fulfill({ + json: { advisoryId: 'some-advisory-id', revision: 'rev-1', csaf }, + }), + ) +} + +test('fetches the advisory detail and passes doc/locale/validatorUrl to the element', async ({ + page, +}) => { + await mockBackend(page, { validatorUrl: 'https://validator.example' }) + + await page.goto('/some-advisory-id/edit') + + await expect(page.getByTestId('secvisogram-mock-doc')).toHaveValue( + JSON.stringify(csaf, null, 2), + ) + + const properties = await page.locator('secvisogram-editor').evaluate((el) => { + const editor = el as unknown as { locale?: string; validatorUrl?: string } + return { locale: editor.locale, validatorUrl: editor.validatorUrl } + }) + expect(properties).toEqual({ + locale: 'en', + validatorUrl: 'https://validator.example', + }) +}) + +test('tracks the latest doc value from csaf-change events', async ({ + page, +}) => { + await mockBackend(page) await page.goto('/some-advisory-id/edit') - await expect(page.locator('secvisogram-editor')).toHaveAttribute( - 'locale', - 'en', + const edited = { document: { csaf_version: '2.1', title: 'Edited title' } } + await page.getByTestId('secvisogram-mock-doc').fill(JSON.stringify(edited)) + + await expect(page.getByTestId('edit_page_tracked_doc')).toHaveText( + JSON.stringify(edited), ) }) + +test('tracks validity from csaf-validate events', async ({ page }) => { + await mockBackend(page) + + await page.goto('/some-advisory-id/edit') + + await expect(page.getByTestId('edit_page_tracked_valid')).toHaveText('true') + + await page.getByTestId('secvisogram-mock-valid-toggle').uncheck() + + await expect(page.getByTestId('edit_page_tracked_valid')).toHaveText('false') +}) diff --git a/tests/e2e/SecvisogramMockElement.spec.ts b/tests/e2e/SecvisogramMockElement.spec.ts index 9ca685a..5cc530a 100644 --- a/tests/e2e/SecvisogramMockElement.spec.ts +++ b/tests/e2e/SecvisogramMockElement.spec.ts @@ -15,6 +15,15 @@ async function gotoEditPage(page: import('@playwright/test').Page) { await page.route('**/oauth2/userinfo', (route) => route.fulfill({ json: { groups: ['editor'] } }), ) + await page.route('**/api/v1/advisories/some-advisory-id', (route) => + route.fulfill({ + json: { + advisoryId: 'some-advisory-id', + revision: 'rev-1', + csaf: null, + }, + }), + ) await page.goto('/some-advisory-id/edit') }