Skip to content

Commit d5a0ede

Browse files
authored
Merge pull request #781 from objectstack-ai/copilot/add-quickfilter-adaptation-layer
2 parents 34fe884 + ccb943c commit d5a0ede

10 files changed

Lines changed: 438 additions & 51 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
685685
- [x] `rowColor` rendering: Color button enabled with color-field picker popover. Row color config wired to ObjectGrid child view, which applies row background colors via `useRowColor` hook.
686686

687687
**P1 — Structural Alignment:**
688-
- [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously.
688+
- [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously. Dual-format type union (`QuickFilterItem = ObjectUIQuickFilterItem | SpecQuickFilterItem`) exported from `@object-ui/types`. Standalone `normalizeQuickFilter()` / `normalizeQuickFilters()` adapter functions in `@object-ui/core`. Bridge (`list-view.ts`) normalizes at spec→SchemaNode transform time. Spec shorthand operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) mapped to ObjectStack AST operators. Mixed-format arrays handled transparently.
689689
- [x] `conditionalFormatting` expression reconciliation: Supports spec `{ condition, style }` format alongside ObjectUI field/operator/value rules. `condition` is treated as alias for `expression`, `style` object merged into CSS properties.
690690
- [x] `exportOptions` schema reconciliation: Accepts both spec `string[]` format (e.g., `['csv', 'xlsx']`) and ObjectUI object format `{ formats, maxRecords, includeHeaders, fileNamePrefix }`.
691691
- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`.

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './registry/WidgetRegistry.js';
1414
export * from './validation/index.js';
1515
export * from './builder/schema-builder.js';
1616
export * from './utils/filter-converter.js';
17+
export * from './utils/normalize-quick-filter.js';
1718
export * from './evaluator/index.js';
1819
export * from './actions/index.js';
1920
export * from './query/index.js';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 } from 'vitest';
10+
import { normalizeQuickFilter, normalizeQuickFilters } from '../normalize-quick-filter';
11+
12+
describe('normalizeQuickFilter', () => {
13+
it('should pass through ObjectUI format unchanged', () => {
14+
const input = { id: 'active', label: 'Active', filters: [['status', '=', 'active']] };
15+
const result = normalizeQuickFilter(input);
16+
expect(result).toBe(input); // same reference
17+
expect(result.id).toBe('active');
18+
expect(result.label).toBe('Active');
19+
expect(result.filters).toEqual([['status', '=', 'active']]);
20+
});
21+
22+
it('should convert spec format { field, operator, value } to ObjectUI format', () => {
23+
const result = normalizeQuickFilter({
24+
field: 'status',
25+
operator: 'eq',
26+
value: 'active',
27+
label: 'Active',
28+
});
29+
expect(result.id).toBe('status-eq-active');
30+
expect(result.label).toBe('Active');
31+
expect(result.filters).toEqual([['status', '=', 'active']]);
32+
});
33+
34+
it('should auto-generate label when omitted', () => {
35+
const result = normalizeQuickFilter({
36+
field: 'status',
37+
operator: 'eq',
38+
value: 'active',
39+
});
40+
expect(result.label).toBe('status eq active');
41+
});
42+
43+
it('should handle null value', () => {
44+
const result = normalizeQuickFilter({
45+
field: 'archived',
46+
operator: 'eq',
47+
value: null,
48+
label: 'Not Archived',
49+
});
50+
expect(result.id).toBe('archived-eq-');
51+
expect(result.filters).toEqual([['archived', '=', null]]);
52+
});
53+
54+
it('should map "equals" operator to "="', () => {
55+
const result = normalizeQuickFilter({
56+
field: 'status',
57+
operator: 'equals',
58+
value: 'active',
59+
});
60+
expect(result.filters).toEqual([['status', '=', 'active']]);
61+
});
62+
63+
it('should map "gt" operator to ">"', () => {
64+
const result = normalizeQuickFilter({
65+
field: 'amount',
66+
operator: 'gt',
67+
value: 100,
68+
});
69+
expect(result.filters).toEqual([['amount', '>', 100]]);
70+
});
71+
72+
it('should map "lte" operator to "<="', () => {
73+
const result = normalizeQuickFilter({
74+
field: 'age',
75+
operator: 'lte',
76+
value: 18,
77+
});
78+
expect(result.filters).toEqual([['age', '<=', 18]]);
79+
});
80+
81+
it('should pass through unknown operators', () => {
82+
const result = normalizeQuickFilter({
83+
field: 'status',
84+
operator: 'custom_op',
85+
value: 'x',
86+
});
87+
expect(result.filters).toEqual([['status', 'custom_op', 'x']]);
88+
});
89+
90+
it('should preserve icon and defaultActive', () => {
91+
const result = normalizeQuickFilter({
92+
field: 'status',
93+
operator: 'eq',
94+
value: 'active',
95+
label: 'Active',
96+
icon: 'check',
97+
defaultActive: true,
98+
});
99+
expect(result.icon).toBe('check');
100+
expect(result.defaultActive).toBe(true);
101+
});
102+
});
103+
104+
describe('normalizeQuickFilters', () => {
105+
it('should return undefined for undefined input', () => {
106+
expect(normalizeQuickFilters(undefined)).toBeUndefined();
107+
});
108+
109+
it('should return undefined for empty array', () => {
110+
expect(normalizeQuickFilters([])).toBeUndefined();
111+
});
112+
113+
it('should normalize mixed format arrays', () => {
114+
const result = normalizeQuickFilters([
115+
{ id: 'vip', label: 'VIP', filters: [['vip', '=', true]] },
116+
{ field: 'status', operator: 'eq', value: 'active', label: 'Active' },
117+
]);
118+
expect(result).toHaveLength(2);
119+
expect(result![0].id).toBe('vip');
120+
expect(result![1].id).toBe('status-eq-active');
121+
expect(result![1].filters).toEqual([['status', '=', 'active']]);
122+
});
123+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
/**
10+
* QuickFilter Normalization Utility
11+
*
12+
* Adapter layer that converts between two QuickFilter formats:
13+
* - Spec format: { field, operator, value }
14+
* - ObjectUI format: { id, label, filters[], icon?, defaultActive? }
15+
*
16+
* Both formats are accepted; spec-format items are auto-converted to ObjectUI format.
17+
*/
18+
19+
import type { QuickFilterItem, ObjectUIQuickFilterItem } from '@object-ui/types';
20+
21+
/** Normalized ObjectUI QuickFilter shape (output of normalizeQuickFilter) */
22+
export type NormalizedQuickFilter = ObjectUIQuickFilterItem;
23+
24+
/**
25+
* Map a human-readable / spec operator to the ObjectStack AST operator.
26+
*/
27+
function mapSpecOperator(op: string): string {
28+
switch (op) {
29+
case 'equals': case 'eq': return '=';
30+
case 'notEquals': case 'ne': case 'neq': return '!=';
31+
case 'contains': return 'contains';
32+
case 'notContains': return 'notcontains';
33+
case 'greaterThan': case 'gt': return '>';
34+
case 'greaterOrEqual': case 'gte': return '>=';
35+
case 'lessThan': case 'lt': return '<';
36+
case 'lessOrEqual': case 'lte': return '<=';
37+
case 'in': return 'in';
38+
case 'notIn': return 'not in';
39+
case 'before': return '<';
40+
case 'after': return '>';
41+
default: return op;
42+
}
43+
}
44+
45+
/**
46+
* Normalize a single QuickFilter item.
47+
* - If it's already in ObjectUI format (has id + label + filters), return as-is.
48+
* - If it's in Spec format (has field + operator), convert to ObjectUI format.
49+
*/
50+
export function normalizeQuickFilter(item: QuickFilterItem): NormalizedQuickFilter {
51+
// Already in ObjectUI format
52+
if ('id' in item && 'filters' in item && item.label && Array.isArray(item.filters)) {
53+
return item as NormalizedQuickFilter;
54+
}
55+
// Spec format: { field, operator, value }
56+
if ('field' in item && 'operator' in item) {
57+
const op = mapSpecOperator(item.operator);
58+
return {
59+
id: `${item.field}-${item.operator}-${String(item.value ?? '')}`,
60+
label: item.label || `${item.field} ${item.operator} ${String(item.value ?? '')}`,
61+
filters: [[item.field, op, item.value]],
62+
icon: item.icon,
63+
defaultActive: item.defaultActive,
64+
};
65+
}
66+
// Unknown format — return as-is
67+
return item as NormalizedQuickFilter;
68+
}
69+
70+
/**
71+
* Normalize an array of QuickFilter items (mixed formats accepted).
72+
*/
73+
export function normalizeQuickFilters(
74+
items: QuickFilterItem[] | undefined,
75+
): NormalizedQuickFilter[] | undefined {
76+
if (!items || items.length === 0) return undefined;
77+
return items.map(normalizeQuickFilter);
78+
}

packages/plugin-list/src/ListView.tsx

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
1717
import { useDensityMode } from '@object-ui/react';
1818
import type { ListViewSchema } from '@object-ui/types';
1919
import { usePullToRefresh } from '@object-ui/mobile';
20-
import { ExpressionEvaluator } from '@object-ui/core';
20+
import { ExpressionEvaluator, normalizeQuickFilters } from '@object-ui/core';
2121
import { useObjectTranslation } from '@object-ui/i18n';
2222

2323
export interface ListViewProps {
@@ -37,19 +37,19 @@ export interface ListViewProps {
3737
// Helper to convert FilterBuilder group to ObjectStack AST
3838
function mapOperator(op: string) {
3939
switch (op) {
40-
case 'equals': return '=';
41-
case 'notEquals': return '!=';
40+
case 'equals': case 'eq': return '=';
41+
case 'notEquals': case 'ne': case 'neq': return '!=';
4242
case 'contains': return 'contains';
4343
case 'notContains': return 'notcontains';
44-
case 'greaterThan': return '>';
45-
case 'greaterOrEqual': return '>=';
46-
case 'lessThan': return '<';
47-
case 'lessOrEqual': return '<=';
44+
case 'greaterThan': case 'gt': return '>';
45+
case 'greaterOrEqual': case 'gte': return '>=';
46+
case 'lessThan': case 'lt': return '<';
47+
case 'lessOrEqual': case 'lte': return '<=';
4848
case 'in': return 'in';
4949
case 'notIn': return 'not in';
5050
case 'before': return '<';
5151
case 'after': return '>';
52-
default: return '=';
52+
default: return op;
5353
}
5454
}
5555

@@ -393,25 +393,10 @@ export const ListView: React.FC<ListViewProps> = ({
393393

394394
// Normalize quickFilters: support both ObjectUI format { id, label, filters[] }
395395
// and spec format { field, operator, value }. Spec items are auto-converted.
396-
const normalizedQuickFilters = React.useMemo(() => {
397-
if (!schema.quickFilters || schema.quickFilters.length === 0) return undefined;
398-
return schema.quickFilters.map((qf: any) => {
399-
// Already in ObjectUI format (has id + label + filters)
400-
if (qf.id && qf.label && Array.isArray(qf.filters)) return qf;
401-
// Spec format: { field, operator, value } → convert to ObjectUI format
402-
if (qf.field && qf.operator) {
403-
const op = mapOperator(qf.operator);
404-
return {
405-
id: `${qf.field}-${qf.operator}-${String(qf.value ?? '')}`,
406-
label: qf.label || `${qf.field} ${qf.operator} ${String(qf.value ?? '')}`,
407-
filters: [[qf.field, op, qf.value]],
408-
icon: qf.icon,
409-
defaultActive: qf.defaultActive,
410-
};
411-
}
412-
return qf;
413-
});
414-
}, [schema.quickFilters]);
396+
const normalizedQuickFilters = React.useMemo(
397+
() => normalizeQuickFilters(schema.quickFilters),
398+
[schema.quickFilters],
399+
);
415400

416401
// Normalize exportOptions: support both ObjectUI object format and spec string[] format
417402
const resolvedExportOptions = React.useMemo(() => {

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1598,7 +1598,7 @@ describe('ListView', () => {
15981598
viewType: 'grid',
15991599
fields: ['name', 'email', 'status'],
16001600
quickFilters: [
1601-
{ field: 'status', operator: 'equals', value: 'active', label: 'Active' } as any,
1601+
{ field: 'status', operator: 'equals', value: 'active', label: 'Active' },
16021602
],
16031603
};
16041604

@@ -1624,6 +1624,73 @@ describe('ListView', () => {
16241624
expect(screen.getByText('Active')).toBeInTheDocument();
16251625
expect(screen.getByText('VIP')).toBeInTheDocument();
16261626
});
1627+
1628+
it('should handle mixed format arrays (ObjectUI + Spec items together)', () => {
1629+
const schema: ListViewSchema = {
1630+
type: 'list-view',
1631+
objectName: 'contacts',
1632+
viewType: 'grid',
1633+
fields: ['name', 'email', 'status'],
1634+
quickFilters: [
1635+
{ id: 'active', label: 'Active', filters: [['status', '=', 'active']] },
1636+
{ field: 'priority', operator: 'eq', value: 'high', label: 'High Priority' },
1637+
],
1638+
};
1639+
1640+
renderWithProvider(<ListView schema={schema} />);
1641+
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1642+
expect(screen.getByText('Active')).toBeInTheDocument();
1643+
expect(screen.getByText('High Priority')).toBeInTheDocument();
1644+
});
1645+
1646+
it('should handle spec shorthand operator "eq"', () => {
1647+
const schema: ListViewSchema = {
1648+
type: 'list-view',
1649+
objectName: 'contacts',
1650+
viewType: 'grid',
1651+
fields: ['name', 'status'],
1652+
quickFilters: [
1653+
{ field: 'status', operator: 'eq', value: 'active', label: 'Active' },
1654+
],
1655+
};
1656+
1657+
renderWithProvider(<ListView schema={schema} />);
1658+
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1659+
expect(screen.getByText('Active')).toBeInTheDocument();
1660+
});
1661+
1662+
it('should auto-generate label when label is omitted in spec format', () => {
1663+
const schema: ListViewSchema = {
1664+
type: 'list-view',
1665+
objectName: 'contacts',
1666+
viewType: 'grid',
1667+
fields: ['name', 'status'],
1668+
quickFilters: [
1669+
{ field: 'status', operator: 'eq', value: 'active' },
1670+
],
1671+
};
1672+
1673+
renderWithProvider(<ListView schema={schema} />);
1674+
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1675+
// Auto-generated label: "status eq active"
1676+
expect(screen.getByText('status eq active')).toBeInTheDocument();
1677+
});
1678+
1679+
it('should handle spec format with missing value', () => {
1680+
const schema: ListViewSchema = {
1681+
type: 'list-view',
1682+
objectName: 'contacts',
1683+
viewType: 'grid',
1684+
fields: ['name', 'archived'],
1685+
quickFilters: [
1686+
{ field: 'archived', operator: 'eq', value: null, label: 'Not Archived' },
1687+
],
1688+
};
1689+
1690+
renderWithProvider(<ListView schema={schema} />);
1691+
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
1692+
expect(screen.getByText('Not Archived')).toBeInTheDocument();
1693+
});
16271694
});
16281695

16291696
// ============================

0 commit comments

Comments
 (0)