|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The Studio view form offers `pagination` to EVERY view type, and keeps the |
| 5 | + * grid-only display options grid-only. |
| 6 | + * |
| 7 | + * ## What is being pinned |
| 8 | + * |
| 9 | + * `pagination.pageSize` is the row bound every view type carries; maintainer |
| 10 | + * ruling D on #19228 makes it the direction for a view's row bound. The form |
| 11 | + * used to offer `pagination` only inside `table_options` |
| 12 | + * (`visibleWhen: "data.type == 'grid' || data.type == null"`), so an author |
| 13 | + * editing any other view type could not see or set that bound short of editing |
| 14 | + * the metadata by hand. Two halves, both asserted: |
| 15 | + * |
| 16 | + * - **offer**: for every value of the list-view `type` enum — and for a view |
| 17 | + * with no `type` yet — some section visible to that type offers `pagination`; |
| 18 | + * the offer is backed by the door, because every type parses a `pagination` |
| 19 | + * block clean and keeps it; |
| 20 | + * - **no leak**: the grid-only fields stay hidden from every non-grid type. |
| 21 | + * |
| 22 | + * ## The kind list is read from the enum, never written here |
| 23 | + * |
| 24 | + * A literal list would stay green through a new view type the form hides |
| 25 | + * `pagination` from. The list is the enum on {@link ListViewSchema} — the |
| 26 | + * list-view shape this form lays out, and the one `ViewSchema`'s `list` / |
| 27 | + * `listViews` entries are derived from — with a floor naming the four types |
| 28 | + * the ruling is about, so an empty derivation cannot pass. |
| 29 | + * |
| 30 | + * ## How visibility is read |
| 31 | + * |
| 32 | + * A section's `visibleWhen` is a CEL predicate over the edited record as |
| 33 | + * `data`. `packages/spec` carries no evaluator and must not grow one (no |
| 34 | + * runtime logic in spec), so {@link visibleFor} reads the ONE grammar this form |
| 35 | + * uses — a disjunction of `data.type == '<type>'` / `data.type == null` terms — |
| 36 | + * and THROWS on anything else, naming the predicate. A predicate this pin cannot |
| 37 | + * read fails it loudly instead of being guessed at. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | + |
| 42 | +import { viewForm } from './view.form'; |
| 43 | +import { ListViewSchema } from './view.zod'; |
| 44 | + |
| 45 | +type Predicate = string | { dialect?: string; source?: string } | undefined; |
| 46 | +type Entry = string | { field?: string; visibleWhen?: Predicate }; |
| 47 | +type Section = { name?: string; visibleWhen?: Predicate; fields?: Entry[] }; |
| 48 | + |
| 49 | +const SECTIONS = (viewForm.sections ?? []) as Section[]; |
| 50 | + |
| 51 | +/** The list-view `type` enum, read at runtime. */ |
| 52 | +const VIEW_TYPES: readonly string[] = ( |
| 53 | + ListViewSchema.shape.type as unknown as { unwrap(): { options: readonly string[] } } |
| 54 | +).unwrap().options; |
| 55 | + |
| 56 | +/** The view types ruling D names — a floor under the derivation, not the list. */ |
| 57 | +const RULING_D_TYPES = ['grid', 'kanban', 'gallery', 'timeline'] as const; |
| 58 | + |
| 59 | +/** |
| 60 | + * The fields that are grid-only by design. Named here rather than read from |
| 61 | + * `table_options`, so moving one out of that section is a visible failure, not a |
| 62 | + * silently re-derived list. |
| 63 | + */ |
| 64 | +const GRID_ONLY_FIELDS = ['resizable', 'compactToolbar', 'rowHeight', 'selection'] as const; |
| 65 | + |
| 66 | +const TERM = /^data\.type\s*==\s*(?:'([a-z_]+)'|(null))$/; |
| 67 | + |
| 68 | +/** |
| 69 | + * Is a predicate true for a record whose `type` is `type` (`undefined` = not |
| 70 | + * set yet)? An absent predicate is always visible. |
| 71 | + */ |
| 72 | +function visibleFor(predicate: Predicate, type: string | undefined, where: string): boolean { |
| 73 | + if (predicate === undefined) return true; |
| 74 | + const source = typeof predicate === 'string' ? predicate : predicate.source; |
| 75 | + if (typeof predicate !== 'string' && predicate.dialect !== undefined && predicate.dialect !== 'cel') { |
| 76 | + throw new Error(`${where}: this pin reads CEL predicates only, got dialect ${JSON.stringify(predicate.dialect)}`); |
| 77 | + } |
| 78 | + if (typeof source !== 'string') throw new Error(`${where}: predicate has no source: ${JSON.stringify(predicate)}`); |
| 79 | + return source.split('||').map((t) => t.trim()).some((term) => { |
| 80 | + const m = TERM.exec(term); |
| 81 | + if (!m) { |
| 82 | + throw new Error( |
| 83 | + `${where}: this pin cannot read the predicate term ${JSON.stringify(term)} in ${JSON.stringify(source)} — ` + |
| 84 | + 'extend visibleFor() to read it; never guess a visibility', |
| 85 | + ); |
| 86 | + } |
| 87 | + return m[2] === 'null' ? type === undefined : type === m[1]; |
| 88 | + }); |
| 89 | +} |
| 90 | + |
| 91 | +const fieldName = (e: Entry): string | undefined => (typeof e === 'string' ? e : e.field); |
| 92 | + |
| 93 | +/** The sections (by name) in which `field` is offered AND visible for `type`. */ |
| 94 | +function offeredTo(field: string, type: string | undefined): string[] { |
| 95 | + const hits: string[] = []; |
| 96 | + for (const section of SECTIONS) { |
| 97 | + const where = `section ${JSON.stringify(section.name)}`; |
| 98 | + if (!visibleFor(section.visibleWhen, type, where)) continue; |
| 99 | + for (const entry of section.fields ?? []) { |
| 100 | + if (fieldName(entry) !== field) continue; |
| 101 | + const own = typeof entry === 'string' ? undefined : entry.visibleWhen; |
| 102 | + if (visibleFor(own, type, `${where} field ${JSON.stringify(field)}`)) hits.push(section.name ?? '(unnamed)'); |
| 103 | + } |
| 104 | + } |
| 105 | + return hits; |
| 106 | +} |
| 107 | + |
| 108 | +describe('view form — the kind list is the enum', () => { |
| 109 | + it('derives a non-empty type list that contains every type ruling D names', () => { |
| 110 | + expect(VIEW_TYPES.length).toBeGreaterThanOrEqual(RULING_D_TYPES.length); |
| 111 | + for (const t of RULING_D_TYPES) expect(VIEW_TYPES, `the enum lost '${t}'`).toContain(t); |
| 112 | + }); |
| 113 | +}); |
| 114 | + |
| 115 | +describe('view form — `pagination` is offered to every view type', () => { |
| 116 | + it('is offered exactly once in the whole form', () => { |
| 117 | + const entries = SECTIONS.flatMap((s) => (s.fields ?? []).filter((e) => fieldName(e) === 'pagination')); |
| 118 | + expect(entries).toHaveLength(1); |
| 119 | + }); |
| 120 | + |
| 121 | + it.each(VIEW_TYPES.map((t) => [t]))("is visible for type '%s'", (type) => { |
| 122 | + expect(offeredTo('pagination', type), `a '${type}' view cannot reach its row bound in the form`).toHaveLength(1); |
| 123 | + }); |
| 124 | + |
| 125 | + it('is visible for a view whose type is not set yet', () => { |
| 126 | + expect(offeredTo('pagination', undefined)).toHaveLength(1); |
| 127 | + }); |
| 128 | + |
| 129 | + it.each(VIEW_TYPES.map((t) => [t]))("is backed by the door: type '%s' parses a pagination block and keeps it", (type) => { |
| 130 | + const r = ListViewSchema.safeParse({ type, columns: ['name'], pagination: { pageSize: 50 } }); |
| 131 | + expect(r.success, JSON.stringify(r.error?.issues ?? '')).toBe(true); |
| 132 | + expect((r.data as { pagination?: unknown }).pagination).toEqual({ pageSize: 50 }); |
| 133 | + }); |
| 134 | +}); |
| 135 | + |
| 136 | +describe('view form — grid-only fields stay grid-only', () => { |
| 137 | + it.each(GRID_ONLY_FIELDS.map((f) => [f]))("'%s' is visible for grid and for an unset type", (field) => { |
| 138 | + expect(offeredTo(field, 'grid')).toHaveLength(1); |
| 139 | + expect(offeredTo(field, undefined)).toHaveLength(1); |
| 140 | + }); |
| 141 | + |
| 142 | + const nonGrid = VIEW_TYPES.filter((t) => t !== 'grid'); |
| 143 | + it.each(nonGrid.flatMap((t) => GRID_ONLY_FIELDS.map((f) => [f, t])))("'%s' is hidden from type '%s'", (field, type) => { |
| 144 | + expect(offeredTo(field, type)).toEqual([]); |
| 145 | + }); |
| 146 | +}); |
| 147 | + |
| 148 | +describe('view form — the predicate reader refuses what it cannot read', () => { |
| 149 | + it('throws on a term outside its grammar rather than guessing', () => { |
| 150 | + expect(() => visibleFor("data.type != 'grid'", 'kanban', 'probe')).toThrow(/cannot read the predicate term/); |
| 151 | + }); |
| 152 | +}); |
0 commit comments