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
49 changes: 49 additions & 0 deletions .changeset/6050-gantt-navigation-basepath.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
'@object-ui/plugin-gantt': patch
---

The package README stops documenting a `navigation` key the spec refuses, and the corrected example is now parsed by the schema that validates it.

`README.md`'s record-navigation override read
`{ mode: 'page', basePath: '/console/apps/.../campaign' }`. `basePath` is not a
member of the spec's `NavigationConfig`, and nothing consumes it:
`useNavigationOverlay` — where a gantt's `navigation` lands — builds no URL out
of the config, and `ObjectGantt` calls the hook with no `onNavigate`, so a
page-mode click falls through to the host's `onRowClick`. The destination route
is owned by the host and was never authorable through this key, under any
spelling.

That made the snippet worse than inert. `NavigationConfigSchema` is a strict
object with no passthrough, so the undeclared key did not fall away quietly — it
rejected the **whole** config with `unrecognized_keys`, taking down the
`mode: 'page'` the sentence was actually teaching. An author who copied the
documented snippet got a rejected navigation config and no page navigation, which
is the copy-the-snippet-get-rejected shape objectui#5057 / #5012 named on other
keys.

The example is corrected to `{ "navigation": { "mode": "page" } }` — the shape
the sentence demonstrates — and the prose now says who owns the destination route
and points at `@objectstack/spec`'s `NavigationConfigSchema` for the member list
instead of restating it, matching the derivation `ObjectGanttSchema.navigation`'s
doc comment (objectui#5903) adopted for the same concept.

`view` is **not** substituted for `basePath`. It is a declared member, but it
names a form view (the spec: *"Name of the form view to use for details"*) and is
forwarded to `onNavigate` as the action argument — it is not a route, so putting
it where `basePath` stood would have replaced an invented key with a wrong one.
It is documented for what it does.

No gate in this repo could have caught the original defect, and that is why the
fix ships with a measurement rather than a re-reading: `check-doc-snippet-types`
compiles `ts`/`tsx` fences and `check-doc-component-types` reads `type` literals,
and both are structurally blind to a metadata key in a README — the former's own
header records schema-key validity as "a different question … left unruled on
purpose". `src/readme-navigation-example.test.ts` closes that hole for this
example by EXTRACTING the fence from the README on every run and parsing it
against `NavigationConfigSchema`, with a control asserting the same parse still
rejects an undeclared key by name, so the green cannot come from a schema that
accepts everything.

`tsconfig.test.json` names `node` in `types` for that test to compile, and its
comment — which had recorded that no test in this package touches a Node global
— is corrected rather than left standing.
19 changes: 16 additions & 3 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,9 +44,22 @@ When used through `ObjectGantt` (the wiring the framework uses for the
fetched by `DetailView` itself when `dataSource.getObjectSchema` is
available).

Override by setting `navigation` on the schema, e.g.
`{ mode: 'page', basePath: '/console/apps/.../campaign' }` to route
to the standalone detail page instead.
Override by setting `navigation` on the schema: set `{ "mode": "page" }` to
route to the standalone detail page instead.

```json
{ "navigation": { "mode": "page" } }
```

The destination route is **not** authorable here — `useNavigationOverlay`
builds no URL out of this config, so page mode hands the record to the
host's `onNavigate` / `onRowClick` and the host owns where it lands. To
choose *which* detail view opens, use the declared `view` member (a
form-view name, e.g. `"summary_view"`). `navigation` is the spec's
`NavigationConfig`, and its schema refuses any key it does not declare: an
undeclared key rejects the whole config, so the `mode` beside it never
takes effect either. `@objectstack/spec`'s `NavigationConfigSchema` owns the
member list.


### Drag-and-drop rescheduling
Expand Down
210 changes: 210 additions & 0 deletions packages/plugin-gantt/src/readme-navigation-example.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
/**
* 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.
*/

/**
* `packages/plugin-gantt/README.md`'s record-navigation example is PARSED by
* the schema that validates it — `@objectstack/spec`'s `NavigationConfigSchema`
* (objectui#6050).
*
* ## Why this file exists
*
* The example documented `{ mode: 'page', basePath: '/console/apps/.../campaign' }`.
* `basePath` is not a `NavigationConfig` member, and no read site consumes it:
* `useNavigationOverlay` — where a gantt's `navigation` lands — builds no URL
* out of the config at all, and `ObjectGantt` calls it with no `onNavigate`, so
* a page-mode click falls through to the host's `onRowClick`. The destination
* route was never authorable through that key, by any spelling.
*
* That made the snippet worse than inert. `NavigationConfigSchema` is a strict
* object, so the undeclared key did not fall away quietly — it REJECTED the
* whole config (`unrecognized_keys`), and the `mode: 'page'` the sentence was
* actually teaching never took effect. An author copying the documented snippet
* got a rejected navigation config.
*
* It was found exactly this way: objectui#5903's pin test used this README's
* example verbatim as its "well-typed" fixture, and the fixture failed.
*
* ## No gate in this repo can catch it, which is the point
*
* `check-doc-snippet-types` compiles `ts`/`tsx` fences and
* `check-doc-component-types` reads `type` literals; both are structurally
* blind to a metadata key in a README. That gate's own header names the hole:
* schema-key validity is "a different question with a different answer …
* left unruled on purpose". A README example can be rejected by the spec while
* every gate stays green. This test is the measurement that closes it for this
* one example.
*
* ## The example is EXTRACTED, never retyped
*
* The fence is read out of the README on every run and parsed as JSON. A hand
* copy drifts from the file it claims to pin and therefore pins nothing — a
* snippet nobody re-measured is the defect this test exists to prevent, so
* reproducing it inside the test would be self-defeating. A moved heading, a
* removed fence or a non-JSON body throws out of the extractor rather than
* vacuously passing on an empty fixture.
*
* ## The green carries its own control
*
* A `safeParse` that succeeds proves nothing on its own if the schema accepts
* everything, so the historical shape is parsed alongside and must be REJECTED
* by name. The pair is the measurement; neither half alone is one.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { NavigationConfigSchema } from '@objectstack/spec/ui';

/** Walk up to the workspace root, so the README is found by repo layout. */
function repoRoot(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 10; i += 1) {
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir;
dir = resolve(dir, '..');
}
throw new Error('repo root (pnpm-workspace.yaml) not found from this test file');
}

const README = join(repoRoot(), 'packages/plugin-gantt/README.md');

/** The bullet that documents the override, and the sentence that introduces it. */
const SECTION_HEADING = '\n### Create / Edit / Delete / View\n';
const ANCHOR = 'Override by setting `navigation` on the schema';

/**
* The declared member names, read out of the schema's OWN shape. Restating them
* here would fork the vocabulary — the drift `ObjectGanttSchema.navigation`'s
* doc comment (objectui#5903) exists to prevent — so they are derived instead.
*/
interface ShapeCarrier {
shape?: Record<string, unknown>;
_def?: {
getter?: () => ShapeCarrier;
innerType?: ShapeCarrier;
shape?: Record<string, unknown> | (() => Record<string, unknown>);
};
}

function declaredMembers(): Set<string> {
let node = NavigationConfigSchema as unknown as ShapeCarrier;
for (let hop = 0; hop < 6; hop += 1) {
if (node.shape) return new Set(Object.keys(node.shape));
const def = node._def;
if (def?.getter) { node = def.getter(); continue; }
if (def?.innerType) { node = def.innerType; continue; }
if (typeof def?.shape === 'function') return new Set(Object.keys(def.shape()));
if (def?.shape) return new Set(Object.keys(def.shape));
break;
}
throw new Error('NavigationConfigSchema no longer exposes an object shape — cannot derive its members');
}

/** The README section this example lives in, bounded to its own heading. */
function navigationSection(): string {
const src = readFileSync(README, 'utf8');
const start = src.indexOf(SECTION_HEADING);
if (start < 0) throw new Error(`"${SECTION_HEADING.trim()}" heading not found in ${README}`);
const next = src.indexOf('\n### ', start + 1);
const section = src.slice(start, next < 0 ? undefined : next);

const anchors = section.split(ANCHOR).length - 1;
if (anchors !== 1) {
throw new Error(`expected exactly one "${ANCHOR}" in the section, found ${anchors}`);
}
return section;
}

/** The `json` fence that follows the anchor sentence — the example itself. */
function readmeExample(): Record<string, unknown> {
const section = navigationSection();
const after = section.slice(section.indexOf(ANCHOR));
const fence = /^[ \t]*```json[ \t]*\n([\s\S]*?)\n[ \t]*```/m.exec(after);
if (!fence) throw new Error('no ```json fence follows the navigation-override sentence in the README');

// Not wrapped in a try/catch: `JSON.parse`'s own `SyntaxError` names the
// offending token and is thrown from this line, which is louder than anything
// a re-throw could add. (A wrapper would also have to attach the caught error
// as a `cause` to satisfy `preserve-caught-error`, and `Error.cause` is ES2022
// — above this project's ES2020 lib.)
const parsed: unknown = JSON.parse(fence[1]);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('the README\'s navigation example is not a JSON object');
}
const doc = parsed as Record<string, unknown>;
const nav = doc.navigation;
if (typeof nav !== 'object' || nav === null || Array.isArray(nav)) {
throw new Error('the README\'s navigation example no longer carries a `navigation` object');
}
return nav as Record<string, unknown>;
}

const MEMBERS = declaredMembers();
const EXAMPLE = readmeExample();

/** The historical shape, kept only as the control's input. */
const REJECTED_KEY = 'basePath';

function unrecognizedKeys(issues: readonly { code: string }[]): string[] {
const out: string[] = [];
for (const issue of issues) {
if (issue.code === 'unrecognized_keys') {
out.push(...((issue as { keys?: string[] }).keys ?? []));
}
}
return out;
}

describe('plugin-gantt README: the record-navigation example', () => {
it('is a shape `NavigationConfigSchema` ACCEPTS', () => {
const result = NavigationConfigSchema.safeParse(EXAMPLE);
expect(
result.success ? [] : result.error.issues.map((i) => `${i.code} ${JSON.stringify(i.path)}: ${i.message}`),
'The documented example must survive the schema that validates it. An author who copies ' +
'this snippet is handing it to exactly this parse — a rejected config renders nothing ' +
'the snippet promises, including the `mode` beside the offending key.',
).toEqual([]);
});

it('CONTROL: the same parse still REJECTS an undeclared key by name', () => {
const result = NavigationConfigSchema.safeParse({ ...EXAMPLE, [REJECTED_KEY]: '/console/apps/.../campaign' });
expect(
result.success,
'This control is what makes the assertion above a measurement rather than a schema that ' +
'accepts anything. If this ever passes, `NavigationConfigSchema` has stopped being ' +
'strict and the green above no longer says what it claims.',
).toBe(false);
expect(result.success ? [] : unrecognizedKeys(result.error.issues)).toContain(REJECTED_KEY);
});

it('names only members the schema declares', () => {
const undeclared = Object.keys(EXAMPLE).filter((k) => !MEMBERS.has(k));
expect(
undeclared,
`The README example must not teach a key \`NavigationConfigSchema\` does not declare. ` +
`\`${REJECTED_KEY}\` was the original offender: a route prefix, in a config that owns no route.`,
).toEqual([]);
});

it('still teaches the thing its sentence promises — page mode', () => {
expect(
EXAMPLE.mode,
'The sentence promises "route to the standalone detail page instead". `mode` is what ' +
'delivers that; an example that lost it would be valid and useless.',
).toBe('page');
});

it('does not reintroduce the route-prefix key anywhere in the section', () => {
expect(
navigationSection().includes(REJECTED_KEY),
`\`${REJECTED_KEY}\` is not authorable here in any spelling — prose or fence. ` +
'`useNavigationOverlay` builds no URL out of this config; the host owns the route.',
).toBe(false);
});
});
21 changes: 15 additions & 6 deletions packages/plugin-gantt/tsconfig.test.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,12 +9,21 @@
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
// No `types` override: nothing in this package's 39 test files reaches for a
// global augmentation. They assert on DOM nodes directly rather than through
// `@testing-library/jest-dom`'s matchers, and none of them touches a Node
// global — so the default automatic `@types/*` inclusion is exactly right
// here, and naming `types` would only switch it off. Same shape as
// `packages/providers/tsconfig.test.json`.
// These tests still assert on DOM nodes directly rather than through
// `@testing-library/jest-dom`'s matchers, so no matcher augmentation is
// named here. `node` is, and only because one test needs it:
// `readme-navigation-example.test.ts` reads this package's README off disk
// to hold the published `navigation` example to the schema that validates
// it (objectui#6050) — the same reason `packages/plugin-calendar` and
// `packages/layout` name it in their own test configs. Naming `types` at all
// switches off automatic `@types/*` inclusion, which is why this is a list
// rather than a single addition; `@types/react` / `@types/react-dom` keep
// resolving through the `react` imports the `.tsx` tests already make, as
// they do in `packages/plugin-calendar/tsconfig.test.json`.
//
// It stays OUT of `tsconfig.json`: package SOURCE ships to browsers and must
// not compile against Node APIs.
"types": ["node"],
//
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and
// `@objectstack/spec` resolve through the workspace dependency's built
Expand Down
Loading