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..8f5a1dabc --- /dev/null +++ b/packages/cmsui/acceptance/tests/personal-information.test.ts @@ -0,0 +1,147 @@ +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 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 + .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'); + }); + }); +}); 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/locales/de/common.json b/packages/cmsui/locales/de/common.json index 6d8e29cd7..6c5becd7e 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -4,6 +4,10 @@ "edit": "Bearbeiten", "save": "Speichern", "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 20968727e..2f68adca2 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -4,6 +4,10 @@ "edit": "Edit", "save": "Save", "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 22f8a746d..ed48d25ba 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -4,6 +4,10 @@ "edit": "Modifica", "save": "Salva", "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/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/cmsui/routes/personal-information.test.tsx b/packages/cmsui/routes/personal-information.test.tsx new file mode 100644 index 000000000..14b3e7d25 --- /dev/null +++ b/packages/cmsui/routes/personal-information.test.tsx @@ -0,0 +1,372 @@ +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 return ok on success', async () => { + const result = await callAction( + buildActionRequest({ fullname: 'Jane Doe' }), + { updateUser: vi.fn().mockResolvedValue({}) }, + mockUser, + ); + + 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 and report an error 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).toEqual({ ok: false, error: 'No authenticated user' }); + }); + }); + + 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: () => ({ state: 'idle', 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' }, + ); + }); + + 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 new file mode 100644 index 000000000..a93a44058 --- /dev/null +++ b/packages/cmsui/routes/personal-information.tsx @@ -0,0 +1,175 @@ +import { + RouterContextProvider, + useFetcher, + useLoaderData, + useNavigate, + type ActionFunctionArgs, + type FetcherWithComponents, + type LoaderFunctionArgs, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { + ploneClientContext, + ploneUserContext, +} from '@plone/aurora/app/middleware.server'; +import { Button, Container, Link } from '@plone/components/quanta'; +import { useTranslation } from 'react-i18next'; +import { useAppForm } from '../components/Form/Form'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + const user = context.get(ploneUserContext); + const cli = context.get(ploneClientContext); + const { data: userschema } = await cli.getUserschema(); + return { user, userschema }; +} + +type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function action({ + request, + context, +}: ActionFunctionArgs): Promise { + await requireAuthCookie(request); + + const data = await request.json(); + const cli = context.get(ploneClientContext); + const user = context.get(ploneUserContext); + + 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 { 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') : ''} +

+
+
+ ); +} + +function PersonalInformationForm({ + user, + userschema, + fetcher, +}: { + user: LoaderData['user']; + userschema: LoaderData['userschema']; + fetcher: FetcherWithComponents; +}) { + const properties = userschema.properties as Record; + const navigate = useNavigate(); + const { t } = useTranslation(); + + const form = useAppForm({ + defaultValues: (user ?? {}) as Record, + onSubmit: async ({ 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' }); + }, + }); + + return ( + <> +

{t('cmsui.personalInformation')}

+
{ + event.preventDefault(); + form.handleSubmit(); + }} + > + {userschema.fieldsets.map((fieldset) => ( +
+ {fieldset.fields.map((schemaField, index) => ( + ( + + )} + /> + ))} +
+ ))} +
+ + +
+
+ {/* route from PR #109 */} + + {t('cmsui.changePassword')} + + + ); +} 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 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 ? (