File size: 5,816 Bytes
016c754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// ---------------------------------------------------------------------------
// 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;
}