diff --git a/backend/druks/ui/blocks.py b/backend/druks/ui/blocks.py index 7d2b97e9..c449a2e8 100644 --- a/backend/druks/ui/blocks.py +++ b/backend/druks/ui/blocks.py @@ -18,25 +18,33 @@ def _subject_identity(value): - """``follows=`` takes the subject a page or a region watches. Druks streams - that subject and rereads the page on every snapshot it sends.""" + """``follows=`` takes the subject a page or a region watches, or a subject + class for every subject of that type. Druks streams what it names and + rereads the page on every snapshot it sends.""" if isinstance(value, dict | Follows) or value is None: return value - identity = getattr(value, "identity", None) - if not identity: - raise ValueError( - f"follows= takes the subject a page watches, not {type(value).__name__}. A run is " - "about a subject, and the subject is what the stream carries." - ) - # A subject id reaches the stream through a URL, so it travels as text. - return {"subject_type": identity["type"], "subject_id": str(identity["id"])} + if isinstance(value, type): + subject_type = getattr(value, "subject_type", "") + if subject_type: + return {"subject_type": subject_type, "subject_id": ""} + else: + identity = getattr(value, "identity", None) + if identity: + # A subject id reaches the stream through a URL, so it travels as text. + return {"subject_type": identity["type"], "subject_id": str(identity["id"])} + raise ValueError( + "follows= takes the subject a page watches, or its class for every subject of that " + f"type, not {type(value).__name__}. A run is about a subject, and the subject is what " + "the stream carries." + ) class Follows(Schema): - """The subject a page or a named region watches.""" + """The subject a page or a named region watches. An empty ``subject_id`` + watches every subject of the type.""" subject_type: str - subject_id: str + subject_id: str = "" Watched = Annotated[Follows | None, BeforeValidator(_subject_identity)] @@ -88,6 +96,11 @@ def __init__(self, label: str, **data): @model_validator(mode="after") def _one_destination(self) -> "Link": + if self.subject and not self.subject.subject_id: + raise ValueError( + f"Link {self.label!r} names the {self.subject.subject_type} type, and a link " + "opens one subject's page. Give the subject itself." + ) if [bool(self.page), bool(self.url), bool(self.subject)].count(True) == 1: return self raise ValueError(f"Link {self.label!r} must set exactly one of page, url, or subject") diff --git a/backend/tests/test_ui_followed_regions.py b/backend/tests/test_ui_followed_regions.py index ea32fe69..78e7c2fd 100644 --- a/backend/tests/test_ui_followed_regions.py +++ b/backend/tests/test_ui_followed_regions.py @@ -24,6 +24,22 @@ def test_a_page_follows_a_subject_too(note: Note): assert page.follows.subject_id == str(note.id) +def test_a_region_follows_every_subject_of_a_type(): + section = Section(name="board", follows=Note, blocks=[]) + + assert section.follows + assert section.follows.subject_type == "note" + assert section.follows.subject_id == "" + + +def test_a_page_follows_every_subject_of_a_type(): + page = Page(title="Notes", follows=Note) + + assert page.follows + assert page.follows.subject_type == "note" + assert page.follows.subject_id == "" + + def test_a_followed_region_needs_a_name(note: Note): with pytest.raises(ValueError, match="needs a name"): Section(follows=note, blocks=[]) @@ -107,3 +123,8 @@ def test_a_link_reaches_the_subjects_own_page(note: Note): def test_a_link_takes_exactly_one_destination(note: Note): with pytest.raises(ValueError, match="exactly one"): Link("Everything druks did", page="notes", subject=note) + + +def test_a_link_refuses_a_subject_type(): + with pytest.raises(ValueError, match="opens one subject's page"): + Link("Everything druks did", subject=Note) diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 0d0703c6..ba177f8e 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -317,6 +317,16 @@ GET /api////stream There is no second streaming system. +`follows=` also takes the subject class. The page or the region then watches +every subject of that type, `subject_id` is empty, and the shell reads the board +stream: + +```text +GET /api///stream +``` + +A page that shows many subjects is live this way. + On a `snapshot` event the shell reads the page again. It takes the named region from the new page and replaces that region in full. It sends no block diffs. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 02e21664..4462a44a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -405,7 +405,8 @@ export type Block = | Link // The subject a page or a named region watches. The shell streams it and -// rereads the page on every snapshot it sends. +// rereads the page on every snapshot it sends. An empty ``subjectId`` watches +// every subject of the type, through the board stream. export interface Follows { subjectType: string subjectId: string diff --git a/frontend/src/druksui/SubjectStream.tsx b/frontend/src/druksui/SubjectStream.tsx index 12d31626..82109100 100644 --- a/frontend/src/druksui/SubjectStream.tsx +++ b/frontend/src/druksui/SubjectStream.tsx @@ -1,7 +1,9 @@ +import { subjectApi } from '../api/client' import type { Follows } from '../api/types' import { useSSE } from '../api/sse' -/** Watches one subject through the stream every app already serves, and calls +/** Watches one subject through the stream every app already serves — with no + * id, every subject of the type through the board stream — and calls * ``onSnapshot`` each time it changes. Renders nothing: it exists so a page can * watch several subjects at once, one component per subject. The hook owns the * EventSource, its reconnect, and the identity recheck. */ @@ -14,7 +16,10 @@ export function SubjectStream({ subject: Follows onSnapshot: (subject: Follows) => void }) { - const path = `/api/${app}/${subject.subjectType}/${encodeURIComponent(subject.subjectId)}/stream` + let path = subjectApi.boardStream(app, subject.subjectType) + if (subject.subjectId) { + path = subjectApi.stream(app, subject.subjectType, encodeURIComponent(subject.subjectId)) + } useSSE(path, { handlers: { snapshot: () => onSnapshot(subject) } }) return null } diff --git a/frontend/src/druksui/followed.test.tsx b/frontend/src/druksui/followed.test.tsx index b0237fa8..04e52bfe 100644 --- a/frontend/src/druksui/followed.test.tsx +++ b/frontend/src/druksui/followed.test.tsx @@ -10,9 +10,11 @@ import type { App, Block, PageSnapshot } from '../api/types' import { AppPage } from './AppPage' import { followedSubjects, mergeRegions } from './pages' -vi.mock('../api/client', () => ({ - api: { listApps: vi.fn(), readPage: vi.fn(), getGate: vi.fn() }, -})) +vi.mock('../api/client', async () => { + const real = await vi.importActual('../api/client') + const stubs = { listApps: vi.fn(), readPage: vi.fn(), getGate: vi.fn() } + return { subjectApi: real.subjectApi, api: stubs } +}) vi.mock('../api/sse', () => ({ useSSE: vi.fn() })) const listApps = vi.mocked(api.listApps) @@ -147,6 +149,13 @@ describe('a followed region', () => { expect(sse.mock.calls.at(-1)?.[0]).toBe('/api/field_notes/note/7/stream') }) + it('watches every subject of the type through the board stream', async () => { + renderPage(snapshot([region('board', 'waiting', { subjectType: 'note', subjectId: '' })])) + + await waitFor(() => expect(screen.getByText('waiting')).toBeTruthy()) + expect(sse.mock.calls.at(-1)?.[0]).toBe('/api/field_notes/note/stream') + }) + it('opens no stream for a page that follows nothing', async () => { renderPage(snapshot([{ block: 'text', text: 'static' }])) diff --git a/frontend/src/druksui/reads.test.tsx b/frontend/src/druksui/reads.test.tsx index 3b6e2b83..f2ab2d67 100644 --- a/frontend/src/druksui/reads.test.tsx +++ b/frontend/src/druksui/reads.test.tsx @@ -9,9 +9,11 @@ import { useSSE } from '../api/sse' import type { App, Block, PageSnapshot } from '../api/types' import { AppPage } from './AppPage' -vi.mock('../api/client', () => ({ - api: { listApps: vi.fn(), readPage: vi.fn(), getGate: vi.fn() }, -})) +vi.mock('../api/client', async () => { + const real = await vi.importActual('../api/client') + const stubs = { listApps: vi.fn(), readPage: vi.fn(), getGate: vi.fn() } + return { subjectApi: real.subjectApi, api: stubs } +}) vi.mock('../api/sse', () => ({ useSSE: vi.fn() })) const listApps = vi.mocked(api.listApps)