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=&apos;project_id&apos;`, 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;