Related items panel
The records linked to a record, with a picker to add or remove a link. It loads its data on the server through the block's client.
pnpm dev:example) to see it.Add the components
terminal npx shadcn add badge button card empty field selectInstall the glue
Install the block with
npx better-supabase add relations. The glue items are copied into your app, so edit them as you need.None: the page calls the block's client on the server.
Write the component
React and shadcn
View in the examplerelated-items-panel.tsx /** Customers linked to this one (the `relates` kind), and a picker to link more. */ export function RelatedItemsPanel({ customerId, relations, canManage, }: { customerId: string; relations: Loaded<Relations>; canManage: boolean; }) { const t = useExtracted("customers"); return ( <Card size="sm" data-recipe="related-items-panel"> <CardHeader> <CardTitle>{t("Related customers")}</CardTitle> <CardDescription> {t("Link customers that belong together, such as a parent company.")} </CardDescription> </CardHeader> <CardContent className="flex flex-col gap-4"> {relations.kind === "error" ? ( <RecordLoadError title={t("The related customers didn't load")} /> ) : ( <> {relations.data.links.length === 0 ? ( <Empty className="border"> <EmptyHeader> <EmptyMedia variant="icon"> <LinkIcon /> </EmptyMedia> <EmptyTitle>{t("No related customers")}</EmptyTitle> <EmptyDescription> {canManage ? t("Pick a customer below to link it.") : t("Nobody linked a customer yet.")} </EmptyDescription> </EmptyHeader> </Empty> ) : ( <RelatedList customerId={customerId} links={relations.data.links} canManage={canManage} /> )} {canManage ? ( <LinkForm customerId={customerId} options={relations.data.options.filter( (option) => !relations.data.links.some( (link) => link.subjectType === "customer" && link.subjectId === option.id, ), )} /> ) : null} </> )} </CardContent> </Card> ); } function RelatedList({ customerId, links, canManage, }: { customerId: string; links: readonly RelatedCustomer[]; canManage: boolean; }) { const t = useExtracted("customers"); const errorMessage = useErrorMessage(); const unlink = useAction(unlinkCustomer, { onSuccess: () => { toast.success(t("Link removed")); }, onError: (error) => { toast.error(errorMessage(error)); }, }); return ( <ul className="flex flex-col divide-y" data-testid="related-customers"> {links.map((link) => ( <li key={link.id} className="flex items-center justify-between gap-2 py-2" > <div className="flex min-w-0 items-center gap-2"> {link.subjectType === "customer" && link.name !== null ? ( <Link href={`/customers/${link.subjectId}`} className="truncate text-sm font-medium underline-offset-4 transition-colors hover:underline" > {link.name} </Link> ) : ( <span className="truncate text-sm text-muted-foreground"> {t("A record you can't open")} </span> )} {link.kind === "relates" ? null : ( <Badge variant="outline">{link.kind}</Badge> )} </div> {canManage ? ( <Button size="icon-sm" variant="ghost" aria-label={t("Remove link")} disabled={unlink.pendingInputs.some( (input) => !(input instanceof FormData) && input.subjectId === link.subjectId, )} onClick={() => { void unlink.run({ customerId, kind: link.kind, subjectType: link.subjectType, subjectId: link.subjectId, }); }} > <UnlinkIcon /> </Button> ) : null} </li> ))} </ul> ); } function LinkForm({ customerId, options, }: { customerId: string; options: readonly { readonly id: string; readonly name: string }[]; }) { const t = useExtracted("customers"); const fieldId = useId(); const errorMessage = useErrorMessage(); const [targetId, setTargetId] = useState<string | null>(null); const link = useAction(linkCustomer, { onSuccess: () => { setTargetId(null); toast.success(t("Customer linked")); }, }); const items = options.map((option) => ({ value: option.id, label: option.name, })); if (items.length === 0) { return null; } return ( <form className="flex items-end gap-2" onSubmit={(event) => { event.preventDefault(); if (targetId) { void link.run({ customerId, targetId }); } }} > <Field data-invalid={link.error ? true : undefined}> <FieldLabel htmlFor={`${fieldId}-related`}> {t("Link a customer")} </FieldLabel> <Select value={targetId} items={items} onValueChange={(value) => { setTargetId(value); }} > <SelectTrigger id={`${fieldId}-related`} className="w-full"> <SelectValue placeholder={t("Pick a customer")} /> </SelectTrigger> <SelectContent> {items.map((item) => ( <SelectItem key={item.value} value={item.value}> {item.label} </SelectItem> ))} </SelectContent> </Select> {link.error ? ( <FieldError>{errorMessage(link.error)}</FieldError> ) : null} </Field> <Button type="submit" variant="outline" disabled={!targetId || link.pending} > {t("Link")} </Button> </form> ); } /** The related customers card while its rows load. */ export function RelatedItemsPanelSkeleton() { return <RecordCardSkeleton rows={2} />; }