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/listview-map-view-level-config-5042.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-list': minor
'@object-ui/app-shell': minor
---

**Behaviour change:** the spec's view-level `map` block on a list view is now read at
runtime. `ListMapConfigSchema` (objectstack#9340) has been authorable and validated since
the `@objectstack/spec` 17.1.0 pin — it flows into this repo's own `ListViewSchema` by
reference — but nothing consumed it: `ListView`'s `case 'map'` forwarded only the legacy
`schema.options.map` bag, so declaring `map: { titleField: 'title', locationField:
'location' }` on a view changed nothing and marker titles fell back to the renderer's
placeholder.

The block now reaches `plugin-map` and drives every one of its seven reads — coordinate
extraction, marker title and description, and the initial camera. Precedence follows the
convention the sibling visualization blocks in the same file already set: the view-level
block wins over `options.map`, per key, exactly as `kanban` / `calendar` / `gallery` /
`timeline` / `gantt` each merge their spec config over the legacy bag. Both sources go
through the existing objectui#5177 key whitelist, and the branch still emits the flat
form, so `getMapConfig`'s objectui#5018 precedence rule ("neither flattener emits a `map`
key at all") stays true.

The visualization switcher had the same gap with a sharper consequence: the capability
gate that decides which visualizations are offered also read `options.map` alone, so a
view binding its coordinates in the spec block was filtered out of its own
`appearance.allowedVisualizations` and fell back to `['grid']`. The gate now asks the same
merged config the render seam forwards, so the two cannot disagree — including for a
binding split across the two sources.

`InterfaceListPage` (ADR-0047 interface pages) forwards the referenced view's `map` block
for the same reason. It is passed alongside the auto-derived `options.map` rather than
replacing it, so a partial authored block — `map: { titleField: 'title' }` — keeps the
derived coordinate binding instead of dropping it.

No defaults are introduced for `zoom` / `center`: an undeclared camera stays undeclared,
so the fit-to-queried-records behaviour ruled in objectui#5000 is unchanged.
152 changes: 152 additions & 0 deletions packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5042, the interface-page seam.
*
* `ListView` learned to read the spec's view-level `map` block
* (`ListMapConfigSchema`), but an ADR-0047 interface page builds the list-view
* schema itself, and its map binding was assembled from `options.map` alone:
*
* const mapCfg = (view.options as any)?.map ?? (allowedSet.has('map') ? … )
*
* So a referenced view declaring the typed block had it dropped one seam ABOVE
* `ListView`, and the auto-derivation silently stood in for it — the same
* declared-but-inert shape the card is about, on the path the showcase map page
* actually renders through.
*
* The block is forwarded as `map` rather than folded into `mapCfg` with `??`
* like the sibling bindings, and the third case here is why: `??` would let a
* PARTIAL authored block replace the derivation wholesale, so
* `map: { titleField: 'title' }` alone would drop the auto-derived
* `locationField` and the page would render no markers at all. Forwarding both
* lets `ListView` merge them per key, which is the precedence the card settled.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';

vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => {
const actual = await (importOriginal as any)();
return {
...actual,
useObjectTranslation: () => ({ t: (_k: string, o?: any) => o?.defaultValue ?? _k }),
};
});

vi.mock('@object-ui/auth', async (importOriginal) => {
const actual = await (importOriginal as any)();
return { ...actual, useAuth: () => ({}) };
});

// Only the map-config half of the schema this page builds is under test.
vi.mock('@object-ui/plugin-list', () => ({
ListView: (props: any) => (
<div
data-testid="list-view-map-config"
data-map={JSON.stringify(props?.schema?.map ?? null)}
data-options-map={JSON.stringify(props?.schema?.options?.map ?? null)}
/>
),
}));

let testDataSource: any;
let testObjects: any[];

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await (importOriginal as any)();
return {
...actual,
useAdapter: () => testDataSource,
useMetadata: () => ({ objects: testObjects }),
};
});

import { InterfaceListPage } from './InterfaceListPage';

const OBJECT_NAME = 'showcase_task';
const VIEW_ID = `${OBJECT_NAME}.work_map`;

/** An object with a location-typed field, so `defaultMapFromObject` can derive. */
const makeObjectDef = (view: Record<string, unknown>) => ({
name: OBJECT_NAME,
fields: {
title: { type: 'text' },
location: { type: 'location' },
},
listViews: {
[VIEW_ID]: { name: VIEW_ID, type: 'map', columns: ['title', 'location'], ...view },
},
});

const page = {
name: 'showcase_task_map',
label: 'Work Map',
interfaceConfig: {
source: OBJECT_NAME,
sourceView: 'work_map',
recordAction: 'none',
appearance: { allowedVisualizations: ['map'] },
},
};

async function renderWith(view: Record<string, unknown>) {
testDataSource = {};
testObjects = [makeObjectDef(view)];
render(<InterfaceListPage page={page as any} />);
await waitFor(() => expect(screen.queryByTestId('list-view-map-config')).not.toBeNull());
const el = screen.getByTestId('list-view-map-config');
return {
map: JSON.parse(el.getAttribute('data-map') || 'null'),
optionsMap: JSON.parse(el.getAttribute('data-options-map') || 'null'),
};
}

describe('InterfaceListPage forwards the view-level `map` block (objectui#5042)', () => {
beforeEach(() => {
testDataSource = undefined;
testObjects = [];
});

// THE DISCRIMINATING ARM — before the fix `map` was `null` here.
it('forwards the referenced view’s spec `map` block verbatim', async () => {
const { map } = await renderWith({
map: { locationField: 'location', titleField: 'title', zoom: 9 },
});

expect(map).toEqual({ locationField: 'location', titleField: 'title', zoom: 9 });
});

it('CONTROL: with no view-level block, no `map` key is emitted at all', async () => {
const { map, optionsMap } = await renderWith({});

expect(map).toBeNull();
// …and the ADR-0047 auto-derivation still fires, unchanged.
expect(optionsMap).toEqual({ locationField: 'location' });
});

it('keeps the auto-derived binding ALONGSIDE a partial authored block', async () => {
// The case that rules out folding the block into `mapCfg` with `??`: the
// author declared only a marker-title field, and the coordinate binding
// still has to come from the derivation. `ListView` merges the two per
// key; a `??` here would have discarded one of them.
const { map, optionsMap } = await renderWith({ map: { titleField: 'title' } });

expect(map).toEqual({ titleField: 'title' });
expect(optionsMap).toEqual({ locationField: 'location' });
});

it('CONTROL: the legacy `options.map` bag is still forwarded on its own path', async () => {
const { map, optionsMap } = await renderWith({
options: { map: { locationField: 'location', titleField: 'legacy_title' } },
});

expect(map).toBeNull();
expect(optionsMap).toEqual({ locationField: 'location', titleField: 'legacy_title' });
});
});
10 changes: 10 additions & 0 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -347,6 +347,13 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
view.gantt ?? (allowedSet.has('gantt') ? defaultGanttFromObject(objectDef) : undefined);
// Map binding lives under options.map (locationField); auto-derive when
// whitelisted so a map interface page renders without hand-wiring.
//
// The referenced view's own spec-level `map` block is NOT folded in here —
// it is forwarded as `map` on the schema below, so `ListView` merges it
// per key over this bag (objectui#5042). Collapsing the two with `??`, the
// way the sibling bindings above do, would make a partial authored block
// REPLACE the derivation: `map: { titleField: 'title' }` alone would drop
// the auto-derived `locationField` and the page would render no markers.
const mapCfg =
(view.options as any)?.map ?? (allowedSet.has('map') ? defaultMapFromObject(objectDef) : undefined);

Expand DownExpand Up@@ -393,6 +400,9 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
gallery,
timeline,
gantt,
// The spec's view-level `map` block (`ListMapConfigSchema`), forwarded
// verbatim so `ListView` can merge it over the `options.map` bag below.
...((view as any).map ? { map: (view as any).map } : {}),
...((mapCfg || (view.options as any)) ? { options: { ...((view.options as any) ?? {}), ...(mapCfg ? { map: mapCfg } : {}) } } : {}),

// Presentation policy — the page layer (ADR-0047).
Expand Down
76 changes: 70 additions & 6 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,42 @@ function pickFlatMapConfig(mapConfig: unknown): Record<string, unknown> {
return Object.fromEntries(FLAT_MAP_CONFIG_KEYS.filter((key) => key in source).map((key) => [key, source[key]]));
}

/**
* The effective map configuration for a list view: the spec's VIEW-LEVEL `map`
* block merged OVER the legacy `options.map` bag, per key.
*
* `map` is `ListMapConfigSchema` (objectstack#9340, consumable here since the
* `@objectstack/spec` 17.1.0 pin) — a strict, seven-key block that flows into
* this package's own `ListViewSchema` by reference (it is not in
* `LIST_VIEW_LOCAL_OVERRIDES`, so `specFieldsExcept` imports it). It was
* authorable and validated but never read: `case 'map'` forwarded only
* `schema.options?.map`, so declaring it changed nothing at runtime
* (objectui#5042).
*
* PRECEDENCE — the view-level block wins, per key. Both halves of that are the
* convention already set by every sibling visualization in this file, not a new
* rule: `kanban`, `calendar`, `gallery`, `timeline` and `gantt` each spread
* `schema.options?.<kind>` FIRST and `schema.<kind>` LAST, which is a per-key
* override in the view-level block's favour. (`tree` and `chart` also put the
* view-level block first, but with `||` — whole-block replacement rather than a
* merge. The direction is unanimous across all seven; only the granularity
* differs, and this follows the five that merge, which are also the five that
* flatten config into props the way the map branch does.)
*
* Both sides go through the same whitelist, so the typed block cannot
* reintroduce the `style` namespace collision that objectui#5177 closed.
*
* NOT a second validation of the seven keys — that reading belongs to
* `getMapConfig` in `ObjectMap.tsx` and stays there (objectui#5018). This is
* the whitelist-flatten that already existed, applied to one more source.
*/
function resolveListMapConfig(schema: { map?: unknown; options?: { map?: unknown } }): Record<string, unknown> {
return {
...pickFlatMapConfig(schema.options?.map),
...pickFlatMapConfig(schema.map),
};
}

/**
* The list view's props.
*
Expand DownExpand Up@@ -1807,8 +1843,19 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
resolvable.push('gantt');
}

// Check for Map capabilities
if (schema.options?.map?.locationField || (schema.options?.map?.latitudeField && schema.options?.map?.longitudeField)) {
// Check for Map capabilities (spec config takes precedence)
//
// Asked of the SAME merged config the render branch forwards
// (`resolveListMapConfig`), not of `options.map` alone: the gate and the
// seam must answer one question, or a view that binds its coordinates in
// the view-level `map` block renders fine but is filtered out of
// `allowedVisualizations` below — whitelist ∩ resolvable — and falls back
// to `['grid']`. That is what made the spec block inert for the SWITCHER
// even where the forward alone would have been enough (objectui#5042).
// Sharing the resolver also means a split binding (`latitudeField` on the
// block, `longitudeField` in the bag) is judged the way it will render.
const mapConfig = resolveListMapConfig(schema);
if (mapConfig.locationField || (mapConfig.latitudeField && mapConfig.longitudeField)) {
resolvable.push('map');
}

Expand All@@ -1834,7 +1881,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
}

return resolvable;
}, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, (schema as any).tree, schema.appearance?.allowedVisualizations]);
}, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.map, (schema as any).tree, schema.appearance?.allowedVisualizations]);

// Sync view from props
React.useEffect(() => {
Expand DownExpand Up@@ -2131,17 +2178,34 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
...(schema.options?.gantt || {}),
...(schema.gantt || {}),
};
case 'map':
case 'map': {
// Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_KEYS`.
// `schema.options.map` is an untyped bag; a raw spread here forwarded
// every key the author wrote, including `style`, which `ObjectMap`'s
// `FlatMapConfigKeys` declares OUT of this flat form.
//
// The spec's view-level `map` block merges over that bag — see
// `resolveListMapConfig` for the precedence and its sibling evidence.
//
// Emitted in the FLAT form, deliberately, exactly as before: a nested
// `map` key would win OUTRIGHT at `getMapConfig` (objectui#5018), which
// would turn this per-key merge into whole-block replacement of the bag
// and would trip `warnOnShadowedFlatMapKeys`. That precedence rule is
// written around the flatten product — "neither flattener emits a `map`
// key at all" — and this branch keeps that true.
//
// No camera is synthesized here: `pickFlatMapConfig` copies only keys
// the author actually wrote, so an undeclared `zoom`/`center` stays
// absent and `ObjectMap` still fits the camera to the queried records
// (objectui#5000, objectui#4941).
const mapConfig = resolveListMapConfig(schema);
return {
type: 'object-map',
...baseProps,
locationField: schema.options?.map?.locationField || 'location',
...pickFlatMapConfig(schema.options?.map),
locationField: mapConfig.locationField || 'location',
...mapConfig,
};
}
case 'tree': {
// Self-referencing tree-grid. Config lives under view.tree.* (direct)
// or options.tree.* (app-shell object pages). parentField auto-detects
Expand Down
Loading
Loading