Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- platform/aios_grid.py +0 -0
- web/src/customer-grid/useGridColumns.ts +422 -391
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v51",
|
| 6 |
"sha": "5740d20",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v52 (8263493)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v52",
|
| 6 |
+
"sha": "8263493",
|
| 7 |
+
"date": "2026-08-23",
|
| 8 |
+
"subject": "release v52"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v51",
|
| 12 |
"sha": "5740d20",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v52 (8263493)
|
platform/aios_grid.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/useGridColumns.ts
CHANGED
|
@@ -1,391 +1,422 @@
|
|
| 1 |
-
import { useCallback, useMemo } from "react";
|
| 2 |
-
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
-
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
-
import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
|
| 5 |
-
import { typeIconName } from "./iconShapes";
|
| 6 |
-
import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
|
| 7 |
-
import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
|
| 8 |
-
import type { ControlTone } from "./theme";
|
| 9 |
-
|
| 10 |
-
const DEFAULT_WIDTH = 150;
|
| 11 |
-
|
| 12 |
-
/**
|
| 13 |
-
* Owner item 16 β measure header text in GLIDE'S OWN HEADER FONT.
|
| 14 |
-
*
|
| 15 |
-
* β NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
|
| 16 |
-
* "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
|
| 17 |
-
* font under-measures, under-truncates, and leaves the label running under the (i) β the exact
|
| 18 |
-
* defect this item is about, surviving a fix that looked correct in the diff.
|
| 19 |
-
*
|
| 20 |
-
* β β Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
|
| 21 |
-
* **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
|
| 22 |
-
* exactly the filtered/sorted/grouped columns β the ones the owner is looking at. Hence TWO
|
| 23 |
-
* cached contexts, and both font strings are read off the theme layer rather than retyped here:
|
| 24 |
-
* a literal in this file is a copy that can drift from the one glide actually paints with, which
|
| 25 |
-
* is the whole failure mode above.
|
| 26 |
-
*
|
| 27 |
-
* β Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
|
| 28 |
-
* instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
|
| 29 |
-
* measures all three correctly β but the constant, not any one of the three theme objects, is
|
| 30 |
-
* what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
|
| 31 |
-
* involved column against whichever tone happened to be listed first.
|
| 32 |
-
*
|
| 33 |
-
* One lazily-built offscreen context per weight, the same shape as CustomerGrid's
|
| 34 |
-
* `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
|
| 35 |
-
* embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
|
| 36 |
-
* direction to fail β a title that slightly overlaps beats one silently cut to "Cβ¦".
|
| 37 |
-
*/
|
| 38 |
-
const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
|
| 39 |
-
base: undefined,
|
| 40 |
-
bold: undefined,
|
| 41 |
-
};
|
| 42 |
-
function measureHeaderText(text: string, bold = false): number {
|
| 43 |
-
const slot = bold ? "bold" : "base";
|
| 44 |
-
if (_hdrCtx[slot] === undefined) {
|
| 45 |
-
const ctx = document.createElement("canvas").getContext("2d");
|
| 46 |
-
if (ctx) {
|
| 47 |
-
const style =
|
| 48 |
-
(bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
|
| 49 |
-
ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
|
| 50 |
-
}
|
| 51 |
-
_hdrCtx[slot] = ctx;
|
| 52 |
-
}
|
| 53 |
-
const ctx = _hdrCtx[slot];
|
| 54 |
-
return ctx ? ctx.measureText(text).width : 0;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
/** The (i) is drawn for exactly this condition β `CustomerGrid.drawGridHeader` resolves the
|
| 58 |
-
* same one from the same field, so the reserve and the mark can never disagree. */
|
| 59 |
-
function hasInfoMark(field: Field): boolean {
|
| 60 |
-
return !!(field.note || field.description);
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
/** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
|
| 64 |
-
* rules are skipped: a rule that is not narrowing anything must not tint a column as though
|
| 65 |
-
* it were β that is the same "it looks like it is working" lie the engine refuses to tell.
|
| 66 |
-
*
|
| 67 |
-
* β Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β a measure
|
| 68 |
-
* condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
|
| 69 |
-
* is what stopped a filtered-and-sorted measure column wearing the sort hue. */
|
| 70 |
-
function filteredKeys(
|
| 71 |
-
nodes: FilterNode[],
|
| 72 |
-
out: Set<string>,
|
| 73 |
-
measureCols: Map<string, string[]>
|
| 74 |
-
): void {
|
| 75 |
-
for (const node of nodes ?? []) {
|
| 76 |
-
if (isFilterGroup(node)) {
|
| 77 |
-
filteredKeys(node.children, out, measureCols);
|
| 78 |
-
continue;
|
| 79 |
-
}
|
| 80 |
-
if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
/**
|
| 85 |
-
* The column's theme override, or `undefined` β glide's fast path for "no override" is an absent
|
| 86 |
-
* object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
|
| 87 |
-
*
|
| 88 |
-
* β Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
|
| 89 |
-
* dead and its replacement is a painted DOT, not a background β so the only thing that can put a
|
| 90 |
-
* themeOverride on a column is being INVOLVED in a control.
|
| 91 |
-
*
|
| 92 |
-
* β Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
|
| 93 |
-
* (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
|
| 94 |
-
*
|
| 95 |
-
* β Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
|
| 96 |
-
* header theme from the column override alone and a cell theme from column β row
|
| 97 |
-
* (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
|
| 98 |
-
* a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
|
| 99 |
-
* table there is no longer a path from "this column is sorted" to a cell's background at all β
|
| 100 |
-
* which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
|
| 101 |
-
* A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
|
| 102 |
-
*/
|
| 103 |
-
function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
|
| 104 |
-
return tone ? COLUMN_TONE_THEME[tone] : undefined;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
/**
|
| 108 |
-
* Owner item 22 β key β which control involves it, or absent.
|
| 109 |
-
*
|
| 110 |
-
* β ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
|
| 111 |
-
* group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
|
| 112 |
-
* doing two jobs the header names the most consequential one. Blending two tints would produce a
|
| 113 |
-
* third colour that matches no chip in the toolbar, which is worse than picking.
|
| 114 |
-
*
|
| 115 |
-
* β THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
|
| 116 |
-
* the least important control is written FIRST. It was `sort, group, filter` β i.e. filter >
|
| 117 |
-
* group > sort β until R6 named the order explicitly; getting this wrong is a one-line edit that
|
| 118 |
-
* changes a colour on screen and nothing else, so it is stated here rather than left to be
|
| 119 |
-
* re-derived from the sequence.
|
| 120 |
-
*/
|
| 121 |
-
/* wave17 GRID β item 2 / owner R5. EXPORTED, and only since the row band died.
|
| 122 |
-
`activeControlTone` used to sit below this function and restate the same precedence for the
|
| 123 |
-
whole-table band; the band was its only consumer, so R5 took both. That left the surviving
|
| 124 |
-
precedence β the one that still paints β asserted by nothing, because `verify_icons` was
|
| 125 |
-
testing the twin. Exported so the gate can reach the function that is actually on screen. */
|
| 126 |
-
export function columnTones(
|
| 127 |
-
config: ViewConfig,
|
| 128 |
-
/* wave20 item 2 β the FIELDS, because a measure condition names its measure and only the
|
| 129 |
-
field list can say which column displays it. Defaulted so the signature stays callable
|
| 130 |
-
with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
|
| 131 |
-
legitimately has no measure columns to resolve. */
|
| 132 |
-
fields: Field[] = []
|
| 133 |
-
): Map<string, ControlTone> {
|
| 134 |
-
const out = new Map<string, ControlTone>();
|
| 135 |
-
if (config.groupBy) out.set(config.groupBy, "group");
|
| 136 |
-
for (const s of config.sorts ?? []) out.set(s.colId, "sort");
|
| 137 |
-
const filtered = new Set<string>();
|
| 138 |
-
filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
|
| 139 |
-
for (const key of filtered) out.set(key, "filter");
|
| 140 |
-
return out;
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
/**
|
| 144 |
-
* β WAVE 30 (owner item 2 scouting) β THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
|
| 145 |
-
*
|
| 146 |
-
* This read `field.default !== false || field.source === "overlay"`. The second arm was written
|
| 147 |
-
* when `overlay` meant "a column a user added here", and those columns have no `default` key at
|
| 148 |
-
* all β so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
|
| 149 |
-
* stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
|
| 150 |
-
* one: the exception quietly became the rule and swallowed the first arm entirely.
|
| 151 |
-
*
|
| 152 |
-
* β WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
|
| 153 |
-
* `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
|
| 154 |
-
* opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
|
| 155 |
-
* `shownCount === fields.length` β so the control that says "some columns are hidden" says the
|
| 156 |
-
* opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
|
| 157 |
-
*
|
| 158 |
-
* β DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
|
| 159 |
-
* believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
|
| 160 |
-
* true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
|
| 161 |
-
* `ut_*` column can never arrive carrying `default: false` by accident β only a shipped field
|
| 162 |
-
* CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
|
| 163 |
-
*
|
| 164 |
-
* β THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
|
| 165 |
-
* `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
|
| 166 |
-
* records" system view the client pins as its landing default. Fixing one side alone changes
|
| 167 |
-
* nothing a user sees β see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
|
| 168 |
-
* languages).
|
| 169 |
-
*/
|
| 170 |
-
function isDefaultVisible(field: Field): boolean {
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
)
|
| 237 |
-
|
| 238 |
-
(
|
| 239 |
-
|
| 240 |
-
);
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useMemo } from "react";
|
| 2 |
+
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
+
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
+
import { isFilterGroup, isRuleActive, measureColumnIndex, ruleColumnKeys } from "./types";
|
| 5 |
+
import { typeIconName } from "./iconShapes";
|
| 6 |
+
import { fitHeaderTitle, headerLabelSpace, headerMarkSizes } from "./overlayPlacement";
|
| 7 |
+
import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
|
| 8 |
+
import type { ControlTone } from "./theme";
|
| 9 |
+
|
| 10 |
+
const DEFAULT_WIDTH = 150;
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Owner item 16 β measure header text in GLIDE'S OWN HEADER FONT.
|
| 14 |
+
*
|
| 15 |
+
* β NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
|
| 16 |
+
* "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
|
| 17 |
+
* font under-measures, under-truncates, and leaves the label running under the (i) β the exact
|
| 18 |
+
* defect this item is about, surviving a fix that looked correct in the diff.
|
| 19 |
+
*
|
| 20 |
+
* β β Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
|
| 21 |
+
* **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
|
| 22 |
+
* exactly the filtered/sorted/grouped columns β the ones the owner is looking at. Hence TWO
|
| 23 |
+
* cached contexts, and both font strings are read off the theme layer rather than retyped here:
|
| 24 |
+
* a literal in this file is a copy that can drift from the one glide actually paints with, which
|
| 25 |
+
* is the whole failure mode above.
|
| 26 |
+
*
|
| 27 |
+
* β Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
|
| 28 |
+
* instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
|
| 29 |
+
* measures all three correctly β but the constant, not any one of the three theme objects, is
|
| 30 |
+
* what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
|
| 31 |
+
* involved column against whichever tone happened to be listed first.
|
| 32 |
+
*
|
| 33 |
+
* One lazily-built offscreen context per weight, the same shape as CustomerGrid's
|
| 34 |
+
* `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
|
| 35 |
+
* embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
|
| 36 |
+
* direction to fail β a title that slightly overlaps beats one silently cut to "Cβ¦".
|
| 37 |
+
*/
|
| 38 |
+
const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
|
| 39 |
+
base: undefined,
|
| 40 |
+
bold: undefined,
|
| 41 |
+
};
|
| 42 |
+
function measureHeaderText(text: string, bold = false): number {
|
| 43 |
+
const slot = bold ? "bold" : "base";
|
| 44 |
+
if (_hdrCtx[slot] === undefined) {
|
| 45 |
+
const ctx = document.createElement("canvas").getContext("2d");
|
| 46 |
+
if (ctx) {
|
| 47 |
+
const style =
|
| 48 |
+
(bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
|
| 49 |
+
ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
|
| 50 |
+
}
|
| 51 |
+
_hdrCtx[slot] = ctx;
|
| 52 |
+
}
|
| 53 |
+
const ctx = _hdrCtx[slot];
|
| 54 |
+
return ctx ? ctx.measureText(text).width : 0;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** The (i) is drawn for exactly this condition β `CustomerGrid.drawGridHeader` resolves the
|
| 58 |
+
* same one from the same field, so the reserve and the mark can never disagree. */
|
| 59 |
+
function hasInfoMark(field: Field): boolean {
|
| 60 |
+
return !!(field.note || field.description);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
|
| 64 |
+
* rules are skipped: a rule that is not narrowing anything must not tint a column as though
|
| 65 |
+
* it were β that is the same "it looks like it is working" lie the engine refuses to tell.
|
| 66 |
+
*
|
| 67 |
+
* β Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β a measure
|
| 68 |
+
* condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
|
| 69 |
+
* is what stopped a filtered-and-sorted measure column wearing the sort hue. */
|
| 70 |
+
function filteredKeys(
|
| 71 |
+
nodes: FilterNode[],
|
| 72 |
+
out: Set<string>,
|
| 73 |
+
measureCols: Map<string, string[]>
|
| 74 |
+
): void {
|
| 75 |
+
for (const node of nodes ?? []) {
|
| 76 |
+
if (isFilterGroup(node)) {
|
| 77 |
+
filteredKeys(node.children, out, measureCols);
|
| 78 |
+
continue;
|
| 79 |
+
}
|
| 80 |
+
if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* The column's theme override, or `undefined` β glide's fast path for "no override" is an absent
|
| 86 |
+
* object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
|
| 87 |
+
*
|
| 88 |
+
* β Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
|
| 89 |
+
* dead and its replacement is a painted DOT, not a background β so the only thing that can put a
|
| 90 |
+
* themeOverride on a column is being INVOLVED in a control.
|
| 91 |
+
*
|
| 92 |
+
* β Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
|
| 93 |
+
* (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
|
| 94 |
+
*
|
| 95 |
+
* β Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
|
| 96 |
+
* header theme from the column override alone and a cell theme from column β row β cell
|
| 97 |
+
* (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
|
| 98 |
+
* a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
|
| 99 |
+
* table there is no longer a path from "this column is sorted" to a cell's background at all β
|
| 100 |
+
* which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
|
| 101 |
+
* A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
|
| 102 |
+
*/
|
| 103 |
+
function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
|
| 104 |
+
return tone ? COLUMN_TONE_THEME[tone] : undefined;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* Owner item 22 β key β which control involves it, or absent.
|
| 109 |
+
*
|
| 110 |
+
* β ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
|
| 111 |
+
* group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
|
| 112 |
+
* doing two jobs the header names the most consequential one. Blending two tints would produce a
|
| 113 |
+
* third colour that matches no chip in the toolbar, which is worse than picking.
|
| 114 |
+
*
|
| 115 |
+
* β THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
|
| 116 |
+
* the least important control is written FIRST. It was `sort, group, filter` β i.e. filter >
|
| 117 |
+
* group > sort β until R6 named the order explicitly; getting this wrong is a one-line edit that
|
| 118 |
+
* changes a colour on screen and nothing else, so it is stated here rather than left to be
|
| 119 |
+
* re-derived from the sequence.
|
| 120 |
+
*/
|
| 121 |
+
/* wave17 GRID β item 2 / owner R5. EXPORTED, and only since the row band died.
|
| 122 |
+
`activeControlTone` used to sit below this function and restate the same precedence for the
|
| 123 |
+
whole-table band; the band was its only consumer, so R5 took both. That left the surviving
|
| 124 |
+
precedence β the one that still paints β asserted by nothing, because `verify_icons` was
|
| 125 |
+
testing the twin. Exported so the gate can reach the function that is actually on screen. */
|
| 126 |
+
export function columnTones(
|
| 127 |
+
config: ViewConfig,
|
| 128 |
+
/* wave20 item 2 β the FIELDS, because a measure condition names its measure and only the
|
| 129 |
+
field list can say which column displays it. Defaulted so the signature stays callable
|
| 130 |
+
with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
|
| 131 |
+
legitimately has no measure columns to resolve. */
|
| 132 |
+
fields: Field[] = []
|
| 133 |
+
): Map<string, ControlTone> {
|
| 134 |
+
const out = new Map<string, ControlTone>();
|
| 135 |
+
if (config.groupBy) out.set(config.groupBy, "group");
|
| 136 |
+
for (const s of config.sorts ?? []) out.set(s.colId, "sort");
|
| 137 |
+
const filtered = new Set<string>();
|
| 138 |
+
filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
|
| 139 |
+
for (const key of filtered) out.set(key, "filter");
|
| 140 |
+
return out;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* β WAVE 30 (owner item 2 scouting) β THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
|
| 145 |
+
*
|
| 146 |
+
* This read `field.default !== false || field.source === "overlay"`. The second arm was written
|
| 147 |
+
* when `overlay` meant "a column a user added here", and those columns have no `default` key at
|
| 148 |
+
* all β so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
|
| 149 |
+
* stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
|
| 150 |
+
* one: the exception quietly became the rule and swallowed the first arm entirely.
|
| 151 |
+
*
|
| 152 |
+
* β WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
|
| 153 |
+
* `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
|
| 154 |
+
* opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
|
| 155 |
+
* `shownCount === fields.length` β so the control that says "some columns are hidden" says the
|
| 156 |
+
* opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
|
| 157 |
+
*
|
| 158 |
+
* β DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
|
| 159 |
+
* believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
|
| 160 |
+
* true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
|
| 161 |
+
* `ut_*` column can never arrive carrying `default: false` by accident β only a shipped field
|
| 162 |
+
* CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
|
| 163 |
+
*
|
| 164 |
+
* β THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
|
| 165 |
+
* `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
|
| 166 |
+
* records" system view the client pins as its landing default. Fixing one side alone changes
|
| 167 |
+
* nothing a user sees β see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
|
| 168 |
+
* languages).
|
| 169 |
+
*/
|
| 170 |
+
function isDefaultVisible(field: Field): boolean {
|
| 171 |
+
if (field.default === false) return false;
|
| 172 |
+
/**
|
| 173 |
+
* ββ OWNER, 2026-08-23 β A NEW VIEW SHOWS THE DATABASE'S OWN COLUMNS AND NOTHING ELSE.
|
| 174 |
+
*
|
| 175 |
+
* Owner: *"when any new View is created only shareable pre-set data should show. Shared to me
|
| 176 |
+
* or Shared to everyone should be auto hidden."*
|
| 177 |
+
*
|
| 178 |
+
* β `custom || shared` IS THE WHOLE PREDICATE, and it is chosen because it is exactly the
|
| 179 |
+
* membership test `FieldsHidePanel` already uses to draw its two SHARED sections:
|
| 180 |
+
* "Shared with me" (field.custom || field.shared) && fieldEditMode === "users"
|
| 181 |
+
* "Shared with everyone" (field.custom || field.shared) && fieldEditMode === "collaborative"
|
| 182 |
+
* Both sections are subsets of `custom || shared`, so hiding on that flag hides both by
|
| 183 |
+
* construction rather than by keeping a second list of section names in step with the panel.
|
| 184 |
+
*
|
| 185 |
+
* β IT ALSO HIDES THE UNTITLED SECTION'S CREATED COLUMNS, and that is deliberate rather than
|
| 186 |
+
* over-reach. A route order carries `shared: true` with `edit: personal`, so it is filed under
|
| 187 |
+
* no heading at all β and 52 of them landed on the customer grid the same day this was asked
|
| 188 |
+
* for. The owner's first sentence is the rule ("ONLY pre-set data shows"); the second names the
|
| 189 |
+
* two headings, it does not bound the set.
|
| 190 |
+
*
|
| 191 |
+
* β MEASURED ON THE LIVE CUSTOMER GRID before it was written: of 105 fields, 77 opened SHOWN.
|
| 192 |
+
* 52 route columns, 14 collaborative `custom_*`, 3 `measure_*`, one legacy route and 7 plain
|
| 193 |
+
* Odoo columns. After this, the 7 remain β which is precisely what
|
| 194 |
+
* `aios_grid_fields.json` declares as `default: true`, i.e. the contract's own answer before
|
| 195 |
+
* anybody's columns piled on top of it.
|
| 196 |
+
*
|
| 197 |
+
* ββ A STORED VIEW IS UNTOUCHED. `normalizeConfig` spreads the saved `config` OVER this
|
| 198 |
+
* base, so a view that already recorded its own `visible` keeps it. What moves is a view born
|
| 199 |
+
* without one: the "All records" system view, a new view, a cohort view.
|
| 200 |
+
*/
|
| 201 |
+
if (field.custom === true || field.shared === true) return false;
|
| 202 |
+
return true;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export function defaultViewConfig(fields: Field[]): ViewConfig {
|
| 206 |
+
const shown = fields.filter(isDefaultVisible).map((field) => field.key);
|
| 207 |
+
const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
|
| 208 |
+
return {
|
| 209 |
+
filters: [],
|
| 210 |
+
filterConj: "and",
|
| 211 |
+
sorts: [],
|
| 212 |
+
groupBy: null,
|
| 213 |
+
colorBy: null,
|
| 214 |
+
rowHeightMode: "short",
|
| 215 |
+
order: [...shown, ...hidden],
|
| 216 |
+
visible: shown,
|
| 217 |
+
widths: {},
|
| 218 |
+
memberPids: [],
|
| 219 |
+
};
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function reconcileOrder(order: string[], fields: Field[]): string[] {
|
| 223 |
+
const valid = new Set(fields.map((field) => field.key));
|
| 224 |
+
const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
|
| 225 |
+
for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
|
| 226 |
+
const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
|
| 227 |
+
if (locked && kept[0] !== locked) {
|
| 228 |
+
const without = kept.filter((key) => key !== locked);
|
| 229 |
+
return [locked, ...without];
|
| 230 |
+
}
|
| 231 |
+
return kept;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
|
| 235 |
+
const valid = new Set(fields.map((field) => field.key));
|
| 236 |
+
const out = new Set(visible.filter((key) => valid.has(key)));
|
| 237 |
+
if (out.size === 0) {
|
| 238 |
+
for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
|
| 239 |
+
}
|
| 240 |
+
if (lockedKey) out.add(lockedKey);
|
| 241 |
+
return out;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
export interface GridColumnsApi {
|
| 245 |
+
visibleCols: GridColumn[];
|
| 246 |
+
fieldByKey: Map<string, Field>;
|
| 247 |
+
order: string[];
|
| 248 |
+
visible: Set<string>;
|
| 249 |
+
widths: Record<string, number>;
|
| 250 |
+
lockedKey: string;
|
| 251 |
+
onColumnResize: (col: GridColumn, newSize: number) => void;
|
| 252 |
+
onColumnMoved: (from: number, to: number) => void;
|
| 253 |
+
onColumnProposeMove: (from: number, to: number) => boolean;
|
| 254 |
+
setColumnVisible: (key: string, show: boolean) => void;
|
| 255 |
+
insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* Controlled column adapter. ViewConfig is the durable source of truth, so
|
| 260 |
+
* choosing a saved view is one atomic state change instead of a chain of hook
|
| 261 |
+
* setters that can flash or overwrite one another.
|
| 262 |
+
*/
|
| 263 |
+
export function useGridColumns(
|
| 264 |
+
fields: Field[],
|
| 265 |
+
config: ViewConfig,
|
| 266 |
+
onConfig: (next: ViewConfig) => void
|
| 267 |
+
): GridColumnsApi {
|
| 268 |
+
const fieldByKey = useMemo(
|
| 269 |
+
() => new Map(fields.map((field) => [field.key, field])),
|
| 270 |
+
[fields]
|
| 271 |
+
);
|
| 272 |
+
const lockedKey = useMemo(
|
| 273 |
+
() => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
|
| 274 |
+
[fields]
|
| 275 |
+
);
|
| 276 |
+
const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
|
| 277 |
+
const visible = useMemo(
|
| 278 |
+
() => reconcileVisible(config.visible, fields, lockedKey),
|
| 279 |
+
[config.visible, fields, lockedKey]
|
| 280 |
+
);
|
| 281 |
+
|
| 282 |
+
// Item 22 β recomputed only when the three controls move, not on every width drag.
|
| 283 |
+
// Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
|
| 284 |
+
// displays it, and a new measure column must re-tint without waiting for a control to move.
|
| 285 |
+
const tones = useMemo(
|
| 286 |
+
() => columnTones(config, fields),
|
| 287 |
+
[config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
|
| 288 |
+
);
|
| 289 |
+
|
| 290 |
+
const visibleCols = useMemo<GridColumn[]>(
|
| 291 |
+
() =>
|
| 292 |
+
order
|
| 293 |
+
.filter((key) => visible.has(key))
|
| 294 |
+
.map((key) => {
|
| 295 |
+
const field = fieldByKey.get(key)!;
|
| 296 |
+
const width = config.widths[key] ?? DEFAULT_WIDTH;
|
| 297 |
+
// Wave-14 item 2 β an INVOLVED column's header paints at 700, so it must be MEASURED
|
| 298 |
+
// at 700. `tones` is already a dep of this memo, so this costs nothing.
|
| 299 |
+
const involved = tones.has(key);
|
| 300 |
+
// R11 β the marks this header carries, in the strip's canonical order. Both this
|
| 301 |
+
// reserve and `CustomerGrid.drawGridHeader`'s painting read `headerMarkSizes`, so a
|
| 302 |
+
// column can never reserve room for one mark and then be given two.
|
| 303 |
+
const marks = headerMarkSizes(hasInfoMark(field), field.source === "overlay");
|
| 304 |
+
return {
|
| 305 |
+
id: key,
|
| 306 |
+
// Owner item 16 β ELLIPSISED so the title can never run under the (i). Glide
|
| 307 |
+
// truncates nothing: `drawHeaderInner` calls `fillText(c.title, β¦)` with no clip
|
| 308 |
+
// and no max width, so on a narrow column the name simply painted through the
|
| 309 |
+
// mark. Shortening the title glide is given is the whole fix β the alternative,
|
| 310 |
+
// clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
|
| 311 |
+
// The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
|
| 312 |
+
// this returns something shorter than `field.label`.
|
| 313 |
+
title: fitHeaderTitle(
|
| 314 |
+
field.label,
|
| 315 |
+
headerLabelSpace(width, marks),
|
| 316 |
+
(text) => measureHeaderText(text, involved)
|
| 317 |
+
),
|
| 318 |
+
width,
|
| 319 |
+
hasMenu: true,
|
| 320 |
+
menuIcon: "dots",
|
| 321 |
+
// Wave-8 I20 β the field-TYPE mark leads every header, so the column says what
|
| 322 |
+
// KIND of thing it holds before you read a single cell (same geometry as the
|
| 323 |
+
// Fields panel's rows β icons.tsx).
|
| 324 |
+
icon: typeIconName(field.type),
|
| 325 |
+
// Wave-9 I3 β the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
|
| 326 |
+
// hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
|
| 327 |
+
// pinned to the far LEFT of the header, and no prop moves it; the owner asked
|
| 328 |
+
// for it right-aligned and centred with the field name. It is drawn instead by
|
| 329 |
+
// CustomerGrid's `drawHeader` callback, which resolves the same
|
| 330 |
+
// `field.note || field.description` condition from the field itself.
|
| 331 |
+
// The hover text is still CustomerGrid's floating tip (pointer-events: none β
|
| 332 |
+
// a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
|
| 333 |
+
// Wave-14 R4 β being INVOLVED in a filter/sort/group is the only thing that tints a
|
| 334 |
+
// column now, and all three tint it the same faint warm grey; the hue that says
|
| 335 |
+
// WHICH control lives on the toolbar chip. User-created ownership moved out of the
|
| 336 |
+
// theme entirely and is the dot drawn in the mark strip (R11).
|
| 337 |
+
themeOverride: columnTheme(tones.get(key)),
|
| 338 |
+
};
|
| 339 |
+
}),
|
| 340 |
+
[order, visible, config.widths, fieldByKey, tones]
|
| 341 |
+
);
|
| 342 |
+
|
| 343 |
+
const onColumnResize = useCallback(
|
| 344 |
+
(column: GridColumn, newSize: number) => {
|
| 345 |
+
if (!column.id) return;
|
| 346 |
+
onConfig({
|
| 347 |
+
...config,
|
| 348 |
+
widths: { ...config.widths, [column.id]: Math.round(newSize) },
|
| 349 |
+
});
|
| 350 |
+
},
|
| 351 |
+
[config, onConfig]
|
| 352 |
+
);
|
| 353 |
+
|
| 354 |
+
const onColumnMoved = useCallback(
|
| 355 |
+
(from: number, to: number) => {
|
| 356 |
+
if (from === 0 || to === 0) return;
|
| 357 |
+
const visibleKeys = order.filter((key) => visible.has(key));
|
| 358 |
+
if (
|
| 359 |
+
from < 0 ||
|
| 360 |
+
from >= visibleKeys.length ||
|
| 361 |
+
to < 0 ||
|
| 362 |
+
to >= visibleKeys.length
|
| 363 |
+
)
|
| 364 |
+
return;
|
| 365 |
+
const movedKeys = visibleKeys.slice();
|
| 366 |
+
const [moved] = movedKeys.splice(from, 1);
|
| 367 |
+
movedKeys.splice(to, 0, moved);
|
| 368 |
+
let index = 0;
|
| 369 |
+
const nextOrder = order.map((key) =>
|
| 370 |
+
visible.has(key) ? movedKeys[index++] : key
|
| 371 |
+
);
|
| 372 |
+
onConfig({ ...config, order: nextOrder });
|
| 373 |
+
},
|
| 374 |
+
[config, onConfig, order, visible]
|
| 375 |
+
);
|
| 376 |
+
|
| 377 |
+
const onColumnProposeMove = useCallback(
|
| 378 |
+
(from: number, to: number) => from !== 0 && to !== 0,
|
| 379 |
+
[]
|
| 380 |
+
);
|
| 381 |
+
|
| 382 |
+
const setColumnVisible = useCallback(
|
| 383 |
+
(key: string, show: boolean) => {
|
| 384 |
+
if (!show && key === lockedKey) return;
|
| 385 |
+
const next = new Set(visible);
|
| 386 |
+
if (show) next.add(key);
|
| 387 |
+
else next.delete(key);
|
| 388 |
+
onConfig({ ...config, visible: [...next] });
|
| 389 |
+
},
|
| 390 |
+
[config, lockedKey, onConfig, visible]
|
| 391 |
+
);
|
| 392 |
+
|
| 393 |
+
const insertColumn = useCallback(
|
| 394 |
+
(key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
|
| 395 |
+
const nextOrder = order.filter((item) => item !== key);
|
| 396 |
+
if (side === "end" || !anchorKey) {
|
| 397 |
+
nextOrder.push(key);
|
| 398 |
+
} else {
|
| 399 |
+
const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
|
| 400 |
+
nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
|
| 401 |
+
}
|
| 402 |
+
const nextVisible = new Set(visible);
|
| 403 |
+
nextVisible.add(key);
|
| 404 |
+
onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
|
| 405 |
+
},
|
| 406 |
+
[config, onConfig, order, visible]
|
| 407 |
+
);
|
| 408 |
+
|
| 409 |
+
return {
|
| 410 |
+
visibleCols,
|
| 411 |
+
fieldByKey,
|
| 412 |
+
order,
|
| 413 |
+
visible,
|
| 414 |
+
widths: config.widths,
|
| 415 |
+
lockedKey,
|
| 416 |
+
onColumnResize,
|
| 417 |
+
onColumnMoved,
|
| 418 |
+
onColumnProposeMove,
|
| 419 |
+
setColumnVisible,
|
| 420 |
+
insertColumn,
|
| 421 |
+
};
|
| 422 |
+
}
|