Skip to content

Commit e9e3143

Browse files
authored
Merge pull request #1094 from objectstack-ai/copilot/optimize-lookup-popup-ui
2 parents 8be85db + ec9bcb4 commit e9e3143

File tree

4 files changed

+547
-62
lines changed

4 files changed

+547
-62
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **RecordPickerDialog UI/UX Overhaul** (`@object-ui/fields`): Major enterprise-grade improvements referencing mainstream low-code platforms:
13+
- **Skeleton Loading Screen**: Replaced simple spinner with a table-shaped skeleton screen during initial data load, matching the column layout for a polished loading experience.
14+
- **Sticky Table Header**: Table header now sticks to the top during vertical scroll, keeping column labels visible at all times.
15+
- **Loading Overlay**: Subsequent data fetches (page navigation, sorting, filtering) show a semi-transparent overlay with spinner over the existing data, preventing layout jank.
16+
- **Page Jump Input**: New input field in pagination bar allows users to type a page number and press Enter to jump directly to any page.
17+
- **Enhanced Search Bar**: Redesigned with a subtle background container and borderless input for a cleaner, more modern appearance.
18+
- **Improved Table Styling**: Even/odd row striping (`bg-muted/20`), refined selected-row highlighting (`bg-primary/5`), uppercase column headers with tighter tracking, rounded table border, and improved cell padding.
19+
- **Responsive Dialog**: Responsive dialog sizing from `sm:max-w-3xl` to `lg:max-w-5xl` for optimal data density across screen sizes; filter panel supports 3-column layout on wide viewports (`lg:grid-cols-3`).
20+
- **Fixed Close-Reset Cycle**: Separated dialog close-reset logic into its own `useEffect` (depends only on `open`) to prevent cascading state updates that could trigger React Error #185 (Maximum update depth exceeded) when selecting a record.
21+
- **8 New Unit Tests**: Comprehensive test coverage for skeleton loading (column count validation), sticky header classes, page jump navigation (valid/invalid/single-page), and loading overlay behavior.
22+
1223
- **"Browse All" Button for Lookup Fields** (`@object-ui/fields`): Added an always-visible "Browse All" (table icon) button next to the Lookup quick-select trigger. Opens the full RecordPickerDialog directly, regardless of record count — making enterprise features (multi-column table, sort/filter bar, cell renderers) discoverable at all times. Previously, the dialog was only accessible via the "Show All Results" in-popover button, which only appeared when total records exceeded the page size. The button uses accessible `aria-label`, `title`, and Lucide `TableProperties` icon. Keyboard and screen reader accessible.
1324
- **CRM Enterprise Lookup Metadata** (`examples/crm`): All 14 lookup fields across 8 CRM objects now have enterprise-grade RecordPicker configuration — `lookup_columns` (with type hints for cell rendering: select, currency, boolean, date, number, percent), `lookup_filters` (base business filters using eq/ne/in/notIn operators), and `description_field`. Uses post-create `Object.assign` injection pattern to bypass `ObjectSchema.create()` Zod stripping (analogous to the listViews passthrough approach).
1425
- **Enterprise Lookup Tests** (`examples/crm`): 12 new test cases validating lookup_columns presence & type diversity, lookup_filters operator validity, description_field coverage, and specific business logic (e.g., active-only users, non-cancelled orders, open opportunities).
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import React, { useState } from 'react';
3+
import { RecordPickerDialog } from './widgets/RecordPickerDialog';
4+
import type { DataSource } from '@object-ui/types';
5+
6+
/**
7+
* **RecordPickerDialog** — Enterprise-grade record picker for lookup fields.
8+
*
9+
* Features: skeleton loading, sticky table header, column sort,
10+
* keyboard navigation, page jump, inline filter bar, column resize,
11+
* and responsive layout.
12+
*/
13+
const meta = {
14+
title: 'Fields/RecordPickerDialog',
15+
component: RecordPickerDialog,
16+
parameters: {
17+
layout: 'centered',
18+
},
19+
tags: ['autodocs'],
20+
} satisfies Meta<typeof RecordPickerDialog>;
21+
22+
export default meta;
23+
type Story = StoryObj<typeof RecordPickerDialog>;
24+
25+
/* ------------------------------------------------------------------ */
26+
/* Mock data used by all stories */
27+
/* ------------------------------------------------------------------ */
28+
29+
const MOCK_ORDERS = [
30+
{ id: '1', order_number: 'ORD-2024-001', amount: 15459.99, status: 'Paid', order_date: '2024-01-15' },
31+
{ id: '2', order_number: 'ORD-2024-002', amount: 289.50, status: 'Pending', order_date: '2024-01-18' },
32+
{ id: '3', order_number: 'ORD-2024-003', amount: 5549.99, status: 'Shipped', order_date: '2024-02-05' },
33+
{ id: '4', order_number: 'ORD-2024-004', amount: 42500.00, status: 'Delivered', order_date: '2024-02-20' },
34+
{ id: '5', order_number: 'ORD-2024-005', amount: 1250.00, status: 'Draft', order_date: '2024-03-01' },
35+
{ id: '6', order_number: 'ORD-2024-006', amount: 8999.94, status: 'Paid', order_date: '2024-03-15' },
36+
{ id: '7', order_number: 'ORD-2024-007', amount: 3200.00, status: 'Shipped', order_date: '2024-04-02' },
37+
{ id: '8', order_number: 'ORD-2024-008', amount: 750.00, status: 'Pending', order_date: '2024-04-10' },
38+
{ id: '9', order_number: 'ORD-2024-009', amount: 19800.00, status: 'Delivered', order_date: '2024-04-22' },
39+
{ id: '10', order_number: 'ORD-2024-010', amount: 4500.00, status: 'Paid', order_date: '2024-05-01' },
40+
{ id: '11', order_number: 'ORD-2024-011', amount: 670.00, status: 'Draft', order_date: '2024-05-08' },
41+
{ id: '12', order_number: 'ORD-2024-012', amount: 12345.67, status: 'Shipped', order_date: '2024-05-15' },
42+
];
43+
44+
/** Simulate async DataSource.find with pagination + search */
45+
function createMockDataSource(): DataSource {
46+
return {
47+
find: async (_objectName: string, params: any) => {
48+
await new Promise(r => setTimeout(r, 400)); // simulate latency
49+
let data = [...MOCK_ORDERS];
50+
51+
// Search filter
52+
if (params?.$search) {
53+
const q = params.$search.toLowerCase();
54+
data = data.filter(
55+
r =>
56+
r.order_number.toLowerCase().includes(q) ||
57+
r.status.toLowerCase().includes(q),
58+
);
59+
}
60+
61+
// Sort
62+
if (params?.$orderby) {
63+
const entries = Object.entries(params.$orderby);
64+
if (entries.length > 0) {
65+
const [field, dir] = entries[0] as [string, string];
66+
data.sort((a: any, b: any) => {
67+
if (a[field] < b[field]) return dir === 'asc' ? -1 : 1;
68+
if (a[field] > b[field]) return dir === 'asc' ? 1 : -1;
69+
return 0;
70+
});
71+
}
72+
}
73+
74+
const total = data.length;
75+
const skip = params?.$skip ?? 0;
76+
const top = params?.$top ?? 10;
77+
return { data: data.slice(skip, skip + top), total };
78+
},
79+
findOne: async () => null,
80+
create: async () => ({}),
81+
update: async () => ({}),
82+
delete: async () => ({}),
83+
} as unknown as DataSource;
84+
}
85+
86+
/* ------------------------------------------------------------------ */
87+
/* Story wrapper — opens the dialog inside a button toggle */
88+
/* ------------------------------------------------------------------ */
89+
90+
function DialogDemo(props: Partial<React.ComponentProps<typeof RecordPickerDialog>>) {
91+
const [open, setOpen] = useState(true);
92+
const [selected, setSelected] = useState<any>(undefined);
93+
const ds = React.useMemo(() => createMockDataSource(), []);
94+
95+
return (
96+
<div className="flex flex-col items-center gap-4 p-6">
97+
<button
98+
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground shadow"
99+
onClick={() => setOpen(true)}
100+
type="button"
101+
>
102+
Open Record Picker
103+
</button>
104+
{selected && (
105+
<p className="text-sm text-muted-foreground">
106+
Selected: <code className="font-mono text-foreground">{JSON.stringify(selected)}</code>
107+
</p>
108+
)}
109+
<RecordPickerDialog
110+
open={open}
111+
onOpenChange={setOpen}
112+
title="Order"
113+
dataSource={ds}
114+
objectName="orders"
115+
columns={[
116+
{ field: 'order_number', label: 'Order Number' },
117+
{ field: 'amount', label: 'Amount' },
118+
{ field: 'status', label: 'Status' },
119+
{ field: 'order_date', label: 'Order Date' },
120+
]}
121+
displayField="order_number"
122+
pageSize={5}
123+
onSelect={(v: any) => setSelected(v)}
124+
{...props}
125+
/>
126+
</div>
127+
);
128+
}
129+
130+
/* ------------------------------------------------------------------ */
131+
/* Stories */
132+
/* ------------------------------------------------------------------ */
133+
134+
/** Default single-select dialog with 4 columns, pagination, and search. */
135+
export const Default: Story = {
136+
render: () => <DialogDemo />,
137+
};
138+
139+
/** Multi-select mode with confirm/cancel footer. */
140+
export const MultiSelect: Story = {
141+
render: () => <DialogDemo multiple />,
142+
};
143+
144+
/** With inline filter columns for interactive filtering. */
145+
export const WithFilters: Story = {
146+
render: () => (
147+
<DialogDemo
148+
filterColumns={[
149+
{ field: 'status', label: 'Status', type: 'select', options: [
150+
{ label: 'Paid', value: 'Paid' },
151+
{ label: 'Pending', value: 'Pending' },
152+
{ label: 'Shipped', value: 'Shipped' },
153+
{ label: 'Delivered', value: 'Delivered' },
154+
{ label: 'Draft', value: 'Draft' },
155+
]},
156+
{ field: 'order_number', label: 'Order Number', type: 'text' },
157+
]}
158+
/>
159+
),
160+
};

0 commit comments

Comments
 (0)