diff --git a/.changeset/6050-gantt-navigation-basepath.md b/.changeset/6050-gantt-navigation-basepath.md new file mode 100644 index 0000000000..e7b6c13c0a --- /dev/null +++ b/.changeset/6050-gantt-navigation-basepath.md @@ -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. diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index b97a3f7d2e..68ddba5dc7 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -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 diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts new file mode 100644 index 0000000000..dc8811a778 --- /dev/null +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -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; + _def?: { + getter?: () => ShapeCarrier; + innerType?: ShapeCarrier; + shape?: Record | (() => Record); + }; +} + +function declaredMembers(): Set { + 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 { + 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; + 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; +} + +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); + }); +}); diff --git a/packages/plugin-gantt/tsconfig.test.json b/packages/plugin-gantt/tsconfig.test.json index ed5f2a6083..00dce8ce94 100644 --- a/packages/plugin-gantt/tsconfig.test.json +++ b/packages/plugin-gantt/tsconfig.test.json @@ -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