Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/7499-gantt-non-axis-floors-omitted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
---

Gantt views no longer hand the renderer invented `progress` / `dependencies`
field names (objectui#7499).

Flavour 3 of objectui#7070 — the half its 2026-09-01 ruling ordered carded
separately and judged on its own terms, ⛔ explicitly forbidding the date-axis
conclusion from being imported. Two faces floored the pair, and app-shell
carries neither key (measured: `|| 'progress'` counts 0 there; the control that
the zero is not a dead probe is that app-shell *does* floor `titleField` at
`'name'`):

- `plugin-list/src/ListView.tsx` — the `object-gantt` render branch.
- `plugin-view/src/ObjectView.tsx` — `generateViewSchema`, the authored
`object-view` element route, which bypasses `ListView` entirely.

**The remedy is OMIT, not refuse — and the asymmetry against the date axis is
exactly why the date-axis conclusion must not be imported.** A fabricated date
axis has no legitimate twin: every bar lands on a column nobody declared, a
whole-chart error, so refusal is right there. A fabricated `progress` /
`dependencies` name yields a per-row `undefined`, which is indistinguishable
from the legitimate and common case — most gantt rows have neither. Refusing
would break that common case; fabricating manufactured a binding the author
never wrote, whose failure was silent. Omission does neither.

**What changes for an author.** A view that declares neither `progressField`
nor `dependenciesField` now passes a config carrying neither key, instead of
`'progress'` / `'dependencies'`. A view that declares one passes the author's
value verbatim, exactly as before, through both the spec-canonical `gantt`
block and the legacy `options.gantt` nesting.

The only population whose behaviour actually changes is a schema carrying a
column literally named `progress` or `dependencies` while NOT declaring the
corresponding field key — those worked by accident, because the floor happened
to match the column name. That population was measured across `examples/`,
`content/docs/**`, the `packages/*/src` fixtures and all 528 JSON/YAML metadata
files before anything was deleted: it is **empty**. The census and its
blind-spot reading are in the PR.

Nothing about the refusal screen moves: `ObjectGantt.getGanttConfig` gates on
the two date fields alone, so deleting this pair cannot resurrect a config —
`plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx` is the
non-regression control and stays green.

⚠️ One consequence worth recording for objectui#6470, which is NOT touched
here. `getGanttConfig`'s flat branch reads
`schema.dependenciesField || schema.dependencyField`. While the floor was in
place, `dependenciesField` was always truthy through these two faces, so the
declared legacy singular alias `dependencyField` was unreachable via them —
shadowed, not read. With the floor gone, a view that wrote `dependencyField`
now resolves through the alias limb as its `@deprecated` contract already
promises. No in-repo view declares it (measured: zero sites at either routed
face), and the alias's own deprecation and retirement remain objectui#6470's
question, deliberately left alone.
31 changes: 24 additions & 7 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2693,11 +2693,30 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// does not throw) — pinned in
// `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`.
//
// ⛔ `progressField` / `dependenciesField` keep their floors here: they
// are NOT date axes, their absent-value semantics differ, and #7070
// scoped them out deliberately. Leaving them cannot resurrect a config —
// `getGanttConfig` gates on the two date fields alone (pinned in the same
// file), so the refusal stays reachable with the pair still present.
// `progressField` / `dependenciesField` are NOT floored either, as of
// objectui#7499 — the flavour-3 card #7070 scoped out and left pinned
// here so that whoever retired them had a place to declare it. This is
// that declaration. The remedy is OMIT, not refuse, and the two differ:
//
// - REFUSING would be wrong. Unlike a date axis, "no progress" and
// "no dependencies" are legitimate and common states — most gantt
// rows have neither — so an absent key must keep rendering exactly
// as it does today. That is why the date-axis conclusion (refuse)
// must NOT be imported here, and #7070's ruling forbids importing it.
// - FABRICATING was also wrong. `|| 'progress'` / `|| 'dependencies'`
// manufactured a binding the author never wrote. Its failure is a
// per-row `undefined`, indistinguishable from the legitimate case
// above — so an author who spelled the key differently got a silent
// accidental hit on a same-named column, with no diagnostic.
//
// Omitting satisfies both: a declared value still reaches the renderer
// verbatim through the `options.gantt` / `gantt` spreads below (which
// always had the last word over these lines anyway — the floor was all
// they ever contributed), and an undeclared one arrives as an ABSENT
// key rather than a fabricated name. Deleting them cannot resurrect a
// config: `getGanttConfig` gates on the two date fields alone, so the
// refusal screen stays exactly as reachable as it was (pinned in
// `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`).
const startDateField = schema.gantt?.startDateField || schema.options?.gantt?.startDateField;
const endDateField = schema.gantt?.endDateField || schema.options?.gantt?.endDateField;
return {
Expand All @@ -2709,8 +2728,6 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...(schema.data ? { data: schema.data } : {}),
...(startDateField ? { startDateField } : {}),
...(endDateField ? { endDateField } : {}),
progressField: schema.gantt?.progressField || schema.options?.gantt?.progressField || 'progress',
dependenciesField: schema.gantt?.dependenciesField || schema.options?.gantt?.dependenciesField || 'dependencies',
...(schema.gantt?.titleField ? { titleField: schema.gantt.titleField } : {}),
...(schema.options?.gantt || {}),
...(schema.gantt || {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@
* restore `|| 'start_date'` / `|| 'end_date'` on the two lines this card
* deletes and the "invents NO binding" case goes RED (the spy reads the
* fabricated names) while every declared-config case here stays GREEN.
*
* objectui#7499 retires the second pair at this face. `progressField` /
* `dependenciesField` were floored at `'progress'` / `'dependencies'` and
* pinned HERE as scope, deliberately, so that whoever retired them had a place
* to declare it. The remedy is OMIT, not refuse — the reasoning lives on the
* cases below, and the same reverse verification applies: restore
* `|| 'progress'` / `|| 'dependencies'` and the two OMITS cases go RED while
* every CONTROL stays GREEN.
*/

import React from 'react';
Expand Down Expand Up @@ -121,17 +129,67 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec
expect(props.endDateField).toBeUndefined();
});

it('invents no date binding when only the OTHER gantt keys are declared', async () => {
// ⛔ Scoped out of #7070 on purpose: `progressField` / `dependenciesField`
// keep their floors — not date axes, different absent-value semantics. This
// case is the measurement that keeping them does not resurrect an axis:
// `getGanttConfig` gates on the two DATE fields alone, so the refusal stays
// reachable with the pair still being handed down.
it('OMITS `dependenciesField` rather than inventing it, when only the OTHER gantt keys are declared (objectui#7499)', async () => {
// ⭐ THE DECLARATION SLOT. #7070 left this case pinning the floors as
// SCOPE, "so that whoever retires them has a place to declare it".
// objectui#7499 retires them, and the remedy is OMIT rather than REFUSE.
//
// WHY NOT REFUSE, the way the date axis does: a fabricated date axis has no
// legitimate twin — every bar lands on a column nobody declared, a
// whole-chart error — whereas absent progress and absent dependencies are
// legitimate and COMMON. Most gantt rows have neither. Refusing here would
// break the common case, which is why #7070's ruling forbids importing the
// date-axis conclusion into this pair.
//
// WHY NOT KEEP FABRICATING: the failure of a fabricated non-axis name is a
// per-row `undefined`, INDISTINGUISHABLE from that legitimate absence — so
// an author who spelled the key differently silently hit a same-named
// column with no diagnostic, and one who did not got a binding they never
// wrote. Omission keeps the legitimate absence rendering exactly as before
// and stops manufacturing the binding.
//
// The DECLARED half of the class predicate is asserted in the same breath:
// `progressField` still arrives verbatim.
const props = await ganttProps({ ...BASE, gantt: { progressField: 'pct' } });
expect(props.startDateField).toBeUndefined();
expect(props.endDateField).toBeUndefined();
expect(props.progressField).toBe('pct');
expect(props.dependenciesField).toBe('dependencies');
expect(props.dependenciesField).toBeUndefined();
expect('dependenciesField' in props).toBe(false);
});

it('OMITS both non-axis keys for a gantt view that declares no config (objectui#7499)', async () => {
// The undeclared half of the class predicate, at the same face. `'progress'`
// / `'dependencies'` used to be handed down here unconditionally.
const props = await ganttProps({ ...BASE });
expect(props.progressField).toBeUndefined();
expect(props.dependenciesField).toBeUndefined();
// Absent, not present-and-undefined: `getGanttConfig`'s flat branch reads
// `schema.dependenciesField || schema.dependencyField`, so a PRESENT
// `dependenciesField` — even undefined — is a different fact about this
// config than an absent one.
expect(Object.keys(props)).not.toContain('progressField');
expect(Object.keys(props)).not.toContain('dependenciesField');
});

it('CONTROL: a DECLARED `dependenciesField` still passes verbatim (objectui#7499)', async () => {
// The control that gives the two assertions above their meaning: this
// harness CAN see a `dependenciesField` on the captured props, so reading
// `undefined` there is a measurement and not a spy that never receives one.
const props = await ganttProps({ ...BASE, gantt: { dependenciesField: 'preds', progressField: 'pct' } });
expect(props.dependenciesField).toBe('preds');
expect(props.progressField).toBe('pct');
});

it('CONTROL: the legacy `options.gantt` nesting carries the pair verbatim too (objectui#7499)', async () => {
// Both nestings are authoring faces here, and the floors used to read both
// legs. Deleting them must not cost the `options.gantt` leg its pass-through.
const props = await ganttProps({
...BASE,
options: { gantt: { progressField: 'percent_done', dependenciesField: 'depends_on' } },
});
expect(props.progressField).toBe('percent_done');
expect(props.dependenciesField).toBe('depends_on');
});

it('CONTROL: forwards the spec-canonical `gantt` block unchanged', async () => {
Expand Down
20 changes: 14 additions & 6 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1491,10 +1491,20 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// (measured — it does not render empty and does not throw); pinned in
// `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`.
//
// ⛔ `progressField` / `dependenciesField` keep their floors: not date
// axes, different absent-value semantics, scoped out of #7070. They
// cannot resurrect a config on their own — `getGanttConfig` gates on the
// two date fields alone.
// `progressField` / `dependenciesField` are NOT floored either, as of
// objectui#7499 — the flavour-3 card #7070 scoped out and left pinned
// here so that whoever retired them had a place to declare it. OMIT,
// not refuse: "no progress" and "no dependencies" are legitimate and
// common states (unlike an absent date axis), so refusing would break
// the common case — but `|| 'progress'` / `|| 'dependencies'` invented
// a binding no author wrote, and its failure mode is a per-row
// `undefined` INDISTINGUISHABLE from that legitimate absence, so a
// misspelt key hit a same-named column silently. Omitting keeps the
// legitimate case rendering exactly as before while a declared value
// still passes verbatim through the `viewOptions.gantt` spread below.
// Deleting them cannot resurrect a config — `getGanttConfig` gates on
// the two date fields alone, so the refusal screen stays as reachable
// as it was (`plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070`).
return {
type: 'object-gantt',
...baseProps,
Expand All @@ -1504,8 +1514,6 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
...(viewOptions.gantt?.endDateField
? { endDateField: viewOptions.gantt.endDateField }
: {}),
progressField: viewOptions.gantt?.progressField || 'progress',
dependenciesField: viewOptions.gantt?.dependenciesField || 'dependencies',
...(viewOptions.gantt || {}),
};
case 'map':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
* REVERSE VERIFICATION — direction predicted before running, then observed:
* restore `|| 'start_date'` / `|| 'end_date'` and the "invents NO binding" case
* goes RED while every CONTROL stays GREEN in either world.
*
* objectui#7499 retires the non-axis pair at this face too. `progressField` /
* `dependenciesField` were floored at `'progress'` / `'dependencies'` and
* pinned HERE as scope, deliberately, so that whoever retired them had a place
* to declare it. The remedy is OMIT, not refuse — reasoning on the case below.
* Same reverse verification: restore `|| 'progress'` / `|| 'dependencies'` and
* the OMITS case goes RED while every CONTROL stays GREEN.
*/

import { describe, it, expect, vi } from 'vitest';
Expand Down Expand Up @@ -96,13 +103,53 @@ describe('ObjectView.generateViewSchema — gantt restates only a DECLARED bindi
expect(schema.endDateField).toBeUndefined();
});

it('keeps the out-of-scope `progress` / `dependencies` floors (objectui#7070 scope)', async () => {
// ⛔ Not date axes, different absent-value semantics, deliberately left. The
// point of pinning them is that the SCOPE is visible: if a later card
// retires them, this case is where it declares that it did.
it('OMITS `progress` / `dependencies` rather than inventing them — the retirement #7070 left a slot for (objectui#7499)', async () => {
// ⭐ THE DECLARATION SLOT, and this case IS the declaration. #7070 pinned
// the pair's presence as SCOPE, "so that whoever retires them has a place
// to declare it"; objectui#7499 retires them.
//
// OMIT, not REFUSE — and the asymmetry against the date axis is the whole
// reason #7070's ruling forbids importing the date-axis conclusion here:
//
// fabricated DATE AXIS → whole-chart error, every bar on a column
// nobody declared. No legitimate twin exists.
// fabricated progress/deps → per-row `undefined`, which is exactly what
// the LEGITIMATE and COMMON case looks like.
// Most gantt rows have neither.
//
// So refusing would break the common case, and fabricating manufactured a
// binding the author never wrote whose failure was silent — an author who
// spelled the key differently got an accidental hit on a same-named column
// and no diagnostic. Omission keeps the legitimate absence rendering
// exactly as before and stops inventing the name.
const schema = await renderGanttView({});
expect(schema.progressField).toBe('progress');
expect(schema.dependenciesField).toBe('dependencies');
expect(schema.progressField).toBeUndefined();
expect(schema.dependenciesField).toBeUndefined();
// Absent, not present-and-undefined. `getGanttConfig`'s flat branch reads
// `schema.dependenciesField || schema.dependencyField`, so whether the key
// is PRESENT is a different fact about this config than what it holds.
expect(Object.keys(schema)).not.toContain('progressField');
expect(Object.keys(schema)).not.toContain('dependenciesField');
});

it('CONTROL: a DECLARED `progressField` / `dependenciesField` still passes verbatim (objectui#7499)', async () => {
// The control that gives the omission case its meaning: this harness CAN
// see the pair on the rendered schema, so reading `undefined` above is a
// measurement rather than a probe that never had anything to find.
const schema = await renderGanttView({
gantt: { progressField: 'percent_done', dependenciesField: 'depends_on' },
});
expect(schema.progressField).toBe('percent_done');
expect(schema.dependenciesField).toBe('depends_on');
});

it('CONTROL: declaring ONE of the pair leaves the other absent, not fabricated (objectui#7499)', async () => {
// The class predicate's mixed case, the one the floors used to erase: a
// view that declares progress but not dependencies got `'dependencies'`
// handed down anyway.
const schema = await renderGanttView({ gantt: { progressField: 'percent_done' } });
expect(schema.progressField).toBe('percent_done');
expect(schema.dependenciesField).toBeUndefined();
});

it('CONTROL: forwards a declared gantt block unchanged', async () => {
Expand Down
Loading