TablePage

tcmts.studio.TablePage · #6zovs3h4 · term · runs on the server

is an async component

A table's rows: newest first (or sorted by a column: sort, desc), searched (q, anywhere in a row), filtered (where: fields equal to values, as find has them), 50 at a time (page), each cell shown as its type reads (dates as when, numbers aligned, nothing as nothing, objects in one short line), its columns saying their types and which are unique or indexed. A row asked for (row; "new", a new one) is above them to edit, as JSON, saved as the table's own writes are; the rows as they're shown download as JSON or CSV. Rows stored as an older row type say so, with a button migrating them.

import {
  dataPage, dataTables, declaredTables, readRow, rowFields, sampleOf, tableSchemaOf, toJson,
} from "#codebase/tcmts/cli/data_pages";
import { ago, at, codeHref, servedCodebase, shareNow } from "#codebase/tcmts/share";
import { AddFilter, ExportRows, MigrateButton, RowEditor, StudioFrame, studioShare } from "#codebase/tcmts/studio";

export async function TablePage({
  table,
  q,
  sort,
  desc,
  page,
  row,
  where,
}: {
  table: string;
  q: string;
  sort: string;
  desc: boolean;
  page: number;
  row: string;
  where: Record<string, string>;
}) {
  studioShare();
  const s = shareNow();
  if (!s.signedIn)
    return (
      <StudioFrame tab="data" title="Sign in">
        {null}
      </StudioFrame>
    );
  const cb = servedCodebase().cb;
  const t = dataTables(cb).find((x) => x.table === table);
  const declared = declaredTables(cb).get(table);
  const schema = tableSchemaOf(cb, table);
  const per = 50;
  const d = dataPage(cb, table, {
    offset: page * per,
    limit: per,
    ...(q ? { search: q } : {}),
    ...(Object.keys(where).length ? { where } : {}),
    ...(sort ? { sort: { field: sort, desc } } : {}),
  });
  const href = (p: {
    q?: string;
    sort?: string;
    desc?: boolean;
    page?: number;
    row?: string;
    where?: Record<string, string>;
  }) => {
    const u = new URLSearchParams();
    const qq = p.q ?? q;
    const so = p.sort ?? sort;
    const de = p.desc ?? desc;
    const pg = p.page ?? 0;
    if (qq) u.set("q", qq);
    if (so) u.set("sort", so);
    if (so && de) u.set("desc", "1");
    for (const [f, v] of Object.entries(p.where ?? where)) u.set(`f.${f}`, v);
    if (pg) u.set("page", String(pg));
    if (p.row) u.set("row", p.row);
    return at(`/data/${encodeURIComponent(table)}${u.size ? `?${u}` : ""}`);
  };
  const here = href({ page });
  const own = s.cb === cb;
  const shown = row && row !== "new" ? readRow(cb, table, row) : undefined;
  const editing = own && !!schema && (row === "new" || !!shown);
  const typeOf = (f: string) => d.fields.find((x) => x.name === f)?.type ?? "";
  // (a cell as its type reads)
  const cell = (f: string, j: unknown, text: string) => {
    if (j === undefined || j === null) return <span className="muted">—</span>;
    if (typeof j === "boolean") return j ? "✓" : <span className="muted">✗</span>;
    // (a date as when, to the minute, in UTC: the same for every row)
    if (/^Date\??$/.test(typeOf(f)) && typeof j === "string")
      return <span title={`${j}, ${ago(Date.parse(j))}`}>{j.slice(0, 16).replace("T", " ")}</span>;
    // (a list of plain values, as a list)
    if (
      Array.isArray(j) &&
      j.every((x) => x === null || ["string", "number", "boolean"].includes(typeof x))
    )
      return j.length ? j.map(String).join(", ") : <span className="muted">none</span>;
    if (typeof j === "object") return <span className="muted">{text}</span>;
    return text;
  };
  const filtered = Object.keys(where).length > 0 || !!q;
  return (
    <StudioFrame tab="data" title={`${table} · data`}>
      <div className="crumbs">
        <a href={at("/data")}>Data</a> <span className="muted">/</span>
      </div>
      <div className="pagehead">
        <h2 className="title mono">{table}</h2>
        <span className="muted">
          {filtered
            ? `${d.total} of ${t?.rows ?? 0} rows`
            : `${d.total} row${d.total === 1 ? "" : "s"}`}
          {t?.path ? (
            <>
              {", declared by "}
              <a className="mono" href={codeHref(t.path)}>
                {t.path}
              </a>
            </>
          ) : (
            ", declared by nothing now"
          )}
        </span>
        <span className="right actions">
          <form action={at(`/data/${encodeURIComponent(table)}`)} method="get">
            {Object.entries(where).map(([f, v]) => (
              <input key={f} type="hidden" name={`f.${f}`} value={v} />
            ))}
            <input
              className="field"
              type="search"
              name="q"
              defaultValue={q}
              placeholder="Find in its rows"
              aria-label="Find in its rows"
            />
          </form>
          {own && d.total > 0 && (
            <ExportRows table={table} q={q} where={where} sort={sort} desc={desc} />
          )}
          {own && schema && row !== "new" && (
            <a className="btn small primary" href={href({ page, row: "new" })}>
              New row
            </a>
          )}
        </span>
      </div>
      <div className="filters datafilters">
        {Object.entries(where).map(([f, v]) => {
          const rest = { ...where };
          delete rest[f];
          return (
            <span key={f} className="datafilter">
              <span className="mono">{`${f} = ${v}`}</span>
              <a href={href({ where: rest })} aria-label={`No longer ${f} = ${v}`}>
                ×
              </a>
            </span>
          );
        })}
        {d.fields.length > 0 && <AddFilter fields={d.fields.map((f) => f.name)} href={here} />}
      </div>
      {!!t?.older && (
        <div className="notice" style={{ marginBottom: 16 }}>
          {`${t.older} row${t.older === 1 ? " is" : "s are"} stored as an older row type: reading one migrates it, or all at once. `}
          {own && <MigrateButton table={table} />}
        </div>
      )}
      {row && (
        <div className="box">
          <div className="boxhead">
            <b className="mono">{row === "new" ? "A new row" : row}</b>
            {shown && (
              <span className="muted">
                {`written ${ago(shown.updated)}${declared && shown.schema !== declared.schema ? ", as an older row type" : ""}`}
              </span>
            )}
            {!editing && (
              <a className="right" href={here}>
                Close
              </a>
            )}
          </div>
          {editing ? (
            <RowEditor
              table={table}
              id={row === "new" ? "" : row}
              initial={`${JSON.stringify(toJson(row === "new" ? sampleOf(schema!.s, schema!.defs) : shown!.value), null, 2)}\n`}
              fields={rowFields(schema!)}
              back={here}
              rowHref={here}
            />
          ) : shown ? (
            <pre className="code">{JSON.stringify(toJson(shown.value), null, 2)}</pre>
          ) : (
            <div className="empty">{`${table} has no row ${row}.`}</div>
          )}
        </div>
      )}
      <div className="box datagrid">
        {d.rows.length ? (
          <table className="grid">
            <thead>
              <tr>
                {["id", ...d.fields.map((f) => f.name), "updated"].map((f) => {
                  const ty = typeOf(f);
                  const marks = [
                    ...(declared?.unique.includes(f) ? ["unique"] : []),
                    ...(declared?.index.includes(f) ? ["indexed"] : []),
                  ];
                  return (
                    <th key={f} className={/^number\??$/.test(ty) ? "num" : ""}>
                      <a
                        href={href({ sort: f, desc: sort === f ? !desc : f === "updated" })}
                        style={{ color: "inherit" }}
                      >
                        {f}
                        {sort === f ? (desc ? " ↓" : " ↑") : ""}
                      </a>
                      {(ty || marks.length > 0) && (
                        <span className="coltype" title={ty}>
                          {[ty, ...marks].filter(Boolean).join(" · ")}
                        </span>
                      )}
                    </th>
                  );
                })}
              </tr>
            </thead>
            <tbody>
              {d.rows.map((r) => {
                const j = (r.json ?? {}) as Record<string, unknown>;
                return (
                  <tr
                    key={r.id}
                    className={`${r.older ? "older" : ""}${r.id === row ? " on" : ""}`}
                  >
                    <td>
                      <a href={href({ page, row: r.id })}>{r.id}</a>
                    </td>
                    {d.fields.map((f) => (
                      <td
                        key={f.name}
                        className={`cellv${typeof j[f.name] === "number" ? " num" : ""}`}
                        title={r.cells[f.name]}
                      >
                        <a className="cellink" href={href({ page, row: r.id })} tabIndex={-1}>
                          {cell(f.name, j[f.name], r.cells[f.name] ?? "")}
                        </a>
                      </td>
                    ))}
                    <td className="muted" title={new Date(r.updated).toISOString()}>
                      {ago(r.updated)}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        ) : (
          <div className="empty">
            {filtered
              ? "No row is like that."
              : schema && own
                ? "No rows yet: New row adds one."
                : "No rows yet."}
          </div>
        )}
      </div>
      {d.total > per && (
        <div className="actions" style={{ marginTop: 12 }}>
          {page > 0 && (
            <a className="btn small" href={href({ page: page - 1 })}>
              Previous
            </a>
          )}
          <span className="muted">{`${page * per + 1}–${Math.min(d.total, (page + 1) * per)} of ${d.total}`}</span>
          {(page + 1) * per < d.total && (
            <a className="btn small" href={href({ page: page + 1 })}>
              Next
            </a>
          )}
        </div>
      )}
    </StudioFrame>
  );
}

uses

used by