diff --git a/.changeset/12053-view-gallery-block.md b/.changeset/12053-view-gallery-block.md new file mode 100644 index 0000000000..9855fc82e1 --- /dev/null +++ b/.changeset/12053-view-gallery-block.md @@ -0,0 +1,11 @@ +--- +'@object-ui/plugin-view': patch +--- + +A gallery view handed to `ObjectView` now draws with the `coverFit`, `cardSize` and `visibleFields` its `gallery` block declares (objectui#12053). + +`ObjectView` builds the `object-gallery` node for a gallery view, whether the view comes from the host `views` prop or from a named `listViews` entry. It built a flat node that carried the cover field and the title field only, while `ObjectGallery` reads `coverFit`, `cardSize` and `visibleFields` from the node's nested `gallery` block. So `coverFit: 'contain'` drew covers cropped, `cardSize` was ignored and cards showed none of their `visibleFields`. + +The node now carries the block nested, the shape `ListView` already hands `ObjectGallery`. It holds the five keys the spec's gallery block declares (`coverField`, `coverFit`, `cardSize`, `titleField`, `visibleFields`), each copied by name. A key the spec does not declare is not carried. The node no longer carries the flat `imageField` and `titleField`, because `ObjectGallery` reads the nested values first. A gallery view with no `titleField` still titles its cards by `name`. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 3411633c44..5fae6dbb4b 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -220,6 +220,21 @@ const TIMELINE_CONFIG_SPELLING = { scale: 'scale', } as const satisfies Record, string>; +/** + * The gallery block's declared keys; see {@link GANTT_CONFIG_SPELLING}. One + * difference from the two tables above: the picked keys go out NESTED, as the + * node's own `gallery` block, because that is where `ObjectGallery` reads all + * five (`schema.gallery.KEY`, objectui#12053). The spec's block and the + * renderer's reads are the same five keys, so this table is both. + */ +const GALLERY_CONFIG_SPELLING = { + coverField: 'coverField', + coverFit: 'coverFit', + cardSize: 'cardSize', + titleField: 'titleField', + visibleFields: 'visibleFields', +} as const satisfies Record, string>; + /** * Copy the declared keys an author actually wrote onto the flat product, * each under its flat spelling — the map's keys by default, another block's @@ -2349,22 +2364,31 @@ export const ObjectView: React.FC = ({ }; } case 'gallery': + // objectui#12053 — the block goes out NESTED, as the node's own + // `gallery`, which is the shape `ListView` already hands + // `ObjectGallery`. `ObjectGallery` reads `coverFit`, `cardSize` and + // `visibleFields` off `schema.gallery` and nowhere else, so the flat + // node this branch used to build (`imageField` from `coverField`, plus + // `titleField`) dropped those three: a view declaring + // `coverFit: 'contain'` drew its covers cropped. + // + // ONE SPELLING PER KEY: no flat `imageField` or `titleField` beside the + // block. `ObjectGallery` reads `gallery.coverField` ahead of + // `imageField` and `gallery.titleField` ahead of `titleField`, so the + // flat pair could only restate the nested values. The branch's own + // `'name'` title floor moves into the block with the key it floors. + // + // ⛔ No raw spread of the block (objectui#6152 round 14): it carried + // every undeclared key, and a block key named like a node key + // (`objectName`) overrode the node's own value. The keys are picked by + // name through `GALLERY_CONFIG_SPELLING`, the spec's declared set. return { type: 'object-gallery', ...baseProps, - // `coverField` is the spec key. It goes out as the FLAT `imageField` - // prop, because this route hands `ObjectGallery` no nested `gallery` - // block and that flat prop is the one it reads after the nested - // `coverField`. objectui#6152 round 14 retired the pre-#2231 - // `gallery.imageField` alias rung that followed it here. - imageField: viewOptions.gallery?.coverField, - titleField: viewOptions.gallery?.titleField || 'name', - // ⛔ No raw spread of the block onto this node (objectui#6152 round - // 14). `ObjectGallery` reads a FLAT `imageField` and `titleField` - // and nothing else flat from the block's vocabulary, so the spread - // delivered no declared key the two lines above do not; it delivered - // every undeclared one, and a block key named like a node key - // (`objectName`) overrode the node's own value. + gallery: { + ...pickFlatMapConfig(viewOptions.gallery, GALLERY_CONFIG_SPELLING), + titleField: viewOptions.gallery?.titleField || 'name', + }, }; case 'timeline': { // `04a67b9dc` (step ③): the SECOND route to `ObjectTimeline`, fixed the diff --git a/packages/plugin-view/src/__tests__/ObjectView.galleryBlockNested-12053.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.galleryBlockNested-12053.test.tsx new file mode 100644 index 0000000000..efcf7a1cd9 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.galleryBlockNested-12053.test.tsx @@ -0,0 +1,160 @@ +/** + * 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#12053 — `generateViewSchema` hands `ObjectGallery` the gallery block + * NESTED, as the node's own `gallery`. + * + * `ObjectGallery` reads `coverFit`, `cardSize` and `visibleFields` off + * `schema.gallery` and nowhere else (`coverField` and `titleField` too, ahead + * of the flat `imageField` / `titleField`). This branch built a FLAT node + * carrying `imageField` and `titleField` only, so a gallery view handed to + * plugin-view's `ObjectView` drew `coverFit: 'contain'` covers cropped, at the + * default card size, with no card body. `ListView`, the other route to the + * same renderer, already hands it the nested block. + * + * The node is read at the `SchemaRenderer` seam, the sink every sibling file + * here uses. The end-to-end reading through the real `ObjectGallery` is on the + * pull request: this package does not depend on `@object-ui/plugin-list`, so + * its type-checked test program cannot import the renderer. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ObjectView } from '../ObjectView'; +import type { NamedListView, ObjectViewSchema } from '@object-ui/types'; + +const HIJACK = 'HIJACK_FROM_THE_BLOCK'; +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const ReactMod = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: ReactMod.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: () =>
, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const dataSource = (): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: { name: { name: 'name', type: 'text' } } }), +}); + +const NODE = { type: 'object-view', objectName: 'task' } as unknown as ObjectViewSchema; + +/** + * Mount and return the last node handed to `SchemaRenderer`. `cleanup()` is + * load-bearing: a previous mount keeps pushing into the sink otherwise + * (objectui#9242). + */ +async function mountAndRead(ui: React.ReactElement): Promise { + cleanup(); + rendered.length = 0; + render(ui); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + const node = rendered[rendered.length - 1]; + expect(node.type).toBe('object-gallery'); + return node; +} + +/** The host `views` prop route: one gallery view carrying `gallery`. */ +function viaViewsProp(gallery: Record): Promise { + return mountAndRead( + , + ); +} + +/** The named `listViews` route: the same block on a named view. */ +function viaNamedView(gallery: NonNullable): Promise { + return mountAndRead( + , + ); +} + +const ROUTES: Array<[string, (gallery: any) => Promise]> = [ + ['the host `views` prop', viaViewsProp], + ['a named `listViews` entry', viaNamedView], +]; + +beforeEach(() => { + rendered.length = 0; +}); + +for (const [route, mount] of ROUTES) { + describe(`${route}: the gallery block reaches ObjectGallery nested (objectui#12053)`, () => { + it('`coverFit: contain` reaches `gallery.coverFit`', async () => { + const node = await mount({ coverField: 'photo', coverFit: 'contain' }); + expect(node.gallery.coverFit).toBe('contain'); + expect(node.gallery.coverField).toBe('photo'); + }); + + it('`cardSize` and `visibleFields` reach `gallery` the same way', async () => { + const node = await mount({ cardSize: 'small', visibleFields: ['stage', 'amount'] }); + expect(node.gallery.cardSize).toBe('small'); + expect(node.gallery.visibleFields).toEqual(['stage', 'amount']); + }); + + it('EVERY key the spec declares on the block reaches `gallery`', async () => { + const declared = Object.keys(SpecListViewSchema.shape.gallery.unwrap().shape); + expect(declared).toContain('coverFit'); + const block = Object.fromEntries(declared.map((k) => [k, `V_${k}`])); + const node = await mount(block); + for (const k of declared) expect(node.gallery[k], k).toBe(`V_${k}`); + }); + + it('one spelling per key: no flat `imageField` or `titleField` beside the block', async () => { + const node = await mount({ coverField: 'photo', titleField: 'subject' }); + expect(node.gallery.titleField).toBe('subject'); + expect(node).not.toHaveProperty('imageField'); + expect(node).not.toHaveProperty('titleField'); + }); + + it('an undeclared title keeps the branch\'s `name` floor, inside the block', async () => { + const node = await mount({ coverField: 'photo' }); + expect(node.gallery.titleField).toBe('name'); + }); + }); +} + +describe('control: an undeclared key in the authored block does not reach the node (objectui#12053)', () => { + it('neither inside `gallery` nor on the node', async () => { + const node = await viaViewsProp({ coverFit: 'contain', zzzUndeclared: 1, objectName: HIJACK }); + expect(node.gallery.coverFit).toBe('contain'); + expect(node.gallery).not.toHaveProperty('zzzUndeclared'); + expect(node).not.toHaveProperty('zzzUndeclared'); + expect(node.objectName).toBe('task'); + expect(JSON.stringify(node)).not.toContain(HIJACK); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index 0d2844fd6a..91956f5c7f 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -201,7 +201,9 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re it('`gallery` → `object-gallery` (ObjectGallery)', async () => { const node = await generatedNodeFor({ label: 'Cards', type: 'gallery', gallery: { coverField: 'photo' } }); expect(node.type).toBe('object-gallery'); - expect(node.imageField).toBe('photo'); + // Nested since objectui#12053: the node's own `gallery`, where + // `ObjectGallery` reads the cover binding. + expect(node.gallery.coverField).toBe('photo'); }); it('`timeline` → `object-timeline` (ObjectTimeline)', async () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx index f5b7a8f242..01d9e8e2d5 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.readersRetired-6152.test.tsx @@ -21,9 +21,10 @@ * 2. The DECLARED keys keep their route, each beside the undeclared key the * spread used to carry with it. The gantt and timeline arms are driven off * the spec's own block shapes. - * 3. The gallery cover still goes out as the FLAT `imageField` this route has - * always used (it hands `ObjectGallery` no nested block), from `coverField` - * only. + * 3. The gallery cover goes out from `coverField` only. Since objectui#12053 + * it rides the node's nested `gallery` block (the shape `ListView` hands + * `ObjectGallery`), not the flat `imageField` this round left in place; + * `ObjectView.galleryBlockNested-12053.test.tsx` pins the rest of the block. */ import React from 'react'; @@ -151,13 +152,15 @@ describe('2 · the declared keys keep their route; the undeclared ones do not (o }); describe('3 · the gallery cover goes out from `coverField` only (objectui#6152 round 14)', () => { - it('`coverField` becomes the flat `imageField` the gallery reads', async () => { + it('`coverField` becomes the nested `gallery.coverField` the gallery reads (objectui#12053)', async () => { const node = await generatedNode('gallery', { gallery: { coverField: 'photo' } }); - expect(node.imageField).toBe('photo'); + expect(node.gallery.coverField).toBe('photo'); }); it('the retired `imageField` alias binds no cover', async () => { const node = await generatedNode('gallery', { gallery: { imageField: 'photo' } }); - expect(node.imageField).toBeUndefined(); + expect(node.gallery).not.toHaveProperty('coverField'); + expect(node.gallery).not.toHaveProperty('imageField'); + expect(node).not.toHaveProperty('imageField'); }); });