Outline: share as a read-only link

- Share on the Outline page opens a sheet: create / copy / turn off a
  read-only link, share the link with the system share sheet, or send the
  outline as text
- The link has its own token (separate from the whole-study share link) and
  the public endpoint returns only the outlines, rebuilt field by field:
  no observations, interpretations, applications, scripts or word notes
- Opening the link shows the outline(s) with no sign-in, a switcher between
  the whole-study and per-chunk outlines, and Print / PDF
- New outline_share_token column (added automatically on start)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 16:02:40 -04:00
1 parent 2ca79730f1
commit d7a1601f80
11 files changed
+444 -10

No files matched your search

+73 -2
View File
@@ -18,7 +18,7 @@ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json:
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
function stubServer({ user = null, putStatus = 200 } = {}) {
const calls = [];
const state = { putStatus };
const state = { putStatus, outlineToken: null };
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
calls.push({ route, url, opts });
@@ -27,6 +27,18 @@ function stubServer({ user = null, putStatus = 200 } = {}) {
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route.endsWith('/outline-share')) {
if (opts.method === 'POST') state.outlineToken = 'abc123';
if (opts.method === 'DELETE') state.outlineToken = null;
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
}
if (route === 'GET /api/share-outline/abc123') {
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
] });
}
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
if (route.startsWith('PUT /api/projects/')) {
return state.putStatus === 409
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
@@ -282,12 +294,21 @@ describe('Outline: per chunk, details, print and share', () => {
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
await user.click(screen.getByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
});
test('Print opens a page of just the outline', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
@@ -295,6 +316,56 @@ describe('Outline: per chunk, details, print and share', () => {
});
});
describe('Read-only outline link', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
test('create the link, see it, and turn it off', async () => {
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^outline$/i }));
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
await u.click(await screen.findByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
const box = await within(sheet).findByLabelText('Outline link');
expect(box.value).toMatch(/\?outline=abc123$/);
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
}, 15000);
});
describe('Opening a read-only outline link', () => {
afterEach(() => window.history.replaceState({}, '', '/'));
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=abc123');
render(<App />);
const frame = await screen.findByTitle('Shared outline');
expect(frame.getAttribute('srcdoc')).toContain('Appoint elders');
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
await userEvent.setup().selectOptions(screen.getByRole('combobox', { name: 'Outline' }), 'c1');
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Greeting');
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Titus — Titus 1:1–4');
});
test('a turned-off or wrong link says so', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=nope');
render(<App />);
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
});
});
describe('Study cards on Home', () => {
test('a long run of chapters and many tags stay short', async () => {
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');