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

`ObjectGanttSchema` declares the flattened `GanttConfig` face `ObjectGantt`
actually reads (objectui#6051). `getGanttConfig` has two branches: when
`startDateField` and `endDateField` are both present at the TOP level it builds
its config from top-level keys and returns early; otherwise it reads the `gantt`
block. Everything the first branch reads was undeclared — and unlike
objectui#5903's ten, none of it was hidden behind a cast. `BaseSchema` carries
`[key: string]: any` (objectui#5155's structural ceiling) and the helper's
parameter was `ObjectGridSchema | any`, so `schema.colorField` type-checked as
`any` with no syntax anywhere to grep for. That is also why the census here is an
AST enumeration and not a compile-and-observe: an index signature absorbs every
literal name, so annotating the parameter compiles clean while enforcing nothing.

**27 keys join the declared surface, each additive and each with a live read
site.** 24 flattened `GanttConfig` members — `colorField`, `borderColorField`,
`dependenciesField`, `parentField`, `typeField`, `lockField`, `objectField`,
`summaryExtent`, `defaultCollapsedDepth`, `tooltipFields`, `baselineStartField`,
`baselineEndField`, `groupByField`, `resourceView`, `assigneeField`,
`effortField`, `capacity`, `quickFilters`, `autoZoomToFilter`, `timeSegments`,
`interactions`, `exportFileName`, `timeZone`, `dependencyTypes` — plus the three
query keys the fetch path reads, `staticData`, `filter` and `sort`. Nothing is
declared that the renderer does not consume.

**`GanttConfig` itself gains nine members and is a published type**, exported by
name from `packages/types/src/index.ts`: `lockField`, `objectField`,
`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`,
`timeZone`, `exportFileName`, `interactions`. The entry file's diff is empty only
because the export list already named the type — the widening happened at the
declaration.

**The 28th measured key, `gantt` (the block face), is deliberately NOT declared**
— see the closing section.

The 24 are DERIVED from `GanttConfig` rather than restated, so the flat spelling
cannot fork from the block spelling, and the invariant is pinned in the type
system: every key of `GanttConfig` must be declared at the node's top level.
Making that derivation possible moved nine members — `lockField`, `objectField`,
`summaryExtent`, `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`,
`timeZone`, `exportFileName`, `interactions` — out of `plugin-gantt`'s
package-private `GanttConfigEx` and into `@object-ui/types`' `GanttConfig`. They
are a MOVE, not new vocabulary: the `gantt` block already honoured all nine, and
a type private to the plugin could be referenced by neither authoring face.

Both halves move together, as in objectui#5903: the TS declaration and its zod
mirror gain the same 27 keys at the same requiredness (all optional), the
spec-modelled ones taken from `GanttConfigSchema.shape` by reference, so the
`zod-mirror-parity` ratchet stays at zero drift for this pair and no `KnownDrift`
or `UnmirroredDeclared` entry is added. The mirror builds the flat face and the
`gantt` block from one field map, so they are one schema expressed twice.

Accept-set change, stated plainly. All 27 keys are additive — every one is
optional, and nothing previously legal loses its slot. What changes is that a
**declared** key is now type-validated, so `capacity: 'one'` and
`summaryExtent: 'parent'` are refused where they used to parse green. An
**undeclared** key is still accepted — `BaseSchema` is `.passthrough()`, so this
bought no rejection of misspellings. There is no narrowing anywhere in this
change.

**`gantt` is severed on purpose (objectui#6475), not overlooked.** It is the 28th
key of the measured residue and a genuine read — `getGanttConfig`'s second branch
honours it in full — but it is the one key whose declaration would NOT have been
additive. It has no mirror entry today, so a block rides through `.passthrough()`
unvalidated; declaring it as `GanttConfig` means it gets parsed against the spec's
`GanttConfigSchema`, which REQUIRES `startDateField`, `endDateField` and
`titleField`, and `ObjectGanttSchema` reaches the CLI's `validate` / `check`
through `AnyComponentSchema`. A published CLI's refusal behaviour is decided on
its own card, where reviewers can see what they are approving; objectui#6475
carries the full measurement, including the case FOR enforcing it (the renderer
already feeds that block to `GanttConfigSchema.safeParse` and warns, so enforcing
restores declared = enforced rather than inventing a contract). Today's behaviour
is pinned in the test file so the omission is a measured state, not a silent gap.
`packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts` pins both
halves so neither can be misread.

Which face WINS is unchanged and was not decided here: the flat branch is checked
first and returns early, so a node carrying both spellings still renders the flat
one. (`plugin-map` had the opposite precedence ruled on in objectui#5018; no
equivalent ruling exists for gantt.)
112 changes: 25 additions & 87 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 { ObjectGanttSchema, ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
import type { ObjectGanttSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
import { GanttConfigSchema } from '@objectstack/spec/ui';
// Aliased on import, following PR #4169's convention: this repo has its OWN
// `resolveI18nLabel` over a DIFFERENT vocabulary (the KEYED `{ key, defaultValue }`
Expand DownExpand Up@@ -89,8 +89,19 @@ export interface QuickFilterDef {
}

/**
* Hierarchy/type fields are ObjectUI extensions on top of the spec's
* GanttConfig (not yet in @objectstack/spec GanttConfigSchema).
* The gantt config as THIS renderer consumes it: `GanttConfig` from
* `@object-ui/types` — the spec's `GanttConfigSchema` plus objectui's own
* extensions — with `quickFilters` and `timeSegments` narrowed to the plugin's
* runtime types, and the spec-declared members re-documented with the behaviour
* this renderer gives them.
*
* ⚠️ Nothing here may declare a key `GanttConfig` does not (objectui#6051). Nine
* members that lived ONLY here — `lockField`, `objectField`, `summaryExtent`,
* `defaultCollapsedDepth`, `borderColorField`, `dependencyTypes`, `timeZone`,
* `exportFileName`, `interactions` — were lifted into `@object-ui/types`, because
* a type private to this package can be referenced by neither authoring face.
* Each key is now declared once and both faces derive from it: the `gantt` block
* and the flattened top-level spelling on `ObjectGanttSchema`.
*/
type GanttConfigEx = GanttConfig & {
parentField?: string;
Expand All@@ -101,55 +112,9 @@ type GanttConfigEx = GanttConfig & {
* style levels that only group, never schedule.
*/
typeField?: string;
/**
* Record field marking a node as view-only (truthy → locked). A locked
* row's bar can't be dragged/resized, its progress can't be dragged, no
* dependency can be drawn from it, and its inline-edit / context-menu
* edit+delete are hidden — but clicking it (open drawer / jump) still works.
* Independent of the global `readOnly`; use to freeze individual levels (e.g.
* work orders) while siblings stay editable. Maps to {@link GanttTask.locked}.
*/
lockField?: string;
/**
* Record field carrying the row's OBJECT API NAME. Mixed-object
* trees (an `api` provider composing parent-object rows with child-object rows)
* need the detail drawer and its full-page link to follow each row's REAL
* object — otherwise a child row's `→` link builds a URL under the view's bound
* object and 404s. Empty/missing value → falls back to the bound object.
*/
objectField?: string;
/**
* How a summary bar's span is computed. `'children'` (default)
* rolls the bar up from its children — min start / max end / duration-weighted
* progress — and IGNORES the record's own dates. `'self'` renders the bar from
* the record's OWN start/end/progress, falling back to rollup
* only for records without dates (e.g. pure grouping levels). Use `'self'`
* when the parent's schedule is authoritative — e.g. a shift plan whose
* work-order children are locked history: under rollup, dragging the plan
* persists its own dates but the bar snaps back to the children's extent on
* refetch.
*/
summaryExtent?: 'children' | 'self';
/**
* Auto-collapse tree nodes at/below this 0-indexed depth on first render.
* Roots are depth 0. Every node at depth `>= defaultCollapsedDepth`
* with children starts folded; the user can still expand them. Example: a
* project→product→production-plan→work-order tree uses
* `defaultCollapsedDepth: 2` so every production plan (and its work orders)
* starts collapsed. Forwarded to {@link GanttView}.
*/
defaultCollapsedDepth?: number;
/** Baseline (planned) start/end fields → planned-vs-actual reference bars. */
baselineStartField?: string;
baselineEndField?: string;
/**
* Record field carrying a per-task alert stroke color: any CSS color or
* semantic palette name (red/orange/…). When present the bar keeps its fill
* but gets an outline + halo in that color — e.g. red for overdue, orange for
* due-soon — typically a server-computed alert field. Empty/null → no stroke.
* Maps to {@link GanttTask.borderColor}.
*/
borderColorField?: string;
/**
* Dynamic Group by. When set, leaf tasks are bucketed by this
* field and rendered under one synthesized summary row per distinct value
Expand DownExpand Up@@ -182,37 +147,6 @@ type GanttConfigEx = GanttConfig & {
* (unfiltered) task set while filtering only hides bars.
*/
autoZoomToFilter?: boolean;
/**
* Whether the backing store persists dependency link TYPES (fs/ss/ff/sf).
* Default true. Set false when dependencies are bare predecessor ids
* (predecessor ids only) — the link menu hides the type switcher (a switch would be
* silently reverted on refetch) and drag-created links are always FS.
* Forwarded to {@link GanttView}.
*/
dependencyTypes?: boolean;
/**
* Business time zone, IANA name like 'Asia/Shanghai'. Renders the
* chart's calendar — shift bands, day columns, snapping, today line, date
* labels — in this zone's wall time for every viewer, instead of the
* browser's zone (which misplaces shift bands for viewers elsewhere). Persisted
* data stays real instants. Forwarded to {@link GanttView}.
*/
timeZone?: string;
/**
* Base name for exported PNG/PDF files, e.g. the view's display
* label — the host's view schema often reaches this component stripped of
* `label`, so views declare it here. Falls back to the object schema label,
* then the object API name. A timestamp suffix is always appended.
*/
exportFileName?: string;
/**
* Per-interaction switches: `move` / `resize` / `progress` / `link`,
* each defaulting to true. Metadata-drivable so a view can e.g. allow bar
* moves but pin durations (`{ resize: false }`) or keep the dependency UI
* read-only (`{ link: false }`). They only narrow what `readOnly` / row locks
* already allow. Forwarded to {@link GanttView}.
*/
interactions?: GanttInteractions;
/**
* Shift segmentation. When set, the day-mode timeline splits each shift-day
* (starting at `dayStart`) into the configured bands (day | night | …):
Expand DownExpand Up@@ -289,10 +223,14 @@ export interface ObjectGanttProps {
* 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.
* objectui#6051 declared what the FLAT branch reads: the 24 flattened
* `GanttConfig` keys `getGanttConfig`'s first branch consumes, plus the
* `staticData` / `filter` / `sort` the fetch path reads. The grid-style
* `{ gantt: { … } }` block keeps working exactly as before and is still read
* through the index signature — declaring it is the one change that would not
* have been additive, and it is severed to objectui#6475. The registered
* renderer (`index.tsx`) still passes `schema: any`, so no runtime shape is
* turned away either way.
*/
schema: ObjectGanttSchema;
dataSource?: DataSource;
Expand DownExpand Up@@ -363,10 +301,10 @@ function extractServerMessage(err: unknown): string | null {
/**
* Helper to get gantt configuration from schema
*/
function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null {
function getGanttConfig(schema: ObjectGanttSchema): GanttConfigEx | null {
let config: GanttConfigEx | null = null;

// 1. Check top-level properties (ObjectGanttSchema style)
// 1. Check top-level properties (the flattened ObjectGanttSchema style)
if (schema.startDateField && schema.endDateField) {
config = {
startDateField: schema.startDateField,
Expand DownExpand Up@@ -402,7 +340,7 @@ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null {
return config;
}

// 2. Check schema.gantt (ObjectGridSchema style)
// 2. Check schema.gantt (the block face, ObjectGridSchema style)
if (schema.gantt) {
config = schema.gantt as GanttConfigEx;
}
Expand Down
Loading
Loading