// --------------------------------------------------------------------------- // customer-grid / icons.tsx // Wave-8 items I18 + I20 — the React half of the grid's icon vocabulary. // // The GEOMETRY lives in iconShapes.ts, which this file and the glide canvas // sprites both read. Two painters, one source: glide's headerIcons API takes a // function returning SVG *source* (a header icon can never be a React // component), so the shapes are kept as data and each painter gets its own thin // renderer. Hand-copying paths into a template literal instead would guarantee // the header and the Fields panel eventually disagree about what a "date" is. // // Components only in this file — the constants/sprite builders sit next door so // fast refresh keeps working (oxlint react/only-export-components). // --------------------------------------------------------------------------- import type { ReactNode } from "react"; import type { DisplayMode, FieldType, FolderIcon, FolderTone } from "./types"; import { DEFAULT_FOLDER_SHAPE, DEFAULT_FOLDER_TONE } from "./types"; import type { IconShape } from "./iconShapes"; import type { ChartKindKey } from "./iconShapes"; import { CHART_KIND_SHAPES, CHART_KIND_TONE, FOLDER_SHAPE_PATHS, folderTonePaint, MODE_SHAPES, TYPE_SHAPES, } from "./iconShapes"; /** * ⭐ WAVE 30 item 2 (R5) — `shapes` IS OPTIONAL, and the `?? []` is the whole point. * * Every caller reaches this through a TOTAL `Record`, so TypeScript promises the * lookup always hits. That promise is only as good as the key: `type` arrives over the WIRE, and * a field whose type is not in the union — a kind added server-side, a stored definition from * before a rename — makes `TYPE_SHAPES[type]` `undefined` at runtime with tsc none the wiser. * `undefined.map` is a TypeError, and until this wave a TypeError anywhere unmounted the entire * React tree: a blank white page, which is exactly what a user calls "it crashes". * * ⚠ RANKED SECOND AND SHIPPED ANYWAY (R5). Every field in `odoo_relational.order_fields` carries * a type present in `TYPE_SHAPES`, and the server's `UT_FIELD_TYPES` filters unknown types off * the wire — so this is LATENT, not the proven cause of owner item 2. The reporter was a user and * cannot be re-interviewed, so both candidate causes are fixed rather than one guessed at. * * An absent lookup now paints NO GLYPH, which is the honest degradation: the row still renders, * still carries its label, and the missing mark says "this type has no icon" rather than taking * the product down to say it. */ function Shapes({ shapes }: { shapes?: IconShape[] }) { return ( <> {(shapes ?? []).map((s, i) => s.fill ? ( ) : ( ) )} ); } /** The field-type mark for panels/menus (I20). Decorative by default — the row * it sits in already names the field; a screen reader reading "currency icon" * before every label is noise. Pass `title` where the icon teaches the * vocabulary (the Fields panel) so hovering it names the type. * * Wave-14 C-FLDSEL: `type` widened to carry the "measure" PSEUDO-KIND, which is not a * `FieldType` (a measure column's real type is currency/int/pct — widening the union would * let it leak into every switch that renders cells). It paints the CURRENCY mark, which is * the decision the create-picker already shipped and its reason still holds: inventing a * 17th glyph for a pseudo-kind would put a mark on screen that no column header ever wears. * Every caller that used to spell that mapping out at the call site now stops. */ export function FieldTypeIcon({ type, size = 14, title, }: { type: FieldType | "measure"; size?: number; title?: string; }) { return ( {title && {title}} ); } /** The display-mode mark for the View switcher (I18). */ export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) { return ( ); } /** * Wave-9 I14 — the SAME mode mark, in a pastel tone (the flyout's "pastel-coloured icons"). * * A separate component rather than a `tone` prop on ModeIcon: every existing ModeIcon call * site inherits `currentColor` from the row it sits in, and quietly giving that a colour * would repaint the toolbar switcher and every radio row along with the flyout. The geometry * is still the one source — only the paint differs. */ export function ToneModeIcon({ mode, tone, size = 15, }: { mode: DisplayMode; tone: FolderTone; size?: number; }) { const paint = folderTonePaint(tone); return ( {/* R5: `?? []` for the same reason as `Shapes` — `mode` is a STORED display mode off the wire, so a view saved under a kind this build no longer knows makes the lookup undefined and `.map` a TypeError. */} {(MODE_SHAPES[mode] ?? []).map((s, i) => s.fill ? ( ) : ( ) )} ); } /** * Wave-9 I16 — the chart-kind mark, in its pastel tone. Same two-painter discipline as * everything else here: the geometry is in iconShapes.ts, this is only a renderer. */ export function ChartKindIcon({ kind, size = 15, }: { kind: ChartKindKey; size?: number; }) { const paint = folderTonePaint(CHART_KIND_TONE[kind]); return ( {/* R5: same guard, same reason — `kind` comes from a saved chart definition. */} {(CHART_KIND_SHAPES[kind] ?? []).map((s, i) => ( ))} ); } // --------------------------------------------------------------------------- // MENU ACTION ICONS (wave-7 item W4, moved here by wave-14 item 19). // // These lived privately in ColumnMenu.tsx, which was fine while the column menu was the only // menu with icons. Item 19 puts icons on the VIEW menu too, and the alternative to one home // was a second hand-drawn padlock that would drift from the first — the same argument // iconShapes.ts records for the canvas/React split, one layer up. // // Exported as a COMPONENT taking a name, not as a record of nodes: a record would be a // non-component export from a components file, which is the `only-export-components` warning // the whole icons.tsx/iconShapes.ts split exists to avoid. // --------------------------------------------------------------------------- const mi = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none" } as const; const miStroke = { stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, }; const MENU_ICONS = { duplicate: ( <> ), insertLeft: ( <> ), insertRight: ( <> ), addEnd: , rename: , description: , permissions: ( <> ), /** The same padlock with an OPEN shackle — "unlock" as the picture of undoing the one above, * rather than as a padlock with a slash, which every other `off` row already uses. */ unlock: ( <> ), format: ( <> ), swap: ( ), sortAsc: ( <> ), sortDesc: ( <> ), off: ( <> ), filter: , group: ( <> ), pin: ( <> ), unpin: ( <> ), hide: ( <> ), trash: , /** Item 19 — the view menu's own three. */ download: ( <> ), cohortAdd: ( <> ), /** * ⭐ W29 close-out — IMPORT's own mark, asked for by E and taken by the integrator while * W29-T78 had this menu open anyway. * * `download` MIRRORED: the same tray, the same stem, the arrowhead at the top instead of the * bottom — so Export and Import read as one gesture in two directions rather than as two * unrelated glyphs. It replaces a borrowed `cohortAdd` (a LIST with a plus), which named * "add to a cohort" on a row that imports a spreadsheet: a borrowed mark is a small lie that * a menu repeats every time it opens. */ upload: ( <> ), } as const; export type MenuIconName = keyof typeof MENU_ICONS; /** One 16px menu glyph, by name. */ export function MenuIcon({ name }: { name: MenuIconName }) { return ( {MENU_ICONS[name]} ); } /** * One menu action row: icon + label. The icon column is fixed-width so labels align. * * `icon` is a NAME by default; a node is accepted for the rows whose mark is not part of this * vocabulary (the view menu's "Lock as Kanban" wears the KANBAN mark, because the picture of * what gets frozen says more than a second padlock). */ export function MenuLabel({ icon, text, }: { icon: MenuIconName | ReactNode; text: string; }) { return ( <> {typeof icon === "string" ? : icon} {text} ); } /** * Wave-14 item 12 — the HIDE-FIELDS mark: an eye with a slash through it. * * The toolbar button that opens the panel wore `IconFields` (three vertical column rules), * which named the NOUN and left the verb to be guessed — the same complaint that renamed the * panel "Hide fields" in the first place. A struck-through eye says what the control does. * * It lives here rather than beside the toolbar's private `Icon*` set because it has two * consumers: the toolbar button, and the copy-configuration modal's "Hidden fields" row * (item 13), which must wear the SAME mark for the same concept. Paths are inline for the * same reason `LockMark`'s are: `iconShapes.ts` holds the geometry the glide CANVAS also * paints, and no header ever draws this one. */ export function EyeOffIcon({ size = 14 }: { size?: number }) { return ( ); } /** * Wave-9 contract C3 (I12) — the "this view's mode is frozen" mark. Drawn from the same * stroke vocabulary as everything else here; deliberately small and muted, because it * annotates the row rather than competing with the mode icon that leads it. */ export function LockMark({ size = 11 }: { size?: number }) { return ( ); } /** * Wave-9 contract C5 (I15) — a folder's chosen mark. `icon` absent renders the DEFAULT * folder shape in grey, which is every pre-wave-9 folder and is exactly what I14 asks for * ("existing folders get the folder icon"). */ export function FolderMark({ icon, size = 14, }: { icon?: FolderIcon; size?: number; }) { const shape = icon?.shape ?? DEFAULT_FOLDER_SHAPE; const paint = folderTonePaint(icon?.tone ?? DEFAULT_FOLDER_TONE); return ( {/* R5: same guard — a folder's shape is stored in nav prefs. */} {(FOLDER_SHAPE_PATHS[shape] ?? []).map((s, i) => ( ))} ); }