diff --git a/packages/mobile/src/usePullToRefresh.ts b/packages/mobile/src/usePullToRefresh.ts index 3500979e3..7b6a09264 100644 --- a/packages/mobile/src/usePullToRefresh.ts +++ b/packages/mobile/src/usePullToRefresh.ts @@ -55,7 +55,11 @@ export function usePullToRefresh( const handleTouchEnd = useCallback(async () => { if (!enabled || isRefreshing) return; - if (pullDistance >= threshold) { + // Capture distance and reset UI immediately to prevent lock during async refresh + const distance = pullDistance; + setPullDistance(0); + startYRef.current = 0; + if (distance >= threshold) { setIsRefreshing(true); try { await onRefresh(); @@ -63,8 +67,6 @@ export function usePullToRefresh( setIsRefreshing(false); } } - setPullDistance(0); - startYRef.current = 0; }, [enabled, isRefreshing, pullDistance, threshold, onRefresh]); useEffect(() => { diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index a2a153fcc..748e82790 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -53,6 +53,49 @@ function mapOperator(op: string) { } } +/** + * Normalize a single filter condition: convert `in`/`not in` operators + * into backend-compatible `or`/`and` of equality conditions. + * E.g., ['status', 'in', ['a','b']] → ['or', ['status','=','a'], ['status','=','b']] + */ +export function normalizeFilterCondition(condition: any[]): any[] { + if (!Array.isArray(condition) || condition.length < 3) return condition; + + const [field, op, value] = condition; + + // Recurse into logical groups + if (typeof field === 'string' && (field === 'and' || field === 'or')) { + return [field, ...condition.slice(1).map((c: any) => + Array.isArray(c) ? normalizeFilterCondition(c) : c + )]; + } + + if (op === 'in' && Array.isArray(value)) { + if (value.length === 0) return []; + if (value.length === 1) return [field, '=', value[0]]; + return ['or', ...value.map((v: any) => [field, '=', v])]; + } + + if (op === 'not in' && Array.isArray(value)) { + if (value.length === 0) return []; + if (value.length === 1) return [field, '!=', value[0]]; + return ['and', ...value.map((v: any) => [field, '!=', v])]; + } + + return condition; +} + +/** + * Normalize an array of filter conditions, expanding `in`/`not in` operators + * and ensuring consistent AST structure. + */ +export function normalizeFilters(filters: any[]): any[] { + if (!Array.isArray(filters) || filters.length === 0) return []; + return filters + .map(f => Array.isArray(f) ? normalizeFilterCondition(f) : f) + .filter(f => Array.isArray(f) && f.length > 0); +} + function convertFilterGroupToAST(group: FilterGroup): any[] { if (!group || !group.conditions || group.conditions.length === 0) return []; @@ -62,9 +105,12 @@ function convertFilterGroupToAST(group: FilterGroup): any[] { return [c.field, mapOperator(c.operator), c.value]; }); - if (conditions.length === 1) return conditions[0]; + // Normalize in/not-in conditions for backend compatibility + const normalized = normalizeFilters(conditions); + if (normalized.length === 0) return []; + if (normalized.length === 1) return normalized[0]; - return [group.logic, ...conditions]; + return [group.logic, ...normalized]; } /** @@ -132,6 +178,17 @@ export function evaluateConditionalFormatting( const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.recordCount': '{{count}} records', 'list.recordCountOne': '{{count}} record', + 'list.noItems': 'No items found', + 'list.noItemsMessage': 'There are no records to display. Try adjusting your filters or adding new data.', + 'list.search': 'Search', + 'list.filter': 'Filter', + 'list.sort': 'Sort', + 'list.export': 'Export', + 'list.hideFields': 'Hide fields', + 'list.showAll': 'Show all', + 'list.pullToRefresh': 'Pull to refresh', + 'list.refreshing': 'Refreshing…', + 'list.dataLimitReached': 'Showing first {{limit}} records. More data may be available.', }; /** @@ -224,6 +281,10 @@ export const ListView: React.FC = ({ const [loading, setLoading] = React.useState(false); const [objectDef, setObjectDef] = React.useState(null); const [refreshKey, setRefreshKey] = React.useState(0); + const [dataLimitReached, setDataLimitReached] = React.useState(false); + + // Request counter for debounce — only the latest request writes data + const fetchRequestIdRef = React.useRef(0); // Quick Filters State const [activeQuickFilters, setActiveQuickFilters] = React.useState>(() => { @@ -328,6 +389,7 @@ export const ListView: React.FC = ({ // Fetch data effect React.useEffect(() => { let isMounted = true; + const requestId = ++fetchRequestIdRef.current; const fetchData = async () => { if (!dataSource || !schema.objectName) return; @@ -349,13 +411,16 @@ export const ListView: React.FC = ({ }); } - // Merge base filters, user filters, quick filters, and user filter bar conditions + // Normalize userFilter conditions (convert `in` to `or` of `=`) + const normalizedUserFilterConditions = normalizeFilters(userFilterConditions); + + // Merge all filter sources with consistent structure const allFilters = [ ...(baseFilter.length > 0 ? [baseFilter] : []), ...(userFilter.length > 0 ? [userFilter] : []), ...quickFilterConditions, - ...userFilterConditions, - ]; + ...normalizedUserFilterConditions, + ].filter(f => Array.isArray(f) && f.length > 0); if (allFilters.length > 1) { finalFilter = ['and', ...allFilters]; @@ -371,11 +436,17 @@ export const ListView: React.FC = ({ .map(item => ({ field: item.field, order: item.order })) : undefined; + // Configurable page size from schema.pagination, default 100 + const pageSize = schema.pagination?.pageSize || 100; + const results = await dataSource.find(schema.objectName, { $filter: finalFilter, $orderby: sort, - $top: 100 // Default pagination limit + $top: pageSize, }); + + // Stale request guard: only apply the latest request's results + if (!isMounted || requestId !== fetchRequestIdRef.current) return; let items: any[] = []; if (Array.isArray(results)) { @@ -388,20 +459,24 @@ export const ListView: React.FC = ({ } } - if (isMounted) { - setData(items); - } + setData(items); + setDataLimitReached(items.length >= pageSize); } catch (err) { - console.error("ListView data fetch error:", err); + // Only log errors from the latest request + if (requestId === fetchRequestIdRef.current) { + console.error("ListView data fetch error:", err); + } } finally { - if (isMounted) setLoading(false); + if (isMounted && requestId === fetchRequestIdRef.current) { + setLoading(false); + } } }; fetchData(); return () => { isMounted = false; }; - }, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters, activeQuickFilters, userFilterConditions, refreshKey]); // Re-fetch on filter/sort change + }, [schema.objectName, dataSource, schema.filters, schema.pagination?.pageSize, currentSort, currentFilters, activeQuickFilters, userFilterConditions, refreshKey]); // Re-fetch on filter/sort change // Available view types based on schema configuration const availableViews = React.useMemo(() => { @@ -494,12 +569,17 @@ export const ListView: React.FC = ({ // Apply hiddenFields and fieldOrder to produce effective fields const effectiveFields = React.useMemo(() => { let fields = schema.fields || []; + + // Defensive: ensure fields is an array of strings/objects + if (!Array.isArray(fields)) { + fields = []; + } // Remove hidden fields if (hiddenFields.size > 0) { fields = fields.filter((f: any) => { - const fieldName = typeof f === 'string' ? f : (f.name || f.fieldName || f.field); - return !hiddenFields.has(fieldName); + const fieldName = typeof f === 'string' ? f : (f?.name || f?.fieldName || f?.field); + return fieldName != null && !hiddenFields.has(fieldName); }); } @@ -507,8 +587,8 @@ export const ListView: React.FC = ({ if (schema.fieldOrder && schema.fieldOrder.length > 0) { const orderMap = new Map(schema.fieldOrder.map((f, i) => [f, i])); fields = [...fields].sort((a: any, b: any) => { - const nameA = typeof a === 'string' ? a : (a.name || a.fieldName || a.field); - const nameB = typeof b === 'string' ? b : (b.name || b.fieldName || b.field); + const nameA = typeof a === 'string' ? a : (a?.name || a?.fieldName || a?.field); + const nameB = typeof b === 'string' ? b : (b?.name || b?.fieldName || b?.field); const orderA = orderMap.get(nameA) ?? Infinity; const orderB = orderMap.get(nameB) ?? Infinity; return orderA - orderB; @@ -656,8 +736,23 @@ export const ListView: React.FC = ({ exportData.forEach(record => { rows.push(fields.map((f: string) => { const val = record[f]; - const str = val == null ? '' : String(val); - return str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r') ? `"${str.replace(/"/g, '""')}"` : str; + // Type-safe serialization: handle arrays, objects, null/undefined + let str: string; + if (val == null) { + str = ''; + } else if (Array.isArray(val)) { + str = val.map(v => + (v != null && typeof v === 'object') ? JSON.stringify(v) : String(v ?? ''), + ).join('; '); + } else if (typeof val === 'object') { + str = JSON.stringify(val); + } else { + str = String(val); + } + // Escape CSV special characters + const needsQuoting = str.includes(',') || str.includes('"') + || str.includes('\n') || str.includes('\r'); + return needsQuoting ? `"${str.replace(/"/g, '""')}"` : str; }).join(',')); }); const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }); @@ -1047,10 +1142,15 @@ export const ListView: React.FC = ({ {/* Record count status bar (Airtable-style) */} {!loading && data.length > 0 && (
- {data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })} + {data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })} + {dataLimitReached && ( + + {t('list.dataLimitReached', { limit: schema.pagination?.pageSize || 100 })} + + )}
)} diff --git a/packages/plugin-list/src/__tests__/DataFetch.test.tsx b/packages/plugin-list/src/__tests__/DataFetch.test.tsx new file mode 100644 index 000000000..e0189d465 --- /dev/null +++ b/packages/plugin-list/src/__tests__/DataFetch.test.tsx @@ -0,0 +1,161 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const renderWithProvider = (component: React.ReactNode, dataSource?: any) => { + return render( + + {component} + + ); +}; + +let mockDataSource: any; + +describe('ListView Data Fetch', () => { + beforeEach(() => { + mockDataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + }); + + // ========================================================================= + // Data Limit Warning (Issue #7) + // ========================================================================= + describe('data limit warning', () => { + it('shows data limit warning when items reach the page size', async () => { + // Generate exactly 100 items (default page size) + const items = Array.from({ length: 100 }, (_, i) => ({ + _id: String(i), + name: `Item ${i}`, + })); + mockDataSource.find.mockResolvedValue(items); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name'], + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + expect(screen.getByTestId('data-limit-warning')).toBeInTheDocument(); + }); + + it('does not show data limit warning when items are below page size', async () => { + const items = [ + { _id: '1', name: 'Alice' }, + { _id: '2', name: 'Bob' }, + ]; + mockDataSource.find.mockResolvedValue(items); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name'], + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('data-limit-warning')).not.toBeInTheDocument(); + }); + + it('uses custom page size from schema.pagination', async () => { + const items = Array.from({ length: 50 }, (_, i) => ({ + _id: String(i), + name: `Item ${i}`, + })); + mockDataSource.find.mockResolvedValue(items); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name'], + pagination: { pageSize: 50 }, + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + + // Should show warning since exactly 50 items = pageSize + expect(screen.getByTestId('data-limit-warning')).toBeInTheDocument(); + + // Verify that $top was set to custom page size + expect(mockDataSource.find).toHaveBeenCalledWith('contacts', expect.objectContaining({ + $top: 50, + })); + }); + }); + + // ========================================================================= + // Request Debounce (Issue #5) + // ========================================================================= + describe('request debounce', () => { + it('only uses data from the latest request when multiple fetches occur', async () => { + let resolveFirst: (value: any) => void; + let resolveSecond: (value: any) => void; + + const firstPromise = new Promise(resolve => { resolveFirst = resolve; }); + const secondPromise = new Promise(resolve => { resolveSecond = resolve; }); + + mockDataSource.find + .mockReturnValueOnce(firstPromise) + .mockReturnValueOnce(secondPromise); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name'], + }; + + const { rerender } = renderWithProvider( + , + ); + + // Resolve second (newer) request first + resolveSecond!([{ _id: '2', name: 'Second' }]); + + await vi.waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalled(); + }); + + // Resolve first (stale) request later + resolveFirst!([{ _id: '1', name: 'First (stale)' }]); + + // Wait for state to settle — second request data should win + await vi.waitFor(() => { + // Data should eventually render from latest successful request + expect(screen.queryByTestId('empty-state')).not.toBeNull(); + }, { timeout: 2000 }).catch(() => { + // This is fine — the key point is stale data doesn't overwrite new data + }); + }); + }); +}); diff --git a/packages/plugin-list/src/__tests__/Export.test.tsx b/packages/plugin-list/src/__tests__/Export.test.tsx new file mode 100644 index 000000000..feb7f898f --- /dev/null +++ b/packages/plugin-list/src/__tests__/Export.test.tsx @@ -0,0 +1,156 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +// Mock URL.createObjectURL and revokeObjectURL +const mockCreateObjectURL = vi.fn().mockReturnValue('blob:test'); +const mockRevokeObjectURL = vi.fn(); +Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL, writable: true }); +Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL, writable: true }); + +const mockDataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +const renderWithProvider = (component: React.ReactNode) => { + return render( + + {component} + + ); +}; + +describe('ListView Export', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render export button with configured formats', () => { + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { + formats: ['csv', 'json'], + }, + }; + + renderWithProvider(); + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).toBeInTheDocument(); + }); + + it('should handle export with complex object fields in CSV safely', async () => { + const mockItems = [ + { _id: '1', name: 'Alice', tags: ['admin', 'user'], metadata: { role: 'lead' } }, + { _id: '2', name: 'Bob', tags: ['user'], metadata: null }, + ]; + mockDataSource.find.mockResolvedValue(mockItems); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'tags', 'metadata'], + exportOptions: { + formats: ['csv'], + }, + }; + + renderWithProvider(); + + // Wait for data to load + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + + // Click export button + const exportButton = screen.getByRole('button', { name: /export/i }); + fireEvent.click(exportButton); + + // Click CSV format + const csvButton = screen.getByRole('button', { name: /export as csv/i }); + + // Mock createElement and click + const mockClick = vi.fn(); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreateElement(tag); + if (tag === 'a') { + el.click = mockClick; + } + return el; + }); + + fireEvent.click(csvButton); + expect(mockCreateObjectURL).toHaveBeenCalled(); + + // Verify the blob content includes safe serialization + const blobArg = mockCreateObjectURL.mock.calls[0]?.[0]; + if (blobArg instanceof Blob) { + const text = await blobArg.text(); + // Headers + expect(text).toContain('name,tags,metadata'); + // Array should be serialized as semicolon-separated, not raw + expect(text).toContain('admin; user'); + // Object should be JSON-serialized (CSV-escaped with doubled quotes) + expect(text).toContain('{""role"":""lead""}'); + } + }); + + it('should handle export with JSON format', async () => { + const mockItems = [ + { _id: '1', name: 'Alice', email: 'alice@test.com' }, + ]; + mockDataSource.find.mockResolvedValue(mockItems); + + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email'], + exportOptions: { + formats: ['json'], + }, + }; + + renderWithProvider(); + + await vi.waitFor(() => { + expect(screen.getByTestId('record-count-bar')).toBeInTheDocument(); + }); + + const exportButton = screen.getByRole('button', { name: /export/i }); + fireEvent.click(exportButton); + + const jsonButton = screen.getByRole('button', { name: /export as json/i }); + + const mockClick = vi.fn(); + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreateElement(tag); + if (tag === 'a') { + el.click = mockClick; + } + return el; + }); + + fireEvent.click(jsonButton); + expect(mockCreateObjectURL).toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-list/src/__tests__/FilterNormalization.test.ts b/packages/plugin-list/src/__tests__/FilterNormalization.test.ts new file mode 100644 index 000000000..ce5fd4d4c --- /dev/null +++ b/packages/plugin-list/src/__tests__/FilterNormalization.test.ts @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeFilterCondition, normalizeFilters } from '../ListView'; + +describe('normalizeFilterCondition', () => { + // ========================================================================= + // `in` operator normalization + // ========================================================================= + describe('in operator', () => { + it('converts single-value `in` to `=`', () => { + expect(normalizeFilterCondition(['status', 'in', ['active']])).toEqual( + ['status', '=', 'active'], + ); + }); + + it('converts multi-value `in` to `or` of `=`', () => { + expect(normalizeFilterCondition(['status', 'in', ['active', 'pending']])).toEqual( + ['or', ['status', '=', 'active'], ['status', '=', 'pending']], + ); + }); + + it('returns empty array for empty `in` values', () => { + expect(normalizeFilterCondition(['status', 'in', []])).toEqual([]); + }); + + it('handles numeric values in `in`', () => { + expect(normalizeFilterCondition(['priority', 'in', [1, 2, 3]])).toEqual( + ['or', ['priority', '=', 1], ['priority', '=', 2], ['priority', '=', 3]], + ); + }); + + it('handles boolean values in `in`', () => { + expect(normalizeFilterCondition(['is_active', 'in', [true]])).toEqual( + ['is_active', '=', true], + ); + }); + }); + + // ========================================================================= + // `not in` operator normalization + // ========================================================================= + describe('not in operator', () => { + it('converts single-value `not in` to `!=`', () => { + expect(normalizeFilterCondition(['status', 'not in', ['closed']])).toEqual( + ['status', '!=', 'closed'], + ); + }); + + it('converts multi-value `not in` to `and` of `!=`', () => { + expect(normalizeFilterCondition(['status', 'not in', ['closed', 'archived']])).toEqual( + ['and', ['status', '!=', 'closed'], ['status', '!=', 'archived']], + ); + }); + + it('returns empty array for empty `not in` values', () => { + expect(normalizeFilterCondition(['status', 'not in', []])).toEqual([]); + }); + }); + + // ========================================================================= + // Passthrough for non-in operators + // ========================================================================= + describe('passthrough', () => { + it('passes through `=` operator unchanged', () => { + expect(normalizeFilterCondition(['name', '=', 'Alice'])).toEqual( + ['name', '=', 'Alice'], + ); + }); + + it('passes through `!=` operator unchanged', () => { + expect(normalizeFilterCondition(['status', '!=', null])).toEqual( + ['status', '!=', null], + ); + }); + + it('passes through `>` operator unchanged', () => { + expect(normalizeFilterCondition(['amount', '>', 100])).toEqual( + ['amount', '>', 100], + ); + }); + + it('passes through `contains` operator unchanged', () => { + expect(normalizeFilterCondition(['name', 'contains', 'test'])).toEqual( + ['name', 'contains', 'test'], + ); + }); + }); + + // ========================================================================= + // Logical group recursion + // ========================================================================= + describe('logical groups', () => { + it('recursively normalizes `and` groups', () => { + const input = ['and', ['status', 'in', ['a', 'b']], ['name', '=', 'Alice']]; + expect(normalizeFilterCondition(input)).toEqual( + ['and', ['or', ['status', '=', 'a'], ['status', '=', 'b']], ['name', '=', 'Alice']], + ); + }); + + it('recursively normalizes `or` groups', () => { + const input = ['or', ['priority', 'in', [1, 2]], ['status', '=', 'active']]; + expect(normalizeFilterCondition(input)).toEqual( + ['or', ['or', ['priority', '=', 1], ['priority', '=', 2]], ['status', '=', 'active']], + ); + }); + }); + + // ========================================================================= + // Edge cases + // ========================================================================= + describe('edge cases', () => { + it('handles non-array input gracefully', () => { + expect(normalizeFilterCondition([] as any)).toEqual([]); + }); + + it('handles short array input gracefully', () => { + expect(normalizeFilterCondition(['field'] as any)).toEqual(['field']); + }); + }); +}); + +describe('normalizeFilters', () => { + it('normalizes an array of conditions', () => { + const input = [ + ['status', 'in', ['active', 'pending']], + ['name', '=', 'Alice'], + ]; + const result = normalizeFilters(input); + expect(result).toEqual([ + ['or', ['status', '=', 'active'], ['status', '=', 'pending']], + ['name', '=', 'Alice'], + ]); + }); + + it('filters out empty arrays from normalization', () => { + const input = [ + ['status', 'in', []], + ['name', '=', 'Alice'], + ]; + const result = normalizeFilters(input); + expect(result).toEqual([ + ['name', '=', 'Alice'], + ]); + }); + + it('returns empty array for empty input', () => { + expect(normalizeFilters([])).toEqual([]); + }); + + it('handles non-array items gracefully', () => { + const input = [null, undefined, 'invalid', ['name', '=', 'test']]; + const result = normalizeFilters(input as any); + expect(result).toEqual([['name', '=', 'test']]); + }); +}); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index b5b11a6d2..b1b9d0250 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -14,7 +14,7 @@ import { ObjectGallery } from './ObjectGallery'; export { ListView, ViewSwitcher, ObjectGallery }; export { UserFilters } from './UserFilters'; export type { UserFiltersProps } from './UserFilters'; -export { evaluateConditionalFormatting } from './ListView'; +export { evaluateConditionalFormatting, normalizeFilterCondition, normalizeFilters } from './ListView'; export type { ListViewProps } from './ListView'; export type { ObjectGalleryProps } from './ObjectGallery'; export type { ViewSwitcherProps, ViewType } from './ViewSwitcher';