|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26703 — the page designer can set a `list-view` block's row cap. |
| 5 | + * |
| 6 | + * A list block in a wide page region draws its first `dataSource.limit` rows, |
| 7 | + * 5 when none is stated (objectstack#26613). `BLOCK_CONFIG` had no `list-view` |
| 8 | + * entry, so selecting one opened an inspector with no typed fields, and the |
| 9 | + * "Advanced" section lists only keys the block already has: the cap could be |
| 10 | + * set only in JSON. These rows drive the REAL inspector and assert the patch |
| 11 | + * it hands the editor: the Object, View and Row limit controls write the |
| 12 | + * node-level `dataSource` binding `ListViewBlock` reads, never `properties`. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; |
| 16 | +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; |
| 17 | +import { PageSchema } from '@objectstack/spec/ui'; |
| 18 | +import { PageBlockInspector } from './PageBlockInspector'; |
| 19 | +import type { MetadataInspectorProps } from '../inspector-registry'; |
| 20 | +import type { SupportedLocale } from '../i18n'; |
| 21 | + |
| 22 | +vi.mock('../previews/useObjectFields', () => ({ |
| 23 | + useObjectFields: () => ({ fields: [], loading: false, error: null }), |
| 24 | +})); |
| 25 | +// No object catalog: the Object picker falls back to its labelled text box. |
| 26 | +vi.mock('../useMetadata', () => ({ |
| 27 | + useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }), |
| 28 | +})); |
| 29 | + |
| 30 | +// The inspector's `engine.inspector.*` rows live in `@object-ui/i18n`'s `studio` |
| 31 | +// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). |
| 32 | +// Resident before any case, so the zh rows below read synchronously. |
| 33 | +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; |
| 34 | +await loadStudioCatalogue('zh'); |
| 35 | + |
| 36 | +afterEach(cleanup); |
| 37 | + |
| 38 | +const BLOCK_PATH = 'regions[0].components[0]'; |
| 39 | + |
| 40 | +/** Author a page the way a user does, parse it with the spec, and hand the RESULT to the inspector. */ |
| 41 | +function pageDraft(block: Record<string, unknown>): Record<string, unknown> { |
| 42 | + return PageSchema.parse({ |
| 43 | + name: 'home', |
| 44 | + label: 'Home', |
| 45 | + type: 'home', |
| 46 | + template: 'default', |
| 47 | + regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }], |
| 48 | + }) as unknown as Record<string, unknown>; |
| 49 | +} |
| 50 | + |
| 51 | +function renderInspector(draft: Record<string, unknown>, locale: SupportedLocale = 'en-US'): Mock<MetadataInspectorProps['onPatch']> { |
| 52 | + const onPatch: Mock<MetadataInspectorProps['onPatch']> = vi.fn(); |
| 53 | + render( |
| 54 | + <PageBlockInspector |
| 55 | + type="page" |
| 56 | + name="home" |
| 57 | + draft={draft} |
| 58 | + selection={{ kind: 'block', id: BLOCK_PATH }} |
| 59 | + onPatch={onPatch} |
| 60 | + onClearSelection={() => {}} |
| 61 | + readOnly={false} |
| 62 | + locale={locale} |
| 63 | + />, |
| 64 | + ); |
| 65 | + return onPatch; |
| 66 | +} |
| 67 | + |
| 68 | +/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */ |
| 69 | +const committedBlock = (onPatch: Mock<MetadataInspectorProps['onPatch']>): Record<string, unknown> => |
| 70 | + (onPatch.mock.calls.at(-1)![0] as { regions: Array<{ components: Array<Record<string, unknown>> }> }).regions[0] |
| 71 | + .components[0]; |
| 72 | + |
| 73 | +describe('the designer sets a list-view block’s row cap (objectstack#26703)', () => { |
| 74 | + it('shows an editable Row limit box, hinting the capped list’s own 5 when empty', () => { |
| 75 | + renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } })); |
| 76 | + const limit = screen.getByLabelText('Row limit') as HTMLInputElement; |
| 77 | + expect(limit.type).toBe('number'); |
| 78 | + expect(limit.value).toBe(''); |
| 79 | + expect(limit.placeholder).toBe('5'); |
| 80 | + expect(limit.disabled).toBe(false); |
| 81 | + }); |
| 82 | + |
| 83 | + it('editing the box writes `dataSource.limit` beside the bound object, not `properties.limit`', () => { |
| 84 | + const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine' }, properties: {} })); |
| 85 | + fireEvent.change(screen.getByLabelText('Row limit'), { target: { value: '12' } }); |
| 86 | + const block = committedBlock(onPatch); |
| 87 | + expect(block.dataSource).toEqual({ object: 'contact', view: 'mine', limit: 12 }); |
| 88 | + expect(block.properties).toEqual({}); |
| 89 | + }); |
| 90 | + |
| 91 | + it('the box shows a stored cap, and the Object and View controls show and write the binding', () => { |
| 92 | + const onPatch = renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact', view: 'mine', limit: 8 } })); |
| 93 | + expect((screen.getByLabelText('Row limit') as HTMLInputElement).value).toBe('8'); |
| 94 | + expect((screen.getByLabelText('Object') as HTMLInputElement).value).toBe('contact'); |
| 95 | + expect((screen.getByLabelText('View') as HTMLInputElement).value).toBe('mine'); |
| 96 | + fireEvent.change(screen.getByLabelText('View'), { target: { value: 'all_open' } }); |
| 97 | + expect(committedBlock(onPatch).dataSource).toEqual({ object: 'contact', view: 'all_open', limit: 8 }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('labels the controls in the designer locale', () => { |
| 101 | + renderInspector(pageDraft({ type: 'list-view', dataSource: { object: 'contact' } }), 'zh-CN'); |
| 102 | + expect(screen.getByLabelText('条数上限')).toBeTruthy(); |
| 103 | + expect(screen.getByLabelText('视图')).toBeTruthy(); |
| 104 | + }); |
| 105 | +}); |
0 commit comments