From 94fd0784073bf886c2a807e60d46f7f99dfb5672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Burgd=C3=B6rfer?= Date: Tue, 13 Oct 2026 11:14:26 +0200 Subject: [PATCH 1/3] enhance EditPage component with advisory detail loading and event tracking; update tests for new functionality --- app/routes/EditPage.tsx | 74 ++++++++++++++++++++++-- tests/e2e/AdvisoryDashboard.spec.ts | 9 +++ tests/e2e/EditPage.spec.ts | 64 ++++++++++++++++++-- tests/e2e/SecvisogramMockElement.spec.ts | 9 +++ 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/app/routes/EditPage.tsx b/app/routes/EditPage.tsx index bd2abc4..a40be5b 100644 --- a/app/routes/EditPage.tsx +++ b/app/routes/EditPage.tsx @@ -1,22 +1,86 @@ -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 } +const LOCALE = 'en' + +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 } const EditPage: React.FC = () => { const data = useLoaderData() const { t } = useTranslation() + const editorRef = useRef(null) + const [doc, setDoc] = useState(data.csaf) + const [valid, setValid] = useState(true) + + useEffect(() => { + let cancelled = false + customElements.whenDefined('secvisogram-editor').then(() => { + const editor = editorRef.current + if (cancelled || !editor) return + editor.locale = LOCALE + editor.validatorUrl = data.validatorUrl + editor.doc = data.csaf + setDoc(data.csaf) + }) + return () => { + cancelled = true + } + }, [data.csaf, data.validatorUrl]) + + useEffect(() => { + const editor = editorRef.current + if (!editor) return + const handleChange = (event: Event) => { + setDoc((event as CustomEvent<{ doc: unknown }>).detail.doc) + } + const handleValidate = (event: Event) => { + setValid((event as CustomEvent<{ valid: boolean }>).detail.valid) + } + editor.addEventListener('csaf-change', handleChange) + editor.addEventListener('csaf-validate', handleValidate) + return () => { + editor.removeEventListener('csaf-change', handleChange) + editor.removeEventListener('csaf-validate', handleValidate) + } + }, []) 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') } From 21bfd96fbb6512ae8711377f3e897a650481b7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Burgd=C3=B6rfer?= Date: Tue, 13 Oct 2026 11:17:57 +0200 Subject: [PATCH 2/3] refactor EditPage component to use dynamic locale from i18n; remove hardcoded locale --- app/routes/EditPage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/routes/EditPage.tsx b/app/routes/EditPage.tsx index a40be5b..9e7a798 100644 --- a/app/routes/EditPage.tsx +++ b/app/routes/EditPage.tsx @@ -5,8 +5,6 @@ import { getAdvisory } from '~/auth/backend' import { getAppConfig } from '~/auth/appConfig' import type { Route } from './+types/EditPage' -const LOCALE = 'en' - export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { const [detail, appConfig] = await Promise.all([ getAdvisory(params.advisoryId), @@ -31,7 +29,7 @@ type EditorElement = HTMLElement & { 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) @@ -41,7 +39,18 @@ const EditPage: React.FC = () => { customElements.whenDefined('secvisogram-editor').then(() => { const editor = editorRef.current if (cancelled || !editor) return - editor.locale = LOCALE + editor.locale = i18n.language + }) + return () => { + cancelled = true + } + }, [i18n.language]) + + useEffect(() => { + let cancelled = false + customElements.whenDefined('secvisogram-editor').then(() => { + const editor = editorRef.current + if (cancelled || !editor) return editor.validatorUrl = data.validatorUrl editor.doc = data.csaf setDoc(data.csaf) From 34f576df299625e281f0c4ff5d3a3500043a19d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Burgd=C3=B6rfer?= Date: Tue, 13 Oct 2026 11:23:07 +0200 Subject: [PATCH 3/3] refactor EditPage component to improve editor readiness handling and event listener management --- app/routes/EditPage.tsx | 61 +++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/app/routes/EditPage.tsx b/app/routes/EditPage.tsx index 9e7a798..36e8db6 100644 --- a/app/routes/EditPage.tsx +++ b/app/routes/EditPage.tsx @@ -27,6 +27,15 @@ type EditorElement = HTMLElement & { 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, i18n } = useTranslation() @@ -35,46 +44,44 @@ const EditPage: React.FC = () => { const [valid, setValid] = useState(true) useEffect(() => { - let cancelled = false - customElements.whenDefined('secvisogram-editor').then(() => { - const editor = editorRef.current - if (cancelled || !editor) return - editor.locale = i18n.language + const controller = new AbortController() + whenEditorReady(editorRef, controller.signal).then((editor) => { + if (editor) editor.locale = i18n.language }) - return () => { - cancelled = true - } + return () => controller.abort() }, [i18n.language]) useEffect(() => { - let cancelled = false - customElements.whenDefined('secvisogram-editor').then(() => { - const editor = editorRef.current - if (cancelled || !editor) return + 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 () => { - cancelled = true - } + return () => controller.abort() }, [data.csaf, data.validatorUrl]) useEffect(() => { const editor = editorRef.current if (!editor) return - const handleChange = (event: Event) => { - setDoc((event as CustomEvent<{ doc: unknown }>).detail.doc) - } - const handleValidate = (event: Event) => { - setValid((event as CustomEvent<{ valid: boolean }>).detail.valid) - } - editor.addEventListener('csaf-change', handleChange) - editor.addEventListener('csaf-validate', handleValidate) - return () => { - editor.removeEventListener('csaf-change', handleChange) - editor.removeEventListener('csaf-validate', handleValidate) - } + 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 (