diff --git a/src/App.flow.test.jsx b/src/App.flow.test.jsx index 84feff8..c541669 100644 --- a/src/App.flow.test.jsx +++ b/src/App.flow.test.jsx @@ -224,6 +224,77 @@ describe('Outline stage', () => { }); }); +describe('Outline: per chunk, details, print and share', () => { + async function openOutline() { + vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false })))); + const user = userEvent.setup(); + await renderApp(); + await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); + await user.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 user.click(screen.getByRole('button', { name: /^outline$/i })); + await screen.findByRole('heading', { name: 'Outline' }); + return user; + } + + test('each chunk has its own outline, separate from the whole study\'s', async () => { + const user = await openOutline(); + const picker = screen.getByRole('combobox', { name: 'Outline for' }); + await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk + expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /start from the verses/i })); + const list = await screen.findByRole('list', { name: 'Outline' }); + expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse + expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words + await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study'); + expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched + expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count + }); + + test('a point can be given a type and minutes, and the total shows', async () => { + const user = await openOutline(); + await user.click(screen.getByRole('button', { name: /start blank/i })); + await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story'); + await user.click(screen.getByRole('button', { name: /details for i\./i })); + await user.selectOptions(screen.getByLabelText('Type'), 'illustration'); + await user.type(screen.getByLabelText('Minutes'), '5'); + expect(await screen.findByText('About 5 minutes')).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly'); + await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line + expect(await screen.findByText('Tell it slowly')).toBeInTheDocument(); + expect(screen.getByText('Illustration')).toBeInTheDocument(); + }); + + test('"Add verse text" puts the passage\'s words into the point', async () => { + const user = await openOutline(); + await user.click(screen.getByRole('button', { name: /start blank/i })); + await user.click(await screen.findByRole('button', { name: /details for i\./i })); + await user.type(screen.getByLabelText('Passage'), 'Titus 1:1'); + await user.click(screen.getByRole('button', { name: /add verse text/i })); + expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument(); + await user.clear(screen.getByLabelText('Passage')); + await user.type(screen.getByLabelText('Passage'), 'nonsense'); + await user.click(screen.getByRole('button', { name: /replace verse text/i })); + 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 () => { + 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 })); + expect(await screen.findByText(/outline copied/i)).toBeInTheDocument(); + 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 })); + expect(doc.write.mock.calls[0][0]).toContain('class="outline"'); + }); +}); + 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(', '); diff --git a/src/pages/OutlinePage.jsx b/src/pages/OutlinePage.jsx index 0f77e30..818921a 100644 --- a/src/pages/OutlinePage.jsx +++ b/src/pages/OutlinePage.jsx @@ -1,21 +1,28 @@ // The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.), -// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to -// the chunk it comes from. Everything is kept on the project and goes out with the exports. +// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at +// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's +// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file. import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useApp } from '../context/AppContext.js'; import { useToast } from '../components/Toasts.jsx'; import StudioShell from '../components/study/StudioShell.jsx'; import { formatChunkReference } from '../utils/bibleUtils.jsx'; +import { buildChapterSummary } from '../utils/projectStorage.js'; +import { compactPassages } from '../utils/passageSummary.js'; +import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js'; +import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js'; import { - addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode, - outdent, outlineToText, removeNode, seedFromChunks, updateNode, + KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate, + moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline, } from '../utils/outline.js'; const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent'; const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50'; +const field = 'mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]'; +const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]'; // A text box that grows with what is typed, so a long point never hides behind a scroll bar. -function PointText({ value, onChange, ...rest }) { +function AutoText({ value, onChange, className, ...rest }) { const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; @@ -23,23 +30,25 @@ function PointText({ value, onChange, ...rest }) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }, [value]); - return ( -