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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
53 changes: 53 additions & 0 deletions .changeset/6837-gantt-tree-referenceto-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-tree': minor
---

`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
spellings a contract carries, dropping the third one no contract declares
(objectui#6837, second slice).

- `ObjectGantt`'s quick-filter option fetch was
`fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
`fd?.reference_to ?? fd?.reference`.
- `ObjectTree`'s `detectParentField` was
`def?.reference || def?.reference_to || def?.referenceTo`; it is now
`def?.reference || def?.reference_to`.

**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
at these two seams.** Concretely: the gantt quick filter for that field falls
back to the distinct values present in the loaded rows instead of fetching the
referenced object's full domain, and the tree stops auto-detecting that field as
its parent pointer, so records render as a flat forest unless `parentField` is
configured explicitly. Nothing else changes; the two surviving arms are
untouched.

Two things bound that move:

- Any def that entered through the ingestion choke point is unaffected.
`normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
already carries `reference_to` before either component sees it. Only a def
that bypassed that door entirely is affected — and that door is not total:
`getObjectSchema` is a required member of the published `DataSource`
interface, and both components call it on the generic `dataSource`.
- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
`FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
at all three strip sites, so the designer read door removes it before a draft
round-trips.

A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
reaching either seam, measured in the cell these components read (a value inside
an object schema's `fields` container) against controls `reference` (92 hits / 36
files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
the only two in-cell hits are negative fixtures of the retirement machinery,
asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
surviving spellings.

Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
named refusal for the deleted key.
285 changes: 285 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6837 (second slice) — the gantt quick-filter's relationship-target
* chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
* value.
*
* Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
* After: `fd?.reference_to ?? fd?.reference`
*
* Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
* which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
*
* ## 1. The measurement this pin stands on (not just its conclusion)
*
* THE CELL: a value inside an object schema's `fields` container — literally
* what this component reads, `objectSchema.fields[name]`. Producer census by
* STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
* parsed JSON), recording each hit's ancestor property chain; EMIT positions
* only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
* — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
* and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
* Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
* THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|------------------|-------------|
* | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
* | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
* | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
*
* Both halves of the control discipline. (1) THE QUERY RAN: the controls are
* hot — 92 and 52 — in the very cells where the subject collapses to 2, from
* the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
* subject and control together; instead it separates 92-to-2. Third check, the
* one only this key affords: `referenceTo` is not a term the scanner cannot
* see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
* only under the cell restriction. The zero-ish is produced by the RESTRICTION,
* not by scanner blindness.
*
* The two surviving in-cell hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys.test.ts:235`,
* `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
* the retired key precisely to assert the read door STRIPS it before
* `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
* producer.
*
* SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
* `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
* cell — while its own fixture corpus is hot on both surviving spellings
* (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
* `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
* actually feeds THIS reader is hot on what survives and empty on what goes.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
* two-directionally on this branch's installed copy:
*
* - `reference: 'crm_account'` → ACCEPT
* - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
* - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
* "Did you mean `referenceTo` → `reference`?"
*
* The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
* the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
* is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
* (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
* `specEquivalent: 'reference'`) at all three strip sites, so the designer read
* door removes it before a draft round-trips. So this arm was not a "redundant"
* fallback: it was INVENTED tolerance surface — a silent absorption point for a
* producer that ought to fail visibly (AGENTS.md #0.1).
*
* ⚠️ What this does NOT rest on: any claim that no production producer of
* `reference_to` exists. That question cannot be answered from inside this repo
* — restricting the cell to production files collapses the CONTROL too, and
* this repo is a UI library, not a metadata-app repo. `reference_to` and
* `reference` are therefore deliberately untouched here; see §4.
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* The deleted arm sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
* contract-carrying spelling. There is therefore NO inversion case to pin, and
* this file deliberately does not invent one: a
* `{ reference: 'projects', referenceTo: 'other' }` case resolves to
* `'projects'` both before and after the change and would measure nothing.
* (Same call, for the same reason, as PR #6916 and PR #6920.)
*
* ## 4. THE FLOOR, restated where someone would try to re-widen it
*
* ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
* spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
* choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
* keys from whichever spelling arrived. Never a renderer-side alias: that is
* how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
* own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
*
* ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
* its classification table measured why a mechanical sweep would be wrong: the
* ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
* report columns, designer fields, related-list config) declare `reference_to`,
* `referenceTo` and `referenceField` but NONE of them declares `reference` —
* these readers sit on a TIER BOUNDARY rather than choosing between a legacy
* and a canonical spelling of one key. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore the deleted arm on the committed tree and the refusal below goes RED
* while every live-arm control stays GREEN — that contrast is what makes the
* controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
* file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
* `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
* hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
*/
import React from 'react';
import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

afterEach(cleanup);

/**
* GanttView is mocked to a thin shell that surfaces the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
* of the option fetch, not of how GanttView paints bars.
*/
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} />
),
}));

/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
const TASKS = [
{ id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
{ id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
];

/** The referenced object's full domain — reachable ONLY by resolving the target. */
const PROJECTS = [
{ id: 'p1', name: 'Apollo' },
{ id: 'p2', name: 'Borealis' },
{ id: 'p3', name: 'Cygnus' },
];

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', reference_to: 'projects' },
spec_spelling: { type: 'lookup', reference: 'projects' },
// Deleted arm — refused by `FieldSchema` by name, retired at the read door.
legacy_camel: { type: 'lookup', referenceTo: 'projects' },
};

function makeDataSource(projectDef: Record<string, unknown>) {
return {
find: vi.fn(async (object: string) =>
object === 'projects' ? { data: PROJECTS } : { data: TASKS },
),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'task',
fields: {
name: { type: 'text' },
start: { type: 'date' },
end: { type: 'date' },
project: projectDef,
},
}),
} as any;
}

const GANTT_SCHEMA = {
type: 'gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
quickFilters: [{ field: 'project', label: 'Project' }],
} as any;

/**
* Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
*
* The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
* lands, the record query is re-issued carrying `$expand`, and
* `buildExpandFields` decides that from the field's `type` alone ("the
* `reference` / `reference_to` target is irrelevant to the decision"). So a
* `find('task', { $expand: [...] })` call proves the component consumed this
* schema — for the refusal probe just as much as for the live-arm ones. The
* option-fetch effect shares that commit and runs synchronously up to its own
* `find`, so by the time this resolves, a resolving arm has ALREADY recorded
* `find('projects', …)`.
*/
async function mount(projectDef: Record<string, unknown>) {
const ds = makeDataSource(projectDef);
const view = render(<ObjectGantt schema={GANTT_SCHEMA} dataSource={ds} />);
await waitFor(() =>
expect(
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
return { ds, view };
}

/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
const fetchedDomain = (ds: any) =>
ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');

describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
const { ds } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
const { ds } = await mount(FIELD_DEFS.spec_spelling);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});

it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
// The user-visible half: `p2`/`p3` exist only on the referenced object.
const { ds, view } = await mount(FIELD_DEFS.canonical);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
await waitFor(() => {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
});
});
});

describe('refusal — one named case for the deleted key', () => {
it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
const { ds } = await mount(FIELD_DEFS.legacy_camel);
expect(fetchedDomain(ds)).toBe(false);
});

it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
// Guards the refusal above against the degenerate pass: a gantt that
// rendered no quick filter at all would also never fetch `projects`.
const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
const panel = view.getByTestId('quick-filter-panel-project');
expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
expect(fetchedDomain(ds)).toBe(false);
});
});

describe('the ingestion choke point is what makes the deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
// The mechanism, not a formality: the normalizer reads
// `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
// keys, so every def that entered through `MetadataProvider` or
// `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
// the time this component sees it. The deleted arm was dead weight there.
//
// ⚠️ And this is exactly why the pin above still matters: the door is
// NOT total. `getObjectSchema` is a required member of the published
// `DataSource` interface and this component calls it on the generic
// `dataSource`, so a third-party implementation reaches this reader raw.
const def = { ...FIELD_DEFS.legacy_camel };
const schema = { name: 'task', fields: { project: def } };
normalizeSchemaReferenceKeys(schema);
const { ds } = await mount(schema.fields.project as Record<string, unknown>);
await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
});
});
});
13 changes: 10 additions & 3 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
// convention); reference_to/referenceTo cover ObjectUI-authored defs.
const refObject: string | undefined =
fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
// convention); `reference_to` covers ObjectUI-authored defs.
//
// A third arm, `referenceTo`, was deleted by objectui#6837: no contract
// declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
// it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
// `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
// (objectui#6041), so the designer read door strips it. It was not a
// redundant fallback but invented tolerance surface. Pinned in
// `ObjectGantt.referenceArms-6837.test.tsx`.
const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
Expand Down
Loading
Loading