Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions backend/druks/ui/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions backend/tests/test_ui_followed_regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[])
Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions docs/druks-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,16 @@ GET /api/<app>/<subject type>/<subject id>/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/<app>/<subject type>/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.
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/druksui/SubjectStream.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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
}
15 changes: 12 additions & 3 deletions frontend/src/druksui/followed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../api/client')>('../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)
Expand Down Expand Up @@ -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' }]))

Expand Down
8 changes: 5 additions & 3 deletions frontend/src/druksui/reads.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../api/client')>('../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)
Expand Down