| // --------------------------------------------------------------------------- | |
| // 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; | |
| } | |