Task board
Task board¶
Adding routing='project_id' to a queryset list makes it fully live: a
task created under a watched project, or moved into it, appears the moment
the write commits – no rebind, unlike
Static queryset, where a new row waits for a rebind.
Moving a task out delivers the removal just as immediately. Routing also
decides what the server sends at all: a connection only receives events
for the projects it watches, so data for other projects never reaches the
client – while the queryset’s other conditions (like this example’s
status='open') are applied client-side, on rows already delivered. To
deliberately send a list’s events to every connection, declare
routing=BroadcastRouter().
Backend¶
from rxdjango import ContextChannel, action, rx
from .models import Task
from .serializers import TaskSerializer
class TaskBoardChannel(ContextChannel):
"""`tasks` declares `routing='project_id'`, so the list is *live* -- a
task created under, or moved into, the selected project appears with
no rebind, and a task moved out disappears just as immediately.
Contrast with `static_queryset.StaticQuerysetChannel`, whose `tasks`
field has no `routing=` and only sees new rows when `rebind()` runs.
`select_project` is a client action rather than a URL parameter: the
channel connects with no project chosen (`tasks` stays `null`), and the
client picks one after connecting -- letting one demo page open several
independently-routed boards over one static endpoint.
"""
tasks = rx.model(TaskSerializer(many=True), routing='project_id')
async def on_connect(self):
self.project_id: int | None = None
@action
async def select_project(self, project_id: int):
self.project_id = project_id
self._bind()
def _bind(self):
self.tasks = Task.objects.filter(
project_id=self.project_id, status='open',
).order_by('-priority', 'id')
@action
async def add_task(self, name: str, priority: int = 0):
# Appears live, at its ordered position, on every connection
# watching this project.
await Task.objects.acreate(
name=name, status='open', priority=priority, project_id=self.project_id,
)
@action
async def move_task(self, task_id: int, project_id: int):
# The task leaves this connection's list live, and appears live on
# any connection watching the destination project.
task = await Task.objects.aget(id=task_id)
task.project_id = project_id
await task.asave()
@action
async def toggle_status(self, task_id: int):
task = await Task.objects.aget(id=task_id)
task.status = 'closed' if task.status == 'open' else 'open'
await task.asave()
@action
async def bump_priority(self, task_id: int, delta: int):
task = await Task.objects.aget(id=task_id)
task.priority += delta
await task.asave()
@action
async def delete_task(self, task_id: int):
task = await Task.objects.aget(id=task_id)
await task.adelete()
Models¶
from django.db import models
from rxdjango.models import ReactiveModel
class Project(models.Model):
"""A board tasks are grouped under. Not itself reactive -- these
rows don't change in this example."""
name = models.CharField(max_length=64)
class Meta:
ordering = ['id']
def __str__(self):
return self.name
STATUS_CHOICES = [
('open', 'Open'),
('closed', 'Closed'),
]
class Task(ReactiveModel):
"""A task on a project's board. `project` is a plain Django
`ForeignKey`; `routing='project_id'` and `routing='project'` name the
same column, and a bound queryset may filter it as
`.filter(project=obj)`, `.filter(project_id=5)`, or
`.filter(project__id=5)` interchangeably.
A task's creation, and any move to a different `project`, is
delivered live to every connection watching that project -- no rebind.
"""
name = models.CharField(max_length=64)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='open')
priority = models.IntegerField(default=0)
project = models.ForeignKey(Project, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['id']
def __str__(self):
return self.name
Serializers¶
from rest_framework import serializers
from .models import Task
class TaskSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = ['id', 'name', 'status', 'priority', 'project', 'created_at']
Frontend¶
import React, { useEffect, useRef, useState } from 'react';
import { useChannel } from '@rxdjango/react';
import { TaskBoardChannel } from '../../rx/task_board/task_board.channels';
import type { Task } from '../../rx/task_board/task_board.models';
import {
Sections,
Note,
Row,
Button,
TextInput,
} from '../../components/demo';
function Board({ projectId, otherProjectId }: { projectId: number; otherProjectId: number }) {
const channel = useChannel(TaskBoardChannel);
const selected = useRef<number | null>(null);
const [name, setName] = useState('');
const [priority, setPriority] = useState('0');
useEffect(() => {
if (selected.current !== projectId) {
selected.current = projectId;
channel.select_project(projectId);
}
});
return (
<div
data-testid={`board-${projectId}`}
className="flex-1 space-y-3 rounded-md border border-ink/20 p-4"
>
<h3 className="font-semibold text-ink">
Project {projectId}
</h3>
{channel.tasks === null ? (
<p>
Connecting...
</p>
) : channel.tasks.length === 0 ? (
<p data-testid={`empty-state-${projectId}`}>
No open tasks.
</p>
) : (
<ul className="space-y-3">
{channel.tasks.map((task: Task) => (
<li
key={task.id}
data-testid={`task-${task.id}`}
className="flex flex-col gap-2 rounded-md border border-ink/20 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<span className="font-medium text-ink">
{task.name}
</span>
<span className="ml-2 text-sm text-primary-700">
priority {task.priority}
</span>
</div>
<Row>
<Button
variant="secondary"
onClick={() => channel.bump_priority(task.id, 1)}
>
+1 priority
</Button>
<Button
variant="secondary"
onClick={() => channel.toggle_status(task.id)}
>
Close
</Button>
<Button
variant="secondary"
onClick={() => channel.move_task(task.id, otherProjectId)}
>
Move to Project {otherProjectId}
</Button>
<Button
variant="secondary"
onClick={() => channel.delete_task(task.id)}
>
Delete
</Button>
</Row>
</li>
))}
</ul>
)}
<Row>
<TextInput
id={`task-board-new-task-name-${projectId}`}
label="New task name"
value={name}
onChange={setName}
/>
<TextInput
id={`task-board-new-task-priority-${projectId}`}
label="Priority"
value={priority}
onChange={setPriority}
/>
<Button
variant="primary"
onClick={() => {
channel.add_task(name, parseInt(priority, 10) || 0);
setName('');
}}
>
Add task
</Button>
</Row>
</div>
);
}
export function TaskBoardDemo() {
return (
<Sections>
<div>
<Note>
Each board below is its own WebSocket connection, calling
`select_project` to pick which `project_id` it watches --
`tasks` declares `routing='project_id'`, so a task added
on one board appears live on that board alone, with no rebind.
Moving a task to the other project makes it disappear from this
board and appear on the other, live, the moment the write
commits.
</Note>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Board projectId={1} otherProjectId={2} />
<Board projectId={2} otherProjectId={1} />
</div>
</Sections>
);
}
export default TaskBoardDemo;