release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* The page editor's Publish button flips the Draft flag and saves at once
|
||||
* (#722). When that save fails, the flipped flag is an unsaved edit: the editor
|
||||
* has to say so and keep it saveable, not show "Saved" over a page that is
|
||||
* still a draft on disk.
|
||||
*/
|
||||
|
||||
const MARKDOWN = '---\ntitle: "FAQ"\ndraft: true\n---\n\nHello\n';
|
||||
|
||||
let putStatus = 500;
|
||||
|
||||
beforeEach(() => {
|
||||
putStatus = 500;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
if (putStatus !== 200) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to save page' }), {
|
||||
status: putStatus,
|
||||
});
|
||||
}
|
||||
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
page: { slug: 'faq', rawMarkdown: sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ page: { slug: 'faq', rawMarkdown: MARKDOWN }, version: 'v1' }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
// Drafts live in sessionStorage; each test starts without one.
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderEditor() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="faq" kind="page" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('JournalEditor page mode: Publish', () => {
|
||||
it('leaves a failed publish as an unsaved change', async () => {
|
||||
renderEditor();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain('Failed to save page'),
|
||||
);
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' });
|
||||
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reads Saved after a publish that went through', async () => {
|
||||
putStatus = 200;
|
||||
renderEditor();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||
|
||||
await screen.findByRole('button', { name: 'Unpublish' });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Saved/ })).toBeTruthy());
|
||||
});
|
||||
});
|
||||
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import PrivacyEditor from '../PrivacyEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* The privacy editor sits in Settings → Legal and unmounts when another
|
||||
* section opens. An unsaved policy used to go with it (A-7); it now keeps a
|
||||
* draft like the About editor (#554, #592).
|
||||
*/
|
||||
|
||||
let serverBody = 'Saved policy';
|
||||
|
||||
beforeEach(() => {
|
||||
serverBody = 'Saved policy';
|
||||
window.sessionStorage.clear();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
body: serverBody,
|
||||
enabled: true,
|
||||
facts: [],
|
||||
starter: '## Controller',
|
||||
version: 'v1',
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PrivacyEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const editor = async () =>
|
||||
(await screen.findByLabelText('Privacy policy (Markdown)')) as HTMLTextAreaElement;
|
||||
|
||||
describe('PrivacyEditor draft (A-7)', () => {
|
||||
it('brings back unsaved text after the editor was unmounted', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
mount();
|
||||
expect((await editor()).value).toBe('Unsaved policy');
|
||||
expect(screen.getByText(/unsaved changes to the privacy policy were restored/)).toBeTruthy();
|
||||
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('discarding the draft shows the saved policy again', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
mount();
|
||||
await editor();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
|
||||
expect((await editor()).value).toBe('Saved policy');
|
||||
await waitFor(() => expect(window.sessionStorage.getItem('folio_draft_privacy')).toBeNull());
|
||||
});
|
||||
|
||||
it('holds a draft back when the file changed since, and asks', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
serverBody = 'Changed in another tab';
|
||||
mount();
|
||||
expect((await editor()).value).toBe('Changed in another tab');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore my changes' }));
|
||||
expect((await editor()).value).toBe('Unsaved policy');
|
||||
});
|
||||
});
|
||||
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
import AlbumPicker from '../AlbumPicker';
|
||||
import AlbumDrawer from '../page-builder/AlbumDrawer';
|
||||
import { StorySettingsModal } from '../journal/StorySettingsModal';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function Dialog({
|
||||
name,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const ref = useModalDialog(onClose);
|
||||
return (
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label={name}>
|
||||
<button type="button">{name} first</button>
|
||||
{children}
|
||||
<button type="button">{name} last</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const escape = () => fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
||||
|
||||
describe('useModalDialog with nested dialogs (A-9)', () => {
|
||||
it('Escape closes only the innermost dialog', () => {
|
||||
const closeOuter = vi.fn();
|
||||
const closeInner = vi.fn();
|
||||
const { rerender } = render(<Dialog name="outer" onClose={closeOuter} />);
|
||||
rerender(
|
||||
<Dialog name="outer" onClose={closeOuter}>
|
||||
<Dialog name="inner" onClose={closeInner} />
|
||||
</Dialog>,
|
||||
);
|
||||
escape();
|
||||
expect(closeInner).toHaveBeenCalledTimes(1);
|
||||
expect(closeOuter).not.toHaveBeenCalled();
|
||||
|
||||
// Once the inner one is gone, the outer one answers again.
|
||||
rerender(<Dialog name="outer" onClose={closeOuter} />);
|
||||
escape();
|
||||
expect(closeOuter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves Escape to an expanded combobox', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Dialog name="d" onClose={close}>
|
||||
<button type="button" role="combobox" aria-expanded="true" aria-controls="x">
|
||||
sort
|
||||
</button>
|
||||
</Dialog>,
|
||||
);
|
||||
screen.getByRole('combobox').focus();
|
||||
escape();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves Escape to an item picked up with the keyboard (dnd-kit)', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Dialog name="d" onClose={close}>
|
||||
<div role="button" tabIndex={0} aria-roledescription="sortable" aria-pressed="true">
|
||||
tile
|
||||
</div>
|
||||
</Dialog>,
|
||||
);
|
||||
screen.getByText('tile').focus();
|
||||
escape();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlbumPicker from the keyboard (A-10)', () => {
|
||||
const albums = [
|
||||
{
|
||||
id: 'a1',
|
||||
albumName: 'Alps',
|
||||
description: '',
|
||||
thumbnailAssetId: null,
|
||||
assetCount: 3,
|
||||
isConfigured: false,
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
albumName: 'Baltic',
|
||||
description: '',
|
||||
thumbnailAssetId: null,
|
||||
assetCount: 5,
|
||||
isConfigured: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('offers each free album as a button and disables the ones in use', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<AlbumPicker
|
||||
albums={albums}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
usedAlbumIds={new Set(['a2'])}
|
||||
/>,
|
||||
);
|
||||
const alps = screen.getByRole('button', { name: /Alps/ });
|
||||
const baltic = screen.getByRole('button', { name: /Baltic/ }) as HTMLButtonElement;
|
||||
expect(baltic.disabled).toBe(true);
|
||||
fireEvent.click(alps);
|
||||
expect(onSelect).toHaveBeenCalledWith('a1');
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AlbumPicker
|
||||
albums={albums}
|
||||
onSelect={() => {}}
|
||||
onClose={onClose}
|
||||
usedAlbumIds={new Set()}
|
||||
/>,
|
||||
);
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlbumDrawer as a dialog (A-9)', () => {
|
||||
it('is a labelled modal dialog that closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AlbumDrawer
|
||||
album={{ id: 'a1' }}
|
||||
name="Alps"
|
||||
count={3}
|
||||
thumbnailId={null}
|
||||
onUpdate={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={onClose}
|
||||
onPickHero={() => {}}
|
||||
onEditOrder={() => {}}
|
||||
/>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: /Edit Album Details/ });
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySettingsModal as a dialog (A-9)', () => {
|
||||
it('is a labelled modal dialog that closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<StorySettingsModal
|
||||
frontmatter={{ title: 'Trip' }}
|
||||
onChange={() => {}}
|
||||
onPickAsset={() => {}}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: /Story Settings/ });
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import PrivacyEditor from '../PrivacyEditor';
|
||||
import SettingsEditor from '../SettingsEditor';
|
||||
import PageBuilder from '../PageBuilder';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
const nav = vi.hoisted(() => ({ section: 'general' }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||
useParams: () => ({ section: nav.section }),
|
||||
usePathname: () => '/admin/settings/general',
|
||||
}));
|
||||
|
||||
/**
|
||||
* A save handler closes over the state it started with. Typing on while the
|
||||
* request is out used to be marked saved when the answer came back: the Save
|
||||
* button went to "Saved" and disabled, the unsaved-changes guard and the draft
|
||||
* let go, and leaving the editor threw those edits away. Where the server
|
||||
* rewrote the text (a hashed password line), the newer text was also replaced
|
||||
* by the file as written.
|
||||
*/
|
||||
|
||||
/** A PUT that answers only when the test says so. */
|
||||
let releasePut: ((written?: string) => void) | null = null;
|
||||
let putBodies: unknown[] = [];
|
||||
|
||||
function stubFetch(
|
||||
loadResponse: unknown,
|
||||
putResponse: (sent: Record<string, unknown>, written?: string) => unknown,
|
||||
) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
putBodies.push(sent);
|
||||
const written = await new Promise<string | undefined>((resolve) => {
|
||||
releasePut = resolve;
|
||||
});
|
||||
return new Response(JSON.stringify(putResponse(sent, written)), { status: 200 });
|
||||
}
|
||||
const body = typeof loadResponse === 'function' ? loadResponse(_url) : loadResponse;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nav.section = 'general';
|
||||
releasePut = null;
|
||||
putBodies = [];
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ENTRY = '---\ntitle: "Trip"\n---\n\nHello\n';
|
||||
|
||||
async function renderJournal() {
|
||||
stubFetch({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }, (sent, written) => ({
|
||||
success: true,
|
||||
entry: { slug: 'trip', rawMarkdown: written ?? sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}));
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="trip" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Raw Markdown' }));
|
||||
return screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('JournalEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
// Still kept as a draft, so leaving the editor does not lose it.
|
||||
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toContain('Second');
|
||||
});
|
||||
|
||||
it('are not replaced by the file as written', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||
// The server rewrote what it was sent, as it does when it hashes a password.
|
||||
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||
});
|
||||
|
||||
it('a save with no edits meanwhile still ends clean, showing the file as written', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||
|
||||
await screen.findByRole('button', { name: /Saved/ });
|
||||
expect(editor.value).toBe(`${ENTRY}First (as written)\n`);
|
||||
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivacyEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
stubFetch(
|
||||
{ body: 'Old', enabled: true, facts: [], starter: '## Controller', version: 'v1' },
|
||||
() => ({ success: true, version: 'v2' }),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PrivacyEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const editor = (await screen.findByLabelText(
|
||||
'Privacy policy (Markdown)',
|
||||
)) as HTMLTextAreaElement;
|
||||
fireEvent.change(editor, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save privacy policy' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Privacy policy saved.');
|
||||
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(putBodies).toEqual([{ body: 'First' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
stubFetch({ settings: { title: 'Old' }, version: 'v1', envLocks: {} }, () => ({
|
||||
success: true,
|
||||
version: 'v2',
|
||||
}));
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const title = (await screen.findByLabelText('Site Title')) as HTMLInputElement;
|
||||
fireEvent.change(title, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(title, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved!');
|
||||
expect(title.value).toBe('First and second');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(window.sessionStorage.getItem('folio_draft_settings')).toContain('First and second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('About editor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
nav.section = 'about';
|
||||
stubFetch(
|
||||
(url: string) =>
|
||||
url === '/api/admin/about'
|
||||
? { meta: { name: 'Ada' }, body: 'Old', version: 'v1' }
|
||||
: { settings: {}, version: 'v1', envLocks: {} },
|
||||
() => ({ success: true, version: 'v2' }),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const bio = (await screen.findByLabelText('Biography (Markdown)')) as HTMLTextAreaElement;
|
||||
fireEvent.change(bio, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(bio, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved!');
|
||||
expect(bio.value).toBe('First and second');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(window.sessionStorage.getItem('folio_draft_about')).toContain('First and second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageBuilder: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable, and are not replaced by the file as written', async () => {
|
||||
stubFetch(
|
||||
(url: string) =>
|
||||
url === '/api/admin/gallery'
|
||||
? { gallery: { hero: [], albums: [], subpages: [] }, version: 'v1' }
|
||||
: url === '/api/admin/albums'
|
||||
? { albums: [] }
|
||||
: { pages: [], taken: { subpages: [], albums: [], journal: [] } },
|
||||
// As written: hashed passwords make the server copy differ from the one sent.
|
||||
(sent) => ({
|
||||
success: true,
|
||||
version: 'v2',
|
||||
gallery: { ...(sent.gallery as object), hero: ['written-by-server'] },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PageBuilder />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /New subpage/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New subpage/ }));
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved successfully!');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
const kept = JSON.parse(window.sessionStorage.getItem('folio_draft_page-builder') ?? 'null');
|
||||
expect(kept?.value?.subpages).toHaveLength(2);
|
||||
expect((putBodies[0] as { gallery: { subpages: unknown[] } }).gallery.subpages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import { EssayBlockEditor } from '../EssayBlockEditor';
|
||||
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* Text blocks are edited as markdown, not as the HTML the parser stores (A-5).
|
||||
* A reloaded entry used to show `<strong>` and `<a href=…>` in the textarea,
|
||||
* text typed into a tag was lost on save, and typed `**bold**` stayed literal
|
||||
* in the live preview until the first save.
|
||||
*/
|
||||
|
||||
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
|
||||
const ENTRY = [
|
||||
'---',
|
||||
'title: "Trip"',
|
||||
'---',
|
||||
'',
|
||||
'Hello **bold** and [Immich](https://immich.app).',
|
||||
'',
|
||||
'> A *quoted* line -- Someone',
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'::facts',
|
||||
'Distance: **21** km',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
// The live preview's FadeIn reveals at once under reduced motion instead of
|
||||
// needing IntersectionObserver.
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes('reduce'),
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
entry: { slug: 'trip', rawMarkdown: sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderJournal() {
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="trip" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
return (await screen.findByLabelText('Text')) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
/** The entry as the editor would save it, read from the Raw Markdown view. */
|
||||
function rawMarkdown(): string {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
|
||||
const value = (screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement)
|
||||
.value;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visual Blocks' }));
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('journal editor: text blocks as markdown (A-5)', () => {
|
||||
it('shows the markdown source, not the stored HTML, after a load', async () => {
|
||||
const text = await renderJournal();
|
||||
expect(text.value).toBe('Hello **bold** and [Immich](https://immich.app).');
|
||||
expect(screen.getByDisplayValue('A *quoted* line')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('A **nice** view')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('**21** km')).toBeTruthy();
|
||||
expect(document.body.querySelector('textarea, input')).toBeTruthy();
|
||||
for (const field of document.body.querySelectorAll('textarea, input')) {
|
||||
expect((field as HTMLInputElement).value).not.toMatch(/<(strong|em|a)\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders typed markdown in the live preview before any save', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, { target: { value: 'Now **strong** text.' } });
|
||||
const preview = document.querySelector('.journal-preview-frame')!;
|
||||
expect(preview.innerHTML).toContain('<strong>strong</strong>');
|
||||
expect(preview.textContent).not.toContain('**strong**');
|
||||
// What was typed stays as typed.
|
||||
expect(text.value).toBe('Now **strong** text.');
|
||||
});
|
||||
|
||||
it('keeps text typed inside a link label or bold run', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, {
|
||||
target: { value: 'Hello **bold and more** and [Immich Photos](https://immich.app).' },
|
||||
});
|
||||
expect(rawMarkdown()).toContain(
|
||||
'Hello **bold and more** and [Immich Photos](https://immich.app).',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the same file when the content is unchanged', async () => {
|
||||
const text = await renderJournal();
|
||||
const original = text.value;
|
||||
fireEvent.change(text, { target: { value: `${original} extra` } });
|
||||
fireEvent.change(screen.getByLabelText('Text'), { target: { value: original } });
|
||||
expect(rawMarkdown()).toBe(ENTRY);
|
||||
});
|
||||
|
||||
it('keeps `__` as typed while the file gets the canonical form', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, { target: { value: 'An __underscored__ word' } });
|
||||
expect(text.value).toBe('An __underscored__ word');
|
||||
expect(rawMarkdown()).toContain('An **underscored** word');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InlineMarkdownField', () => {
|
||||
function Harness({ initial }: { initial: string }) {
|
||||
const [html, setHtml] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<InlineMarkdownField aria-label="Field" html={html} onChange={setHtml} />
|
||||
<button type="button" onClick={() => setHtml('<em>replaced</em>')}>
|
||||
Replace
|
||||
</button>
|
||||
<output data-testid="html">{html}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('hands back HTML and re-reads it only on an outside change', () => {
|
||||
render(<Harness initial="<strong>x</strong>" />);
|
||||
const field = screen.getByLabelText('Field') as HTMLInputElement;
|
||||
expect(field.value).toBe('**x**');
|
||||
fireEvent.change(field, { target: { value: 'a [half](http' } });
|
||||
expect(field.value).toBe('a [half](http');
|
||||
fireEvent.change(field, { target: { value: 'a & b <c>' } });
|
||||
expect(screen.getByTestId('html').textContent).toBe('a & b <c>');
|
||||
expect(field.value).toBe('a & b <c>');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
|
||||
expect(field.value).toBe('*replaced*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('essay block editor: text blocks as markdown (A-5)', () => {
|
||||
it('shows markdown and serializes an edit through the renderer', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<EssayBlockEditor
|
||||
markdown={'Some *italic* and [a link](https://immich.app).'}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const text = screen.getByDisplayValue(
|
||||
'Some *italic* and [a link](https://immich.app).',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(text, {
|
||||
target: { value: 'Some *italic* and [a longer link](https://immich.app).' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
'Some *italic* and [a longer link](https://immich.app).',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import SettingsEditor from '../SettingsEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
const nav = vi.hoisted(() => ({ section: 'grid' }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||
useParams: () => ({ section: nav.section }),
|
||||
usePathname: () => `/admin/settings/${nav.section}`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* A value the site would ignore is named next to its input as it is typed,
|
||||
* not only after a refused save (QA A-14).
|
||||
*/
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ settings: { title: 'Folio' }, version: 'v1', envLocks: {} }),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Settings form: values the site would ignore', () => {
|
||||
it('marks grid columns outside 1–6 at the input', async () => {
|
||||
nav.section = 'grid';
|
||||
mount();
|
||||
const columns = (await screen.findByLabelText(/Columns/)) as HTMLInputElement;
|
||||
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||
|
||||
fireEvent.change(columns, { target: { value: '9' } });
|
||||
expect(columns.getAttribute('aria-invalid')).toBe('true');
|
||||
const error = document.getElementById(columns.getAttribute('aria-describedby')!);
|
||||
expect(error?.textContent).toBe('Must be a whole number from 1 to 6.');
|
||||
|
||||
fireEvent.change(columns, { target: { value: '4' } });
|
||||
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an accent that is not hex', async () => {
|
||||
nav.section = 'theme';
|
||||
mount();
|
||||
const accent = (await screen.findByLabelText('Accent colour (hex)')) as HTMLInputElement;
|
||||
fireEvent.change(accent, { target: { value: 'rot' } });
|
||||
expect(accent.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(screen.getByText('Must be a hex colour such as #e60012.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user