|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#27515 — the list-view panel's filter offers a picklist field's |
| 5 | + * options and stores the option's value. |
| 6 | + * |
| 7 | + * Measured on the card: Studio, Interfaces, a list view's Filter, Add filter, |
| 8 | + * Status, Equals — the value was a plain text box. The author typed the label |
| 9 | + * they see (`New Request`), the view saved `value: 'New Request'`, and the |
| 10 | + * runtime list matched no record, because the stored value is `new`. |
| 11 | + * |
| 12 | + * The panel mounts the runtime `FilterBuilder`, which already draws a picklist |
| 13 | + * column as a picker over its options; the field catalog it was handed had |
| 14 | + * dropped `options` on the way (`useObjectFields`, the panel's catalog, the |
| 15 | + * `filter-builder` widget). The REAL hook (`useObjectListViewDraft`), the REAL |
| 16 | + * panel and the REAL `useObjectFields` over a REAL `MetadataClient` here, so |
| 17 | + * all three layers are crossed. |
| 18 | + * |
| 19 | + * Pinned: |
| 20 | + * - a picklist rule's value is a picker over the field's options, showing the |
| 21 | + * option's LABEL for the stored value, with no free-text box; |
| 22 | + * - picking an option writes the option's VALUE (`new`, not `New Request`); |
| 23 | + * - CONTROL: a number field keeps its plain value input. |
| 24 | + */ |
| 25 | + |
| 26 | +import '@testing-library/jest-dom/vitest'; |
| 27 | +import * as React from 'react'; |
| 28 | +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 29 | +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; |
| 30 | +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; |
| 31 | +import { MetadataClient } from '@object-ui/data-objectstack'; |
| 32 | + |
| 33 | +// The builder lives inside a Radix popover, which does not open in jsdom. |
| 34 | +// Rendering the popover parts inline is this directory's existing way of |
| 35 | +// reaching it (`widgets.filterBuilderHeldRow-26738.test.tsx`); the rest of the |
| 36 | +// barrel stays real — the value picker included. No JSX in the factory: |
| 37 | +// `vi.mock` is hoisted above the imports. |
| 38 | +vi.mock('@object-ui/components', async () => { |
| 39 | + const actual = await vi.importActual<Record<string, unknown>>('@object-ui/components'); |
| 40 | + const Pass = ({ children }: { children?: unknown }) => children as never; |
| 41 | + return { ...actual, Popover: Pass, PopoverTrigger: Pass, PopoverContent: Pass }; |
| 42 | +}); |
| 43 | + |
| 44 | +const PKG = 'com.acme.app'; |
| 45 | +const VIEW = 'acme_work_order.default'; |
| 46 | + |
| 47 | +const OBJECT_DEF = { |
| 48 | + name: 'acme_work_order', |
| 49 | + label: 'Work Order', |
| 50 | + fields: { |
| 51 | + status: { |
| 52 | + name: 'status', |
| 53 | + label: 'Status', |
| 54 | + type: 'select', |
| 55 | + options: [ |
| 56 | + { value: 'new', label: 'New Request' }, |
| 57 | + { value: 'done', label: 'Completed' }, |
| 58 | + ], |
| 59 | + }, |
| 60 | + estimated_hours: { name: 'estimated_hours', label: 'Estimated Hours', type: 'number' }, |
| 61 | + }, |
| 62 | +}; |
| 63 | + |
| 64 | +const gridView = (extra: Record<string, unknown> = {}) => ({ |
| 65 | + name: VIEW, |
| 66 | + object: 'acme_work_order', |
| 67 | + viewKind: 'list', |
| 68 | + isDefault: true, |
| 69 | + config: { type: 'grid', data: { provider: 'object', object: 'acme_work_order' }, columns: ['status'], ...extra }, |
| 70 | +}); |
| 71 | + |
| 72 | +const server = { active: new Map<string, Record<string, unknown>>() }; |
| 73 | + |
| 74 | +const json = (status: number, body: unknown) => |
| 75 | + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); |
| 76 | + |
| 77 | +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { |
| 78 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 79 | + const url = new URL(String(input), 'http://studio.test'); |
| 80 | + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); |
| 81 | + if (!meta) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } }); |
| 82 | + const seg = meta[1].split('/').map(decodeURIComponent); |
| 83 | + const key = `${seg[0]}/${seg[1]}`; |
| 84 | + if (seg[0] === '_drafts') return json(200, { drafts: [] }); |
| 85 | + if (method === 'PUT') return json(200, { success: true, version: 'v2', state: 'draft' }); |
| 86 | + if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF }); |
| 87 | + if (seg.length === 3 && seg[2] === 'layers') { |
| 88 | + const row = server.active.get(key); |
| 89 | + return row |
| 90 | + ? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row }) |
| 91 | + : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } }); |
| 92 | + } |
| 93 | + // No view draft is pending in this suite. |
| 94 | + if (seg.length === 2 && url.searchParams.get('state') === 'draft') { |
| 95 | + return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); |
| 96 | + } |
| 97 | + const row = server.active.get(key); |
| 98 | + return row ? json(200, { type: seg[0], name: seg[1], item: row }) : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } }); |
| 99 | +} |
| 100 | + |
| 101 | +const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); |
| 102 | + |
| 103 | +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { |
| 104 | + const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>(); |
| 105 | + return { ...mod, useMetadataClient: () => client }; |
| 106 | +}); |
| 107 | + |
| 108 | +import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector'; |
| 109 | +import type { Surface } from './navSurface'; |
| 110 | + |
| 111 | +const METADATA: MetadataContextValue = { |
| 112 | + apps: [], |
| 113 | + objects: [OBJECT_DEF], |
| 114 | + dashboards: [], |
| 115 | + reports: [], |
| 116 | + pages: [], |
| 117 | + loading: false, |
| 118 | + error: null, |
| 119 | + refresh: async () => {}, |
| 120 | + invalidate: () => {}, |
| 121 | + ensureType: async () => [], |
| 122 | + getItem: async () => null, |
| 123 | + getItemsByType: () => [], |
| 124 | +}; |
| 125 | + |
| 126 | +const LEAF: Surface = { type: 'object', name: 'acme_work_order', label: 'Work Orders' }; |
| 127 | + |
| 128 | +/** The panel, and the view item its edits leave in the buffer. */ |
| 129 | +function Host() { |
| 130 | + const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 }); |
| 131 | + return ( |
| 132 | + <> |
| 133 | + <ObjectListViewInspector listView={listView} readOnly={false} locale="en" /> |
| 134 | + <pre data-testid="buffered-row">{JSON.stringify(listView.row)}</pre> |
| 135 | + </> |
| 136 | + ); |
| 137 | +} |
| 138 | + |
| 139 | +async function settle() { |
| 140 | + await act(async () => { |
| 141 | + for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0)); |
| 142 | + }); |
| 143 | +} |
| 144 | + |
| 145 | +async function openFilter(): Promise<HTMLElement> { |
| 146 | + render( |
| 147 | + <MetadataCtx.Provider value={METADATA}> |
| 148 | + <Host /> |
| 149 | + </MetadataCtx.Provider>, |
| 150 | + ); |
| 151 | + await settle(); |
| 152 | + return within(screen.getByTestId('studio-list-view-inspector')).getByTestId('list-view-filter'); |
| 153 | +} |
| 154 | + |
| 155 | +const bufferedFilter = () => |
| 156 | + (JSON.parse(screen.getByTestId('buffered-row').textContent ?? 'null') as { config?: { filter?: unknown } } | null) |
| 157 | + ?.config?.filter; |
| 158 | + |
| 159 | +// Radix Select opens on pointer events jsdom does not implement |
| 160 | +// (`option-value-round-trip.test.tsx` in `@object-ui/components`). |
| 161 | +const savedPointer = { |
| 162 | + PointerEvent: (window as { PointerEvent?: unknown }).PointerEvent, |
| 163 | + hasPointerCapture: (HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture, |
| 164 | + releasePointerCapture: (HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture, |
| 165 | +}; |
| 166 | +beforeAll(() => { |
| 167 | + class MockPointerEvent extends Event { |
| 168 | + button: number; |
| 169 | + ctrlKey: boolean; |
| 170 | + pointerType: string; |
| 171 | + constructor(type: string, props: { button?: number; ctrlKey?: boolean; pointerType?: string } & EventInit = {}) { |
| 172 | + super(type, props); |
| 173 | + this.button = props.button ?? 0; |
| 174 | + this.ctrlKey = props.ctrlKey ?? false; |
| 175 | + this.pointerType = props.pointerType ?? 'mouse'; |
| 176 | + } |
| 177 | + } |
| 178 | + (window as { PointerEvent?: unknown }).PointerEvent = MockPointerEvent; |
| 179 | + (HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture = vi.fn(); |
| 180 | + (HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture = vi.fn(); |
| 181 | +}); |
| 182 | +afterAll(() => { |
| 183 | + (window as { PointerEvent?: unknown }).PointerEvent = savedPointer.PointerEvent; |
| 184 | + (HTMLElement.prototype as { hasPointerCapture?: unknown }).hasPointerCapture = savedPointer.hasPointerCapture; |
| 185 | + (HTMLElement.prototype as { releasePointerCapture?: unknown }).releasePointerCapture = savedPointer.releasePointerCapture; |
| 186 | +}); |
| 187 | + |
| 188 | +beforeEach(() => { |
| 189 | + server.active.clear(); |
| 190 | +}); |
| 191 | +afterEach(cleanup); |
| 192 | + |
| 193 | +describe('ObjectListViewInspector — a picklist filter offers the field’s options (objectstack#27515)', () => { |
| 194 | + it('a picklist rule’s value is a picker showing the option label for the stored value, not a text box', async () => { |
| 195 | + server.active.set(`view/${VIEW}`, gridView({ filter: [{ field: 'status', operator: 'equals', value: 'new' }] })); |
| 196 | + const filter = await openFilter(); |
| 197 | + |
| 198 | + expect(within(filter).queryAllByPlaceholderText('Value')).toHaveLength(0); |
| 199 | + const pickers = within(filter).getAllByRole('combobox'); |
| 200 | + expect(pickers.map((p) => p.textContent)).toContain('New Request'); |
| 201 | + // Opening it is a read: nothing is written. |
| 202 | + expect(bufferedFilter()).toEqual([{ field: 'status', operator: 'equals', value: 'new' }]); |
| 203 | + }); |
| 204 | + |
| 205 | + it('picking an option writes the option’s value, not its label', async () => { |
| 206 | + server.active.set(`view/${VIEW}`, gridView()); |
| 207 | + const filter = await openFilter(); |
| 208 | + |
| 209 | + fireEvent.click(within(filter).getByRole('button', { name: 'Add filter' })); |
| 210 | + // The new row starts on the first field, `status`, and offers its options. |
| 211 | + const valuePicker = within(filter) |
| 212 | + .getAllByRole('combobox') |
| 213 | + .find((p) => p.textContent === 'Select value'); |
| 214 | + expect(valuePicker).toBeTruthy(); |
| 215 | + fireEvent.pointerDown(valuePicker!, { button: 0 }); |
| 216 | + expect(screen.getByRole('option', { name: 'Completed' })).toBeInTheDocument(); |
| 217 | + fireEvent.click(screen.getByRole('option', { name: 'New Request' })); |
| 218 | + |
| 219 | + expect(bufferedFilter()).toEqual([{ field: 'status', operator: 'equals', value: 'new' }]); |
| 220 | + }); |
| 221 | + |
| 222 | + it('CONTROL: a number field keeps its plain value input', async () => { |
| 223 | + server.active.set(`view/${VIEW}`, gridView({ filter: [{ field: 'estimated_hours', operator: 'equals', value: 4 }] })); |
| 224 | + const filter = await openFilter(); |
| 225 | + |
| 226 | + const inputs = within(filter).getAllByPlaceholderText('Value') as HTMLInputElement[]; |
| 227 | + expect(inputs).toHaveLength(1); |
| 228 | + expect(inputs[0].value).toBe('4'); |
| 229 | + }); |
| 230 | +}); |
0 commit comments