@@ -7,7 +7,7 @@ import { ComponentRegistry, chartMeasureKey, humanizeLabel, extractRecords, comp
77import { Sheet , SheetContent , SheetHeader , SheetTitle , Dialog , DialogContent , DialogHeader , DialogTitle , RefreshIndicator , Button , ChartSkeleton , DataEmptyState } from '@object-ui/components' ;
88import { AlertCircle , ArrowUpRight , Inbox } from 'lucide-react' ;
99import { builtinAggregateLabels , useSafeFieldLabel , useSafeTranslate } from '@object-ui/i18n' ;
10- import type { DrillDownConfig } from '@object-ui/types' ;
10+ import type { DrillDownConfig , ObjectChartSchema } from '@object-ui/types' ;
1111
1212/**
1313 * Humanize a snake_case or kebab-case string into Title Case.
@@ -126,14 +126,46 @@ export function resolveChartCategoryField(schema: {
126126/** Suffix the previous-window value carries under a compareTo overlay. */
127127export const COMPARISON_SUFFIX = '__comparison' ;
128128
129+ /**
130+ * The COLUMN an inline `aggregate` projects its group under — the string every
131+ * column lookup in this file needs.
132+ *
133+ * `groupBy` is a union: a bare field name, or the structured date-bucketing
134+ * node `{ field, dateGranularity, alias }` the engine takes, in which case the
135+ * projected column is the `alias` (or the `field` it defaults to). This is that
136+ * normalisation, hoisted out of the comparison-merge leg below, which has
137+ * spelled it inline since the structured node arrived — "Normalise to the
138+ * underlying string field name so all column lookups work".
139+ *
140+ * ⚠️ Module-local on purpose: it is a spelling this file already owned twice,
141+ * not a new published export. It is also NOT a fourth answer to "what is the
142+ * category axis" — {@link resolveChartCategoryField} remains that one, and it
143+ * answers a wider question (it also resolves the spec's `xAxis` through
144+ * `normalizeChartSchema`). This one answers only "which column did the
145+ * aggregate project the group under", which is what a row lookup needs.
146+ */
147+ function aggregateGroupByKey (
148+ aggregate : ObjectChartSchema [ 'aggregate' ] ,
149+ ) : string | undefined {
150+ const gb = aggregate ?. groupBy ;
151+ if ( gb && typeof gb === 'object' && ! Array . isArray ( gb ) ) return gb . alias || gb . field ;
152+ return typeof gb === 'string' ? gb : undefined ;
153+ }
154+
129155/**
130156 * Client-side aggregation for fetched records.
131157 * Groups records by `groupBy` field and applies the aggregation function
132158 * to the `field` values in each group.
159+ *
160+ * `function` is optional because the `switch` below already implements that:
161+ * an absent (or unknown) function falls through to `sum`. The parameter used to
162+ * say `function: string`, which was a claim about the CALLER rather than about
163+ * this body — and no caller could satisfy it once `ObjectChartSchema.aggregate`
164+ * was declared with the requiredness its own reads have (objectui#7946).
133165 */
134166export function aggregateRecords (
135167 records : any [ ] ,
136- aggregate : { field ?: string ; function : string ; groupBy : string }
168+ aggregate : { field ?: string ; function ? : string ; groupBy : string }
137169) : any [ ] {
138170 const { field, function : aggFn , groupBy } = aggregate ;
139171 const valueKey = aggregateValueKey ( aggregate ) ;
@@ -358,11 +390,53 @@ export async function resolveGroupByLabels(
358390// Re-export extractRecords from @object-ui/core for backward compatibility
359391export { extractRecords } from '@object-ui/core' ;
360392
361- export const ObjectChart = ( props : any ) => {
393+ /**
394+ * Props of {@link ObjectChart} — anchored to the published `ObjectChartSchema`
395+ * (objectui#7946, maintainer ruling 2026-09-09 option A), as objectui#6576 did
396+ * for `ObjectGalleryProps.schema`.
397+ *
398+ * ## What this replaces, and what it buys
399+ *
400+ * This component was published as `(props: any)`, so every `schema={{ … }}`
401+ * literal handed to it — including the two in `app-shell`'s `ObjectView` — was
402+ * type-checked against NOTHING. That is the mechanism that let objectui#7891's
403+ * undeclared `config` rung live from the day it was written until someone read
404+ * the spec by hand, and it is why the two `as any` casts on those literals
405+ * measured INERT: an `any` consumer accepts a cast and its absence alike.
406+ *
407+ * With the anchor, a wrong VALUE TYPE on a declared key is a compile error at
408+ * the producer (`xAxisKey: 42`, `series: 'x'`, `type: 'chart'`, and every
409+ * `BaseSchema` member — `visible: 42`). ⚠️ A MISSPELLED key is still accepted:
410+ * `BaseSchema` carries `[key: string]: any` (objectui#5155), the same ceiling
411+ * objectui#6576 accepted knowingly. `__tests__/ObjectChart.schemaAnchor-7946.test.ts`
412+ * pins both halves, the ceiling included, so the anchor is not read as more
413+ * than it is.
414+ */
415+ export interface ObjectChartProps {
416+ /**
417+ * The `object-chart` node — anchored to the exported schema type. Every
418+ * `BaseSchema` member is writable, `bind` / `className` / `data` included;
419+ * the widget's own keys are declared there.
420+ */
421+ schema : ObjectChartSchema ;
422+ /**
423+ * Host data source. `any` deliberately, and it is NOT a residue of the shape
424+ * this card removed: it is the type `SchemaRendererContext.dataSource`
425+ * itself carries, and this component falls back to that context value, so a
426+ * narrower declaration here would claim a guarantee the fallback cannot
427+ * keep. Narrowing it is a repo-wide `dataSource` interface, not this card.
428+ */
429+ dataSource ?: any ;
430+ /**
431+ * Optional host-owned segment click. When provided (e.g. a dataset widget
432+ * that owns precise drill-through), it takes over the chart click and the
433+ * widget's own object-drill drawer is suppressed.
434+ */
435+ onSegmentClick ?: ( ev : ChartSegmentClickEvent ) => void ;
436+ }
437+
438+ export const ObjectChart = ( props : ObjectChartProps ) => {
362439 const { schema } = props ;
363- // Optional host-owned segment click. When provided (e.g. a dataset widget
364- // that owns precise drill-through), it takes over the chart click and the
365- // widget's own object-drill drawer is suppressed.
366440 const onSegmentClick : ( ( ev : ChartSegmentClickEvent ) => void ) | undefined = props . onSegmentClick ;
367441 const context = useContext ( SchemaRendererContext ) ;
368442 const dataSource = props . dataSource || context ?. dataSource ;
@@ -616,8 +690,16 @@ export const ObjectChart = (props: any) => {
616690 if ( typeof ds . find === 'function' ) {
617691 const results = await ds . find ( schema . objectName , { $filter : filterForRun } ) ;
618692 let data = extractRecords ( results ) ;
619- if ( schema . aggregate && data . length > 0 ) {
620- data = aggregateRecords ( data , schema . aggregate ) ;
693+ // `aggregateRecords` buckets on `record[groupBy]`, so it needs the
694+ // projected COLUMN, not the raw union: a structured `groupBy` node used
695+ // as an index stringifies, and every row lands in one bucket keyed by
696+ // that stringification. Declaring the key (objectui#7946) is what made
697+ // that reachable to a compiler. When no column resolves there is nothing
698+ // to group by, and the chart is already refused for exactly that reason
699+ // by the absent-category screen below (objectui#8168).
700+ const clientGroupBy = aggregateGroupByKey ( schema . aggregate ) ;
701+ if ( schema . aggregate && clientGroupBy && data . length > 0 ) {
702+ data = aggregateRecords ( data , { ...schema . aggregate , groupBy : clientGroupBy } ) ;
621703 }
622704 return data ;
623705 }
@@ -753,15 +835,23 @@ export const ObjectChart = (props: any) => {
753835 // Resolve groupBy value→label using field metadata. Now that the
754836 // merge has happened on raw keys, the resolver can convert the
755837 // shared groupBy column (e.g. 'closed_won' → 'Closed Won') uniformly.
756- if ( groupByField && typeof ds . getObjectSchema === 'function' ) {
838+ // `schema.objectName` joins the guard rather than being asserted:
839+ // it is OPTIONAL since ADR-0021 (a chart may bind a `dataset`
840+ // instead), and every read in this leg — the metadata fetch and the
841+ // per-option label lookup — is keyed by an object NAME. Naming the
842+ // precondition is what `props: any` used to hide (objectui#7946);
843+ // the leg is on the object-bound path, so it is the shape it already
844+ // assumed.
845+ if ( groupByField && schema . objectName && typeof ds . getObjectSchema === 'function' ) {
846+ const objectName = schema . objectName ;
757847 try {
758- const objectSchema = await ds . getObjectSchema ( schema . objectName ) ;
848+ const objectSchema = await ds . getObjectSchema ( objectName ) ;
759849 data = await resolveGroupByLabels (
760850 data ,
761851 groupByField ,
762852 objectSchema ,
763853 ds ,
764- ( value , fallback ) => fieldOptionLabel ( schema . objectName , groupByField , value , fallback ) ,
854+ ( value , fallback ) => fieldOptionLabel ( objectName , groupByField , value , fallback ) ,
765855 ) ;
766856 } catch {
767857 // Schema fetch failed — continue with raw values
@@ -853,7 +943,13 @@ export const ObjectChart = (props: any) => {
853943 // composes an `object-chart` schema directly, and stays absent from the
854944 // registry `inputs` below.
855945 const drillDown = ( schema as { drillDown ?: DrillDownConfig } ) . drillDown ;
856- const groupByField = schema . aggregate ?. groupBy || schema . xAxisKey ;
946+ // Spelled through the shared normalisation rather than `aggregate?.groupBy`
947+ // raw: this value is used as a ROW INDEX (`row[groupByField]`), as a FIELD
948+ // NAME (`fieldOptionLabel`) and as a drill-filter key, and a structured
949+ // `groupBy` node is none of those. The comparison-merge leg above has always
950+ // normalised before its own column lookups; this site did not, and `props:
951+ // any` is why nothing said so (objectui#7946).
952+ const groupByField = aggregateGroupByKey ( schema . aggregate ) || schema . xAxisKey ;
857953
858954 // Build a label→raw map from the resolved chart data. resolveGroupByLabels
859955 // stashes the original raw enum/id under `__raw_${groupByField}`. The chart
0 commit comments