Shared UI kit
records.jsx 333 lines
Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333import React, { useState, useSyncExternalStore } from "react";
import { validate } from "./record-store.js";
import {
Page,
Header,
Button,
Stats,
Search,
EmptyState,
ErrorNotice,
} from "./ui.jsx";
import { RecordForm, ConfirmDelete } from "./forms.jsx";
import "./records.css";
function RecordEditor({ config, record, store, onClose }) {
const fields = config.fields.map((field) => {
const spec = store.inputSchema.properties[field.key];
const value = record
? (record[field.key] ?? "")
: (field.default?.() ?? "");
return {
...field,
name: field.key,
hint: field.help,
defaultValue: field.scale && value !== "" ? value / field.scale : value,
disabled: !!record && field.key === store.key,
type: field.numeric ? "number" : "text",
step: field.scale
? "any"
: [].concat(spec.type).includes("integer")
? "1"
: "any",
min:
spec.minimum !== undefined
? spec.minimum / (field.scale || 1)
: undefined,
max: spec.maximum,
inputMode: field.decimal ? "decimal" : undefined,
};
});
return (
<RecordForm
title={`${record ? "Edit" : "Add"} ${config.singular}`}
fields={fields}
wide
onClose={onClose}
prepare={(draft) => {
let data = {};
for (const field of config.fields) {
const value = draft[field.key],
spec = store.inputSchema.properties[field.key];
if (value === "" && field.key === store.key && !field.required)
continue;
if (value === "" && [].concat(spec.type).includes("null"))
data[field.key] = null;
else if (
field.numeric ||
(field.options && typeof field.options[0]?.value === "number")
) {
if (value === "") continue;
let parsed = Number(value) * (field.scale || 1);
if ([].concat(spec.type).includes("integer") && field.scale)
parsed = Math.round(parsed);
data[field.key] = parsed;
} else data[field.key] = value;
}
if (config.prepare) data = config.prepare(data, record);
validate(store.inputSchema, data);
return data;
}}
onSave={async (data) => {
if (record) await store.update(record[store.key], data);
else await store.add(data);
onClose();
}}
/>
);
}
export function RecordApp({ store, config }) {
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const [editor, setEditor] = useState(null);
const [deleting, setDeleting] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [extra, setExtra] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
async function run(operation) {
setBusy(true);
setError("");
setNotice("");
try {
return await operation();
} catch (err) {
setError(String(err.message || err));
} finally {
setBusy(false);
}
}
const rows = [...state.rows].sort(config.sort),
filters = config.filters(rows);
const visible = rows.filter(
(row) =>
(filter === "all" || config.matches(row, filter)) &&
config.search(row).toLowerCase().includes(search.toLowerCase()),
);
const selected = rows.find((row) => row[store.key] === selectedId);
const actions = {
busy,
edit: setEditor,
remove: setDeleting,
act: (row, data) =>
run(() => store.update(row[store.key], { ...row, ...data })),
open: (row) => {
setSelectedId(row[store.key]);
run(() => store.emit("item-opened", row));
},
};
return (
<Page className={`ra-app ra-${config.slug}`}>
<Header
title={store.manifest.name}
subtitle={config.subtitle}
actions={
<>
{config.extraLabel && (
<Button
disabled={!state.ready || busy}
onClick={() => setExtra("import")}
>
{config.extraLabel}
</Button>
)}
<Button
variant="primary"
disabled={!state.ready || busy}
onClick={() => setEditor("new")}
>
Add {config.singular}
</Button>
</>
}
/>
<Stats
items={config.stats(rows).map((stat) => [stat.value, stat.label])}
/>
<ErrorNotice error={error || state.error} pending={state.warning} />
{notice && (
<p className="ui-notice" role="status">
{notice}
</p>
)}
{state.offerSamples && (
<div className="ui-notice">
<div>
<strong>{config.emptyTitle}</strong>
<p className="ui-muted">{config.emptyDescription}</p>
</div>
<div className="ui-actions">
<Button
disabled={busy}
onClick={() => run(() => store.dismissSamples())}
>
Start empty
</Button>
<Button
disabled={busy}
onClick={() => run(() => store.seed(config.samples))}
>
Add sample data
</Button>
</div>
</div>
)}
<nav className="ui-toolbar" aria-label={config.navigationLabel}>
{filters.map((item) => (
<Button
key={item.id}
variant={filter === item.id ? "primary" : "ghost"}
aria-pressed={filter === item.id}
onClick={() => {
setFilter(item.id);
setSelectedId(null);
}}
>
{item.label} <span>{item.count}</span>
</Button>
))}
</nav>
<section aria-label={config.listLabel}>
<div className="ui-list-header">
<h2>
{filters.find((item) => item.id === filter)?.label ||
config.listLabel}
</h2>
<Search
value={search}
onChange={setSearch}
placeholder={config.searchPlaceholder}
/>
</div>
{!state.ready ? (
<p className="ui-muted" role="status">
Loading {config.plural}…
</p>
) : visible.length === 0 ? (
<EmptyState
title={rows.length ? "No matches" : config.emptyTitle}
action={
rows.length > 0 && (
<Button
onClick={() => {
setFilter("all");
setSearch("");
}}
>
Clear filters
</Button>
)
}
>
{rows.length
? "Try another search or view."
: `Add your first ${config.singular}.`}
</EmptyState>
) : (
<div
className={
selected && config.renderDetail ? "ui-master-detail" : ""
}
>
<div
className={`ui-records ${config.slug === "reading" ? "ra-book-grid" : ""}`}
>
{visible.map((row) => (
<React.Fragment key={row[store.key]}>
{config.renderCard(
row,
actions,
row[store.key] === selectedId,
)}
</React.Fragment>
))}
</div>
{selected &&
config.renderDetail?.(selected, {
...actions,
close: () => setSelectedId(null),
})}
</div>
)}
</section>
{editor && (
<RecordEditor
store={store}
config={config}
record={editor === "new" ? null : editor}
onClose={() => setEditor(null)}
/>
)}
{deleting && (
<ConfirmDelete
title={`Delete ${config.singular}?`}
busy={busy}
onClose={() => setDeleting(null)}
onConfirm={() =>
run(async () => {
await store.remove(deleting[store.key]);
setDeleting(null);
})
}
>
Delete “{config.title(deleting)}”?
</ConfirmDelete>
)}
{extra &&
config.renderExtra?.({
store,
onClose: () => setExtra(null),
onDone: setNotice,
})}
</Page>
);
}
export function RecordActions({ row, actions, title }) {
return (
<div className="ui-actions">
<Button
variant="ghost"
className="ra-icon-button"
disabled={actions.busy}
aria-label={`Edit ${title}`}
title="Edit"
onClick={() => actions.edit(row)}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
>
<path d="m15 5 4 4M4 20l4-1L20 7a2.8 2.8 0 0 0-4-4L4 15z" />
</svg>
</Button>
<Button
variant="ghost"
className="ra-icon-button ra-delete"
disabled={actions.busy}
aria-label={`Delete ${title}`}
title="Delete"
onClick={() => actions.remove(row)}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
>
<path d="M4 6h16M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7M14 10v7" />
</svg>
</Button>
</div>
);
}