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
36 changes: 36 additions & 0 deletions .changeset/5157-object-map-config-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

feat(types): `ObjectMapConfigSchema` is `.strict()`, so `objectui validate` refuses an undeclared key in an `object-map` node's `map` block (objectui#5157)

⚠️ **The accept set narrows.** `ObjectMapSchema.map` is `ObjectMapConfigSchema`, which used
to be a plain `z.object()`: a key it did not declare was stripped and the node parsed clean.
The card's own typo, `latitudeFieId`, therefore validated clean and was never read, and neither
`objectui validate` nor the runtime named it (only a TypeScript author's compiler did). The block is now closed, as ruled on objectui#5157 (letter A, carrying the earlier
ruling that limits the change to the `map` block):

- **`safeValidateSchema` / `objectui validate`** refuse a `map` block carrying an undeclared
key with an `unrecognized_keys` issue at `map` that names the key, and exit 1. One level
down — the node inside a container's `children` — the refusal arrives as an
`invalid_union` at `children`, and the arm detail `objectui validate` prints under it
names the key at `children → 0 → map`.
- **`objectui check`** is advisory and only uses validation to recognise a file: a root
`object-map` document with no structural root key now moves into its "did not validate"
list; a document whose root carries a structural key (`children`, `className`, …) is not
validated by `check` and still passes it.
- **The runtime keeps warning, not throwing.** `ObjectMap` still renders; its existing
`safeParse` of the block now fails, so `[ObjectMap] Invalid map configuration` is warned
in the console, naming the key.
- `.shape` is unchanged, so `ObjectMap`'s `FLAT_MAP_CONFIG_KEYS` and the view flatten
whitelists see the same keys.

**Measured breakage: none.** The pre-landing sweep the ruling required found no `map` block
in authored metadata (objectui examples, apps and docs; objectstack examples and apps)
carrying an undeclared key. Its population is held as a fixture in
`packages/types/src/__tests__/object-map-config-strict-5157.test.ts`, which re-parses every
block under the strict schema on each run; the fixture names the commits the population was
enumerated at, and a block added since is not in it.

Other component sub-block schemas are untouched: whether they close the same way is a
separate decision.
9 changes: 9 additions & 0 deletions content/docs/plugins/plugin-map.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ to `onMarkerClick` is not `===` the authored object.
}
```

The block is closed (objectui#5157). A key it does not declare — a typo such as
`latitudeFieId` — is refused by `objectui validate` with an `unrecognized_keys`
issue at `map` that names the key (on a root node; a nested node reports under
`invalid_union` at `children`, with the key in the arm detail). At runtime
`ObjectMap` does not throw: it renders from the declared keys, and the console
warns `[ObjectMap] Invalid map configuration`, naming the same key. A typo that
leaves the block with no coordinate binding, as `latitudeFieId` does, draws the
refusal described under "An unconfigured map refuses" rather than a map.

## Configuration

### An unconfigured map refuses
Expand Down
11 changes: 10 additions & 1 deletion packages/plugin-map/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@ round-trip, so inline rows must be JSON-serializable.

## The `map` block

The declared configuration input. Every key is optional:
The declared configuration input. Every key is optional, and the block is
**closed** (objectui#5157): a key outside this table — a typo such as
`latitudeFieId` — is refused by `objectui validate` with an `unrecognized_keys`
issue at `map` that names it (on a root node; a nested node reports under
`invalid_union` at `children`, with the key in the arm detail). At runtime
`ObjectMap` does not throw: it renders from the declared keys and warns
`[ObjectMap] Invalid map configuration` in the console, naming the same key; the
undeclared key is never read. A typo that leaves the block with no coordinate
binding, as `latitudeFieId` does, therefore draws the "Map configuration
required" refusal below rather than a map.

| Key | Description |
| --- | --- |
Expand Down
95 changes: 95 additions & 0 deletions packages/plugin-map/src/ObjectMap.strictConfigWarn-5157.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* 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.
*
* objectui#5157 — the RUNTIME face of `ObjectMapConfigSchema.strict()`.
*
* Ruled letter A on objectui#5157: the schema closes, so `safeValidateSchema`
* refuses an undeclared `map` key (pinned in `@object-ui/types`'
* `object-map-config-strict-5157.test.ts`), while the component itself keeps
* the channel it already had — it RENDERS, and `getMapConfig`'s `safeParse`
* of the block now fails and `console.warn`s the issue. Warn, not throw: a
* document the validator refuses still draws on a running page.
*
* Before the strict schema the same block parsed clean with the key stripped,
* so this warning never fired: the card's symptom was a quiet console: no
* warning named the key. The pin therefore reads the warning's ARGUMENTS, not its first line —
* the key arrives in the formatted issue passed after the prefix string.
*/

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ObjectMapSchema } from '@object-ui/types';
import { ObjectMap } from './ObjectMap';

type Slot = { children?: React.ReactNode };

vi.mock('react-map-gl/maplibre', () => ({
default: ({ children }: Slot) => <div aria-label="Map">{children}</div>,
Map: ({ children }: Slot) => <div aria-label="Map">{children}</div>,
NavigationControl: () => <div data-testid="nav-control" />,
Marker: ({ children, longitude, latitude }: Slot & { longitude: number; latitude: number }) => (
<div data-testid="map-marker" data-lat={latitude} data-lng={longitude}>
{children}
</div>
),
Popup: ({ children }: Slot) => <div data-testid="map-popup">{children}</div>,
}));

/** The card's own typo: `latitudeFieId`, capital i where the l belongs. */
const TYPO_KEY = 'latitudeFieId';

const ROWS = [{ id: '1', name: 'Ferry Building', location: { lat: 37.7955, lng: -122.3937 } }];

let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

/** Every argument of every `[ObjectMap] Invalid map configuration` call, serialised. */
const invalidConfigWarnings = () =>
warnSpy.mock.calls
.filter((args: unknown[]) => String(args[0]).includes('[ObjectMap] Invalid map configuration'))
.map((args: unknown[]) => args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));

const renderMap = async (map: Record<string, unknown>) => {
const schema = { type: 'object-map', staticData: ROWS, map } as unknown as ObjectMapSchema;
const utils = render(<ObjectMap schema={schema} />);
await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull());
return utils;
};

describe('(i) an undeclared `map` key still renders, and the warning names it (objectui#5157)', () => {
it('renders the map and places the marker the declared keys bind', async () => {
await renderMap({ locationField: 'location', titleField: 'name', [TYPO_KEY]: 'lat' });

expect(screen.getByLabelText('Map')).toBeTruthy();
const markers = screen.getAllByTestId('map-marker');
expect(markers).toHaveLength(1);
expect(markers[0]).toHaveAttribute('data-lat', '37.7955');
});

it('warns through the existing channel, naming the undeclared key', async () => {
await renderMap({ locationField: 'location', titleField: 'name', [TYPO_KEY]: 'lat' });

const warned = invalidConfigWarnings();
expect(warned.length).toBeGreaterThan(0);
expect(warned.join('\n')).toContain(TYPO_KEY);
});

it('says nothing about the configuration when every key is declared (control)', async () => {
await renderMap({ locationField: 'location', titleField: 'name' });

expect(screen.getAllByTestId('map-marker')).toHaveLength(1);
expect(invalidConfigWarnings()).toEqual([]);
});
});
4 changes: 3 additions & 1 deletion packages/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ type ButtonSchema = SchemaByType<'button'>;

- The **rendering face** (`AnyComponentSchema`, `SchemaNodeSchema`, every named
mirror) is tolerant: a node may carry keys the schema does not declare, because
renderer props ride through it.
renderer props ride through it. Some declared sub-blocks are closed on this face
as well — an `object-map` node's `map` block is one (objectui#5157) — so a key
misspelled inside one of them is refused by both faces.
- The **strict authoring face** is a derived twin that closes every declared
object, at every depth. It is meant for authoring-time checking — validating a
document a person or an agent just wrote — where an undeclared key is far more
Expand Down
Loading
Loading