|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| import { choiceOptions } from "./types";
|
| import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
|
|
|
|
|
| import type { SurfaceScope } from "./apiBridge";
|
| import RecordComments from "./RecordComments";
|
| import { Documents } from "./Documents";
|
| import { formatDisplay } from "./cells";
|
| import { actionHref } from "./display";
|
| import { optionTint } from "./choiceColors";
|
| import { ModeIcon } from "./icons";
|
|
|
|
|
|
|
|
|
|
|
| import "./SwipeView.css";
|
|
|
| export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;
|
|
|
|
|
| type BindingFault =
|
| | { kind: "unbound" }
|
| | { kind: "field-gone"; fieldKey: string }
|
| | { kind: "not-select"; field: Field }
|
| | { kind: "option-gone"; field: Field; missing: string[] };
|
|
|
| function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
|
| { ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
|
| if (!spec) return { ok: false, fault: { kind: "unbound" } };
|
| const field = fieldByKey.get(spec.fieldKey);
|
| if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
|
|
|
|
|
|
|
| if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
|
| const options = choiceOptions(field);
|
| const has = new Set(options.map((o) => o.toLowerCase()));
|
| const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
|
| if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
|
| return { ok: true, field, spec };
|
| }
|
|
|
|
|
| const isUndecided = (row: Row, key: string): boolean =>
|
| String(row[key] ?? "").trim() === "";
|
|
|
| export function SwipeView({
|
| rows,
|
| fields,
|
| fieldByKey,
|
| spec,
|
| cardKeys,
|
| detailKeys,
|
| scope,
|
| viewer,
|
| docs,
|
| docPayload,
|
| onDocAdd,
|
| onDocFetch,
|
| onDocDelete,
|
| titleKey,
|
| canWrite,
|
| readOnlyReason,
|
| canBind,
|
| onSpec,
|
| onSwipe,
|
| onOpen,
|
| }: {
|
| /** DISTINCT data rows from the full pipeline, overlay edits layered β the kanban's contract.
|
| * The deck re-derives from these, so a written record leaves it as soon as the optimistic
|
| * patch lands; there is no local cursor to drift out of step with the data. */
|
| rows: Row[];
|
| /** Every field, for the picker's field list. */
|
| fields: Field[];
|
| fieldByKey: Map<string, Field>;
|
| /** The stored binding, or undefined for a deck nobody has configured yet. */
|
| spec: SwipeSpec | undefined;
|
| /** The handful of visible fields the card lists under its title. */
|
| cardKeys: string[];
|
| titleKey: string;
|
| /** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
|
| canWrite: boolean;
|
| /** Stated when the deck cannot be decided β permissions, or a computed column. */
|
| readOnlyReason: string | null;
|
| /** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
|
| * view-editing permission and NOT `canWrite` β conflating the two is
|
| * [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
|
| * nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
|
| canBind: boolean;
|
| /** `undefined` DELETES the binding β absent is the honest unconfigured state, and storing a
|
| * half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
|
| onSpec: (next: SwipeSpec | undefined) => void;
|
| onSwipe: (pid: number, value: string) => void;
|
| onOpen: (pid: number) => void;
|
| /**
|
| * ββ WAVE-29 T26 (owner R9) β WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
|
| *
|
| * R9: *"the swipe CARD itself renders the record detail inline β fields, comments and
|
| * attachments β so a reviewer decides without leaving the deck."* Everything below is that,
|
| * and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
|
| * different amounts. A card must render with none of them rather than throw β the standalone
|
| * embed has no `scope`, and a deployment with no document storage serves no handlers.
|
| */
|
| /** Every field the VIEW shows, in its order β not the three-key card summary. */
|
| detailKeys?: string[];
|
| /** The surface these records live on. Comments are keyed by it; absent β no comments section,
|
| * which is the pre-existing precondition `RecordDetail` already carries. */
|
| scope?: SurfaceScope;
|
| viewer?: Viewer;
|
| /** This record's attachments, and the plumbing. Handlers absent β the section is not rendered
|
| * at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
|
| * cannot keep). */
|
| /** Keyed by pid, because a deck shows many records β `RecordDetail` takes ONE record's list
|
| * and that shape cannot serve a deck. */
|
| docs?: Record<string, CustomerDoc[]>;
|
| docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
|
| /** β EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
|
| * open record. A deck paints many records at once, so a handler bound to a single pid would
|
| * attach every reviewer's upload to whichever card happened to be open. */
|
| onDocAdd?: (
|
| pid: number,
|
| file: { name: string; mime: string; size: number; data_b64: string }
|
| ) => void;
|
| onDocFetch?: (pid: number, docId: string) => void;
|
| onDocDelete?: (pid: number, docId: string) => void;
|
| }) {
|
|
|
|
|
|
|
|
|
| const [later, setLater] = useState<Set<number>>(new Set());
|
| const deckRef = useRef<HTMLDivElement>(null);
|
|
|
| const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
|
| const boundKey = binding.ok ? binding.field.key : null;
|
|
|
| const undecided = useMemo(
|
| () => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
|
| [rows, boundKey]
|
| );
|
| const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
|
| const card = deck[0];
|
|
|
|
|
|
|
| useEffect(() => {
|
| setLater((prev) => {
|
| if (prev.size === 0) return prev;
|
| const live = new Set(undecided.map((r) => r.pid));
|
| const next = new Set([...prev].filter((pid) => live.has(pid)));
|
| return next.size === prev.size ? prev : next;
|
| });
|
| }, [undecided]);
|
|
|
| const decide = useCallback(
|
| (value: string) => {
|
| if (!card || !canWrite) return;
|
| onSwipe(card.pid, value);
|
| },
|
| [card, canWrite, onSwipe]
|
| );
|
|
|
|
|
|
|
| useEffect(() => {
|
| if (!binding.ok || !canWrite || !card) return;
|
| const onKey = (e: KeyboardEvent) => {
|
| if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
| const el = document.activeElement as HTMLElement | null;
|
|
|
| if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
|
| return;
|
| if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
|
| e.preventDefault();
|
| decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
|
| };
|
| window.addEventListener("keydown", onKey);
|
| return () => window.removeEventListener("keydown", onKey);
|
| }, [binding, canWrite, card, decide]);
|
|
|
| if (!binding.ok) {
|
| return (
|
| <div className="cg-swipe" ref={deckRef}>
|
| <SwipeBinder
|
| fault={binding.fault}
|
| fields={fields}
|
| fieldByKey={fieldByKey}
|
| spec={spec}
|
| canBind={canBind}
|
| onSpec={onSpec}
|
| />
|
| </div>
|
| );
|
| }
|
|
|
| const { field } = binding;
|
| const leftTint = optionTint(field, binding.spec.leftOption);
|
| const rightTint = optionTint(field, binding.spec.rightOption);
|
|
|
| return (
|
| <div className="cg-swipe" ref={deckRef} tabIndex={-1}>
|
| <div className="cg-swipe-bar">
|
| <span className="cg-swipe-bound">
|
| <ModeIcon mode="swipe" />
|
| Sorting into <strong>{field.label}</strong>
|
| </span>
|
| <span className="cg-swipe-left">
|
| {deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
|
| </span>
|
| {canBind && (
|
| <button
|
| type="button"
|
| className="cg-link-btn"
|
| onClick={() => onSpec(undefined)}
|
| title="Choose a different field or different options for this deck"
|
| >
|
| Change
|
| </button>
|
| )}
|
| </div>
|
| {readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
|
| {card ? (
|
| <div className="cg-swipe-deck">
|
| <button
|
| type="button"
|
| className="cg-swipe-side cg-swipe-side--left"
|
| disabled={!canWrite}
|
| style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
|
| onClick={() => decide(binding.spec.leftOption)}
|
| title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
|
| >
|
| <span aria-hidden className="cg-swipe-arrow">
|
| <svg width="16" height="16" viewBox="0 0 16 16">
|
| <path
|
| d="M11 3.2 6.2 8l4.8 4.8"
|
| fill="none"
|
| stroke="currentColor"
|
| strokeWidth="1.6"
|
| strokeLinecap="round"
|
| strokeLinejoin="round"
|
| />
|
| </svg>
|
| </span>
|
| {binding.spec.leftOption}
|
| </button>
|
| <article
|
| className="cg-swipe-card"
|
| role="button"
|
| tabIndex={0}
|
| onClick={() => onOpen(card.pid)}
|
| onKeyDown={(e) => {
|
| if (e.key !== "Enter" && e.key !== " ") return;
|
| e.preventDefault();
|
| onOpen(card.pid);
|
| }}
|
| >
|
| <h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
|
| <div className="cg-swipe-cells">
|
| {/* β WAVE-29 T26 (R9) β the RECORD's visible fields, not the three-key summary the
|
| kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
|
| been widened yet renders exactly what it rendered before. */}
|
| {(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
|
| const f = fieldByKey.get(k);
|
| if (!f) return null;
|
| const text = formatDisplay(f, card[k]);
|
| if (!text) return null;
|
| // A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
|
| // scheme guard β and every gesture that reaches it must be stopped from also
|
| // reaching the card, which is a button (the kanban card's note, same trap).
|
| const href = actionHref(f, card[k]);
|
| return (
|
| <div key={k} className="cg-swipe-cell">
|
| <span className="cg-lv-k">{f.label}</span>
|
| {href ? (
|
| <a
|
| className="cg-lv-v"
|
| href={href}
|
| target="_blank"
|
| rel="noopener noreferrer"
|
| title={text}
|
| onClick={(e) => e.stopPropagation()}
|
| onKeyDown={(e) => e.stopPropagation()}
|
| >
|
| {text}
|
| </a>
|
| ) : (
|
| <span className="cg-lv-v">{text}</span>
|
| )}
|
| </div>
|
| );
|
| })}
|
| </div>
|
| {/* β R9's other two thirds. Both stop their own events: the card is a `role="button"`
|
| that opens the record, and a click on a comment box or an upload control must not
|
| also open the modal it exists to make unnecessary (the kanban card's link trap,
|
| one surface over). */}
|
| {onDocAdd && onDocFetch && onDocDelete && (
|
| <div
|
| className="cg-swipe-docs"
|
| onClick={(e) => e.stopPropagation()}
|
| onKeyDown={(e) => e.stopPropagation()}
|
| role="presentation"
|
| >
|
| <Documents
|
| pid={Number(card.pid)}
|
| docs={docs?.[String(card.pid)] ?? []}
|
| docPayload={docPayload}
|
| onAdd={(file) => onDocAdd(card.pid, file)}
|
| onFetch={(docId) => onDocFetch(card.pid, docId)}
|
| onDelete={(docId) => onDocDelete(card.pid, docId)}
|
| />
|
| </div>
|
| )}
|
| {scope != null && (
|
| <div
|
| className="cg-swipe-comments"
|
| onClick={(e) => e.stopPropagation()}
|
| onKeyDown={(e) => e.stopPropagation()}
|
| role="presentation"
|
| >
|
| {/* β KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
|
| reuses the mounted instance across a card change and paints one record's
|
| comments under another's name until the fetch returns. */}
|
| <RecordComments
|
| key={`${scope}:${Number(card.pid)}`}
|
| scope={scope}
|
| pid={Number(card.pid)}
|
| viewer={viewer}
|
| />
|
| </div>
|
| )}
|
| {canWrite && (
|
| <button
|
| type="button"
|
| className="cg-link-btn cg-swipe-later"
|
| onClick={(e) => {
|
| e.stopPropagation();
|
| setLater((prev) => new Set(prev).add(card.pid));
|
| }}
|
| >
|
| Decide later
|
| </button>
|
| )}
|
| </article>
|
| <button
|
| type="button"
|
| className="cg-swipe-side cg-swipe-side--right"
|
| disabled={!canWrite}
|
| style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
|
| onClick={() => decide(binding.spec.rightOption)}
|
| title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
|
| >
|
| {binding.spec.rightOption}
|
| <span aria-hidden className="cg-swipe-arrow">
|
| <svg width="16" height="16" viewBox="0 0 16 16">
|
| <path
|
| d="M5 3.2 9.8 8 5 12.8"
|
| fill="none"
|
| stroke="currentColor"
|
| strokeWidth="1.6"
|
| strokeLinecap="round"
|
| strokeLinejoin="round"
|
| />
|
| </svg>
|
| </span>
|
| </button>
|
| </div>
|
| ) : (
|
| // The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
|
| // between "the deck is finished" and "the filter hid everything", and the two want
|
| // different next actions. `later` is disclosed rather than quietly subtracted.
|
| <div className="cg-mode-empty cg-swipe-empty">
|
| {later.size > 0 ? (
|
| <>
|
| <p>
|
| {later.size.toLocaleString()}{" "}
|
| {later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
|
| in this view is missing a <strong>{field.label}</strong>.
|
| </p>
|
| <button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
|
| Bring them back
|
| </button>
|
| </>
|
| ) : (
|
| <p>
|
| Every record in this view already has a <strong>{field.label}</strong>. Widen the
|
| view's filters to sort more.
|
| </p>
|
| )}
|
| </div>
|
| )}
|
| </div>
|
| );
|
| }
|
|
|
| |
| |
| |
| |
|
|
| function SwipeBinder({
|
| fault,
|
| fields,
|
| fieldByKey,
|
| spec,
|
| canBind,
|
| onSpec,
|
| }: {
|
| fault: BindingFault;
|
| fields: Field[];
|
| fieldByKey: Map<string, Field>;
|
| spec: SwipeSpec | undefined;
|
| canBind: boolean;
|
| onSpec: (next: SwipeSpec | undefined) => void;
|
| }) {
|
| const selects = useMemo(
|
| () => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
|
| [fields]
|
| );
|
| const [fieldKey, setFieldKey] = useState<string>(() => {
|
| if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
|
| return selects[0]?.key ?? "";
|
| });
|
| const chosen = fieldByKey.get(fieldKey);
|
| const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
|
| const [left, setLeft] = useState<string>("");
|
| const [right, setRight] = useState<string>("");
|
|
|
|
|
|
|
|
|
| useEffect(() => {
|
| setLeft(options[0] ?? "");
|
| setRight(options.find((o) => o !== options[0]) ?? "");
|
| }, [options]);
|
|
|
| const message = ((): string => {
|
| switch (fault.kind) {
|
| case "field-gone":
|
| return "The field this deck sorted into has been deleted. Pick another one.";
|
| case "not-select":
|
| return `β${fault.field.label}β is no longer a single-select, so it has no options to`
|
| + " sort into. Pick another field.";
|
| case "option-gone":
|
| return `${fault.missing.map((m) => `β${m}β`).join(" and ")} ${
|
| fault.missing.length === 1 ? "is" : "are"
|
| } no longer ${fault.missing.length === 1 ? "an option" : "options"} on β${
|
| fault.field.label
|
| }β. Pick the sides again.`;
|
| default:
|
| return "Sort records one at a time into two options of a single-select field.";
|
| }
|
| })();
|
|
|
| if (!canBind) {
|
| return (
|
| <div className="cg-mode-empty">
|
| <p>{message}</p>
|
| <p>Its creator or an admin can set this view up.</p>
|
| </div>
|
| );
|
| }
|
| if (selects.length === 0) {
|
|
|
|
|
| return (
|
| <div className="cg-mode-empty">
|
| <p>
|
| A swipe deck sorts into a single-select field with at least two options. This database
|
| does not have one yet β add a single-select column, then come back.
|
| </p>
|
| </div>
|
| );
|
| }
|
| const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
|
| return (
|
| <div className="cg-swipe-setup">
|
| <p className="cg-swipe-setup-note">{message}</p>
|
| <label className="cg-swipe-setup-row">
|
| <span>Sort into</span>
|
| <select
|
| className="cg-select"
|
| value={fieldKey}
|
| onChange={(e) => setFieldKey(e.target.value)}
|
| >
|
| {/* Every option carries an explicit `value`: a <select> whose value names nothing
|
| renders its FIRST option and reports a choice the user never made
|
| ([[cg-condition-builder-items]]). */}
|
| {selects.map((f) => (
|
| <option key={f.key} value={f.key}>
|
| {f.label}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| <div className="cg-swipe-setup-sides">
|
| <label className="cg-swipe-setup-row">
|
| <span>Swipe left</span>
|
| <select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
|
| {options.map((o) => (
|
| <option key={o} value={o}>
|
| {o}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| <label className="cg-swipe-setup-row">
|
| <span>Swipe right</span>
|
| <select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
|
| {options.map((o) => (
|
| <option key={o} value={o}>
|
| {o}
|
| </option>
|
| ))}
|
| </select>
|
| </label>
|
| </div>
|
| {!ready && left && right && (
|
| // Stated, not silently refused: both engines DROP a binding whose sides are the same
|
| // option, so a Save that looked like it worked would simply not persist.
|
| <p className="cg-swipe-setup-warn">
|
| The two sides must be different options β otherwise both gestures do the same thing.
|
| </p>
|
| )}
|
| <button
|
| type="button"
|
| className="cg-btn cg-btn--primary"
|
| disabled={!ready}
|
| onClick={() =>
|
| chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
|
| }
|
| >
|
| Start sorting
|
| </button>
|
| </div>
|
| );
|
| }
|
|
|
| export default SwipeView;
|
| |