project-bifrost-platform/src/admin/components/ResourceListView.astro
Jonathan Hvid e9a986d484 feat(admin): council-group resources (users, invitations, join requests)
Three more resources land. /admin/users replaces the old participants
tab, /admin/invitations replaces the old invites tab, /admin/join_requests
replaces the read-only join queue.

- src/admin/resources/users.ts ("People"): single resource for all users,
  filter chips swap visible columns (council shows member_number +
  focus_tags; pilots/team show role + last_seen_at). Form fields are
  conditional — title / pull_quote / focus_tags / cab_joined_date /
  member_number render only when role === cab. No ops.create (users
  come via invites); deactivateUser is the delete handler.
- src/admin/resources/invitations.ts: form-for-create, summary-for-view.
  Create generates a token via generateInviteToken(), stores its hash,
  surfaces the magic link as a one-shot ?invite_url= block in the panel.
  Revoke is an action (sets expires_at = now); the row stays for audit.
- src/admin/resources/join-requests.ts: form: null, review-mode panel
  with the user's summary + approve_as_cab / decline actions.

Plumbing to support the above:
- src/admin/resource-types.ts: new Resource.summary callback (read-only
  field pairs for review panels); OpContext.result lets ops surface
  ActionResults (e.g. invite-link).
- src/admin/components/ResourceEditPanel.astro: review mode when an
  existing item is shown and resource.summary is defined; renders the
  ?invite_url= block above the summary with a copy-to-clipboard button.
- src/admin/components/ResourceListView.astro: "+ New" suppressed when
  ops.create is undefined.
- src/pages/admin/[resource].astro: captures ctx.result and action
  handler return values, propagates them via &invite_url=...; routes to
  the list view (not the row) when an action removes the item.
- src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest,
  getInviteById.

Deviation from the original delta: no approve_as_pilot action and no
invite-link result on join-request approval. The existing
join_requests schema only stores user_id — requests come from
already-authenticated pilots asking for a CAB upgrade, not from
strangers needing an invite. The schema change for stranger sign-ups
is left for a future follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:32:26 +02:00

205 lines
8 KiB
Text

---
/* ---------------------------------------------------------------------------
* ResourceListView — shared list rendering for every Backstage resource.
*
* Reads URL state (?filter, ?q, ?page) and derives:
* - active filter (with isDefault fallback)
* - active column set (columnsByFilter override → columns)
* - filtered + searched + sorted + paginated row set
*
* Rows are full anchor elements pointing at ?edit=<id> so the table is
* fully keyboard-navigable and works without JS. The panel that consumes
* the edit param ships in step 6.
* ------------------------------------------------------------------------- */
import ListCell from './ListCell.astro';
import type { Column, Resource, ResourceGroup } from '../resource-types';
interface Props {
resource: Resource;
groups: ResourceGroup[];
}
const { resource, groups } = Astro.props;
const url = Astro.url;
// ── Resolve state from URL ────────────────────────────────────────────────
const filters = resource.list.filters ?? [];
const defaultFilterKey =
filters.find((f) => f.isDefault)?.key ?? filters[0]?.key ?? 'all';
const filterKey = url.searchParams.get('filter') ?? defaultFilterKey;
const activeFilter = filters.find((f) => f.key === filterKey);
const search = (url.searchParams.get('q') ?? '').trim();
const pageParam = Number(url.searchParams.get('page') ?? '1');
const requestedPage = Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
const pageSize = resource.list.pageSize ?? 25;
// ── Load + transform ──────────────────────────────────────────────────────
const queried = await resource.list.queryFn();
const allItems = (Array.isArray(queried) ? queried : []) as Record<string, unknown>[];
const filtered = activeFilter
? allItems.filter((item) => activeFilter.predicate(item))
: allItems;
const searched = search && resource.list.search
? filtered.filter((item) => {
const q = search.toLowerCase();
return resource.list.search!.fields.some((field) => {
const v = item[field as string];
return typeof v === 'string' && v.toLowerCase().includes(q);
});
})
: filtered;
const sort = resource.list.defaultSort;
const sorted = sort
? [...searched].sort((a, b) => {
const av = a[sort.key];
const bv = b[sort.key];
if (av === bv) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : 1;
return sort.direction === 'desc' ? -cmp : cmp;
})
: searched;
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const page = Math.min(requestedPage, totalPages);
const start = (page - 1) * pageSize;
const pageItems = sorted.slice(start, start + pageSize);
// ── Resolve column set (columnsByFilter override → columns) ───────────────
const columns: Column<Record<string, unknown>>[] =
(resource.list.columnsByFilter && resource.list.columnsByFilter[filterKey]) ??
resource.list.columns;
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
// ── Group eyebrow ─────────────────────────────────────────────────────────
const group = groups.find((g) => g.key === resource.groupKey);
// ── Helper: build a query string preserving the other params ──────────────
function withParams(overrides: Record<string, string | number | null>): string {
const next = new URLSearchParams(url.searchParams);
for (const [k, v] of Object.entries(overrides)) {
if (v == null) next.delete(k);
else next.set(k, String(v));
}
const s = next.toString();
return s ? `${url.pathname}?${s}` : url.pathname;
}
const showNewButton = resource.form !== null && resource.ops.create !== undefined;
const hasItems = allItems.length > 0;
const hasMatches = pageItems.length > 0;
---
<section class="bs-list">
<!-- ── Page header ─────────────────────────────────────────────── -->
<header class="bs-list-header">
<div class="bs-list-heading">
{group && <p class="bs-list-eyebrow">{group.label}</p>}
<h1 class="bs-list-title">{resource.pluralLabel}</h1>
{resource.description && <p class="bs-list-desc">{resource.description}</p>}
</div>
{showNewButton && (
<a href={withParams({ new: '1', edit: null })} class="bs-list-new label-sm">
+ New {resource.singularLabel.toLowerCase()}
</a>
)}
</header>
<!-- ── Toolbar: search + filter chips ──────────────────────────── -->
{(resource.list.search || filters.length > 0) && (
<div class="bs-toolbar">
{resource.list.search && (
<form method="get" action={url.pathname} class="bs-search-form" role="search">
{/* Preserve the active filter when submitting a search */}
{filterKey !== defaultFilterKey && (
<input type="hidden" name="filter" value={filterKey} />
)}
<input
type="search"
name="q"
class="bs-search-input"
placeholder={resource.list.search.placeholder}
value={search}
aria-label={resource.list.search.placeholder}
/>
</form>
)}
{filters.length > 0 && (
<div class="bs-filters" role="tablist" aria-label="Filter">
{filters.map((f) => {
const isActive = f.key === filterKey;
const href = withParams({ filter: f.key === defaultFilterKey ? null : f.key, page: null });
return (
<a
href={href}
class:list={['bs-chip', { active: isActive }]}
role="tab"
aria-selected={isActive}
>
{f.label}
</a>
);
})}
</div>
)}
</div>
)}
<!-- ── Grid table ──────────────────────────────────────────────── -->
{hasMatches ? (
<div class="bs-grid" role="table" style={`--bs-grid-cols: ${gridTemplate}`}>
<div class="bs-grid-head" role="row">
{columns.map((col) => (
<div class="bs-grid-th" role="columnheader">{col.label}</div>
))}
</div>
{pageItems.map((item) => {
const id = Number(item.id);
return (
<a
href={withParams({ edit: id, new: null })}
class="bs-grid-row"
role="row"
aria-label={`Edit ${resource.singularLabel.toLowerCase()} ${id}`}
>
{columns.map((col) => (
<div class="bs-grid-td" role="cell">
<ListCell column={col} item={item} />
</div>
))}
</a>
);
})}
</div>
) : (
<p class="bs-list-empty">
{hasItems
? 'No items match the current filters.'
: `No ${resource.pluralLabel.toLowerCase()} yet.`}
</p>
)}
<!-- ── Pagination (only when paged) ────────────────────────────── -->
{totalPages > 1 && (
<nav class="bs-pagination" aria-label="Pagination">
{page > 1 ? (
<a class="bs-page-link" href={withParams({ page: page - 1 })}>← Previous</a>
) : (
<span class="bs-page-link disabled" aria-hidden="true">← Previous</span>
)}
<span class="bs-page-status">Page {page} of {totalPages}</span>
{page < totalPages ? (
<a class="bs-page-link" href={withParams({ page: page + 1 })}>Next →</a>
) : (
<span class="bs-page-link disabled" aria-hidden="true">Next →</span>
)}
</nav>
)}
</section>