|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * The THIRD half of a view's field appetite (objectui#7179). |
| 11 | + * |
| 12 | + * A list view projects what it DISPLAYS — `$select` is built from `columns`. |
| 13 | + * `predicate-fields.ts` covers the fields a view's PREDICATES read. This covers |
| 14 | + * the fields it GROUPS BY, which is a third, independent source of demand: the |
| 15 | + * spec's `grouping` block is a sibling of `columns`, not a subset of it, so a |
| 16 | + * view may legitimately group by a field it never shows. |
| 17 | + * |
| 18 | + * ## Why it needed a fix rather than a gate |
| 19 | + * |
| 20 | + * `GroupingConfigSchema` accepts `grouping` with no matching column, and the |
| 21 | + * neighbouring view kinds (kanban / gantt / timeline) already union their |
| 22 | + * `groupByField` into the projection with no column required. Grouping by a |
| 23 | + * field you do not want on screen is an ordinary thing to want. Refusing it at |
| 24 | + * author time would make the grid the odd one out and reject working intent. |
| 25 | + * |
| 26 | + * ## The failure this closes |
| 27 | + * |
| 28 | + * With the grouping field absent from `$select` the server never returns it, |
| 29 | + * so `useGroupedData` reads `undefined` on every row and `buildSegmentLabel` |
| 30 | + * answers `(empty)` for all of them: ONE group holding every record, with no |
| 31 | + * error, no warning and no empty state. It reads as "these records have no |
| 32 | + * value for this field" — a plausible, wrong, actionable conclusion about the |
| 33 | + * data rather than a visible bug in the view. |
| 34 | + * |
| 35 | + * ## ⛔ THE RESULT IS CANDIDATES, NOT VERIFIED FIELDS — the caller MUST gate it |
| 36 | + * |
| 37 | + * `GroupingFieldSchema.field` is a bare `z.ZodString`. Nothing in the schema |
| 38 | + * requires it to name a field the object declares, and the whole premise of |
| 39 | + * this harvest is that it has NOT been through column validation. Some backends |
| 40 | + * answer an unknown `$select` key with an EMPTY RESULT SET rather than ignoring |
| 41 | + * it — the cloud multi-tenant runtime does exactly that — so a single unknown |
| 42 | + * grouping field put in the projection unguarded silently zeroes the whole |
| 43 | + * list. That would convert this card's bug (one `(empty)` group holding every |
| 44 | + * row) into a strictly worse one (no rows at all, still silent). |
| 45 | + * |
| 46 | + * So every caller intersects this result with the object's declared fields — |
| 47 | + * {@link isProjectableField}, or the caller's equivalent known-field set — |
| 48 | + * exactly as {@link collectPredicateFieldRefs}'s callers do, and for the same |
| 49 | + * measured reason. Callers additionally FLS-gate it: a grouping field names a |
| 50 | + * field just as capable of being denied as a column is, and the projection is |
| 51 | + * what goes on the wire (objectui#6898). |
| 52 | + * |
| 53 | + * @param grouping - The view's `grouping` block in any authored state |
| 54 | + * (`undefined`, malformed, or the spec shape). Anything that is not an entry |
| 55 | + * carrying a non-empty string `field` contributes nothing, so a malformed |
| 56 | + * block yields an empty harvest instead of a plausible wrong name. |
| 57 | + * @returns Grouping field names in first-seen order, deduplicated. |
| 58 | + */ |
| 59 | +export function collectGroupingFieldRefs(grouping: unknown): string[] { |
| 60 | + const fields = (grouping as { fields?: unknown } | null | undefined)?.fields; |
| 61 | + if (!Array.isArray(fields)) return []; |
| 62 | + const out: string[] = []; |
| 63 | + const seen = new Set<string>(); |
| 64 | + for (const entry of fields) { |
| 65 | + // The spec shape is `{ field, order, collapsed }`. A bare string is NOT |
| 66 | + // accepted here even though it would be a natural shorthand: `grouping` is |
| 67 | + // a `$strict` object schema in `@objectstack/spec`, so a bare string is |
| 68 | + // off-spec metadata, and reading it anyway would be exactly the lenient |
| 69 | + // renderer-side alias AGENTS.md #0.1 forbids — it fossilizes a second |
| 70 | + // de-facto contract instead of having the producer rejected at publish. |
| 71 | + const name = (entry as { field?: unknown } | null | undefined)?.field; |
| 72 | + if (typeof name !== 'string') continue; |
| 73 | + const trimmed = name.trim(); |
| 74 | + if (!trimmed || seen.has(trimmed)) continue; |
| 75 | + seen.add(trimmed); |
| 76 | + out.push(trimmed); |
| 77 | + } |
| 78 | + return out; |
| 79 | +} |
0 commit comments