Skip to content

Commit a4ae6dc

Browse files
Copilothotlong
andcommitted
Add SortUI component test suite
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent eb91925 commit a4ae6dc

1 file changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, fireEvent } from '@testing-library/react';
11+
import { SortUI } from '../SortUI';
12+
import type { SortUISchema } from '@object-ui/types';
13+
14+
// ---------------------------------------------------------------------------
15+
// Mock @object-ui/components – provide lightweight stand-ins for Shadcn
16+
// primitives so tests render without a full component tree.
17+
// ---------------------------------------------------------------------------
18+
vi.mock('@object-ui/components', () => {
19+
const cn = (...args: any[]) => args.filter(Boolean).join(' ');
20+
21+
const Button = ({ children, onClick, variant, ...rest }: any) => (
22+
<button onClick={onClick} data-variant={variant} {...rest}>
23+
{children}
24+
</button>
25+
);
26+
27+
const Select = ({ children, value, onValueChange }: any) => (
28+
<div data-testid="select-root" data-value={value}>
29+
{typeof children === 'function'
30+
? children({ value, onValueChange })
31+
: children}
32+
</div>
33+
);
34+
35+
const SelectTrigger = ({ children, className }: any) => (
36+
<button data-testid="select-trigger" className={className}>
37+
{children}
38+
</button>
39+
);
40+
41+
const SelectValue = ({ placeholder }: any) => (
42+
<span data-testid="select-value">{placeholder}</span>
43+
);
44+
45+
const SelectContent = ({ children }: any) => (
46+
<div data-testid="select-content">{children}</div>
47+
);
48+
49+
const SelectItem = ({ children, value }: any) => (
50+
<div data-testid="select-item" data-value={value}>
51+
{children}
52+
</div>
53+
);
54+
55+
const SortBuilder = ({ fields, value, onChange }: any) => (
56+
<div data-testid="sort-builder" data-fields={JSON.stringify(fields)} data-value={JSON.stringify(value)}>
57+
<button
58+
data-testid="sort-builder-change"
59+
onClick={() =>
60+
onChange?.([
61+
{ id: 'date-desc', field: 'date', order: 'desc' },
62+
])
63+
}
64+
>
65+
Change Sort
66+
</button>
67+
</div>
68+
);
69+
70+
return { cn, Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SortBuilder };
71+
});
72+
73+
// ---------------------------------------------------------------------------
74+
// Helpers
75+
// ---------------------------------------------------------------------------
76+
const baseFields: SortUISchema['fields'] = [
77+
{ field: 'name', label: 'Name' },
78+
{ field: 'date', label: 'Date' },
79+
];
80+
81+
const makeSchema = (overrides: Partial<SortUISchema> = {}): SortUISchema => ({
82+
type: 'sort-ui',
83+
fields: baseFields,
84+
...overrides,
85+
});
86+
87+
// ---------------------------------------------------------------------------
88+
// Tests
89+
// ---------------------------------------------------------------------------
90+
describe('SortUI', () => {
91+
// -------------------------------------------------------------------------
92+
// 1. Renders with default (buttons) variant
93+
// -------------------------------------------------------------------------
94+
describe('buttons variant', () => {
95+
it('renders sort buttons for each field', () => {
96+
render(<SortUI schema={makeSchema({ variant: 'buttons' })} />);
97+
98+
expect(screen.getByText('Name')).toBeInTheDocument();
99+
expect(screen.getByText('Date')).toBeInTheDocument();
100+
});
101+
102+
it('renders all fields as outline buttons when no sort is active', () => {
103+
render(<SortUI schema={makeSchema({ variant: 'buttons' })} />);
104+
105+
const buttons = screen.getAllByRole('button');
106+
expect(buttons).toHaveLength(2);
107+
buttons.forEach(btn => {
108+
expect(btn).toHaveAttribute('data-variant', 'outline');
109+
});
110+
});
111+
112+
it('highlights active sort field with secondary variant', () => {
113+
render(
114+
<SortUI
115+
schema={makeSchema({
116+
variant: 'buttons',
117+
sort: [{ field: 'name', direction: 'asc' }],
118+
})}
119+
/>,
120+
);
121+
122+
const nameBtn = screen.getByText('Name').closest('button')!;
123+
expect(nameBtn).toHaveAttribute('data-variant', 'secondary');
124+
125+
const dateBtn = screen.getByText('Date').closest('button')!;
126+
expect(dateBtn).toHaveAttribute('data-variant', 'outline');
127+
});
128+
129+
it('cycles through asc → desc → removed on repeated clicks', () => {
130+
const onChange = vi.fn();
131+
render(
132+
<SortUI schema={makeSchema({ variant: 'buttons' })} onChange={onChange} />,
133+
);
134+
135+
const nameBtn = screen.getByText('Name').closest('button')!;
136+
137+
// First click: activate asc
138+
fireEvent.click(nameBtn);
139+
expect(onChange).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]);
140+
});
141+
});
142+
143+
// -------------------------------------------------------------------------
144+
// 2. Renders with dropdown variant
145+
// -------------------------------------------------------------------------
146+
describe('dropdown variant', () => {
147+
it('renders select elements for field and direction', () => {
148+
render(<SortUI schema={makeSchema({ variant: 'dropdown' })} />);
149+
150+
const selectRoots = screen.getAllByTestId('select-root');
151+
expect(selectRoots.length).toBe(2);
152+
});
153+
154+
it('renders field options inside select', () => {
155+
render(<SortUI schema={makeSchema({ variant: 'dropdown' })} />);
156+
157+
expect(screen.getByText('Name')).toBeInTheDocument();
158+
expect(screen.getByText('Date')).toBeInTheDocument();
159+
});
160+
161+
it('renders direction options (Ascending / Descending)', () => {
162+
render(<SortUI schema={makeSchema({ variant: 'dropdown' })} />);
163+
164+
expect(screen.getByText('Ascending')).toBeInTheDocument();
165+
expect(screen.getByText('Descending')).toBeInTheDocument();
166+
});
167+
168+
it('defaults to dropdown when variant is omitted', () => {
169+
render(<SortUI schema={makeSchema()} />);
170+
171+
// dropdown renders select-root elements, not buttons
172+
const selectRoots = screen.getAllByTestId('select-root');
173+
expect(selectRoots.length).toBe(2);
174+
});
175+
});
176+
177+
// -------------------------------------------------------------------------
178+
// 3. Renders with builder variant (multiple = true)
179+
// -------------------------------------------------------------------------
180+
describe('builder variant (multiple)', () => {
181+
it('renders SortBuilder when multiple is true', () => {
182+
render(
183+
<SortUI
184+
schema={makeSchema({ multiple: true })}
185+
/>,
186+
);
187+
188+
expect(screen.getByTestId('sort-builder')).toBeInTheDocument();
189+
});
190+
191+
it('passes fields and value to SortBuilder', () => {
192+
render(
193+
<SortUI
194+
schema={makeSchema({
195+
multiple: true,
196+
sort: [{ field: 'name', direction: 'asc' }],
197+
})}
198+
/>,
199+
);
200+
201+
const builder = screen.getByTestId('sort-builder');
202+
const fields = JSON.parse(builder.getAttribute('data-fields')!);
203+
expect(fields).toEqual([
204+
{ value: 'name', label: 'Name' },
205+
{ value: 'date', label: 'Date' },
206+
]);
207+
208+
const value = JSON.parse(builder.getAttribute('data-value')!);
209+
expect(value).toEqual([
210+
{ id: 'name-asc', field: 'name', order: 'asc' },
211+
]);
212+
});
213+
214+
it('calls onChange when SortBuilder triggers a change', () => {
215+
const onChange = vi.fn();
216+
render(
217+
<SortUI
218+
schema={makeSchema({ multiple: true })}
219+
onChange={onChange}
220+
/>,
221+
);
222+
223+
fireEvent.click(screen.getByTestId('sort-builder-change'));
224+
expect(onChange).toHaveBeenCalledWith([{ field: 'date', direction: 'desc' }]);
225+
});
226+
});
227+
228+
// -------------------------------------------------------------------------
229+
// 4. Initial sort configuration from schema
230+
// -------------------------------------------------------------------------
231+
describe('initial sort from schema', () => {
232+
it('initialises state from schema.sort in buttons variant', () => {
233+
render(
234+
<SortUI
235+
schema={makeSchema({
236+
variant: 'buttons',
237+
sort: [{ field: 'date', direction: 'desc' }],
238+
})}
239+
/>,
240+
);
241+
242+
const dateBtn = screen.getByText('Date').closest('button')!;
243+
expect(dateBtn).toHaveAttribute('data-variant', 'secondary');
244+
});
245+
246+
it('renders without error when schema.sort is undefined', () => {
247+
render(<SortUI schema={makeSchema({ variant: 'buttons' })} />);
248+
249+
const buttons = screen.getAllByRole('button');
250+
expect(buttons).toHaveLength(2);
251+
});
252+
253+
it('renders without error when schema.sort is empty', () => {
254+
render(
255+
<SortUI schema={makeSchema({ variant: 'buttons', sort: [] })} />,
256+
);
257+
258+
const buttons = screen.getAllByRole('button');
259+
buttons.forEach(btn => {
260+
expect(btn).toHaveAttribute('data-variant', 'outline');
261+
});
262+
});
263+
});
264+
265+
// -------------------------------------------------------------------------
266+
// 5. onChange callback
267+
// -------------------------------------------------------------------------
268+
describe('onChange callback', () => {
269+
it('fires onChange when a button sort is toggled', () => {
270+
const onChange = vi.fn();
271+
render(
272+
<SortUI
273+
schema={makeSchema({ variant: 'buttons' })}
274+
onChange={onChange}
275+
/>,
276+
);
277+
278+
fireEvent.click(screen.getByText('Name').closest('button')!);
279+
expect(onChange).toHaveBeenCalledTimes(1);
280+
expect(onChange).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]);
281+
});
282+
283+
it('dispatches custom window event when schema.onChange is set', () => {
284+
const spy = vi.fn();
285+
window.addEventListener('sort:changed', spy);
286+
287+
render(
288+
<SortUI
289+
schema={makeSchema({ variant: 'buttons', onChange: 'sort:changed' })}
290+
/>,
291+
);
292+
293+
fireEvent.click(screen.getByText('Name').closest('button')!);
294+
expect(spy).toHaveBeenCalledTimes(1);
295+
296+
const detail = (spy.mock.calls[0][0] as CustomEvent).detail;
297+
expect(detail).toEqual({ sort: [{ field: 'name', direction: 'asc' }] });
298+
299+
window.removeEventListener('sort:changed', spy);
300+
});
301+
302+
it('replaces active sort when multiple is false (buttons)', () => {
303+
const onChange = vi.fn();
304+
render(
305+
<SortUI
306+
schema={makeSchema({
307+
variant: 'buttons',
308+
sort: [{ field: 'name', direction: 'asc' }],
309+
})}
310+
onChange={onChange}
311+
/>,
312+
);
313+
314+
// Click a different field — should replace, not append
315+
fireEvent.click(screen.getByText('Date').closest('button')!);
316+
expect(onChange).toHaveBeenCalledWith([{ field: 'date', direction: 'asc' }]);
317+
});
318+
});
319+
320+
// -------------------------------------------------------------------------
321+
// 6. Helper functions (toSortEntries / toSortItems) – tested indirectly
322+
// -------------------------------------------------------------------------
323+
describe('helper functions (toSortEntries / toSortItems)', () => {
324+
it('toSortEntries: maps schema.sort to internal state shown via button variant', () => {
325+
render(
326+
<SortUI
327+
schema={makeSchema({
328+
variant: 'buttons',
329+
sort: [
330+
{ field: 'name', direction: 'asc' },
331+
{ field: 'date', direction: 'desc' },
332+
],
333+
multiple: true,
334+
})}
335+
/>,
336+
);
337+
338+
// Both fields should be highlighted since both are in the sort config
339+
const nameBtn = screen.getByText('Name').closest('button')!;
340+
const dateBtn = screen.getByText('Date').closest('button')!;
341+
expect(nameBtn).toHaveAttribute('data-variant', 'secondary');
342+
expect(dateBtn).toHaveAttribute('data-variant', 'secondary');
343+
});
344+
345+
it('toSortItems: maps sort entries to SortBuilder items', () => {
346+
render(
347+
<SortUI
348+
schema={makeSchema({
349+
multiple: true,
350+
sort: [
351+
{ field: 'name', direction: 'asc' },
352+
{ field: 'date', direction: 'desc' },
353+
],
354+
})}
355+
/>,
356+
);
357+
358+
const builder = screen.getByTestId('sort-builder');
359+
const value = JSON.parse(builder.getAttribute('data-value')!);
360+
expect(value).toEqual([
361+
{ id: 'name-asc', field: 'name', order: 'asc' },
362+
{ id: 'date-desc', field: 'date', order: 'desc' },
363+
]);
364+
});
365+
366+
it('toSortEntries: returns empty array when sort is undefined', () => {
367+
render(
368+
<SortUI
369+
schema={makeSchema({ variant: 'buttons' })}
370+
/>,
371+
);
372+
373+
// No button should have secondary variant
374+
const buttons = screen.getAllByRole('button');
375+
buttons.forEach(btn => {
376+
expect(btn).toHaveAttribute('data-variant', 'outline');
377+
});
378+
});
379+
});
380+
});

0 commit comments

Comments
 (0)