Skip to content

Commit 508ac7c

Browse files
committed
Add SortBuilder component and integrate sorting functionality in ListView
1 parent f4dc040 commit 508ac7c

File tree

3 files changed

+188
-24
lines changed

3 files changed

+188
-24
lines changed

packages/components/src/custom/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './item';
99
export * from './kbd';
1010
export * from './native-select';
1111
export * from './spinner';
12+
export * from './sort-builder';
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 * as React from "react"
10+
import { X, Plus, Trash2 } from "lucide-react"
11+
12+
import { cn } from "../lib/utils"
13+
import { Button } from "../ui/button"
14+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
15+
16+
export interface SortItem {
17+
id: string;
18+
field: string;
19+
order: 'asc' | 'desc';
20+
}
21+
22+
export interface SortBuilderProps {
23+
fields?: Array<{
24+
value: string
25+
label: string
26+
}>;
27+
value?: SortItem[];
28+
onChange?: (value: SortItem[]) => void;
29+
className?: string;
30+
}
31+
32+
export function SortBuilder({
33+
fields = [],
34+
value = [],
35+
onChange,
36+
className,
37+
}: SortBuilderProps) {
38+
// Use internal state initialization prop changes
39+
const [items, setItems] = React.useState<SortItem[]>(value || []);
40+
41+
React.useEffect(() => {
42+
if (value && JSON.stringify(value) !== JSON.stringify(items)) {
43+
setItems(value);
44+
}
45+
}, [value]);
46+
47+
const handleChange = (newItems: SortItem[]) => {
48+
setItems(newItems);
49+
onChange?.(newItems);
50+
};
51+
52+
const addItem = () => {
53+
const newItem: SortItem = {
54+
id: crypto.randomUUID(),
55+
field: fields[0]?.value || "",
56+
order: 'asc',
57+
};
58+
handleChange([...items, newItem]);
59+
};
60+
61+
const updateItem = (id: string, updates: Partial<SortItem>) => {
62+
handleChange(items.map(item => item.id === id ? { ...item, ...updates } : item));
63+
};
64+
65+
const removeItem = (id: string) => {
66+
handleChange(items.filter(item => item.id !== id));
67+
};
68+
69+
return (
70+
<div className={cn("space-y-3", className)}>
71+
<div className="space-y-2">
72+
{items.map((item, index) => (
73+
<div key={item.id} className="flex items-center gap-2">
74+
<span className="text-sm font-medium w-16 text-muted-foreground">
75+
{index === 0 ? "Sort by" : "Then by"}
76+
</span>
77+
<div className="flex-1">
78+
<Select
79+
value={item.field}
80+
onValueChange={(val) => updateItem(item.id, { field: val })}
81+
>
82+
<SelectTrigger className="h-9">
83+
<SelectValue placeholder="Select field" />
84+
</SelectTrigger>
85+
<SelectContent>
86+
{fields.map(f => (
87+
<SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>
88+
))}
89+
</SelectContent>
90+
</Select>
91+
</div>
92+
<div className="w-28">
93+
<Select
94+
value={item.order}
95+
onValueChange={(val) => updateItem(item.id, { order: val as 'asc' | 'desc' })}
96+
>
97+
<SelectTrigger className="h-9">
98+
<SelectValue />
99+
</SelectTrigger>
100+
<SelectContent>
101+
<SelectItem value="asc">A -&gt; Z</SelectItem>
102+
<SelectItem value="desc">Z -&gt; A</SelectItem>
103+
</SelectContent>
104+
</Select>
105+
</div>
106+
<Button
107+
variant="ghost"
108+
size="icon-sm"
109+
className="h-9 w-9 shrink-0"
110+
onClick={() => removeItem(item.id)}
111+
>
112+
<X className="h-4 w-4" />
113+
</Button>
114+
</div>
115+
))}
116+
</div>
117+
<Button
118+
variant="outline"
119+
size="sm"
120+
onClick={addItem}
121+
className="h-8"
122+
disabled={fields.length === 0}
123+
>
124+
<Plus className="h-3 w-3 mr-2" />
125+
Add sort
126+
</Button>
127+
</div>
128+
);
129+
}

packages/plugin-list/src/ListView.tsx

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import * as React from 'react';
10-
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder } from '@object-ui/components';
10+
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder } from '@object-ui/components';
11+
import type { SortItem } from '@object-ui/components';
1112
import { Search, SlidersHorizontal, ArrowUpDown, X } from 'lucide-react';
1213
import type { FilterGroup } from '@object-ui/components';
1314
import { ViewSwitcher, ViewType } from './ViewSwitcher';
@@ -70,8 +71,20 @@ export const ListView: React.FC<ListViewProps> = ({
7071
(schema.viewType as ViewType) || 'grid'
7172
);
7273
const [searchTerm, setSearchTerm] = React.useState('');
73-
const [sortField] = React.useState(schema.sort?.[0]?.field || '');
74-
const [sortOrder, setSortOrder] = React.useState<'asc' | 'desc'>(schema.sort?.[0]?.order || 'asc');
74+
75+
// Sort State
76+
const [showSort, setShowSort] = React.useState(false);
77+
const [currentSort, setCurrentSort] = React.useState<SortItem[]>(() => {
78+
if (schema.sort && schema.sort.length > 0) {
79+
return schema.sort.map(s => ({
80+
id: crypto.randomUUID(),
81+
field: s.field,
82+
order: (s.order as 'asc' | 'desc') || 'asc'
83+
}));
84+
}
85+
return [];
86+
});
87+
7588
const [showFilters, setShowFilters] = React.useState(false);
7689

7790
const [currentFilters, setCurrentFilters] = React.useState<FilterGroup>({
@@ -135,7 +148,9 @@ export const ListView: React.FC<ListViewProps> = ({
135148

136149
// Convert sort to query format
137150
// ObjectQL uses simple object: { field: 'asc' }
138-
const sort: any = sortField ? { [sortField]: sortOrder } : undefined;
151+
const sort: any = currentSort.length > 0
152+
? currentSort.reduce((acc, item) => ({ ...acc, [item.field]: item.order }), {})
153+
: undefined;
139154

140155
const results = await dataSource.find(schema.objectName, {
141156
$filter: finalFilter,
@@ -167,7 +182,7 @@ export const ListView: React.FC<ListViewProps> = ({
167182
fetchData();
168183

169184
return () => { isMounted = false; };
170-
}, [schema.objectName, dataSource, schema.filters, sortField, sortOrder, currentFilters]); // Re-fetch on filter/sort change
185+
}, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters]); // Re-fetch on filter/sort change
171186

172187
// Load saved view preference
173188
React.useEffect(() => {
@@ -196,19 +211,13 @@ export const ListView: React.FC<ListViewProps> = ({
196211
onSearchChange?.(value);
197212
}, [onSearchChange]);
198213

199-
const handleSortChange = React.useCallback(() => {
200-
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc';
201-
setSortOrder(newOrder);
202-
onSortChange?.({ field: sortField, order: newOrder });
203-
}, [sortField, sortOrder, onSortChange]);
204-
205214
// Generate the appropriate view component schema
206215
const viewComponentSchema = React.useMemo(() => {
207216
const baseProps = {
208217
objectName: schema.objectName,
209218
fields: schema.fields,
210219
filters: schema.filters,
211-
sort: [{ field: sortField, order: sortOrder }],
220+
sort: currentSort,
212221
className: "h-full w-full",
213222
// Disable internal controls that clash with ListView toolbar
214223
showSearch: false,
@@ -278,7 +287,7 @@ export const ListView: React.FC<ListViewProps> = ({
278287
default:
279288
return baseProps;
280289
}
281-
}, [currentView, schema, sortField, sortOrder]);
290+
}, [currentView, schema, currentSort]);
282291

283292
// Available view types based on schema configuration
284293
const availableViews = React.useMemo(() => {
@@ -399,17 +408,42 @@ export const ListView: React.FC<ListViewProps> = ({
399408
</PopoverContent>
400409
</Popover>
401410

402-
{sortField && (
403-
<Button
404-
variant="ghost"
405-
size="sm"
406-
onClick={handleSortChange}
407-
className="h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary"
408-
>
409-
<ArrowUpDown className="h-4 w-4 mr-2" />
410-
<span className="hidden lg:inline">Sort</span>
411-
</Button>
412-
)}
411+
<Popover open={showSort} onOpenChange={setShowSort}>
412+
<PopoverTrigger asChild>
413+
<Button
414+
variant={currentSort.length > 0 ? "secondary" : "ghost"}
415+
size="sm"
416+
className={cn(
417+
"h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
418+
currentSort.length > 0 && "text-primary bg-secondary/50"
419+
)}
420+
>
421+
<ArrowUpDown className="h-4 w-4 mr-2" />
422+
<span className="hidden lg:inline">Sort</span>
423+
{currentSort.length > 0 && (
424+
<span className="ml-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
425+
{currentSort.length}
426+
</span>
427+
)}
428+
</Button>
429+
</PopoverTrigger>
430+
<PopoverContent align="start" className="w-[600px] p-4">
431+
<div className="space-y-4">
432+
<div className="flex items-center justify-between border-b pb-2">
433+
<h4 className="font-medium text-sm">Sort Records</h4>
434+
</div>
435+
<SortBuilder
436+
fields={filterFields}
437+
value={currentSort}
438+
onChange={(newSort) => {
439+
console.log('Sort Changed:', newSort);
440+
setCurrentSort(newSort);
441+
if (onSortChange) onSortChange(newSort);
442+
}}
443+
/>
444+
</div>
445+
</PopoverContent>
446+
</Popover>
413447

414448
{/* Future: Group, Color, Height */}
415449
</div>

0 commit comments

Comments
 (0)