loopable / web /src /customer-grid /copyConfig.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
016c754 verified
Raw
History Blame Contribute Delete
5.82 kB
// ---------------------------------------------------------------------------
// customer-grid / copyConfig.ts
// Wave-14 item 13 (contract C-COPYVIEW) β€” "Copy configuration from <view> to this view".
//
// PURE. The modal collects a source view and a set of parts; this turns that into the ONE
// `Partial<ViewConfig>` the caller passes to `updateConfig`. It is a separate module with its
// own gate for one reason: it is the only thing in PANEL's fence this wave that writes
// DURABLE state, and the wrong answer here is silent β€” a patch that names one key too many
// overwrites a property of the target view that the user never asked about and cannot see was
// touched until they next open the thing it broke.
//
// THE RULES, in the order they matter:
//
// 1. **An ALLOW-LIST, never a spread.** The patch can only ever contain the six keys below.
// `ViewConfig` also carries `display`, `widths`, `memberPids`, `frozenCount` and
// `cohortLock`; a `{...source}` with a few `delete`s would silently start copying the
// seventh key the day somebody adds one.
// 2. **`cohortLock` is never copied, even when "Filter conditions" is chosen.** A lock is not
// a condition β€” the engine intersects it FIRST, it cannot be expressed or removed as a
// row, and the host validates it against the sets the SESSION can see
// ([[cg-cohort-set-conditions]]). Copying it would hand one view's permission boundary to
// another view by way of a checkbox labelled "Filter conditions".
// 3. **`filters` drags `filterConj` with it.** They are one answer to one question. Copy the
// tree without the root conjunction and a source view meaning "any of these three" lands
// as "all of these three" β€” same rows on screen, different set, nothing to see.
// 4. **Deep copy.** A patch holding the SOURCE's own arrays would alias two views onto one
// filter tree: editing a condition in the target would edit it in the source too, until
// something forced a reload. JSON round-trip, which every slice here survives (they are
// the wire shapes).
// 5. **Nothing chosen = `{}`.** The caller must treat an empty patch as a no-op rather than
// as "write nothing over everything".
// ---------------------------------------------------------------------------
import type { ViewConfig } from "./types";
/** The slices offered, in the modal's own order. */
export type CopyPart =
| "filters" | "sorts" | "groupBy" | "visible" | "order" | "rowHeightMode";
/**
* The offer, v1 (C-COPYVIEW). `reference/Airtable 13.png` also lists "Column widths and row
* height"; `widths` is deliberately NOT here β€” column widths are a property of how wide THIS
* reader dragged THIS view's columns, and importing them silently re-lays-out a table the
* user was not complaining about. It is one line to add if the owner asks for it.
*/
export const COPY_PARTS: readonly { key: CopyPart; label: string; blurb: string }[] = [
{ key: "filters", label: "Filter conditions",
blurb: "Which records the view shows, and how the conditions combine." },
{ key: "sorts", label: "Sorts", blurb: "The order records are listed in." },
{ key: "groupBy", label: "Groups", blurb: "The field records are bucketed by." },
{ key: "visible", label: "Hidden fields", blurb: "Which columns are shown." },
{ key: "order", label: "Field order", blurb: "Left-to-right column order." },
{ key: "rowHeightMode", label: "Row height", blurb: "Short, medium or tall rows." },
];
export const COPY_PART_KEYS: readonly CopyPart[] = COPY_PARTS.map((p) => p.key);
export function isCopyPart(value: unknown): value is CopyPart {
return typeof value === "string" && (COPY_PART_KEYS as readonly string[]).includes(value);
}
/** JSON round-trip: every slice here is a wire shape, so this is a faithful deep copy AND it
* drops any `undefined` member, which is exactly how absent is spelled on the wire. */
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
/**
* The patch. `lockedKey` β€” when given β€” is force-kept in a copied `visible` list: the identity
* column cannot be hidden, and a source view that somehow lacks it would otherwise hand the
* target a list the grid has to silently correct.
*/
export function copyViewConfig(
source: ViewConfig | undefined | null,
parts: Iterable<CopyPart | string>,
lockedKey?: string
): Partial<ViewConfig> {
const out: Partial<ViewConfig> = {};
if (!source) return out;
const chosen = new Set<CopyPart>();
for (const part of parts) if (isCopyPart(part)) chosen.add(part);
if (chosen.size === 0) return out;
if (chosen.has("filters")) {
out.filters = clone(Array.isArray(source.filters) ? source.filters : []);
// Rule 3 β€” the root conjunction rides with the tree. `undefined` is a legal value here
// (legacy views mean "and"), and writing it explicitly is how the target stops meaning
// whatever IT used to mean.
out.filterConj = source.filterConj;
}
if (chosen.has("sorts")) out.sorts = clone(Array.isArray(source.sorts) ? source.sorts : []);
if (chosen.has("groupBy")) out.groupBy = source.groupBy ?? null;
if (chosen.has("visible")) {
const visible = clone(Array.isArray(source.visible) ? source.visible : []);
// One statement, deliberately: the gate mutates a compiled LINE, and a wrapped ternary
// carries tsc's current indentation into the needle (wave-13 RECORD learning).
const keepLocked = !!lockedKey && !visible.includes(lockedKey);
out.visible = keepLocked ? [lockedKey!, ...visible] : visible;
}
if (chosen.has("order")) out.order = clone(Array.isArray(source.order) ? source.order : []);
if (chosen.has("rowHeightMode") && source.rowHeightMode)
out.rowHeightMode = source.rowHeightMode;
return out;
}