From 5ff9b4f754e306b370d70c4c06d2c62a65bd7b2f Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Wed, 10 Jun 2026 15:07:19 +0200 Subject: [PATCH 01/14] register @@personal-information route --- packages/cmsui/config/routes.ts | 13 +++++++++++++ packages/cmsui/routes/personal-information.tsx | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 packages/cmsui/routes/personal-information.tsx diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index cccac5bec..e066d7b91 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -67,6 +67,19 @@ export default function install(config: ConfigType) { }, ], }, + { + type: 'prefix', + path: '@@personal-information', + children: [ + { + type: 'index', + file: '@plone/cmsui/routes/personal-information.tsx', + options: { + id: 'personal-information', + }, + }, + ], + }, { type: 'prefix', path: 'test-layout', diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx new file mode 100644 index 000000000..5e43f6c9e --- /dev/null +++ b/packages/cmsui/routes/personal-information.tsx @@ -0,0 +1,3 @@ +export default function PersonalInformation() { + return

Personal Information

; +} From be7f2465a2a282e9b7890234b258f8fbbeb0f348 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Wed, 10 Jun 2026 15:14:11 +0200 Subject: [PATCH 02/14] limit @@personal-information to logged in users (#117) --- packages/cmsui/routes/personal-information.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 5e43f6c9e..1236ed17f 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -1,3 +1,13 @@ +import { RouterContextProvider, type LoaderFunctionArgs } from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; + +export async function loader({ + request, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + return {}; +} + export default function PersonalInformation() { return

Personal Information

; } From 1019fa95ee391372223d13a276cee0edd23982d0 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Wed, 10 Jun 2026 15:30:01 +0200 Subject: [PATCH 03/14] fetch user data (#117) --- .../cmsui/routes/personal-information.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 1236ed17f..387de9ec0 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -1,13 +1,26 @@ -import { RouterContextProvider, type LoaderFunctionArgs } from 'react-router'; +import { + RouterContextProvider, + useLoaderData, + type LoaderFunctionArgs, +} from 'react-router'; import { requireAuthCookie } from '@plone/react-router'; +import { ploneUserContext } from '@plone/aurora/app/middleware.server'; export async function loader({ request, + context, }: LoaderFunctionArgs) { await requireAuthCookie(request); - return {}; + const user = context.get(ploneUserContext); + return { user }; } export default function PersonalInformation() { - return

Personal Information

; + const { user } = useLoaderData(); + return ( + <> +

Personal Information

+
{JSON.stringify(user, null, 2)}
+ + ); } From 41ba002b902b69bd22eec7807395d45482113c40 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Wed, 10 Jun 2026 16:22:42 +0200 Subject: [PATCH 04/14] personal-information as schema driven form --- .../cmsui/routes/personal-information.tsx | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 387de9ec0..fa1549e54 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -4,7 +4,11 @@ import { type LoaderFunctionArgs, } from 'react-router'; import { requireAuthCookie } from '@plone/react-router'; -import { ploneUserContext } from '@plone/aurora/app/middleware.server'; +import { + ploneClientContext, + ploneUserContext, +} from '@plone/aurora/app/middleware.server'; +import { useAppForm } from '../components/Form/Form'; export async function loader({ request, @@ -12,15 +16,50 @@ export async function loader({ }: LoaderFunctionArgs) { await requireAuthCookie(request); const user = context.get(ploneUserContext); - return { user }; + const cli = context.get(ploneClientContext); + const { data: userschema } = await cli.getUserschema(); + return { user, userschema }; } export default function PersonalInformation() { - const { user } = useLoaderData(); + const { user, userschema } = useLoaderData(); + const properties = userschema.properties as Record; + + const form = useAppForm({ + defaultValues: (user ?? {}) as Record, + onSubmit: async ({ value }) => { + // eslint-disable-next-line no-console + console.log('personal-information submit (not wired yet)', value); + }, + }); + return ( <>

Personal Information

-
{JSON.stringify(user, null, 2)}
+
+ {userschema.fieldsets.map((fieldset) => ( +
+ {fieldset.fields.map((schemaField, index) => ( + ( + + )} + /> + ))} +
+ ))} +
); } From fbb6f30b1cbff55e4fffeafc145cf090a5c9dd9b Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Wed, 10 Jun 2026 17:15:11 +0200 Subject: [PATCH 05/14] persist personal-information changes and reload (#117) --- .../cmsui/routes/personal-information.tsx | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index fa1549e54..19a4102dd 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -1,6 +1,9 @@ import { + redirect, RouterContextProvider, + useFetcher, useLoaderData, + type ActionFunctionArgs, type LoaderFunctionArgs, } from 'react-router'; import { requireAuthCookie } from '@plone/react-router'; @@ -8,6 +11,7 @@ import { ploneClientContext, ploneUserContext, } from '@plone/aurora/app/middleware.server'; +import { Button } from '@plone/components/quanta'; import { useAppForm } from '../components/Form/Form'; export async function loader({ @@ -21,15 +25,60 @@ export async function loader({ return { user, userschema }; } +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const data = await request.json(); + const cli = context.get(ploneClientContext); + const user = context.get(ploneUserContext); + + if (user?.id) { + await cli.updateUser({ id: user.id, data }); + } + + return redirect('/@@personal-information'); +} + +type LoaderData = Awaited>; + export default function PersonalInformation() { const { user, userschema } = useLoaderData(); + // reload persisted values + return ( + + ); +} + +function PersonalInformationForm({ + user, + userschema, +}: { + user: LoaderData['user']; + userschema: LoaderData['userschema']; +}) { const properties = userschema.properties as Record; + const fetcher = useFetcher(); const form = useAppForm({ defaultValues: (user ?? {}) as Record, onSubmit: async ({ value }) => { - // eslint-disable-next-line no-console - console.log('personal-information submit (not wired yet)', value); + // skip `portrait` + const data: Record = {}; + userschema.fieldsets.forEach((fieldset) => + fieldset.fields.forEach((field) => { + if (field === 'portrait') return; + const v = (value as Record)[field]; + if (typeof v === 'string' && v !== '') data[field] = v; + }), + ); + fetcher.submit(data, { method: 'post', encType: 'application/json' }); }, }); @@ -59,6 +108,14 @@ export default function PersonalInformation() { ))} ))} + ); From 1b1fb5bc1171061ebb100146744ea85e11430e02 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Thu, 11 Jun 2026 11:39:43 +0200 Subject: [PATCH 06/14] add cancel button, fix reload and enter submit (#117) --- .../cmsui/routes/personal-information.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 19a4102dd..911daa3f0 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -3,6 +3,7 @@ import { RouterContextProvider, useFetcher, useLoaderData, + useNavigate, type ActionFunctionArgs, type LoaderFunctionArgs, } from 'react-router'; @@ -12,6 +13,7 @@ import { ploneUserContext, } from '@plone/aurora/app/middleware.server'; import { Button } from '@plone/components/quanta'; +import { useTranslation } from 'react-i18next'; import { useAppForm } from '../components/Form/Form'; export async function loader({ @@ -65,6 +67,8 @@ function PersonalInformationForm({ }) { const properties = userschema.properties as Record; const fetcher = useFetcher(); + const navigate = useNavigate(); + const { t } = useTranslation(); const form = useAppForm({ defaultValues: (user ?? {}) as Record, @@ -85,7 +89,12 @@ function PersonalInformationForm({ return ( <>

Personal Information

-
+ { + event.preventDefault(); + form.handleSubmit(); + }} + > {userschema.fieldsets.map((fieldset) => (
{fieldset.fields.map((schemaField, index) => ( @@ -108,14 +117,17 @@ function PersonalInformationForm({ ))}
))} - +
+ + +
); From 9e0eda2ad46efe087fc39b49bb0c0712abbb3956 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Thu, 11 Jun 2026 12:32:37 +0200 Subject: [PATCH 07/14] add translations (#117) --- packages/cmsui/locales/de/common.json | 2 ++ packages/cmsui/locales/en/common.json | 2 ++ packages/cmsui/locales/it/common.json | 2 ++ packages/cmsui/routes/personal-information.tsx | 4 ++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index fcd14d912..0a51da33b 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -3,6 +3,8 @@ "add": "Hinzufügen", "edit": "Bearbeiten", "save": "Speichern", + "cancel": "Abbrechen", + "personalInformation": "Persönliche Informationen", "controlpanel": "Kontrollzentrum", "objectbrowserwidget": { "openDialog": "Inhalt auswählen", diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 46fd15f42..189e9d28e 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -3,6 +3,8 @@ "add": "Add", "edit": "Edit", "save": "Save", + "cancel": "Cancel", + "personalInformation": "Personal Information", "recurrence": { "editRecurrence": "Edit recurrence", "repeat": "Repeat", diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 7cd7fb030..7db052367 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -3,6 +3,8 @@ "add": "Aggiungi", "edit": "Modifica", "save": "Salva", + "cancel": "Annulla", + "personalInformation": "Informazioni Personali", "recurrence": { "editRecurrence": "Cambia la ricorrenza", "repeat": "Ricorrenza", diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 911daa3f0..0679f3101 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -88,7 +88,7 @@ function PersonalInformationForm({ return ( <> -

Personal Information

+

{t('cmsui.personalInformation')}

{ event.preventDefault(); @@ -126,7 +126,7 @@ function PersonalInformationForm({ > {t('cmsui.save')} - +
From 478761ca0c0979632c11b431bdad6bc74b36a163 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Thu, 11 Jun 2026 13:52:06 +0200 Subject: [PATCH 08/14] add reset-password link, route from #109 (#117) --- packages/cmsui/locales/de/common.json | 1 + packages/cmsui/locales/en/common.json | 1 + packages/cmsui/locales/it/common.json | 1 + packages/cmsui/routes/personal-information.tsx | 6 +++++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index 0a51da33b..f4448c392 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -5,6 +5,7 @@ "save": "Speichern", "cancel": "Abbrechen", "personalInformation": "Persönliche Informationen", + "changePassword": "Passwort ändern", "controlpanel": "Kontrollzentrum", "objectbrowserwidget": { "openDialog": "Inhalt auswählen", diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 189e9d28e..4ca8c892d 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -5,6 +5,7 @@ "save": "Save", "cancel": "Cancel", "personalInformation": "Personal Information", + "changePassword": "Change Password", "recurrence": { "editRecurrence": "Edit recurrence", "repeat": "Repeat", diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 7db052367..e0b77785f 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -5,6 +5,7 @@ "save": "Salva", "cancel": "Annulla", "personalInformation": "Informazioni Personali", + "changePassword": "Cambia Password", "recurrence": { "editRecurrence": "Cambia la ricorrenza", "repeat": "Ricorrenza", diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 0679f3101..068773d16 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -12,7 +12,7 @@ import { ploneClientContext, ploneUserContext, } from '@plone/aurora/app/middleware.server'; -import { Button } from '@plone/components/quanta'; +import { Button, Link } from '@plone/components/quanta'; import { useTranslation } from 'react-i18next'; import { useAppForm } from '../components/Form/Form'; @@ -129,6 +129,10 @@ function PersonalInformationForm({ + {/* route from PR #109 */} + + {t('cmsui.changePassword')} + ); } From bc62249c34f0a76c1150668c12038dda448950c2 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Thu, 11 Jun 2026 13:58:56 +0200 Subject: [PATCH 09/14] personal-information next to login/logout (temporary location) (#117) --- packages/layout/slots/Tools.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/layout/slots/Tools.tsx b/packages/layout/slots/Tools.tsx index 1f8f2eea5..61d5b7189 100644 --- a/packages/layout/slots/Tools.tsx +++ b/packages/layout/slots/Tools.tsx @@ -14,6 +14,12 @@ const HeaderTools = () => { icon: '🔨', url: '/logout', }, + { + id: '3', + label: 'personal information', + icon: '👤', + url: '/@@personal-information', + }, ]; // Inline styles since this is temporary during seven development return import.meta.env.DEV ? ( From a55948033eb34c908ecb15c0bf7df5009228b64a Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Fri, 12 Jun 2026 08:59:31 +0200 Subject: [PATCH 10/14] test coverage for @@presonal-information (#117) --- .../routes/personal-information.test.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 packages/cmsui/routes/personal-information.test.tsx diff --git a/packages/cmsui/routes/personal-information.test.tsx b/packages/cmsui/routes/personal-information.test.tsx new file mode 100644 index 000000000..94bea00ce --- /dev/null +++ b/packages/cmsui/routes/personal-information.test.tsx @@ -0,0 +1,295 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { + ploneClientContext, + ploneUserContext, +} from '@plone/aurora/app/middleware.server'; +import { loader, action } from './personal-information'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +const mockUserschema = { + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: [ + 'fullname', + 'email', + 'home_page', + 'description', + 'location', + 'portrait', + ], + }, + ], + properties: { + fullname: { title: 'Full Name', type: 'string' }, + email: { title: 'Email', type: 'string' }, + home_page: { title: 'Home page', type: 'string' }, + description: { title: 'Biography', type: 'string' }, + location: { title: 'Location', type: 'string' }, + portrait: { title: 'Portrait', type: 'object' }, + }, + required: ['email'], +}; + +const mockUser = { + '@id': 'http://example.com/++api++/@users/john', + id: 'john', + username: 'john', + fullname: 'John Doe', + email: 'john@example.com', + home_page: null, + description: 'A test user', + location: 'Kerpen', + portrait: null, + roles: ['Member'], +}; + +function buildContext(cli: Record, user?: typeof mockUser) { + const context = new RouterContextProvider(); + context.set(ploneClientContext, cli as any); + if (user) { + context.set(ploneUserContext, user as any); + } + return context; +} + +describe('Personal information route', () => { + afterEach(() => vi.restoreAllMocks()); + + describe('loader', () => { + it('should return the user from context and the userschema', async () => { + const getUserschemaMock = vi + .fn() + .mockResolvedValue({ data: mockUserschema }); + const context = buildContext( + { getUserschema: getUserschemaMock }, + mockUser, + ); + + const request = new Request('http://example.com/@@personal-information'); + + const result = await loader({ + request, + params: {}, + context, + unstable_pattern: '/@@personal-information', + unstable_url: new URL(request.url), + }); + + expect(getUserschemaMock).toHaveBeenCalled(); + expect(result.user).toEqual(mockUser); + expect(result.userschema).toEqual(mockUserschema); + }); + + it('should require an auth cookie', async () => { + const context = buildContext( + { getUserschema: vi.fn().mockResolvedValue({ data: mockUserschema }) }, + mockUser, + ); + + const request = new Request('http://example.com/@@personal-information'); + + await loader({ + request, + params: {}, + context, + unstable_pattern: '/@@personal-information', + unstable_url: new URL(request.url), + }); + + expect(requireAuthCookie).toHaveBeenCalledWith(request); + }); + + it('should propagate the redirect for unauthenticated requests', async () => { + const redirectResponse = new Response(null, { + status: 302, + headers: { Location: '/login' }, + }); + vi.mocked(requireAuthCookie).mockRejectedValueOnce(redirectResponse); + + const context = buildContext({ getUserschema: vi.fn() }); + const request = new Request('http://example.com/@@personal-information'); + + await expect( + loader({ + request, + params: {}, + context, + unstable_pattern: '/@@personal-information', + unstable_url: new URL(request.url), + }), + ).rejects.toBe(redirectResponse); + }); + }); + + describe('action', () => { + function buildActionRequest(body: Record) { + return new Request('http://example.com/@@personal-information', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + function callAction( + request: Request, + cli: Record, + user?: typeof mockUser, + ) { + const context = buildContext(cli, user); + return action({ + request, + params: {}, + context, + unstable_pattern: '/@@personal-information', + unstable_url: new URL(request.url), + }); + } + + it('should call updateUser with the user id and the submitted data', async () => { + const updateUserMock = vi.fn().mockResolvedValue({}); + const body = { fullname: 'Jane Doe', email: 'jane@example.com' }; + + await callAction( + buildActionRequest(body), + { updateUser: updateUserMock }, + mockUser, + ); + + expect(updateUserMock).toHaveBeenCalledWith({ id: 'john', data: body }); + }); + + it('should redirect back to the route', async () => { + const result = await callAction( + buildActionRequest({ fullname: 'Jane Doe' }), + { updateUser: vi.fn().mockResolvedValue({}) }, + mockUser, + ); + + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe( + '/@@personal-information', + ); + }); + + it('should skip updateUser when there is no user in context', async () => { + const updateUserMock = vi.fn(); + + const result = await callAction(buildActionRequest({ fullname: 'X' }), { + updateUser: updateUserMock, + }); + + expect(updateUserMock).not.toHaveBeenCalled(); + expect((result as Response).status).toBe(302); + }); + }); + + describe('PersonalInformation component', () => { + it('should render the title, schema fields, buttons and change-password link', async () => { + // Reset module registry so doMock takes effect on fresh imports + vi.resetModules(); + + vi.doMock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), + })); + + const fetcherSubmit = vi.fn(); + + vi.doMock('react-router', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + useLoaderData: () => ({ + user: mockUser, + userschema: mockUserschema, + }), + useFetcher: () => ({ submit: fetcherSubmit }), + useNavigate: () => vi.fn(), + }; + }); + + // mock the TanStack-form/Quanta-widget machinery + let formOptions: any; + vi.doMock('../components/Form/Form', () => ({ + useAppForm: (options: any) => { + formOptions = options; + return { + handleSubmit: vi.fn(), + AppField: ({ name, children }: any) => + children({ + name, + state: { value: '', meta: { errors: [] } }, + Quanta: (props: any) => ( + + ), + }), + }; + }, + })); + + // Dynamic imports to pick up the mocks + const { render, screen } = await import('@testing-library/react'); + const { default: PersonalInformationMocked } = await import( + './personal-information' + ); + + render(); + + // useTranslation falls back to returning the key in tests + expect( + screen.getByRole('heading', { name: 'cmsui.personalInformation' }), + ).toBeInTheDocument(); + + for (const field of mockUserschema.fieldsets[0].fields) { + expect( + screen.getByLabelText( + (mockUserschema.properties as Record)[field].title, + ), + ).toBeInTheDocument(); + } + + expect( + screen.getByRole('button', { name: 'cmsui.save' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'cmsui.cancel' }), + ).toBeInTheDocument(); + + const changePassword = screen.getByRole('link', { + name: 'cmsui.changePassword', + }); + // route from PR #109 + expect(changePassword).toHaveAttribute('href', '/reset-password'); + + // the form is seeded with the loader's user + expect(formOptions.defaultValues).toEqual(mockUser); + + // onSubmit submits only non-empty schema text fields, + // never portrait or non-schema user props + await formOptions.onSubmit({ + value: { + ...mockUser, + fullname: 'Jane Doe', + home_page: '', + portrait: 'data:image/png;base64,abc', + }, + }); + + expect(fetcherSubmit).toHaveBeenCalledWith( + { + fullname: 'Jane Doe', + email: 'john@example.com', + description: 'A test user', + location: 'Kerpen', + }, + { method: 'post', encType: 'application/json' }, + ); + }); + }); +}); From 6eae6928526d73bb0bdece50264b641f762ca5b6 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Fri, 12 Jun 2026 13:16:13 +0200 Subject: [PATCH 11/14] acceptance test coverage for @@personal-information (#117) --- .../tests/personal-information.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/cmsui/acceptance/tests/personal-information.test.ts diff --git a/packages/cmsui/acceptance/tests/personal-information.test.ts b/packages/cmsui/acceptance/tests/personal-information.test.ts new file mode 100644 index 000000000..2db774152 --- /dev/null +++ b/packages/cmsui/acceptance/tests/personal-information.test.ts @@ -0,0 +1,144 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; + +// The standard plone.app.testing member, not the Zope root admin +// Its member fields start out empty, so we seed them below. +const TEST_USER = { + userId: 'test_user_1_', + username: 'test-user', + password: 'correct horse battery staple', + email: 'jane@example.com', + fullname: 'Jane Doe', + location: 'Bonn', +}; + +function getApiURL() { + const hostname = process.env.BACKEND_HOST || '127.0.0.1'; + const siteId = process.env.SITE_ID || 'plone'; + return process.env.API_PATH || `http://${hostname}:55001/${siteId}`; +} + +function basicAuthHeader(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`; +} + +async function seedTestUser(page: Page) { + const url = `${getApiURL()}/@users/${TEST_USER.userId}`; + const response = await page.request.patch(url, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: basicAuthHeader('admin', 'secret'), + }, + data: { + email: TEST_USER.email, + fullname: TEST_USER.fullname, + location: TEST_USER.location, + }, + }); + if (!response.ok()) { + throw new Error( + `User seeding failed: PATCH ${url} returned ${response.status()} ${response.statusText()}`, + ); + } +} + +async function getTestUserData(page: Page) { + const url = `${getApiURL()}/@users/${TEST_USER.userId}`; + const response = await page.request.get(url, { + headers: { + Accept: 'application/json', + Authorization: basicAuthHeader(TEST_USER.username, TEST_USER.password), + }, + }); + if (!response.ok()) { + throw new Error( + `User fetch failed: GET ${url} returned ${response.status()} ${response.statusText()}`, + ); + } + return (await response.json()) as Record; +} + +test.describe('Personal Information Route Tests', () => { + test('As an anonymous visitor I am redirected to login', async ({ page }) => { + await page.goto('/@@personal-information'); + await page.waitForURL('**/login'); + await expect(page.getByText('Sign in')).toBeVisible({ timeout: 10_000 }); + }); + + test.describe('As a logged-in user', () => { + test.beforeEach(async ({ page }) => { + await seedTestUser(page); + await login(page, { + username: TEST_USER.username, + password: TEST_USER.password, + }); + await page.goto('/@@personal-information', { + waitUntil: 'networkidle', + }); + await expect( + page.getByRole('heading', { name: 'Personal Information' }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('I see the form seeded with my member data', async ({ page }) => { + await expect(page.getByLabel('Full Name')).toHaveValue( + TEST_USER.fullname, + ); + await expect(page.getByLabel('Email')).toHaveValue(TEST_USER.email); + await expect(page.getByLabel('Location')).toHaveValue(TEST_USER.location); + await expect(page.getByLabel('Home page')).toHaveValue(''); + await expect(page.getByLabel('Biography')).toHaveValue(''); + await expect(page.getByLabel('Portrait')).toHaveValue(''); + }); + + test('I can edit my data and save, and the changes persist', async ({ + page, + }) => { + await page.getByLabel('Full Name').fill('Janet Doe'); + await page.getByLabel('Location').fill('Berlin'); + await page.getByRole('button', { name: 'Save' }).click(); + + // The PATCH happens server-side in the route action, so verify + // persistence directly against the backend. + await expect + .poll(async () => (await getTestUserData(page)).location, { + timeout: 10_000, + }) + .toBe('Berlin'); + expect((await getTestUserData(page)).fullname).toBe('Janet Doe'); + + // A full reload re-runs the loader; the form must re-seed with the + // saved values. + await page.reload({ waitUntil: 'networkidle' }); + await expect(page.getByLabel('Full Name')).toHaveValue('Janet Doe'); + await expect(page.getByLabel('Location')).toHaveValue('Berlin'); + }); + + test('Pressing Enter in a field saves the form', async ({ page }) => { + await page.getByLabel('Location').fill('Hamburg'); + await page.getByLabel('Location').press('Enter'); + + await expect + .poll(async () => (await getTestUserData(page)).location, { + timeout: 10_000, + }) + .toBe('Hamburg'); + }); + + test('Cancel takes me back to the home page', async ({ page }) => { + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page).toHaveURL('/'); + }); + + // Change-password link points to `reset-password` route from PR #109 + test('A change-password link points at the reset-password route', async ({ + page, + }) => { + const link = page.getByRole('link', { name: 'Change Password' }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', '/reset-password'); + }); + }); +}); From 41f7f417e05043fab07a3da148d4c7fe09448068 Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Fri, 12 Jun 2026 13:18:27 +0200 Subject: [PATCH 12/14] add news (#117) --- packages/cmsui/news/47.feature | 1 + packages/layout/news/47.feature | 1 + 2 files changed, 2 insertions(+) create mode 100644 packages/cmsui/news/47.feature create mode 100644 packages/layout/news/47.feature diff --git a/packages/cmsui/news/47.feature b/packages/cmsui/news/47.feature new file mode 100644 index 000000000..024ce19de --- /dev/null +++ b/packages/cmsui/news/47.feature @@ -0,0 +1 @@ +Added `@@personal-information` route with a schema-driven form where logged-in users can view and update their member data. @szakitibi diff --git a/packages/layout/news/47.feature b/packages/layout/news/47.feature new file mode 100644 index 000000000..90af7eb82 --- /dev/null +++ b/packages/layout/news/47.feature @@ -0,0 +1 @@ +Added a personal information link to the temporary development header tools. @szakitibi From cd1bbcda063819ab633b2233bacf926ccfc68a3e Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Fri, 12 Jun 2026 14:54:08 +0200 Subject: [PATCH 13/14] wrap with Container to match controlpanel layout (#117) --- packages/cmsui/routes/personal-information.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index 068773d16..d928dc76b 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -12,7 +12,7 @@ import { ploneClientContext, ploneUserContext, } from '@plone/aurora/app/middleware.server'; -import { Button, Link } from '@plone/components/quanta'; +import { Button, Container, Link } from '@plone/components/quanta'; import { useTranslation } from 'react-i18next'; import { useAppForm } from '../components/Form/Form'; @@ -48,13 +48,17 @@ type LoaderData = Awaited>; export default function PersonalInformation() { const { user, userschema } = useLoaderData(); - // reload persisted values return ( - +
+ + {/* key: remount to reload persisted values */} + + +
); } From 0ada74af413f5ca785fd8ff5e35893b6710a03aa Mon Sep 17 00:00:00 2001 From: Tibor Szakmany Date: Fri, 12 Jun 2026 16:29:32 +0200 Subject: [PATCH 14/14] a11y, error feedback --- .../tests/personal-information.test.ts | 3 + packages/cmsui/locales/de/common.json | 2 + packages/cmsui/locales/en/common.json | 2 + packages/cmsui/locales/it/common.json | 2 + .../routes/personal-information.test.tsx | 91 +++++++++++++++++-- .../cmsui/routes/personal-information.tsx | 45 +++++++-- 6 files changed, 132 insertions(+), 13 deletions(-) diff --git a/packages/cmsui/acceptance/tests/personal-information.test.ts b/packages/cmsui/acceptance/tests/personal-information.test.ts index 2db774152..8f5a1dabc 100644 --- a/packages/cmsui/acceptance/tests/personal-information.test.ts +++ b/packages/cmsui/acceptance/tests/personal-information.test.ts @@ -100,6 +100,9 @@ test.describe('Personal Information Route Tests', () => { await page.getByLabel('Location').fill('Berlin'); await page.getByRole('button', { name: 'Save' }).click(); + // the sr-only live region announces the successful save + await expect(page.getByRole('status')).toHaveText('Changes saved.'); + // The PATCH happens server-side in the route action, so verify // persistence directly against the backend. await expect diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index 66b7f0ceb..6c5becd7e 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -6,6 +6,8 @@ "cancel": "Abbrechen", "personalInformation": "Persönliche Informationen", "changePassword": "Passwort ändern", + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "saveSuccess": "Änderungen gespeichert.", "controlpanel": "Konfiguration", "objectbrowserwidget": { "openDialog": "Inhalt auswählen", diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index ca2aa8701..2f68adca2 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -6,6 +6,8 @@ "cancel": "Cancel", "personalInformation": "Personal Information", "changePassword": "Change Password", + "saveError": "Saving failed. Please try again.", + "saveSuccess": "Changes saved.", "recurrence": { "editRecurrence": "Edit recurrence", "repeat": "Repeat", diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 0a2e43c98..ed48d25ba 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -6,6 +6,8 @@ "cancel": "Annulla", "personalInformation": "Informazioni Personali", "changePassword": "Cambia Password", + "saveError": "Salvataggio non riuscito. Riprova.", + "saveSuccess": "Modifiche salvate.", "recurrence": { "editRecurrence": "Cambia la ricorrenza", "repeat": "Ricorrenza", diff --git a/packages/cmsui/routes/personal-information.test.tsx b/packages/cmsui/routes/personal-information.test.tsx index 94bea00ce..14b3e7d25 100644 --- a/packages/cmsui/routes/personal-information.test.tsx +++ b/packages/cmsui/routes/personal-information.test.tsx @@ -165,20 +165,27 @@ describe('Personal information route', () => { expect(updateUserMock).toHaveBeenCalledWith({ id: 'john', data: body }); }); - it('should redirect back to the route', async () => { + it('should return ok on success', async () => { const result = await callAction( buildActionRequest({ fullname: 'Jane Doe' }), { updateUser: vi.fn().mockResolvedValue({}) }, mockUser, ); - expect((result as Response).status).toBe(302); - expect((result as Response).headers.get('Location')).toBe( - '/@@personal-information', + expect(result).toEqual({ ok: true }); + }); + + it('should return the error instead of throwing when updateUser rejects', async () => { + const result = await callAction( + buildActionRequest({ fullname: 'Jane Doe' }), + { updateUser: vi.fn().mockRejectedValue(new Error('PATCH failed')) }, + mockUser, ); + + expect(result).toEqual({ ok: false, error: 'PATCH failed' }); }); - it('should skip updateUser when there is no user in context', async () => { + it('should skip updateUser and report an error when there is no user in context', async () => { const updateUserMock = vi.fn(); const result = await callAction(buildActionRequest({ fullname: 'X' }), { @@ -186,7 +193,7 @@ describe('Personal information route', () => { }); expect(updateUserMock).not.toHaveBeenCalled(); - expect((result as Response).status).toBe(302); + expect(result).toEqual({ ok: false, error: 'No authenticated user' }); }); }); @@ -209,7 +216,7 @@ describe('Personal information route', () => { user: mockUser, userschema: mockUserschema, }), - useFetcher: () => ({ submit: fetcherSubmit }), + useFetcher: () => ({ state: 'idle', submit: fetcherSubmit }), useNavigate: () => vi.fn(), }; }); @@ -291,5 +298,75 @@ describe('Personal information route', () => { { method: 'post', encType: 'application/json' }, ); }); + + it('should show an alert on failed save, announce success, and disable Save while busy', async () => { + vi.resetModules(); + + vi.doMock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), + })); + + // mutable so each render below can see a different fetcher state + let fetcherValue: any = { + state: 'idle', + submit: vi.fn(), + data: { ok: false, error: 'PATCH failed' }, + }; + + vi.doMock('react-router', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + useLoaderData: () => ({ + user: mockUser, + userschema: mockUserschema, + }), + useFetcher: () => fetcherValue, + useNavigate: () => vi.fn(), + }; + }); + + vi.doMock('../components/Form/Form', () => ({ + useAppForm: () => ({ + handleSubmit: vi.fn(), + AppField: ({ name, children }: any) => + children({ + name, + state: { value: '', meta: { errors: [] } }, + Quanta: (props: any) => ( + + ), + }), + }), + })); + + const { render, screen } = await import('@testing-library/react'); + const { default: PersonalInformationMocked } = await import( + './personal-information' + ); + + // drop leftovers from the previous component test + document.body.innerHTML = ''; + + // failed save: visible alert with the generic message, detail in title + const failed = render(); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('cmsui.saveError'); + expect(alert).toHaveAttribute('title', 'PATCH failed'); + expect(screen.getByRole('status')).toHaveTextContent(''); + failed.unmount(); + + // successful save: no alert, live region announces + fetcherValue = { state: 'idle', submit: vi.fn(), data: { ok: true } }; + const succeeded = render(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('cmsui.saveSuccess'); + succeeded.unmount(); + + // in-flight submission: Save is disabled + fetcherValue = { state: 'submitting', submit: vi.fn() }; + render(); + expect(screen.getByRole('button', { name: 'cmsui.save' })).toBeDisabled(); + }); }); }); diff --git a/packages/cmsui/routes/personal-information.tsx b/packages/cmsui/routes/personal-information.tsx index d928dc76b..a93a44058 100644 --- a/packages/cmsui/routes/personal-information.tsx +++ b/packages/cmsui/routes/personal-information.tsx @@ -1,10 +1,10 @@ import { - redirect, RouterContextProvider, useFetcher, useLoaderData, useNavigate, type ActionFunctionArgs, + type FetcherWithComponents, type LoaderFunctionArgs, } from 'react-router'; import { requireAuthCookie } from '@plone/react-router'; @@ -27,36 +27,67 @@ export async function loader({ return { user, userschema }; } +type ActionResult = { ok: true } | { ok: false; error: string }; + export async function action({ request, context, -}: ActionFunctionArgs) { +}: ActionFunctionArgs): Promise { await requireAuthCookie(request); const data = await request.json(); const cli = context.get(ploneClientContext); const user = context.get(ploneUserContext); - if (user?.id) { + if (!user?.id) { + return { ok: false, error: 'No authenticated user' }; + } + + try { await cli.updateUser({ id: user.id, data }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } - return redirect('/@@personal-information'); + return { ok: true }; } type LoaderData = Awaited>; export default function PersonalInformation() { const { user, userschema } = useLoaderData(); + // fetcher: successful save remounts the form (fresh key), feedback survives + const fetcher = useFetcher(); + const { t } = useTranslation(); return (
- + {/* key: remount to reload persisted values */} + {fetcher.data?.ok === false ? ( +

+ {t('cmsui.saveError')} +

+ ) : null} + {/* live region, changes get announced here */} +

+ {fetcher.data?.ok === true ? t('cmsui.saveSuccess') : ''} +

); @@ -65,12 +96,13 @@ export default function PersonalInformation() { function PersonalInformationForm({ user, userschema, + fetcher, }: { user: LoaderData['user']; userschema: LoaderData['userschema']; + fetcher: FetcherWithComponents; }) { const properties = userschema.properties as Record; - const fetcher = useFetcher(); const navigate = useNavigate(); const { t } = useTranslation(); @@ -126,6 +158,7 @@ function PersonalInformationForm({ type="submit" variant="primary" accent + isDisabled={fetcher.state !== 'idle'} onPress={() => form.handleSubmit()} > {t('cmsui.save')}