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";

// Adapt the app's manifest fields to the same form used by the core apps.
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>
  );
}