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
34 changes: 34 additions & 0 deletions .changeset/7483-console-objectview-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/app-shell': minor
---

**Narrowing — `ObjectView`'s props are the declared `ConsoleObjectViewProps`, not `any`.**

`ObjectView`, the route-level object surface this package exports, took a bare
`any` for its props. That erased the prop names as well as their types: a
misspelled prop compiled clean and was silently dropped, and a mount that left
out `objects` compiled and then threw on first render at `objects.find(...)`.

Its props are now the exported `ConsoleObjectViewProps`:

- `dataSource: DataSource` — the published `@object-ui/types` contract, required;
- `objects: any[]` — required;
- `onEdit: (record: Record<string, unknown>) => void` — required;
- `externalRefreshKey?: number`.

**What this means for a consumer.** Nothing observable changes at runtime; the
one runtime byte is an optional call behind an existing guard. At the type
level, `onEdit` and `objects` are now required (a mount without them is a
compile error, TS2739), a misspelled prop is a compile error (TS2322), and
`dataSource` must be a `DataSource`. A mount that passes the four props with
values of the declared types compiles as before. An `onEdit` handler whose
parameter is a narrower record type (for example an interface with required
fields) is now refused under `strictFunctionTypes` and must accept
`Record<string, unknown>`, and `externalRefreshKey` must be a number. The name
is `ConsoleObjectViewProps`
because `@object-ui/plugin-view` already exports `ObjectViewProps` for its
schema-driven view, a different shape.

Marked `minor`, not `major`: this repo's fixed release group is pinned to the
`@objectstack` major, so a narrowing is declared in the narrative rather than in
the bump.
24 changes: 21 additions & 3 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,31 @@ object and view from the host's route, so it takes no `objectName` prop —
mount it on a route that supplies them, as the console does with
`/apps/:appName/:objectName` and `/apps/:appName/:objectName/view/:viewId`.

Its props are the exported `ConsoleObjectViewProps`:

- `dataSource` (required) — the host's adapter, the `DataSource` contract from
`@object-ui/types`.
- `objects` (required) — the app's object definitions; the route's
`:objectName` is resolved against this list, and an unknown name renders the
"object not found" state.
- `onEdit` (required) — called with the record to edit.
- `externalRefreshKey` (optional) — bump it to refetch after a change made
outside the view.

```tsx
import { ObjectView } from '@object-ui/app-shell';
import { ObjectView, type ConsoleObjectViewProps } from '@object-ui/app-shell';
import type { DataSource } from '@object-ui/types';

declare const dataSource: DataSource;

<ObjectView dataSource={dataSource} />;
declare const objects: ConsoleObjectViewProps['objects'];
declare const openEditor: (record: Record<string, unknown>) => void;

<ObjectView
dataSource={dataSource}
objects={objects}
onEdit={openEditor}
externalRefreshKey={0}
/>;
```

To render an object view from a schema instead of from a route, use
Expand Down
1 change: 1 addition & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export {
FlowRunner,
} from './views/index.js';
export type {
ConsoleObjectViewProps,
RecordFormPageProps,
DeclaredActionsBarProps,
FlowRunnerProps,
Expand Down
124 changes: 124 additions & 0 deletions packages/app-shell/src/views/ObjectView.consoleProps.types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* 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.
*/

/**
* Type-level pin for objectui#7483 — the Console `ObjectView`'s props are the
* declared `ConsoleObjectViewProps`, not `any`.
*
* ## What the defect was
*
* `ObjectView` (exported from the package entry) and `ObjectViewInner` both took
* a bare `any`. That erased the prop NAMES, not just their value types: a
* misspelled prop compiled clean and was silently dropped, and the README's
* `ObjectView` block — covered by `check:doc-snippets` — mounted the view with
* `dataSource` alone and still compiled, although the wrapper's first act is
* `objects.find(...)`. A sibling whose props are an object type, even one whose
* values are `any` (`DashboardView`), already refused a misspelling.
*
* Ruled on the card (letter B): export `ConsoleObjectViewProps` — the name
* `ObjectViewProps` belongs to `@object-ui/plugin-view` (objectui#6172, one
* authority per exported type name) — with `dataSource: DataSource` and
* `onEdit` required, `objects: any[]`, and `externalRefreshKey?: number`.
*
* ## Why the instrument is `tsc` and not a test run
*
* Every assertion here is erased before vitest sees it. What makes them a pin is
* `packages/app-shell/tsconfig.test.json`, which compiles every
* `src/**\/*.test.tsx` in the package and is chained off the package's
* `type-check` script. Two instruments, because they fail in different
* directions:
*
* - `Assert<Equal<…>>` on the parameter reds if it becomes anything other than
* the declared interface — including `any`.
* - `@ts-expect-error` reds via TS2578 ("unused '@ts-expect-error' directive")
* the moment a refusal stops biting. With the parameter back to `any`, both
* refusals below compile again and each directive becomes the error.
*
* The runtime `expect`s only keep this a file vitest can run; the elements are
* created, never rendered.
*/
import * as React from 'react';
import { describe, it, expect } from 'vitest';
import type { DataSource } from '@object-ui/types';

import { ObjectView, type ConsoleObjectViewProps } from './ObjectView';

type Assert<T extends true> = T;
type IsAny<T> = 0 extends 1 & T ? true : false;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;

/** What the exported component actually declares it accepts. */
type Param = Parameters<typeof ObjectView>[0];

// Guard against the probe lying: were any of these `any`, every assertion and
// every refusal below would pass or fail for the wrong reason.
type _ParamNotAny = Assert<Equal<IsAny<Param>, false>>;
type _PropsNotAny = Assert<Equal<IsAny<ConsoleObjectViewProps>, false>>;
type _DataSourceNotAny = Assert<Equal<IsAny<DataSource>, false>>;

/** The card as one type equation: the parameter IS the exported interface. */
type _ParamIsTheDeclaredProps = Assert<Equal<Param, ConsoleObjectViewProps>>;
/** `dataSource` is the published contract, not a loose value type. */
type _DataSourceIsTheContract = Assert<Equal<ConsoleObjectViewProps['dataSource'], DataSource>>;
/**
* The ruled shape, member by member: names, optionality, value types
* (5871194128). The `@ts-expect-error` rows below accept ANY error code, so on
* their own they would stay green if `objects` or `onEdit` became optional —
* the alternative the ruling priced and rejected. This equation is what binds
* the shape.
*/
type _PropsAreTheRuledShape = Assert<
Equal<
ConsoleObjectViewProps,
{
dataSource: DataSource;
objects: any[];
onEdit: (record: Record<string, unknown>) => void;
externalRefreshKey?: number;
}
>
>;

/**
* Never rendered — only passed as a prop value to elements that are created and
* discarded, so no member of the contract is ever called.
*/
const DATA_SOURCE = {} as DataSource;
const OBJECTS = [{ name: 'account', label: 'Account', fields: {} }];
const noop = (_record: Record<string, unknown>) => {};

describe('objectui#7483 — ObjectView props are the declared ConsoleObjectViewProps', () => {
it('refuses a misspelled prop name (TS2322)', () => {
// THE CARD's probe. Under the bare `any` this compiled with no diagnostic.
// @ts-expect-error objectui#7483 — `dataSourceX` is not a prop (TS2322).
const misspelled = <ObjectView dataSourceX={DATA_SOURCE} objects={OBJECTS} onEdit={noop} />;

expect(React.isValidElement(misspelled)).toBe(true);
});

it('refuses a mount that omits the required `objects` and `onEdit` (TS2739)', () => {
// The README's `ObjectView` block before this change: it compiled, and a
// verbatim copy threw on first render at `objects.find(...)`.
// @ts-expect-error objectui#7483 — `objects` and `onEdit` are required (TS2739).
const incomplete = <ObjectView dataSource={DATA_SOURCE} />;

expect(React.isValidElement(incomplete)).toBe(true);
});

it('accepts the four props the component reads', () => {
// The overshoot control: an interface that refused the real call shape
// (AppContent mounts exactly this) would fail here.
const complete = (
<ObjectView dataSource={DATA_SOURCE} objects={OBJECTS} onEdit={noop} externalRefreshKey={1} />
);

expect(React.isValidElement(complete)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,11 @@ describe('objectui#5233 ratchet — the owned-key list tracks the writers', () =
* caller is a `useCallback` inside a ~2700-line component.
*/
it('the toolbar write routes its body through `buildPersistedViewBody`', () => {
const call = objectViewSrc.match(/dataSource\.updateViewConfig\(([\s\S]{0,400}?)\{\s*isSavedView/);
// The call is an optional member call since objectui#7483 typed the
// `dataSource` prop (the contract declares `updateViewConfig` optional
// and the guard above the debounce does not narrow into its closure),
// so both spellings of the call site are the same call site.
const call = objectViewSrc.match(/dataSource\.updateViewConfig(?:\?\.)?\(([\s\S]{0,400}?)\{\s*isSavedView/);
// Vacuous-pass guard: no match means the call site was renamed or
// restructured, not that it is clean.
expect(call, 'the `dataSource.updateViewConfig(...)` call site was not found').toBeTruthy();
Expand Down
43 changes: 39 additions & 4 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { Plus, Upload, Star, StarOff, Table as TableIcon, KanbanSquare, Calendar
import { useFavorites } from '../hooks/useFavorites.js';
import { useTenancyPosture } from '../hooks/useTenancyPosture.js';
import { getIcon } from '../utils/getIcon.js';
import type { ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types';
import type { DataSource, ListViewSchema, TreeViewConfig, ViewNavigationConfig } from '@object-ui/types';
import { detectStatusField, isSystemManagedField } from '@object-ui/types';
import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js';
import { ViewConfigPanel } from './ViewConfigPanel.js';
Expand Down Expand Up @@ -1191,7 +1191,38 @@ export function selectFilterParams(searchParams: URLSearchParams): URLSearchPara
return filterParams;
}

export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: any) {
/**
* Props of the Console `ObjectView`, the route-level object surface
* `@object-ui/app-shell` exports (objectui#7483).
*
* The parameter used to be a bare `any`, which erased the prop NAMES as well as
* their values: a misspelled prop compiled clean and was silently dropped. As an
* object type, JSX excess-property checking now refuses a name that is not here,
* and the two required members are refused when omitted.
*
* Named `ConsoleObjectViewProps` rather than `ObjectViewProps` because
* `@object-ui/plugin-view` already owns that name for its schema-driven view,
* a different shape (objectui#6172: one authority per exported type name).
*/
export interface ConsoleObjectViewProps {
/** The host's data adapter — the published `DataSource` contract. */
dataSource: DataSource;
/**
* The app's object definitions. The view resolves the route's
* `:objectName` against this list and renders the "object not found"
* state when it is absent.
*/
objects: any[];
/** Opens a record for editing (the toolbar's and the drawer's edit action). */
onEdit: (record: Record<string, unknown>) => void;
/**
* Bumped by the host to refetch the list after a change made outside this
* view (for example a record saved in a form the host owns).
*/
externalRefreshKey?: number;
}

export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: ConsoleObjectViewProps) {
const { objectName } = useParams();
const { t } = useObjectTranslation();

Expand Down Expand Up @@ -1236,7 +1267,7 @@ export function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }:
* definition is known to exist, so every hook below runs unconditionally on
* every render of this component — no early return sits between hook calls.
*/
function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: any) {
function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: ConsoleObjectViewProps) {
const navigate = useNavigate();
const { appName, objectName, viewId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
Expand Down Expand Up @@ -1320,8 +1351,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
// `buildPersistedViewBody` for why the saved-view branch is
// deliberately NOT narrowed — its row IS the view, and
// `saveItem` is a whole-document PUT.
// Optional call: the guard at the top of `persistViewPatch`
// does not narrow across this `setTimeout` closure, and the
// contract declares `updateViewConfig` optional. `?.()` on the
// member keeps the adapter as `this`.
Promise.resolve(
dataSource.updateViewConfig(
dataSource.updateViewConfig?.(
objectName,
viewIdLocal,
buildPersistedViewBody(baseViewDef, merged, { isSavedView: targetIsSavedView }),
Expand Down
1 change: 1 addition & 0 deletions packages/app-shell/src/views/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ObjectView } from './ObjectView.js';
export type { ConsoleObjectViewProps } from './ObjectView.js';
export { RecordDetailView } from './RecordDetailView.js';
export { RecordFormPage } from './RecordFormPage.js';
export type { RecordFormPageProps } from './RecordFormPage.js';
Expand Down
11 changes: 10 additions & 1 deletion packages/app-shell/src/views/runtime-metadata-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,17 @@ export interface RuntimePersistCtx {
*
* Optional: report / dashboard / page writes need nothing here, and a call
* site without an adapter still persists exactly as before.
*
* Declared as what the seam really takes: the host's adapter OBJECT, whatever
* its class, probed for the one capability named here (objectui#7483). The
* bare `Partial<ViewCacheInvalidator>` it used to be is a weak type (every
* member optional), so TypeScript refused a typed `DataSource` for sharing no
* member with it (TS2559) — the published contract does not declare
* `invalidateViewKeys`, only the `ObjectStackAdapter` class does. The `object`
* half admits any adapter; the `Partial` half still refuses a primitive and a
* wrongly-typed `invalidateViewKeys`.
*/
dataSource?: Partial<ViewCacheInvalidator> | null;
dataSource?: (object & Partial<ViewCacheInvalidator>) | null;
/**
* The object a `view` write belongs to — the `{object}` half of both keys.
*
Expand Down
Loading