// --------------------------------------------------------------------------- // customer-grid / SwipeView.tsx // ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) — the SWIPE deck. // // One record at a time, decided left or right into two options of ONE // single-select. R2 fixes the shape and it is narrow on purpose: // · SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding. // · the deck holds only the records whose bound field is EMPTY — a record // that already has a value has been decided, and re-asking is not triage. // · a swipe writes through the NORMAL cell door (`onSwipe`, which the host // maps to the same `patchAndRecord` a kanban card move uses), so undo, // echo-suppression and the permission wall all hold without being // re-implemented here. // // Honesty rules carried from the other modes: // · nothing is silently hidden — the deck states what is left, and the // empty state names the bound field rather than saying "all done". // · a lost binding is SHOWN, never repaired by guessing. `_clean_display` // validates that `fieldKey` names a field the table HAS and stops there; // it cannot know the field is still a select, or that the two options are // still in its vocabulary (its docstring draws that line explicitly). So // those three losses land here, and each one says what happened and // re-opens the picker — the `viewModes.tsx:190-194` rule, which exists // because the wave-7 trap was a picker silently falling back to its first // option. // --------------------------------------------------------------------------- import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { choiceOptions } from "./types"; import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types"; // ⚠ `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is // loaded here) — the same import `RecordComments` takes for the same prop. 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"; // ⭐ W29-T73 — the line its three siblings all have, and the one this file shipped without. // `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered // unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right — // a partially-styled surface reads as a design choice, which is why nobody reported it and no // gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]). import "./SwipeView.css"; export type SwipeSpec = NonNullable; /** The three ways a stored binding can rot that the host cannot see (see the header note). */ 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): { 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 } }; // R2 — SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET, // so "write this option" would mean append-or-replace, and a triage gesture that sometimes // adds and sometimes overwrites is two gestures wearing one button. 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 }; } /** The bound cell is EMPTY — the one predicate that decides deck membership (R2). */ 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; /** 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; 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; }) { // "Later" — session-only, never stored. A deck with no way past a record you cannot decide // blocks on that record forever, so this is navigation WITHIN the deck rather than a third // gesture: it writes nothing, survives no reload, and the count is disclosed in the empty // state rather than quietly shrinking the deck (rule 8b). const [later, setLater] = useState>(new Set()); const deckRef = useRef(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]; // A record decided elsewhere (the grid, another user's echo) must not stay "later" forever — // otherwise the disclosed skip count drifts away from what is actually on the deck. 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] ); // ← / → decide, and they are the SAME two actions the buttons are rather than a second code // path: the whole point of the mode is that one hand can clear a queue. 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; // Never steal an arrow key from a text field or a native control the user is inside. 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 (
); } const { field } = binding; const leftTint = optionTint(field, binding.spec.leftOption); const rightTint = optionTint(field, binding.spec.rightOption); return (
Sorting into {field.label} {deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort {canBind && ( )}
{readOnlyReason &&
{readOnlyReason}
} {card ? (
onOpen(card.pid)} onKeyDown={(e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); onOpen(card.pid); }} >

{String(card[titleKey] ?? "")}

{/* ⭐ 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 ( ); })}
{/* ⭐ 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 && (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="presentation" > onDocAdd(card.pid, file)} onFetch={(docId) => onDocFetch(card.pid, docId)} onDelete={(docId) => onDocDelete(card.pid, docId)} />
)} {scope != null && (
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. */}
)} {canWrite && ( )}
) : ( // 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.
{later.size > 0 ? ( <>

{later.size.toLocaleString()}{" "} {later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else in this view is missing a {field.label}.

) : (

Every record in this view already has a {field.label}. Widen the view's filters to sort more.

)}
)}
); } /** * The binding picker: field + two options. It is also the surface every LOST binding lands on, * with the loss stated above it — one place that answers "what is this deck for", rather than an * error screen beside a separate setup screen. */ function SwipeBinder({ fault, fields, fieldByKey, spec, canBind, onSpec, }: { fault: BindingFault; fields: Field[]; fieldByKey: Map; 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(() => { 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(""); const [right, setRight] = useState(""); // The two option selects follow the FIELD. Kept in an effect rather than derived so the user's // pick survives re-renders, and reset whenever the field changes — carrying an option from the // previous field would offer a value the new field cannot hold. 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 (

{message}

Its creator or an admin can set this view up.

); } if (selects.length === 0) { // Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the // requirement is what turns a dead end into a next action. return (

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.

); } const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase(); return (

{message}

{!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.

The two sides must be different options — otherwise both gestures do the same thing.

)}
); } export default SwipeView;