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:
1 parent
2ca79730f1
commit
d7a1601f80
11 files changed
+444
-10
No files matched your search
+73
-2
@@ -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(', ');
|
||||
|
||||
Reference in new issue
Block a user