// --------------------------------------------------------------------------- // customer-grid / copyConfig.ts // Wave-14 item 13 (contract C-COPYVIEW) — "Copy configuration from to this view". // // PURE. The modal collects a source view and a set of parts; this turns that into the ONE // `Partial` 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(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, lockedKey?: string ): Partial { const out: Partial = {}; if (!source) return out; const chosen = new Set(); 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; }