loopable / web /src /customer-grid /clipboard.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
f5ffed6 verified
Raw
History Blame Contribute Delete
11.8 kB
import { choiceOptions, isDerivedLink, isMachineWritten, ratingMax } from "./types";
import type { Field } from "./types";
function choiceMatch(raw: string, allowed: string[]): string | undefined {
const wanted = raw.trim().toLowerCase();
return allowed.find((value) => value.trim().toLowerCase() === wanted);
}
function numericValue(raw: string): string | undefined {
const clean = raw.trim().replace(/[$,%\s]/g, "");
if (clean === "") return "";
const value = Number(clean);
return Number.isFinite(value) ? String(value) : undefined;
}
/**
* Convert one clipboard cell into the overlay store's string contract. `undefined` means the
* paste is invalid and the caller must reject the whole operation; an empty string is a valid
* clear. Choice fields always return their canonical declared spelling.
*/
export function coerceClipboardValue(
field: Field,
raw: string,
allowedChoices: string[] = choiceOptions(field)
): string | undefined {
// ⭐ OWNER ITEM 4 (2026-08-06) β€” THE PASTE DOOR, which the type switch below structurally
// cannot cover. This module's own design note is that a paste never opens an editor, so a cell
// that is read-only by NATURE and absent from the switch is uneditable by keyboard and
// overwritable by Ctrl+V. Every case below is a TYPE; `isMachineWritten` is not type-based β€” an
// automation-filled column is an ordinary `text` field wearing a tag β€” so the Instagram preset
// columns fell straight through it. `grid_events` refuses the write either way, but a
// multi-cell paste would paint optimistically across them and then bounce, which reads to the
// person doing it as the app losing their data rather than as a wall.
if (isMachineWritten(field)) return undefined;
// ⭐ 2026-08-07 β€” the DERIVED half of `link`, refused for the same reason and by the same
// shape: it is read-only per FIELD, not per type, so the switch below structurally cannot see
// it. The server rewrites the cell on the next refresh pass, so a pasted value would paint,
// survive a moment, and vanish β€” the failure this whole function exists to prevent.
if (isDerivedLink(field)) return undefined;
const value = raw.replace(/\r/g, "");
const trimmed = value.trim();
switch (field.type) {
case "formula":
case "created_time":
case "status":
// ⭐ 2026-08-07 β€” a ROLLUP cell is an aggregate the host computed. Same argument as
// `automation` below: a paste would be overwritten by the next refresh with nothing on any
// surface saying so.
case "rollup":
// C5-AUTOFIELD (wave 18) β€” an automation cell is what the last RUN wrote, so a paste into
// one is refused HERE and not only by the renderer. `READONLY_CELL_TYPES` decides what
// gets an editor; a paste never opens one, so a type that is read-only by nature and
// absent from this list would be uneditable by keyboard and overwritable by Ctrl+V β€”
// and the next run would silently replace whatever landed.
case "automation":
return undefined;
case "checkbox": {
if (trimmed === "") return "";
const key = trimmed.toLowerCase();
if (["1", "true", "yes", "y", "on", "checked", "x"].includes(key)) return "1";
if (["0", "false", "no", "n", "off", "unchecked"].includes(key)) return "";
return undefined;
}
case "int":
case "currency":
case "pct":
return numericValue(value);
case "rating": {
if (trimmed === "") return "";
const n = Number(trimmed);
return Number.isInteger(n) && n >= 1 && n <= ratingMax(field)
? String(n)
: undefined;
}
case "select":
case "user":
return trimmed === "" ? "" : choiceMatch(trimmed, allowedChoices);
case "multiselect": {
if (trimmed === "") return "";
const out: string[] = [];
const seen = new Set<string>();
for (const part of value.split(/[,\n]/)) {
if (!part.trim()) continue;
const match = choiceMatch(part, allowedChoices);
if (!match) return undefined;
const key = match.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
out.push(match);
}
}
return out.length ? out.join(",") : "";
}
/**
* ⭐ WAVE-26 ITEM 3 β€” a pasted date is NORMALISED to the stored contract, not stored as
* whatever text happened to parse.
*
* It used to `return trimmed` for anything `Date.parse` accepted, which meant pasting a
* DISPLAY string put a display string in the cell: `8/5/2026` from Excel, or β€” after item 3
* changed the rendering β€” `Aug 5, 2026` copied out of this very grid. The cell then LOOKED
* right (an unparseable date renders verbatim) while the stored value had stopped being a
* date: it sorts as text, no date filter matches it, and the JSON export carries the browser
* locale's spelling of somebody's calendar.
*
* ⚠ AN ALREADY-CANONICAL VALUE IS LEFT ALONE, and that guard is the load-bearing half.
* `new Date("2026-08-05")` is UTC midnight, so re-deriving `YYYY-MM-DD` from its LOCAL parts
* would move it to the 4th for every viewer west of Greenwich β€” a normaliser that corrupts
* exactly the values that needed no normalising. Anything already starting with an ISO day
* (bare, or carrying a time) passes through untouched.
*
* A display string, by contrast, was written in the reader's own clock, so its LOCAL parts
* are the day they meant β€” never `toISOString()`, which would shift it back the other way.
*/
case "date": {
if (trimmed === "") return "";
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed;
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return undefined;
const pad = (n: number) => String(n).padStart(2, "0");
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
}
default:
return value;
}
}
/** The product deliberately supports vertical field paste, never a cross-field matrix. */
export function isSingleColumnClipboard(values: readonly (readonly string[])[]): boolean {
return values.length > 0 && values.every((row) => row.length === 1);
}
export interface GridCopyProvenance {
fieldKey: string | null;
/** The native copy event follows the grid key event; this short arm identifies that one event. */
armedUntil: number;
}
export const EMPTY_GRID_COPY_PROVENANCE: GridCopyProvenance = {
fieldKey: null,
armedUntil: 0,
};
export function markGridCopy(
fieldKey: string | null,
now: number
): GridCopyProvenance {
return { fieldKey, armedUntil: now + 1_000 };
}
/**
* Preserve provenance for the native copy event belonging to the grid shortcut. Any later
* document copy is external to the grid and clears it, so stale source metadata cannot block
* an unrelated paste.
*/
export function observeCopyEvent(
provenance: GridCopyProvenance,
now: number
): GridCopyProvenance {
return now <= provenance.armedUntil
? { ...provenance, armedUntil: 0 }
: EMPTY_GRID_COPY_PROVENANCE;
}
export interface FieldPastePatch {
pid: number;
value: string;
}
/**
* The selected RECTANGLE, in glide's own coordinates (`GridSelection.current.range`).
* `width`/`height` are counts of columns/rows, so a single clicked cell is 1Γ—1.
*/
export interface PasteSelection {
x: number;
y: number;
width: number;
height: number;
}
/**
* Wave-15 item 3 (R8) β€” HOW MANY ROWS a paste writes.
*
* The defect this replaces: the target rows were walked down from the anchor for exactly
* `values.length` rows and THE SELECTION WAS NEVER CONSULTED. So copying one cell, selecting
* fifty rows and pasting wrote ONE β€” the fifty-row selection was painted, obeyed by nothing, and
* the user had no way to tell the difference between "it filled" and "it filled the first row"
* without scrolling.
*
* R8: the clipboard TILES TO FILL the selection. A pattern of K repeats top-to-bottom until N
* rows are covered (`planFieldPaste` takes `values[i % K]`), and N need not be a multiple of K β€”
* Excel refuses that, Airtable tiles, and the owner asked for tiling. A clipboard LARGER than
* the selection is not truncated either: it pastes from the anchor downward, past the selection
* end, because the rows the user copied are the thing they chose and silently dropping the tail
* would be the same class of quiet lie as the bug above.
*
* ⚠ `target` IS the selection origin whenever there is a selection β€” glide's `onPasteInternal`
* computes `target = [current.range.x, current.range.y]` (data-editor.tsx:3361), MEASURED, not
* assumed. The equality guards below are therefore normally trivially true; they earn their
* place by covering glide's OTHER two target branches (a whole-column or whole-row selection
* with no `current`), where `range` is undefined and the honest answer is today's behaviour.
*/
export function pasteRowCount(
clipboardRows: number,
selection: PasteSelection | undefined,
target: { col: number; row: number }
): number {
if (clipboardRows <= 0) return 0;
// No rectangle to fill: paste the clipboard as it comes, from the anchor down.
if (!selection) return clipboardRows;
// Same-field VERTICAL paste only, exactly as today (`isSingleColumnClipboard` is the other
// half). A selection spanning columns describes a matrix this product deliberately refuses,
// so filling it would invent a policy nobody asked for.
if (selection.width !== 1) return clipboardRows;
// The paste has to be landing IN the selection for the selection to mean anything.
if (selection.x !== target.col || selection.y !== target.row) return clipboardRows;
return Math.max(clipboardRows, selection.height);
}
/**
* The whole paste policy as a pure, atomic plan. CustomerGrid supplies the actual target row
* pids (null for a group/header row) β€” `pasteRowCount` of them β€” then applies the returned
* patches only when every value passed. `null` means apply nothing.
*
* ⚠ ONE PATCH PER TARGET ROW, not per clipboard row (wave-15 R8). The clipboard is a PATTERN
* that repeats: row i takes `values[i % values.length]`. With one copied cell and fifty targets
* that is fifty identical patches; with a two-row pattern and seven targets it is
* A B A B A B A.
*/
export function planFieldPaste({
field,
sourceFieldKey,
editable,
values,
targetPids,
allowedChoices,
}: {
field: Field;
sourceFieldKey: string | null;
editable: boolean;
values: readonly (readonly string[])[];
targetPids: readonly (number | null)[];
allowedChoices?: string[];
}): FieldPastePatch[] | null {
if (!editable || !isSingleColumnClipboard(values)) return null;
if (sourceFieldKey && sourceFieldKey !== field.key) return null;
// ⚠ This was `!==` until wave 15, and the change is deliberate rather than a loosening.
// FEWER targets than clipboard rows still refuses β€” that would drop copied rows on the floor
// silently. MORE is now legal, because that IS the tile-to-fill case. The null test is
// untouched and is what keeps the whole operation ATOMIC: a selection running past the last
// record, or over a group header, refuses everything rather than half-writing.
if (targetPids.length < values.length || targetPids.some((pid) => pid == null))
return null;
const patches: FieldPastePatch[] = [];
for (let index = 0; index < targetPids.length; index += 1) {
const value = coerceClipboardValue(
field, values[index % values.length][0], allowedChoices);
const pid = targetPids[index];
if (value === undefined || pid == null) return null;
patches.push({ pid, value });
}
return patches;
}