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
38 changes: 38 additions & 0 deletions .changeset/5903-objectgantt-declared-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
'@object-ui/types': minor
'@object-ui/plugin-gantt': minor
---

`ObjectGanttSchema` declares the ten gantt keys `ObjectGantt` actually reads
(objectui#5903, triage 2026-08-24). Every one is a real, working, documented
feature — `readOnly`, `mobileReadOnly`, `markers`, `navigation`, `skipWeekends`,
`holidays`, `criticalPath`, `showBaselines`, `persistLayout`, `viewName` — and
none of them was discoverable from the published type, because all ten were read
as `(schema as any).K`. The cast was the load-bearing part: it kept the read
invisible to `tsc`, to the zod mirror and to the designer's registry `inputs`.

Both halves move together. The TS declaration (`packages/types/src/objectql.ts`)
and its zod mirror (`src/zod/objectql.zod.ts`) gain the same ten keys at the same
requiredness — all optional — so the `zod-mirror-parity` ratchet stays at zero
drift for this pair and no `KnownDrift` entry is added. `navigation` is taken
from `@objectstack/spec`'s `NavigationConfigSchema` by reference rather than
restated, matching `ObjectGridSchema.navigation`.

`ObjectGanttProps.schema` is retyped from `ObjectGridSchema` to
`ObjectGanttSchema`. That is what makes the declaration load-bearing: the ten
keys are not grid keys, so with the old prop type, dropping the casts would have
left the reads landing on `BaseSchema`'s index signature — the same invisibility
in different syntax. The grid-style `{ gantt: { … } }` block is unaffected;
`getGanttConfig` reads it through that index signature exactly as before, and the
registered renderer passes `schema: any`, so no runtime shape is turned away.

Accept-set change, stated plainly: a **declared** key is now type-validated, so
`readOnly: 'yes'` is refused where it used to parse green — the same narrowing
objectui#5074 landed for `viewMode`. An **undeclared** key is still accepted:
`BaseSchema` is `.passthrough()` and carries an index signature (objectui#5155's
structural ceiling), so declaring these ten did not buy rejection of a
misspelling. `packages/types/src/__tests__/gantt-declared-keys.test.ts` pins both
halves so neither can be misread.

The eleventh reported key, `label`, needed no declaration — `BaseSchema` already
carries it — so only its cast was dropped.
45 changes: 30 additions & 15 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@

import React, { useContext, useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
import type { ObjectGanttSchema, ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
import { GanttConfigSchema } from '@objectstack/spec/ui';
import { useNavigationOverlay, SchemaRendererContext } from '@object-ui/react';
import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n';
Expand DownExpand Up@@ -274,7 +274,22 @@ export function normalizeDependencies(raw: unknown): GanttDependency[] {
}

export interface ObjectGanttProps {
schema: ObjectGridSchema;
/**
* The gantt node. Typed as {@link ObjectGanttSchema} (objectui#5903) — the
* declaration this component's schema reads actually resolve against.
*
* It used to be `ObjectGridSchema`, and that is why ten genuine reads had to
* be spelled `(schema as any).K`: the keys are not grid keys, so the only
* thing that admitted them was `BaseSchema`'s index signature, under a cast
* that hid even that. Removing the casts without moving the type would have
* changed nothing — the reads would still land on the index signature.
*
* The grid-style `{ gantt: { … } }` block keeps working exactly as before:
* `getGanttConfig` reads it through the same index signature, and the
* registered renderer (`index.tsx`) passes `schema: any`, so no runtime shape
* is turned away.
*/
schema: ObjectGanttSchema;
dataSource?: DataSource;
className?: string;
onTaskClick?: (record: any) => void;
Expand All@@ -296,7 +311,7 @@ export interface ObjectGanttProps {
/**
* Helper to get data configuration from schema
*/
function getDataConfig(schema: ObjectGridSchema): ViewData | null {
function getDataConfig(schema: ObjectGanttSchema): ViewData | null {
if (schema.data) {
return schema.data;
}
Expand DownExpand Up@@ -888,8 +903,8 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// holiday list, duration/reschedule math is measured in working days. The
// holidays array (ISO yyyy-mm-dd strings) becomes a Set for O(1) lookups.
const workingCalendar = useMemo<WorkingCalendar | undefined>(() => {
const sw = (schema as any).skipWeekends;
const hol = (schema as any).holidays as string[] | undefined;
const sw = schema.skipWeekends;
const hol = schema.holidays;
if (!sw && (!hol || hol.length === 0)) return undefined;
return {
skipWeekends: !!sw,
Expand DownExpand Up@@ -1020,9 +1035,9 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// snapshot under persistLayoutKey and fires onLayoutChange; the chips live up
// here, so they get a sibling localStorage key and restore on mount.
const persistLayoutKey =
(schema as any).persistLayout === false
schema.persistLayout === false
? undefined
: `${schema.objectName || (dataConfig?.provider === 'object' ? dataConfig.object : '') || 'gantt'}:${(schema as any).viewName || 'default'}`;
: `${schema.objectName || (dataConfig?.provider === 'object' ? dataConfig.object : '') || 'gantt'}:${schema.viewName || 'default'}`;
const filtersStorageKey = persistLayoutKey ? `gantt-layout:${persistLayoutKey}:filters` : null;
const [filterValues, setFilterValues] = useState<Record<string, string[]>>(() => {
if (!filtersStorageKey || typeof window === 'undefined') return {};
Expand DownExpand Up@@ -1129,7 +1144,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// providing its own `navigation` config (e.g., page mode).
// detail panel inline (no full-page navigation). Schema can override by
// providing its own `navigation` config (e.g., page mode).
const navConfig = (schema as any).navigation ?? { mode: 'drawer', width: 'min(960px, 60vw)' };
const navConfig = schema.navigation ?? { mode: 'drawer', width: 'min(960px, 60vw)' };
const navIsOverlay = navConfig.mode === 'drawer' || navConfig.mode === 'modal' || navConfig.mode === 'split' || navConfig.mode === 'popover';
const navigation = useNavigationOverlay({
navigation: navConfig,
Expand DownExpand Up@@ -1468,15 +1483,15 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
onTaskDelete={requestDelete}
onDependencyCreate={ganttConfig?.dependenciesField ? handleDependencyCreate : undefined}
onDependencyDelete={ganttConfig?.dependenciesField ? handleDependencyDelete : undefined}
markers={(schema as any).markers}
markers={schema.markers}
autoSchedule={!!ganttConfig?.dependenciesField}
rescheduleOnConflict={!!ganttConfig?.dependenciesField}
criticalPathDefault={!!(schema as any).criticalPath}
criticalPathDefault={!!schema.criticalPath}
workingCalendar={workingCalendar}
shiftSegments={shiftSegments}
showBaselines={(schema as any).showBaselines !== false}
readOnly={!!(schema as any).readOnly}
mobileReadOnly={(schema as any).mobileReadOnly !== false}
showBaselines={schema.showBaselines !== false}
readOnly={!!schema.readOnly}
mobileReadOnly={schema.mobileReadOnly !== false}
persistLayoutKey={persistLayoutKey}
onLayoutChange={filtersStorageKey ? persistFilters : undefined}
groupBy={groupByAccessor}
Expand All@@ -1491,7 +1506,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// `label` off the schema it hands us — then the bound object's
// label, then its API name.
String(
ganttConfig?.exportFileName ?? (schema as any).label ?? objectSchema?.label ?? schema.objectName ?? ''
ganttConfig?.exportFileName ?? schema.label ?? objectSchema?.label ?? schema.objectName ?? ''
) || undefined
}
inlineEdit
Expand DownExpand Up@@ -1520,7 +1535,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// Row-level lock (lockField) and global readOnly must also lock the
// drawer: omitting onFieldSave/onDelete renders it strictly read-only.
const recLocked =
!!(schema as any).readOnly ||
!!schema.readOnly ||
(ganttConfig?.lockField ? !!rec[ganttConfig.lockField] : false);
// #2473: prefer the fetched business record + schema over the raw row
// payload (see the drawerFetch effect above for why they can differ).
Expand Down
200 changes: 200 additions & 0 deletions packages/types/src/__tests__/gantt-declared-keys.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
/**
* 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.
*/

/**
* Declaration pin — the ten gantt keys `ObjectGantt` reads that
* `ObjectGanttSchema` did not declare (objectui#5903).
*
* ## What was wrong
*
* All ten were read as `(schema as any).K` in
* `plugin-gantt/src/ObjectGantt.tsx`. Every one is a real, working, documented
* feature (they are named in the package README), but nothing connected the
* read to a declaration: not `tsc`, not the registry `inputs`, not this
* package's zod mirror. An author following the published type could not
* discover any of them.
*
* Eleven keys were reported. `label` is the eleventh and it needed no
* declaration — `BaseSchema` already declares it — so only its cast was
* dropped. `label` is pinned below anyway, because "already declared" is the
* claim that would silently stop being true.
*
* ## What the pin has teeth against, and what it does not
*
* `BaseSchema` is `.passthrough()` on the zod side and carries
* `[key: string]: any` on the TS side (objectui#5155 records that ceiling), so:
*
* - an UNDECLARED key is still accepted, by both halves. Declaring these ten
* did NOT buy rejection of a misspelling, and the test below pins that
* plainly rather than leaving it to be assumed;
* - a DECLARED key IS validated. `readOnly: 'yes'` parsed green before this
* card and is refused now — that is the accept-set narrowing landed here,
* the same one objectui#5074 landed for `viewMode`;
* - on the TS side the index signature means a read site can never be the
* detector: `schema.readOnly` type-checks as `any` whether or not the key
* is declared. So the compile-time pin is the `@ts-expect-error` block at
* the bottom — remove a declaration and its member resolves to `any`, the
* wrong-typed assignment starts succeeding, and the now-unused directive
* fails the build (TS2578) NAMING the key. `tsconfig.test.json` compiles
* this file, so that is real enforcement and not decoration (#3009).
*/

import { describe, it, expect } from 'vitest';
import { ObjectGanttSchema } from '../zod/objectql.zod.js';
import type { ObjectGanttSchema as ObjectGanttSchemaTS } from '../objectql.js';

const MINIMAL = {
type: 'object-gantt',
objectName: 'task',
startDateField: 'start',
endDateField: 'end',
} as const;

/** The ten keys this card declared, each with a value its declared type refuses. */
const DECLARED: ReadonlyArray<readonly [string, unknown]> = [
['skipWeekends', 'yes'],
['holidays', [1]],
['persistLayout', 'no'],
['viewName', 1],
['navigation', 'drawer'],
['markers', [{ date: 5 }]],
['criticalPath', 'on'],
['showBaselines', 'off'],
['readOnly', 'yes'],
['mobileReadOnly', 'yes'],
];

describe('ObjectGanttSchema — the ten cast-read keys are declared (objectui#5903)', () => {
it('the mirror declares every one of them', () => {
const shape = Object.keys(ObjectGanttSchema.shape);
for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key);
});

it('declares them all OPTIONAL — none of the ten may become required', () => {
// Requiredness is the half the zod-mirror-parity ratchet compares against
// `../objectql.ts`, where all ten are `?:`. A mirror that required one would
// reject every gantt already published.
for (const [key] of DECLARED) {
const result = ObjectGanttSchema.safeParse(MINIMAL);
expect(result.success, `omitting ${key} must stay legal`).toBe(true);
}
});

it('materialises NO defaults — an omitted key stays absent after parse', () => {
// `showBaselines` and `mobileReadOnly` default ON *in the renderer*, which
// reads `!== false`. A `.default(true)` here would arrive downstream as an
// explicit author choice; the two spellings are not interchangeable.
const result = ObjectGanttSchema.safeParse(MINIMAL);
expect(result.success).toBe(true);
if (!result.success) return;
for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false);
});

it('refuses a wrong-typed value on each declared key (declared-key validation under passthrough)', () => {
for (const [key, bad] of DECLARED) {
const result = ObjectGanttSchema.safeParse({ ...MINIMAL, [key]: bad });
expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false);
if (result.success) continue;
const issue = result.error.issues.find((i) => i.path[0] === key);
expect(issue, `${key} failed, but not on the ${key} path`).toBeTruthy();
}
});

it('accepts a well-typed value on each declared key', () => {
// Counter-probe for the assertion above: it must be the VALUE being refused,
// not the key. A pin that only ever sees red proves nothing.
const good = {
...MINIMAL,
skipWeekends: true,
holidays: ['2024-06-05'],
persistLayout: false,
viewName: 'shift-plan',
navigation: { mode: 'page' as const, view: 'task_detail', openNewTab: false },
markers: [{ date: '2024-06-05', label: 'Release', color: '#ef4444' }],
criticalPath: true,
showBaselines: false,
readOnly: true,
mobileReadOnly: false,
};
const result = ObjectGanttSchema.safeParse(good);
expect(result.success ? null : result.error.issues).toBe(null);
});

it('`label` needs no declaration here — BaseSchema already carries it', () => {
// The eleventh reported key. It was cast-read too, but the cast was the only
// defect: dropping it is the whole fix. Pinned so that "already declared"
// cannot quietly stop being true.
const inherited = ObjectGanttSchema.safeParse({ ...MINIMAL, label: 'Shift Plan' });
expect(inherited.success).toBe(true);
expect(ObjectGanttSchema.safeParse({ ...MINIMAL, label: 5 }).success).toBe(false);
});

it('does NOT reject an undeclared key — objectui#5155’s ceiling, measured not assumed', () => {
// Declaring the ten bought validation of DECLARED keys, not rejection of
// undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this
// card as "misspellings now fail" is reading it wrong, and this pin says so
// in the one place that cannot rot.
const misspelled = ObjectGanttSchema.safeParse({ ...MINIMAL, readonly: true, skipWeekend: true });
expect(misspelled.success).toBe(true);
});
});

describe('ObjectGanttSchema (TS) — compile-time pin on the same ten keys', () => {
it('refuses a wrong-typed value on every declared key', () => {
// Each directive below fails the build (TS2578, "unused '@ts-expect-error'")
// the moment its key stops being declared, because the member then resolves
// to `any` through `BaseSchema`'s index signature and the assignment starts
// succeeding. That failure is the signal this card exists to create.

// @ts-expect-error — `skipWeekends` is declared `boolean | undefined`.
const skipWeekends: ObjectGanttSchemaTS['skipWeekends'] = 'yes';
// @ts-expect-error — `holidays` is declared `string[] | undefined`.
const holidays: ObjectGanttSchemaTS['holidays'] = [1];
// @ts-expect-error — `persistLayout` is declared `boolean | undefined`.
const persistLayout: ObjectGanttSchemaTS['persistLayout'] = 'no';
// @ts-expect-error — `viewName` is declared `string | undefined`.
const viewName: ObjectGanttSchemaTS['viewName'] = 1;
// @ts-expect-error — `navigation` is declared `ViewNavigationConfig | undefined`, an object.
const navigation: ObjectGanttSchemaTS['navigation'] = 'drawer';
// @ts-expect-error — `markers[].date` is declared `string` (schemas are JSON).
const markers: ObjectGanttSchemaTS['markers'] = [{ date: 5 }];
// @ts-expect-error — `criticalPath` is declared `boolean | undefined`.
const criticalPath: ObjectGanttSchemaTS['criticalPath'] = 'on';
// @ts-expect-error — `showBaselines` is declared `boolean | undefined`.
const showBaselines: ObjectGanttSchemaTS['showBaselines'] = 'off';
// @ts-expect-error — `readOnly` is declared `boolean | undefined`.
const readOnly: ObjectGanttSchemaTS['readOnly'] = 'yes';
// @ts-expect-error — `mobileReadOnly` is declared `boolean | undefined`.
const mobileReadOnly: ObjectGanttSchemaTS['mobileReadOnly'] = 'yes';

expect([
skipWeekends, holidays, persistLayout, viewName, navigation,
markers, criticalPath, showBaselines, readOnly, mobileReadOnly,
]).toHaveLength(10);
});

it('accepts the well-typed value on every declared key', () => {
// Counter-probe for the directives above: without this, a declaration
// narrowed to `never` would satisfy all ten of them.
const ok: ObjectGanttSchemaTS = {
type: 'object-gantt',
objectName: 'task',
skipWeekends: true,
holidays: ['2024-06-05'],
persistLayout: false,
viewName: 'shift-plan',
navigation: { mode: 'page', view: 'task_detail', openNewTab: false },
markers: [{ date: '2024-06-05', label: 'Release', color: '#ef4444' }],
criticalPath: true,
showBaselines: false,
readOnly: true,
mobileReadOnly: false,
};
expect(ok.markers?.[0].date).toBe('2024-06-05');
});
});
Loading
Loading