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
11 changes: 11 additions & 0 deletions .changeset/12053-view-gallery-block.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 37 additions & 13 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,21 @@ const TIMELINE_CONFIG_SPELLING = {
scale: 'scale',
} as const satisfies Record<keyof NonNullable<SpecListView['timeline']>, 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<keyof NonNullable<SpecListView['gallery']>, 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
Expand Down Expand Up @@ -2349,22 +2364,31 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
};
}
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
SchemaRenderer: ({ schema }: any) => {
rendered.push(schema);
return <div data-testid="schema-renderer">{schema?.type}</div>;
},
SchemaRendererContext: ReactMod.createContext(null),
subscribeDataChanges: () => () => {},
notifyDataChanged: () => {},
};
});
vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ObjectGrid: () => <div data-testid="object-grid" />,
}));
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ObjectForm: () => <div data-testid="object-form" />,
}));

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<any> {
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<string, unknown>): Promise<any> {
return mountAndRead(
<ObjectView
schema={NODE}
views={[{ id: 'v', label: 'Cards', type: 'gallery', gallery }] as any}
dataSource={dataSource()}
/>,
);
}

/** The named `listViews` route: the same block on a named view. */
function viaNamedView(gallery: NonNullable<NamedListView['gallery']>): Promise<any> {
return mountAndRead(
<ObjectView
schema={{ ...NODE, listViews: { cards: { label: 'Cards', type: 'gallery', gallery } } } as unknown as ObjectViewSchema}
dataSource={dataSource()}
/>,
);
}

const ROUTES: Array<[string, (gallery: any) => Promise<any>]> = [
['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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
Loading