Data table blocks
Sortable, filterable tables with pagination and URL-synced state for large datasets.
Account card grid
The same account records as a responsive card grid instead of rows, for screens where a table would be read on a phone
-
components
-
account_cards
-
arden
-
-
templates
-
pages
-
<main class="container mx-auto max-w-6xl px-4 py-10 flex flex-col gap-8">
<c-account-cards.masthead :total=total :mrr=mrr :seats=seats />
{% if customers %}
<div id="account-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for customer in customers %}
<c-account-cards.card :customer=customer />
{% endfor %}
</div>
{% else %}
<div class="rounded-box border border-dashed border-base-300 bg-base-100">
<c-account-cards.empty
id="account-grid-empty"
icon="rmx.building"
title="No accounts yet"
body="Arden starts billing the moment an account lands here. Add the first one, or import the book you already have."
action="Import from CSV" />
</div>
{% endif %}
<c-account-cards.flagged :flagged=flagged />
<p class="text-xs text-base-content/40 max-w-2xl">
The health score blends product usage, invoice history and support volume over the
last thirty days. It is the same number the table view sorts on, drawn here as a bar
because a card has the room for it.
</p>
</main>
<c-vars :customer="{}" />
<c-lb.card id="account-card-{{ customer.initials|lower }}" border size="sm" class="h-full bg-base-100">
<c-lb.card.body class="gap-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="sm" rounded="md" />
<div class="min-w-0 leading-tight">
<h2 class="font-semibold truncate">{{ customer.company }}</h2>
<p class="text-xs text-base-content/50 truncate">{{ customer.contact }}</p>
</div>
</div>
<c-lb.badge variant="{{ customer.status_variant }}" style="soft" size="sm">{{ customer.status }}</c-lb.badge>
</div>
<div class="grid grid-cols-3 gap-2 rounded-box bg-base-200/50 px-3 py-2.5">
<div class="flex flex-col">
<span class="text-xs text-base-content/50">MRR</span>
<span class="font-semibold tabular-nums">${{ customer.mrr }}</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-base-content/50">Seats</span>
<span class="font-semibold tabular-nums">{{ customer.seats }}</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-base-content/50">Plan</span>
<span class="font-semibold truncate">{{ customer.plan }}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-base-content/50">Health</span>
<span class="text-xs font-medium tabular-nums">{{ customer.health }} · {{ customer.health_score }}</span>
</div>
<c-lb.progress variant="{{ customer.health_variant }}" value="{{ customer.health_score }}" max="100" class="w-full" />
</div>
<div class="flex items-center justify-between gap-2 border-t border-base-300 pt-3">
<span class="text-xs text-base-content/50 tabular-nums whitespace-nowrap">Renews {{ customer.renews }}</span>
<div class="flex items-center gap-1">
<c-lb.button btnStyle="ghost" size="xs" icon="rmx.receipt" aria-label="Invoices for {{ customer.company }}" />
<c-lb.button btnStyle="outline" size="xs">Open</c-lb.button>
</div>
</div>
</c-lb.card.body>
</c-lb.card>
<c-vars id="" icon="rmx.inbox" title="" body="" action="" />
<div id="{{ id }}" class="flex flex-col items-center gap-3 px-6 py-12 text-center">
<c-lb.avatar placeholder icon="{{ icon }}" bgColor="neutral" size="md" rounded="full" />
<div class="flex flex-col gap-1">
<h3 class="text-base font-semibold tracking-tight">{{ title }}</h3>
<p class="mx-auto max-w-sm text-sm text-base-content/60">{{ body }}</p>
</div>
{% if action %}
<c-lb.button btnStyle="outline" size="sm">{{ action }}</c-lb.button>
{% endif %}
</div>
<c-vars :flagged="[]" />
<section class="flex flex-col gap-3">
<div class="flex items-baseline justify-between gap-3">
<h2 class="text-sm font-semibold tracking-tight">Flagged for review</h2>
<span class="text-xs text-base-content/50 tabular-nums">{{ flagged|length }} this week</span>
</div>
{% if flagged %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for customer in flagged %}
<c-account-cards.card :customer=customer />
{% endfor %}
</div>
{% else %}
<div class="rounded-box border border-dashed border-base-300 bg-base-100">
<c-account-cards.empty
id="flagged-empty"
icon="rmx.shield-check"
title="No account flagged this week"
body="A flag is raised when health falls two bands in seven days, or an invoice passes thirty days late. Nothing has done either."
action="Review the flag rules" />
</div>
{% endif %}
</section>
<c-vars :total=0 mrr="" seats="" />
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="xs" />
<span class="text-xs font-semibold tracking-tight text-base-content/70">Arden</span>
</div>
<h1 class="mt-6 text-2xl font-semibold tracking-tight">Accounts</h1>
<p class="mt-1 text-sm text-base-content/60 max-w-lg">
The same records the table view shows, laid out for a screen you hold in one hand.
Nothing is truncated into a column that will not fit.
</p>
</div>
<div class="flex flex-wrap items-center gap-5">
<div class="flex flex-col">
<span class="text-xs text-base-content/50">Recurring revenue</span>
<span class="text-lg font-semibold tracking-tight tabular-nums">${{ mrr }}</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-base-content/50">Accounts</span>
<span class="text-lg font-semibold tracking-tight tabular-nums">{{ total }}</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-base-content/50">Seats</span>
<span class="text-lg font-semibold tracking-tight tabular-nums">{{ seats }}</span>
</div>
<c-lb.button variant="primary" size="sm" icon="rmx.add">New account</c-lb.button>
</div>
</div>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Same eight records as the table view, laid out as cards. There is no JavaScript on this page at all, so a phone downloads markup and stops.
-
The health score is a number in the table and a bar here, because the card has the space to draw it. The bar takes its colour from the same theme token as the badge, so it recolours with the theme rather than fighting it.
-
Nothing is flagged this week, and the block says so with an icon, a sentence that explains what a flag even is, and the one action worth offering. That is the difference between a component and a template.
Compact table
A dense account table for data-heavy screens: eight columns, tight rows, tabular figures and zero JavaScript
-
components
-
arden
-
compact_table
-
-
templates
-
pages
-
<main class="container mx-auto max-w-7xl px-4 py-10 flex flex-col gap-6">
<c-compact-table.masthead :total=total :updated=updated />
<c-compact-table.metrics :metrics=metrics />
<c-compact-table.table :customers=customers />
<c-compact-table.attention :invoices=overdue_invoices />
<p class="text-xs text-base-content/40 max-w-2xl">
MRR is the current run rate on the account's live plan. Trials and churned accounts
carry no run rate until their first invoice is raised. Renewal dates follow the plan
term, not the billing anniversary.
</p>
</main>
<c-vars :invoices="[]" />
<section class="rounded-box border border-base-300 bg-base-100">
<header class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-2.5">
<div class="flex items-center gap-2">
<c-lbi.rmx.receipt w="16" h="16" class="text-base-content/40" />
<h2 class="text-sm font-semibold tracking-tight">Needs attention</h2>
</div>
<span class="text-xs text-base-content/50 tabular-nums">{{ invoices|length }} overdue</span>
</header>
{% if invoices %}
<c-lb.table size="xs" class="align-middle">
<tbody>
{% for invoice in invoices %}
<tr class="border-base-300">
<td class="font-medium">{{ invoice.company }}</td>
<td class="text-base-content/60 tabular-nums">{{ invoice.number }}</td>
<td class="text-end tabular-nums">${{ invoice.amount }}</td>
<td class="text-end tabular-nums text-error">{{ invoice.days }} days late</td>
</tr>
{% endfor %}
</tbody>
</c-lb.table>
{% else %}
<c-compact-table.empty
id="compact-attention-empty"
icon="rmx.checkbox-circle"
title="Nothing overdue"
body="Every invoice raised against this workspace is paid or still inside its terms. A run appears here the day it slips."
action="Review payment terms" />
{% endif %}
</section>
<c-vars id="" icon="rmx.inbox" title="" body="" action="" />
<div id="{{ id }}" class="flex flex-col items-center gap-3 px-6 py-10 text-center">
<c-lb.avatar placeholder icon="{{ icon }}" bgColor="neutral" size="sm" rounded="full" />
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold tracking-tight">{{ title }}</h3>
<p class="mx-auto max-w-sm text-xs text-base-content/60">{{ body }}</p>
</div>
{% if action %}
<c-lb.button btnStyle="outline" size="xs">{{ action }}</c-lb.button>
{% endif %}
</div>
<c-vars :total=0 updated="" />
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="xs" />
<span class="text-xs font-semibold tracking-tight text-base-content/70">Arden</span>
<span class="text-xs text-base-content/40">Revenue operations</span>
</div>
<h1 class="mt-6 text-2xl font-semibold tracking-tight">Accounts</h1>
<p class="mt-1 text-sm text-base-content/60 max-w-lg">
Every account billing through this workspace, on one screen. Built for the person
who reads it forty times a day, not for a screenshot.
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-base-content/40 tabular-nums">Synced {{ updated }}</span>
<c-lb.button btnStyle="ghost" size="xs" icon="rmx.download">Export</c-lb.button>
<c-lb.button variant="primary" size="xs" icon="rmx.add">New account</c-lb.button>
</div>
</div>
<c-vars :metrics="[]" />
<div id="compact-metrics" class="grid gap-px overflow-hidden rounded-box border border-base-300 bg-base-300 sm:grid-cols-2 xl:grid-cols-4">
{% for metric in metrics %}
<div class="flex flex-col gap-1 bg-base-100 px-4 py-3">
<span class="text-xs font-medium text-base-content/50">{{ metric.label }}</span>
<span class="text-xl font-semibold tracking-tight tabular-nums">{{ metric.value }}</span>
<span class="text-xs tabular-nums {% if metric.tone == 'success' %}text-success{% else %}text-base-content/40{% endif %}">
{{ metric.note }}
</span>
</div>
{% endfor %}
</div>
<c-vars :customer="{}" :index=0 />
<tr id="compact-row-{{ index }}" class="border-base-300 hover:bg-base-200/40">
<td>
<div class="flex items-center gap-2.5">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="xs" rounded="md" />
<div class="min-w-0 leading-tight">
<div class="font-medium truncate">{{ customer.company }}</div>
<div class="text-xs text-base-content/50 truncate">{{ customer.contact }}</div>
</div>
</div>
</td>
<td class="hidden md:table-cell text-base-content/70">{{ customer.plan }}</td>
<td>
<c-lb.badge variant="{{ customer.status_variant }}" style="soft" size="xs">{{ customer.status }}</c-lb.badge>
</td>
<td class="text-end tabular-nums font-medium">${{ customer.mrr }}</td>
<td class="hidden lg:table-cell text-end tabular-nums text-base-content/70">{{ customer.seats }}</td>
<td class="hidden md:table-cell text-end tabular-nums text-base-content/60 whitespace-nowrap">{{ customer.renews }}</td>
<td class="text-end">
<c-lb.badge variant="{{ customer.health_variant }}" style="outline" size="xs">{{ customer.health }}</c-lb.badge>
</td>
</tr>
<c-vars :customers="[]" />
{% if customers %}
<div class="overflow-x-auto rounded-box border border-base-300 bg-base-100">
<c-lb.table size="xs" class="align-middle">
<thead>
<tr class="border-base-300">
<th class="font-medium text-base-content/50">Account</th>
<th class="hidden md:table-cell font-medium text-base-content/50">Plan</th>
<th class="font-medium text-base-content/50">Status</th>
<th class="font-medium text-base-content/50 text-end">MRR</th>
<th class="hidden lg:table-cell font-medium text-base-content/50 text-end">Seats</th>
<th class="hidden md:table-cell font-medium text-base-content/50 text-end">Renews</th>
<th class="font-medium text-base-content/50 text-end">Health</th>
</tr>
</thead>
<tbody id="compact-rows">
{% for customer in customers %}
<c-compact-table.row :customer=customer :index=forloop.counter />
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<div class="rounded-box border border-dashed border-base-300 bg-base-100">
<c-compact-table.empty
id="compact-accounts-empty"
icon="rmx.building"
title="No accounts yet"
body="Arden starts billing the moment an account lands here. Add the first one, or import the book you already have."
action="Import from CSV" />
</div>
{% endif %}
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Nothing on this page is reactive, so nothing loads. No Datastar, no Alpine, no bundle.
-
The compact treatment is one prop. `size="xs"` tightens every cell, and the row keeps a two-line account cell so the contact still fits without a second column.
-
The overdue panel has nothing in it, and it says so on purpose. A table that only knows how to render rows is unfinished.
Customers table
Customer table with search, sort, pagination, inline row edit and bulk delete — all over ordinary Django views, with the view state kept in a shareable URL
import json
from decimal import Decimal
from django.db.models import Count, DecimalField, Q, Sum, Value
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from labb.contrib.blocks import lm, render_page
from labb.signals import Dict, Int, Signals, Str
LbCustomer = lm("LbCustomer")
LbInvoice = lm("LbInvoice")
LbPlan = lm("LbPlan")
INDEX = "lb/data-table/customers/pages/index.html"
PAGE_SIZE = 8
# Sort key → ORM expression. The keys are what travel in the URL, so they stay
# short and stable even when the column moves or the annotation is renamed.
SORT_FIELDS = {
"company": "company",
"plan": "plan__price_monthly",
"status": "status",
"mrr": "mrr",
"seats": "seats",
"billed": "billed",
"renews": "renews_on",
}
DEFAULT_SORT = "company"
STATUS_CHOICES = [
("active", "Active"),
("trial", "Trial"),
("past_due", "Past due"),
("churned", "Churned"),
]
STATUS_VALUES = {value for value, _ in STATUS_CHOICES}
STATUS_VARIANT = {
"active": "success",
"trial": "info",
"past_due": "warning",
"churned": "neutral",
}
HEALTH_VARIANT = {"good": "success", "watch": "warning", "at_risk": "error"}
class QuerySignals(Signals):
q = Str(path="filters.q", default="")
status = Str(path="filters.status", default="")
sort_field = Str(path="sort.field", default=DEFAULT_SORT)
sort_dir = Str(path="sort.dir", default="asc")
page = Int(default=1, min_value=1)
class EditSignals(Signals):
contact_name = Str(path="edit.contact_name", default="")
email = Str(path="edit.email", default="")
status = Str(path="edit.status", default="")
plan = Str(path="edit.plan", default="")
seats = Int(path="edit.seats", default=0, min_value=0)
mrr = Str(path="edit.mrr", default="")
class UISignals(Signals):
editing_pk = Int(path="ui.editingPk", default=0)
# Which row the browser's edit buffer belongs to.
buffer_pk = Int(path="ui.editBufferPk", default=0)
selected = Dict(default_factory=dict)
def _query_signals(request):
# The reactivity middleware restores syncQuery state into request.signals on
# a cold load, and Datastar sends the same state on subsequent requests.
s = QuerySignals(request)
if s.sort_field not in SORT_FIELDS:
s.sort_field = DEFAULT_SORT
if s.sort_dir not in ("asc", "desc"):
s.sort_dir = "asc"
if s.status not in STATUS_VALUES:
s.status = ""
return s
def _money(value):
return f"{Decimal(value or 0):,.0f}"
def _next_dir(s, field):
return "desc" if s.sort_field == field and s.sort_dir == "asc" else "asc"
def _filtered(s):
qs = LbCustomer.objects.select_related("plan").annotate(
billed=Coalesce(
Sum("invoices__amount", filter=Q(invoices__status="paid")),
Value(0),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
overdue=Count("invoices", filter=Q(invoices__status="overdue"), distinct=True),
)
if s.q:
qs = qs.filter(
Q(company__icontains=s.q)
| Q(contact_name__icontains=s.q)
| Q(email__icontains=s.q)
| Q(industry__icontains=s.q)
| Q(country__icontains=s.q)
)
if s.status:
qs = qs.filter(status=s.status)
field = SORT_FIELDS[s.sort_field]
return qs.order_by(f"-{field}" if s.sort_dir == "desc" else field, "pk")
def _summary(qs):
totals = qs.aggregate(
mrr=Coalesce(
Sum("mrr"),
Value(0),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
seats=Coalesce(Sum("seats"), Value(0)),
)
return {
"mrr": _money(totals["mrr"]),
"seats": totals["seats"],
"at_risk": qs.filter(health="at_risk").count(),
"overdue": LbInvoice.objects.filter(customer__in=qs, status="overdue").count(),
}
def _edit_signals(request, ui, editing):
"""The edit buffer to render, and who owns it.
The declaration never disturbs a buffer the browser holds. buffer_pk exists
only to spot the one case that must overwrite: a different row loaded in.
"""
if not ui.editing_pk:
ui.buffer_pk = 0
return None
if editing is None:
return None # row is off this page; the buffer stays with it
if ui.buffer_pk == editing.pk:
return EditSignals(request)
ui.buffer_pk = editing.pk
signals = EditSignals(
{
"edit": {
"contact_name": editing.contact_name,
"email": editing.email,
"status": editing.status,
"plan": str(editing.plan_id or ""),
"seats": editing.seats,
"mrr": str(editing.mrr),
}
}
)
signals.mark_changed()
return signals
def _context(request, editing_pk=None):
s = _query_signals(request)
ui = UISignals(request)
if editing_pk is not None:
ui.editing_pk = editing_pk
qs = _filtered(s)
total = qs.count()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(s.page, total_pages)
s.page = page
customers = list(qs[(page - 1) * PAGE_SIZE : page * PAGE_SIZE])
for c in customers:
c.initials = "".join(
word[0] for word in c.company.split()[:2] if word[0].isalnum()
).upper()
c.mrr_display = _money(c.mrr)
c.billed_display = _money(c.billed)
c.status_variant = STATUS_VARIANT.get(c.status, "neutral")
c.health_variant = HEALTH_VARIANT.get(c.health, "neutral")
editing = next((c for c in customers if c.pk == ui.editing_pk), None)
edit_signals = _edit_signals(request, ui, editing)
return {
"query_signals": s,
"ui_signals": ui,
"customers": customers,
"editing_pk": ui.editing_pk,
"update_url": reverse(
"block_data_table_customers:update", kwargs={"pk": editing.pk}
)
if editing
else "",
"edit_signals": edit_signals,
"sort_field": s.sort_field,
"sort_dir": s.sort_dir,
"next_dir": {field: _next_dir(s, field) for field in SORT_FIELDS},
"status_choices": STATUS_CHOICES,
"searching": bool(s.q or s.status),
"summary": _summary(qs),
"plans": LbPlan.objects.all(),
"page_pks": json.dumps([str(c.pk) for c in customers]),
"first_index": (page - 1) * PAGE_SIZE + 1 if total else 0,
"last_index": min(page * PAGE_SIZE, total),
"total": total,
"page": page,
"total_pages": total_pages,
"prev_page": page - 1,
"next_page": page + 1,
}
def _render(request, ctx):
return render_page(request, INDEX, ctx, title=f"Customers ({ctx['total']})")
def index(request):
return _render(request, _context(request))
def update(request, pk):
customer = get_object_or_404(LbCustomer, pk=pk)
if request.method == "POST":
edit = EditSignals(request)
customer.contact_name = edit.contact_name.strip() or customer.contact_name
customer.email = edit.email.strip() or customer.email
customer.status = edit.status or customer.status
customer.seats = edit.seats or customer.seats
customer.mrr = Decimal(edit.mrr or customer.mrr)
if edit.plan:
customer.plan = LbPlan.objects.filter(pk=edit.plan).first()
customer.save()
if request.is_datastar:
return _render(request, _context(request, editing_pk=0))
return redirect("block_data_table_customers:index")
def _deselect(ui, keys):
"""Switch `keys` off. Signals merge, so {} would leave every key set."""
ui.selected = {**ui.selected, **{str(key): False for key in keys}}
def delete(request, pk):
if request.method in ("POST", "DELETE"):
get_object_or_404(LbCustomer, pk=pk).delete()
if request.is_datastar:
ctx = _context(request)
_deselect(ctx["ui_signals"], [pk])
return _render(request, ctx)
return redirect("block_data_table_customers:index")
def bulk_delete(request):
if request.method == "POST":
pks = [int(key) for key, on in UISignals(request).selected.items() if on]
if pks:
LbCustomer.objects.filter(pk__in=pks).delete()
if request.is_datastar:
ctx = _context(request)
_deselect(ctx["ui_signals"], pks)
return _render(request, ctx)
return redirect("block_data_table_customers:index")
from django.urls import path
from . import views
app_name = "block_data_table_customers"
urlpatterns = [
path("", views.index, name="index"),
path("update/<int:pk>/", views.update, name="update"),
path("delete/<int:pk>/", views.delete, name="delete"),
path("bulk-delete/", views.bulk_delete, name="bulk_delete"),
]
<c-lbr.signals id="customer-query" :schema=query_signals syncQuery />
<c-lbr.signals id="customer-ui" :schema=ui_signals />
<main class="container mx-auto max-w-7xl px-4 py-10">
<c-customers.masthead :total=total />
<c-customers.summary :summary=summary :total=total :searching=searching />
<c-customers.toolbar
:signals=query_signals
:status_choices=status_choices />
<div id="customer-table" class="mt-5">
<c-customers.table
:customers=customers
:plans=plans
:status_choices=status_choices
:editing_pk=editing_pk
:update_url=update_url
:sort_field=sort_field
:sort_dir=sort_dir
:next_dir=next_dir
:page_pks=page_pks
:searching=searching />
</div>
{% if total %}
<c-customers.pager
:page=page
:total_pages=total_pages
:prev_page=prev_page
:next_page=next_page
:first_index=first_index
:last_index=last_index
:total=total />
{% endif %}
<p class="mt-6 text-xs text-base-content/40 max-w-2xl">
Billed to date counts paid invoices only; open and overdue runs are excluded.
Renewal dates come from the customer's current plan term.
</p>
</main>
<c-vars label="" class="" />
<div class="{{ class }}">
<label class="label label-text text-xs text-base-content/50 mb-0.5">{{ label }}</label>
{{ slot }}
</div>
<c-vars :customer="{}" :plans="[]" :status_choices="[]" update_url="" :edit_signals="None" />
<tr id="customer-{{ customer.pk }}" class="border-base-300 bg-base-200/50">
<td colspan="9" class="p-3">
<c-lbr.signals id="customer-edit-signals" :schema=edit_signals />
<c-lbr.post :to=update_url>
<div class="flex flex-wrap items-end gap-2">
<div class="flex items-center gap-3 min-w-48 pb-1.5">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="sm" rounded="lg" />
<div class="min-w-0">
<div class="font-medium truncate">{{ customer.company }}</div>
<div class="text-xs text-base-content/50">Editing</div>
</div>
</div>
<c-customers.edit-field label="Contact" class="flex-1 min-w-40">
<c-lb.input
type="text"
name="contact_name"
bind="$edit.contact_name"
size="sm"
class="w-full"
:ghost="False"
:validate="False"
autofocus
required />
</c-customers.edit-field>
<c-customers.edit-field label="Email" class="flex-1 min-w-48">
<c-lb.input
type="email"
name="email"
bind="$edit.email"
size="sm"
class="w-full"
:ghost="False"
:validate="False"
required />
</c-customers.edit-field>
<c-customers.edit-field label="Plan" class="w-36">
<c-lb.select name="plan" bind="$edit.plan" size="sm" class="w-full" :validate="False">
{% for plan in plans %}
<option value="{{ plan.pk }}" {% if edit_signals.plan == plan.pk|stringformat:"s" %}selected{% endif %}>{{ plan.name }}</option>
{% endfor %}
</c-lb.select>
</c-customers.edit-field>
<c-customers.edit-field label="Status" class="w-32">
<c-lb.select name="status" bind="$edit.status" size="sm" class="w-full" :validate="False">
{% for value, label in status_choices %}
<option value="{{ value }}" {% if edit_signals.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-customers.edit-field>
<c-customers.edit-field label="MRR" class="w-28">
<c-lb.input
type="number"
name="mrr"
bind="$edit.mrr"
min="0"
step="1"
size="sm"
class="w-full tabular-nums"
:ghost="False"
:validate="False" />
</c-customers.edit-field>
<c-customers.edit-field label="Seats" class="w-20">
<c-lb.input
type="number"
name="seats"
bind="$edit.seats"
min="1"
step="1"
size="sm"
class="w-full tabular-nums"
:ghost="False"
:validate="False" />
</c-customers.edit-field>
<div class="flex gap-1 pb-0.5">
<c-lb.button type="submit" variant="primary" size="sm">Save</c-lb.button>
<c-lbr.get to="block_data_table_customers:index" before="$ui.editingPk=0">
<c-lb.button type="button" btnStyle="ghost" size="sm">Cancel</c-lb.button>
</c-lbr.get>
</div>
</div>
</c-lbr.post>
</td>
</tr>
<c-vars :searching="False" />
<div class="rounded-box border border-dashed border-base-300 bg-base-100 px-6 py-16">
<div class="mx-auto flex max-w-sm flex-col items-center text-center">
<c-lb.avatar
placeholder
icon="{% if searching %}rmx.user-search{% else %}rmx.building{% endif %}"
bgColor="neutral"
size="lg"
rounded="full" />
{% if searching %}
<h3 class="mt-5 text-lg font-semibold tracking-tight">No customers match that filter</h3>
<p class="mt-1.5 text-sm text-base-content/60">
Nothing in this workspace lines up with what you searched for. Try a company
name, a contact, an industry or a country.
</p>
<div class="mt-5">
<c-lbr.get
to="block_data_table_customers:index"
before="$filters.q=''; $filters.status=''; $page=1">
<c-lb.button btnStyle="outline" size="sm" icon="rmx.close-circle">Clear filters</c-lb.button>
</c-lbr.get>
</div>
{% else %}
<h3 class="mt-5 text-lg font-semibold tracking-tight">No customers yet</h3>
<p class="mt-1.5 text-sm text-base-content/60">
Arden starts billing the moment a customer lands here. Add the first one, or
import the book you already have.
</p>
<div class="mt-5 flex gap-2">
<c-lb.button variant="primary" size="sm" icon="rmx.add">New customer</c-lb.button>
<c-lb.button btnStyle="ghost" size="sm">Import from CSV</c-lb.button>
</div>
{% endif %}
</div>
</div>
<c-vars :total=0 />
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="sm" />
<span class="text-sm font-semibold tracking-tight text-base-content/70">Arden</span>
</div>
<h1 class="mt-6 text-3xl font-semibold tracking-tight">Customers</h1>
</div>
<div class="flex items-center gap-2">
<c-lb.button btnStyle="ghost" size="sm" icon="rmx.download">Export CSV</c-lb.button>
<c-lb.button variant="primary" size="sm" icon="rmx.add">New customer</c-lb.button>
</div>
</div>
<c-vars :page=1 :total_pages=1 :prev_page=1 :next_page=1 :first_index=0 :last_index=0 :total=0 />
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-base-content/50 tabular-nums">
Showing {{ first_index }}–{{ last_index }} of {{ total }} customers
</p>
{% if total_pages > 1 %}
<div class="flex items-center gap-1">
{% if page > 1 %}
<c-lbr.get to="block_data_table_customers:index" before="$page={{ prev_page }}">
<c-lb.button btnStyle="ghost" size="sm">Previous</c-lb.button>
</c-lbr.get>
{% else %}
<c-lb.button btnStyle="ghost" size="sm" behavior="disabled">Previous</c-lb.button>
{% endif %}
<span class="px-3 text-sm text-base-content/60 tabular-nums">Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<c-lbr.get to="block_data_table_customers:index" before="$page={{ next_page }}">
<c-lb.button btnStyle="ghost" size="sm">Next</c-lb.button>
</c-lbr.get>
{% else %}
<c-lb.button btnStyle="ghost" size="sm" behavior="disabled">Next</c-lb.button>
{% endif %}
</div>
{% endif %}
</div>
<c-vars :customer="{}" />
<tr id="customer-{{ customer.pk }}" class="border-base-300 hover:bg-base-200/40">
<td>
<c-lb.checkbox
size="sm"
:validate="False"
bind="$selected.{{ customer.pk }}" />
</td>
<td>
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="sm" rounded="lg" />
<div class="min-w-0">
<div class="font-medium truncate">{{ customer.company }}</div>
<div class="text-xs text-base-content/50 truncate">{{ customer.contact_name }} · {{ customer.email }}</div>
</div>
</div>
</td>
<td class="hidden md:table-cell">
<c-lb.badge style="soft" size="sm">{{ customer.plan.name|default:"No plan" }}</c-lb.badge>
</td>
<td>
<div class="flex items-center gap-1.5">
<c-lb.badge :variant=customer.status_variant style="soft" size="sm">
{{ customer.get_status_display }}
</c-lb.badge>
{% if customer.health != "good" %}
<c-lb.badge :variant=customer.health_variant style="outline" size="xs">
{{ customer.get_health_display }}
</c-lb.badge>
{% endif %}
</div>
</td>
<td class="text-end tabular-nums font-medium">${{ customer.mrr_display }}</td>
<td class="hidden lg:table-cell text-end tabular-nums text-base-content/70">{{ customer.seats }}</td>
<td class="hidden lg:table-cell text-end">
<div class="tabular-nums text-base-content/70">${{ customer.billed_display }}</div>
{% if customer.overdue %}
<div class="text-xs text-error tabular-nums">{{ customer.overdue }} overdue</div>
{% endif %}
</td>
<td class="hidden md:table-cell text-end tabular-nums text-base-content/60 whitespace-nowrap">
{{ customer.renews_on|date:"j M Y"|default:"—" }}
</td>
<td>
<div class="flex justify-end gap-0.5">
<c-lbr.get to="block_data_table_customers:index" before="$ui.editingPk={{ customer.pk }}">
<c-lb.button btnStyle="ghost" size="xs" icon="rmx.pencil" aria-label="Edit {{ customer.company }}" />
</c-lbr.get>
<c-lbr.delete
to="block_data_table_customers:delete"
pk=customer.pk
confirm="Remove this customer and every invoice against them?">
<c-lb.button variant="error" btnStyle="ghost" size="xs" icon="rmx.delete-bin" aria-label="Delete customer" />
</c-lbr.delete>
</div>
</td>
</tr>
<c-vars field="" label="" next_dir="asc" sort_field="" sort_dir="" numeric />
<c-lbr.get
to="block_data_table_customers:index"
before="$sort.field='{{ field }}'; $sort.dir='{{ next_dir }}'; $page=1"
class="{% if numeric %}flex justify-end{% endif %}">
<c-lb.button
btnStyle="ghost"
size="xs"
class="gap-1 px-1 font-semibold {% if sort_field == field %}text-primary{% else %}text-base-content/60{% endif %}">
{{ label }}
{% if sort_field == field %}
{% if sort_dir == "asc" %}
<c-lbi.rmx.arrow-up-s w="3.5" h="3.5" />
{% else %}
<c-lbi.rmx.arrow-down-s w="3.5" h="3.5" />
{% endif %}
{% endif %}
</c-lb.button>
</c-lbr.get>
<c-vars :summary="{}" :total=0 :searching="False" />
<c-lb.stat.group class="mt-6 w-full border border-base-300 bg-base-100 shadow-none">
<c-lb.stat>
<c-lb.stat.title>{% if searching %}Matching customers{% else %}Customers{% endif %}</c-lb.stat.title>
<c-lb.stat.value class="tabular-nums text-2xl">{{ total }}</c-lb.stat.value>
<c-lb.stat.desc class="tabular-nums">{{ summary.seats }} seats billed</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.title>Monthly recurring</c-lb.stat.title>
<c-lb.stat.value variant="primary" class="tabular-nums text-2xl">${{ summary.mrr }}</c-lb.stat.value>
<c-lb.stat.desc>{% if searching %}Across the current filter{% else %}Across the whole book{% endif %}</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.title>At risk</c-lb.stat.title>
<c-lb.stat.value variant="{% if summary.at_risk %}error{% else %}success{% endif %}" class="tabular-nums text-2xl">{{ summary.at_risk }}</c-lb.stat.value>
<c-lb.stat.desc>Flagged by health score</c-lb.stat.desc>
</c-lb.stat>
<c-lb.stat>
<c-lb.stat.title>Overdue invoices</c-lb.stat.title>
<c-lb.stat.value variant="{% if summary.overdue %}warning{% else %}success{% endif %}" class="tabular-nums text-2xl">{{ summary.overdue }}</c-lb.stat.value>
<c-lb.stat.desc>Past their due date</c-lb.stat.desc>
</c-lb.stat>
</c-lb.stat.group>
<c-vars :customers="[]" :plans="[]" :status_choices="[]" :editing_pk=0 update_url="" sort_field="" sort_dir="" :next_dir="{}" page_pks="[]" :searching="False" />
{% if customers %}
<div class="overflow-x-auto rounded-box border border-base-300 bg-base-100">
<c-lb.table size="sm" class="align-middle">
<thead>
<tr class="border-base-300">
<th class="w-10">
<c-lb.checkbox
size="sm"
:validate="False"
aria-label="Select every customer on this page"
data-on:change="{{ page_pks }}.forEach(k => $selected[k] = el.checked)"
data-effect="el.checked = {{ page_pks }}.every(k => !!$selected[k]); el.indeterminate = !el.checked && {{ page_pks }}.some(k => !!$selected[k])" />
</th>
<th>
<c-customers.sort-header field="company" label="Customer" :next_dir=next_dir.company :sort_field=sort_field :sort_dir=sort_dir />
</th>
<th class="hidden md:table-cell">
<c-customers.sort-header field="plan" label="Plan" :next_dir=next_dir.plan :sort_field=sort_field :sort_dir=sort_dir />
</th>
<th>
<c-customers.sort-header field="status" label="Status" :next_dir=next_dir.status :sort_field=sort_field :sort_dir=sort_dir />
</th>
<th class="text-end">
<c-customers.sort-header field="mrr" label="MRR" :next_dir=next_dir.mrr :sort_field=sort_field :sort_dir=sort_dir numeric />
</th>
<th class="hidden lg:table-cell text-end">
<c-customers.sort-header field="seats" label="Seats" :next_dir=next_dir.seats :sort_field=sort_field :sort_dir=sort_dir numeric />
</th>
<th class="hidden lg:table-cell text-end">
<c-customers.sort-header field="billed" label="Billed" :next_dir=next_dir.billed :sort_field=sort_field :sort_dir=sort_dir numeric />
</th>
<th class="hidden md:table-cell text-end">
<c-customers.sort-header field="renews" label="Renews" :next_dir=next_dir.renews :sort_field=sort_field :sort_dir=sort_dir numeric />
</th>
<th class="w-20"></th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
{% if customer.pk == editing_pk %}
<c-customers.edit-row :customer=customer :plans=plans :status_choices=status_choices :update_url=update_url :edit_signals=edit_signals />
{% else %}
<c-customers.row :customer=customer />
{% endif %}
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<c-customers.empty :searching=searching />
{% endif %}
<c-vars :signals="{}" :status_choices="[]" />
<div class="mt-6 flex flex-wrap items-center gap-2">
<c-lbr.get
to="block_data_table_customers:index"
on="input__debounce.300ms"
before="$page=1"
class="flex-1 min-w-56">
<c-lb.input
type="search"
placeholder="Search company, contact, industry…"
class="w-full"
data-lbb-start
:ghost="False"
:validate="False"
:bind=signals.fields.q />
</c-lbr.get>
<c-lbr.get to="block_data_table_customers:index" on="change" before="$page=1">
<c-lb.select :validate="False" :bind=signals.fields.status class="w-40">
<option value="">All statuses</option>
{% for value, label in status_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-lbr.get>
<div class="flex items-center gap-2 ms-auto" data-show="Object.values($selected).some(Boolean)">
<span
class="text-sm text-base-content/60 tabular-nums"
data-text="Object.values($selected).filter(Boolean).length + ' selected'"></span>
<c-lbr.post to="block_data_table_customers:bulk_delete" tag="div" on="click">
<c-lb.button type="button" variant="error" btnStyle="soft" size="sm" icon="rmx.delete-bin">
Delete
</c-lb.button>
</c-lbr.post>
</div>
</div>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
The page declares search, filters, sorting, and pagination once. The same schema gives the template a starting state and gives the view typed values.
-
The input binds to the search field, then a debounced GET sends that state to Django. The list view filters the queryset and returns HTML for Datastar to update.
-
syncQuery keeps the active filters, sort, and page in the URL. A fresh request restores them through ReactivityMiddleware before the view runs.
-
Delete actions call an ordinary Django view and return the updated page. The stable row id lets Datastar update the changed row while the page summary stays in sync.
Expandable rows
Rows that open a detail panel in place from a client signal — the invoices, the owner and the activity, without leaving the table
-
components
-
arden
-
expando_table
-
-
templates
-
pages
-
<c-lbr.signals id="expando-signals" :$open=open />
<main class="container mx-auto max-w-6xl px-4 py-10 flex flex-col gap-6">
<c-expando-table.masthead :total=total />
<c-expando-table.table :customers=customers />
<p class="text-xs text-base-content/40 max-w-2xl">
The detail panel is rendered with the row and hidden until you ask for it. Six rows of
detail cost less than one round trip, and the account you opened stays open while you
read it.
</p>
</main>
<c-vars :customer="{}" />
<tr
id="expando-detail-{{ customer.id }}"
class="border-base-300 bg-base-200/30"
style="display:none"
data-show="$open === {{ customer.id }}"
>
<td colspan="7" class="p-0">
<div class="grid gap-6 px-6 py-5 lg:grid-cols-3">
<div class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-base-content/50">Who to call</h3>
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="secondary" size="sm" rounded="md" />
<div class="min-w-0 leading-tight">
<div class="text-sm font-medium truncate">{{ customer.contact }}</div>
<div class="text-xs text-base-content/50 truncate">{{ customer.title }}</div>
</div>
</div>
<div class="flex flex-col gap-1 text-xs text-base-content/60">
<span class="truncate">{{ customer.email }}</span>
<span>Owned by {{ customer.owner }} · Customer since {{ customer.since }}</span>
</div>
<div class="flex items-center gap-1">
<c-lb.button btnStyle="outline" size="xs" icon="rmx.mail">Email</c-lb.button>
<c-lb.button btnStyle="ghost" size="xs">Open account</c-lb.button>
</div>
</div>
<div class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-base-content/50">Where it stands</h3>
<dl class="flex flex-col gap-1.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-base-content/60">Plan</dt>
<dd class="font-medium">{{ customer.plan }}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-base-content/60">Seats in use</dt>
<dd class="font-medium tabular-nums">{{ customer.seats_used }} of {{ customer.seats }}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-base-content/60">Run rate</dt>
<dd class="font-medium tabular-nums">${{ customer.mrr }} / month</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-base-content/60">Renews</dt>
<dd class="font-medium tabular-nums">{{ customer.renews }}</dd>
</div>
</dl>
<p class="text-xs text-base-content/60">{{ customer.activity }}</p>
</div>
<div class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-base-content/50">Recent invoices</h3>
{% if customer.invoices %}
<div class="flex flex-col divide-y divide-base-300 rounded-box border border-base-300 bg-base-100">
{% for invoice in customer.invoices %}
<div class="flex items-center justify-between gap-3 px-3 py-2">
<div class="leading-tight">
<div class="text-sm font-medium tabular-nums">{{ invoice.number }}</div>
<div class="text-xs text-base-content/50 tabular-nums">{{ invoice.issued }}</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm tabular-nums">${{ invoice.amount }}</span>
<c-lb.badge variant="{{ invoice.state_variant }}" style="soft" size="xs">{{ invoice.state }}</c-lb.badge>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="rounded-box border border-dashed border-base-300 bg-base-100">
<c-expando-table.empty
id="expando-invoices-empty-{{ customer.id }}"
icon="rmx.receipt"
title="No invoices yet"
body="{{ customer.company }} is still on trial. The first invoice is raised the day the plan starts, on {{ customer.renews }}." />
</div>
{% endif %}
</div>
</div>
</td>
</tr>
<c-vars id="" icon="rmx.inbox" title="" body="" />
<div id="{{ id }}" class="flex flex-col items-center gap-2.5 px-5 py-8 text-center">
<c-lb.avatar placeholder icon="{{ icon }}" bgColor="neutral" size="sm" rounded="full" />
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold tracking-tight">{{ title }}</h3>
<p class="mx-auto max-w-xs text-xs text-base-content/60">{{ body }}</p>
</div>
</div>
<c-vars :total=0 />
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="xs" />
<span class="text-xs font-semibold tracking-tight text-base-content/70">Arden</span>
</div>
<h1 class="mt-6 text-2xl font-semibold tracking-tight">Accounts</h1>
<p class="mt-1 text-sm text-base-content/60 max-w-lg">
Open a row to see the invoices, the owner and what the account has been doing,
without losing the row you were reading.
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-base-content/50 tabular-nums">{{ total }} accounts</span>
<c-lb.button variant="primary" size="sm" icon="rmx.add">New account</c-lb.button>
</div>
</div>
<c-vars :customer="{}" />
<tr
id="expando-row-{{ customer.id }}"
class="border-base-300 hover:bg-base-200/40"
>
<td class="text-base-content/40">
<button
id="expando-toggle-{{ customer.id }}"
type="button"
data-lbb-start
class="btn btn-square btn-ghost btn-xs"
aria-controls="expando-detail-{{ customer.id }}"
aria-label="Toggle details for {{ customer.company }}"
data-attr:aria-expanded="$open === {{ customer.id }} ? 'true' : 'false'"
data-on:click="$open = $open === {{ customer.id }} ? 0 : {{ customer.id }}">
<span data-show="$open !== {{ customer.id }}">
<c-lbi.rmx.arrow-right-s w="16" h="16" />
</span>
<span style="display:none" data-show="$open === {{ customer.id }}">
<c-lbi.rmx.arrow-down-s w="16" h="16" />
</span>
</button>
</td>
<td>
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="sm" rounded="md" />
<div class="min-w-0 leading-tight">
<div class="font-medium truncate">{{ customer.company }}</div>
<div class="text-xs text-base-content/50 truncate">{{ customer.contact }} · {{ customer.title }}</div>
</div>
</div>
</td>
<td class="hidden md:table-cell text-base-content/70">{{ customer.plan }}</td>
<td>
<div class="flex items-center gap-1.5">
<c-lb.badge variant="{{ customer.status_variant }}" style="soft" size="sm">{{ customer.status }}</c-lb.badge>
<c-lb.badge variant="{{ customer.health_variant }}" style="outline" size="xs">{{ customer.health }}</c-lb.badge>
</div>
</td>
<td class="text-end tabular-nums font-medium">${{ customer.mrr }}</td>
<td class="hidden lg:table-cell text-end tabular-nums text-base-content/70">{{ customer.seats_used }} / {{ customer.seats }}</td>
<td class="hidden md:table-cell text-end tabular-nums text-base-content/60 whitespace-nowrap">{{ customer.renews }}</td>
</tr>
<c-vars :customers="[]" />
{% if customers %}
<div class="overflow-x-auto rounded-box border border-base-300 bg-base-100">
<c-lb.table size="sm" class="align-middle">
<thead>
<tr class="border-base-300">
<th class="w-8"></th>
<th class="font-medium text-base-content/50">Account</th>
<th class="hidden md:table-cell font-medium text-base-content/50">Plan</th>
<th class="font-medium text-base-content/50">Status</th>
<th class="font-medium text-base-content/50 text-end">MRR</th>
<th class="hidden lg:table-cell font-medium text-base-content/50 text-end">Seats</th>
<th class="hidden md:table-cell font-medium text-base-content/50 text-end">Renews</th>
</tr>
</thead>
<tbody id="expando-rows">
{% for customer in customers %}
<c-expando-table.row :customer=customer />
<c-expando-table.detail :customer=customer />
{% endfor %}
</tbody>
</c-lb.table>
</div>
{% else %}
<div class="rounded-box border border-dashed border-base-300 bg-base-100">
<c-expando-table.empty
id="expando-accounts-empty"
icon="rmx.building"
title="No accounts yet"
body="Arden starts billing the moment an account lands here. Add the first one, or import the book you already have." />
</div>
{% endif %}
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
`open` holds the id of the row that is expanded, and zero means none. One signal, one number.
-
Clicking the row sets the signal to its id, or back to zero if it was already open. Because only one id fits in the signal, opening a second row closes the first for free.
-
The detail is a second `<tr>` rendered next to the row it belongs to, hidden until the signal names it. It expands in place: the table does not navigate, no drawer slides over the data you were comparing, and the scroll position is exactly where you left it.
-
Northwind Co is on trial, so it has no invoices, and the panel says so rather than showing an empty box. Every region that can be empty gets a state, not just the table as a whole.
Table with filters
A filter toolbar whose chips narrow the table from a client signal — no endpoint, no query string, nothing on the wire
-
components
-
arden
-
filter_table
-
-
templates
-
pages
-
<script>
const ARDEN_ACCOUNTS = [
{% for customer in customers %}
{ status: "{{ customer.status_key }}", health: "{{ customer.health_key }}" }{% if not forloop.last %},{% endif %}
{% endfor %}
];
function ardenFilters(status, health) {
var narrowed = status !== '' || health !== '';
return {
status: status,
health: health,
tone: narrowed ? 'primary' : 'neutral',
summary: narrowed ? 'Filtered' : 'All accounts'
};
}
function ardenMatches(status, health, filters) {
return (filters.status === '' || filters.status === status)
&& (filters.health === '' || filters.health === health);
}
function ardenVisible(filters) {
return ARDEN_ACCOUNTS.filter(function (account) {
return ardenMatches(account.status, account.health, filters);
}).length;
}
</script>
<c-lbr.signals id="account-filters" :$filters=filters />
<main class="container mx-auto max-w-7xl px-4 py-10 flex flex-col gap-6">
<c-filter-table.masthead />
<c-filter-table.toolbar
:status_chips=status_chips
:health_chips=health_chips
:total=total />
<c-filter-table.table :customers=customers />
<p class="text-xs text-base-content/40 max-w-2xl">
Health is recomputed nightly from product usage, invoice history and support volume.
An account moves to At risk when two of the three fall below their plan's baseline.
</p>
</main>
<c-vars group="status" value="" label="" />
{% if group == 'status' %}
<c-lb.button
data-lbb-start
type="button"
id="chip-status-{{ value|default:'all' }}"
size="xs"
btnStyle="ghost"
class="rounded-full {% if not value %}btn-active{% endif %}"
data-class="{'btn-active': $filters.status === '{{ value }}'}"
data-on:click="$filters = ardenFilters('{{ value }}', $filters.health)"
>{{ label }}</c-lb.button>
{% else %}
<c-lb.button
type="button"
id="chip-health-{{ value|default:'all' }}"
size="xs"
btnStyle="ghost"
class="rounded-full {% if not value %}btn-active{% endif %}"
data-class="{'btn-active': $filters.health === '{{ value }}'}"
data-on:click="$filters = ardenFilters($filters.status, '{{ value }}')"
>{{ label }}</c-lb.button>
{% endif %}
<div
id="filter-empty"
class="flex flex-col items-center gap-3 border-t border-base-300 px-6 py-12 text-center"
style="display:none"
data-show="ardenVisible($filters) === 0"
>
<c-lb.avatar placeholder icon="rmx.filter-off" bgColor="neutral" size="md" rounded="full" />
<div class="flex flex-col gap-1">
<h3 class="text-base font-semibold tracking-tight">No account matches those filters</h3>
<p class="mx-auto max-w-sm text-sm text-base-content/60">
Nothing in the book sits in that combination of status and health. Widen one of
them, or clear both and start again.
</p>
</div>
<c-lb.button
type="button"
btnStyle="outline"
size="sm"
icon="rmx.close-circle"
data-on:click="$filters = ardenFilters('', '')"
>Clear filters</c-lb.button>
</div>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="xs" />
<span class="text-xs font-semibold tracking-tight text-base-content/70">Arden</span>
</div>
<h1 class="mt-6 text-2xl font-semibold tracking-tight">Account book</h1>
<p class="mt-1 text-sm text-base-content/60 max-w-lg">
Slice the book by status and health. The rows are already in the page, so the
filters answer instantly and the server never hears about it.
</p>
</div>
<div class="flex items-center gap-2">
<c-lb.button btnStyle="ghost" size="xs" icon="rmx.download">Export</c-lb.button>
<c-lb.button variant="primary" size="xs" icon="rmx.add">New account</c-lb.button>
</div>
</div>
<c-vars :customer="{}" />
<tr
id="filter-row-{{ customer.initials|lower }}"
class="border-base-300 hover:bg-base-200/40"
data-show="ardenMatches('{{ customer.status_key }}', '{{ customer.health_key }}', $filters)"
>
<td>
<div class="flex items-center gap-3">
<c-lb.avatar placeholder initials="{{ customer.initials }}" bgColor="neutral" size="sm" rounded="md" />
<div class="min-w-0 leading-tight">
<div class="font-medium truncate">{{ customer.company }}</div>
<div class="text-xs text-base-content/50 truncate">{{ customer.contact }} · {{ customer.email }}</div>
</div>
</div>
</td>
<td class="hidden md:table-cell text-base-content/70">{{ customer.plan }}</td>
<td>
<c-lb.badge variant="{{ customer.status_variant }}" style="soft" size="sm">{{ customer.status }}</c-lb.badge>
</td>
<td>
<c-lb.badge variant="{{ customer.health_variant }}" style="outline" size="sm">{{ customer.health }}</c-lb.badge>
</td>
<td class="text-end tabular-nums font-medium">${{ customer.mrr }}</td>
<td class="hidden lg:table-cell text-end tabular-nums text-base-content/70">{{ customer.seats }}</td>
<td class="hidden md:table-cell text-end tabular-nums text-base-content/60 whitespace-nowrap">{{ customer.renews }}</td>
</tr>
<c-vars :customers="[]" />
<div class="rounded-box border border-base-300 bg-base-100">
<div class="overflow-x-auto">
<c-lb.table size="sm" class="align-middle">
<thead>
<tr class="border-base-300">
<th class="font-medium text-base-content/50">Account</th>
<th class="hidden md:table-cell font-medium text-base-content/50">Plan</th>
<th class="font-medium text-base-content/50">Status</th>
<th class="font-medium text-base-content/50">Health</th>
<th class="font-medium text-base-content/50 text-end">MRR</th>
<th class="hidden lg:table-cell font-medium text-base-content/50 text-end">Seats</th>
<th class="hidden md:table-cell font-medium text-base-content/50 text-end">Renews</th>
</tr>
</thead>
<tbody id="filter-rows">
{% for customer in customers %}
<c-filter-table.row :customer=customer />
{% endfor %}
</tbody>
</c-lb.table>
</div>
<c-filter-table.empty />
</div>
<c-vars :status_chips="[]" :health_chips="[]" :total=0 />
<div class="flex flex-col gap-3 rounded-box border border-base-300 bg-base-100 px-4 py-3">
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-base-content/50">Status</span>
<div class="flex flex-wrap items-center gap-1">
{% for chip in status_chips %}
<c-filter-table.chip group="status" :value=chip.value :label=chip.label />
{% endfor %}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-base-content/50">Health</span>
<div class="flex flex-wrap items-center gap-1">
{% for chip in health_chips %}
<c-filter-table.chip group="health" :value=chip.value :label=chip.label />
{% endfor %}
</div>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-300 pt-3">
<p class="text-xs text-base-content/50">
Filtering runs in the browser. Nothing here touches the server.
</p>
<div class="flex items-center gap-2">
<c-lb.badge
id="filter-tone"
variant="$filters.tone:neutral"
style="soft"
size="sm"
data-text="$filters.summary"
>All accounts</c-lb.badge>
<span
id="filter-count"
class="text-xs tabular-nums text-base-content/60"
data-text="ardenVisible($filters) + ' of {{ total }} shown'"
>{{ total }} of {{ total }} shown</span>
</div>
</div>
</div>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
Status, health, the badge tone and the summary label all live in one client signal, declared in prop mode from the object the page was rendered with. There is no view behind this block because there is nothing for a server to do.
-
Clicking a chip replaces the filter object through one helper. That is the entire interaction.
-
Each row carries its own status and health, and shows itself when the filter agrees. No list is re-rendered and no DOM is rebuilt, so the table cannot flicker or lose your scroll position.
-
Prefix any component prop with `$` and it tracks a signal. The part after the colon is what the server renders, so the badge is right before a byte of JavaScript runs.
-
Filter to Churned and Watch and the book has nothing to show you. The empty state is shipped with the block, driven by the same signal as the rows, and it offers the one action worth offering: put the filters back.