loopable / web /src /customer-grid /SwipeView.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
a878ebb verified
Raw
History Blame Contribute Delete
24.7 kB
// ---------------------------------------------------------------------------
// 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<DisplaySpec["swipe"]>;
/** 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<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 } };
// 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<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;
}) {
// "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<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];
// 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 (
<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>
);
}
/**
* 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<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>("");
// 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 (
<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) {
// 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 (
<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;