@@ -53,6 +53,49 @@ function mapOperator(op: string) {
5353 }
5454}
5555
56+ /**
57+ * Normalize a single filter condition: convert `in`/`not in` operators
58+ * into backend-compatible `or`/`and` of equality conditions.
59+ * E.g., ['status', 'in', ['a','b']] → ['or', ['status','=','a'], ['status','=','b']]
60+ */
61+ export function normalizeFilterCondition ( condition : any [ ] ) : any [ ] {
62+ if ( ! Array . isArray ( condition ) || condition . length < 3 ) return condition ;
63+
64+ const [ field , op , value ] = condition ;
65+
66+ // Recurse into logical groups
67+ if ( typeof field === 'string' && ( field === 'and' || field === 'or' ) ) {
68+ return [ field , ...condition . slice ( 1 ) . map ( ( c : any ) =>
69+ Array . isArray ( c ) ? normalizeFilterCondition ( c ) : c
70+ ) ] ;
71+ }
72+
73+ if ( op === 'in' && Array . isArray ( value ) ) {
74+ if ( value . length === 0 ) return [ ] ;
75+ if ( value . length === 1 ) return [ field , '=' , value [ 0 ] ] ;
76+ return [ 'or' , ...value . map ( ( v : any ) => [ field , '=' , v ] ) ] ;
77+ }
78+
79+ if ( op === 'not in' && Array . isArray ( value ) ) {
80+ if ( value . length === 0 ) return [ ] ;
81+ if ( value . length === 1 ) return [ field , '!=' , value [ 0 ] ] ;
82+ return [ 'and' , ...value . map ( ( v : any ) => [ field , '!=' , v ] ) ] ;
83+ }
84+
85+ return condition ;
86+ }
87+
88+ /**
89+ * Normalize an array of filter conditions, expanding `in`/`not in` operators
90+ * and ensuring consistent AST structure.
91+ */
92+ export function normalizeFilters ( filters : any [ ] ) : any [ ] {
93+ if ( ! Array . isArray ( filters ) || filters . length === 0 ) return [ ] ;
94+ return filters
95+ . map ( f => Array . isArray ( f ) ? normalizeFilterCondition ( f ) : f )
96+ . filter ( f => Array . isArray ( f ) && f . length > 0 ) ;
97+ }
98+
5699function convertFilterGroupToAST ( group : FilterGroup ) : any [ ] {
57100 if ( ! group || ! group . conditions || group . conditions . length === 0 ) return [ ] ;
58101
@@ -62,9 +105,12 @@ function convertFilterGroupToAST(group: FilterGroup): any[] {
62105 return [ c . field , mapOperator ( c . operator ) , c . value ] ;
63106 } ) ;
64107
65- if ( conditions . length === 1 ) return conditions [ 0 ] ;
108+ // Normalize in/not-in conditions for backend compatibility
109+ const normalized = normalizeFilters ( conditions ) ;
110+ if ( normalized . length === 0 ) return [ ] ;
111+ if ( normalized . length === 1 ) return normalized [ 0 ] ;
66112
67- return [ group . logic , ...conditions ] ;
113+ return [ group . logic , ...normalized ] ;
68114}
69115
70116/**
@@ -132,6 +178,17 @@ export function evaluateConditionalFormatting(
132178const LIST_DEFAULT_TRANSLATIONS : Record < string , string > = {
133179 'list.recordCount' : '{{count}} records' ,
134180 'list.recordCountOne' : '{{count}} record' ,
181+ 'list.noItems' : 'No items found' ,
182+ 'list.noItemsMessage' : 'There are no records to display. Try adjusting your filters or adding new data.' ,
183+ 'list.search' : 'Search' ,
184+ 'list.filter' : 'Filter' ,
185+ 'list.sort' : 'Sort' ,
186+ 'list.export' : 'Export' ,
187+ 'list.hideFields' : 'Hide fields' ,
188+ 'list.showAll' : 'Show all' ,
189+ 'list.pullToRefresh' : 'Pull to refresh' ,
190+ 'list.refreshing' : 'Refreshing…' ,
191+ 'list.dataLimitReached' : 'Showing first {{limit}} records. More data may be available.' ,
135192} ;
136193
137194/**
@@ -224,6 +281,10 @@ export const ListView: React.FC<ListViewProps> = ({
224281 const [ loading , setLoading ] = React . useState ( false ) ;
225282 const [ objectDef , setObjectDef ] = React . useState < any > ( null ) ;
226283 const [ refreshKey , setRefreshKey ] = React . useState ( 0 ) ;
284+ const [ dataLimitReached , setDataLimitReached ] = React . useState ( false ) ;
285+
286+ // Request counter for debounce — only the latest request writes data
287+ const fetchRequestIdRef = React . useRef ( 0 ) ;
227288
228289 // Quick Filters State
229290 const [ activeQuickFilters , setActiveQuickFilters ] = React . useState < Set < string > > ( ( ) => {
@@ -328,6 +389,7 @@ export const ListView: React.FC<ListViewProps> = ({
328389 // Fetch data effect
329390 React . useEffect ( ( ) => {
330391 let isMounted = true ;
392+ const requestId = ++ fetchRequestIdRef . current ;
331393
332394 const fetchData = async ( ) => {
333395 if ( ! dataSource || ! schema . objectName ) return ;
@@ -349,13 +411,16 @@ export const ListView: React.FC<ListViewProps> = ({
349411 } ) ;
350412 }
351413
352- // Merge base filters, user filters, quick filters, and user filter bar conditions
414+ // Normalize userFilter conditions (convert `in` to `or` of `=`)
415+ const normalizedUserFilterConditions = normalizeFilters ( userFilterConditions ) ;
416+
417+ // Merge all filter sources with consistent structure
353418 const allFilters = [
354419 ...( baseFilter . length > 0 ? [ baseFilter ] : [ ] ) ,
355420 ...( userFilter . length > 0 ? [ userFilter ] : [ ] ) ,
356421 ...quickFilterConditions ,
357- ...userFilterConditions ,
358- ] ;
422+ ...normalizedUserFilterConditions ,
423+ ] . filter ( f => Array . isArray ( f ) && f . length > 0 ) ;
359424
360425 if ( allFilters . length > 1 ) {
361426 finalFilter = [ 'and' , ...allFilters ] ;
@@ -371,11 +436,17 @@ export const ListView: React.FC<ListViewProps> = ({
371436 . map ( item => ( { field : item . field , order : item . order } ) )
372437 : undefined ;
373438
439+ // Configurable page size from schema.pagination, default 100
440+ const pageSize = schema . pagination ?. pageSize || 100 ;
441+
374442 const results = await dataSource . find ( schema . objectName , {
375443 $filter : finalFilter ,
376444 $orderby : sort ,
377- $top : 100 // Default pagination limit
445+ $top : pageSize ,
378446 } ) ;
447+
448+ // Stale request guard: only apply the latest request's results
449+ if ( ! isMounted || requestId !== fetchRequestIdRef . current ) return ;
379450
380451 let items : any [ ] = [ ] ;
381452 if ( Array . isArray ( results ) ) {
@@ -388,20 +459,24 @@ export const ListView: React.FC<ListViewProps> = ({
388459 }
389460 }
390461
391- if ( isMounted ) {
392- setData ( items ) ;
393- }
462+ setData ( items ) ;
463+ setDataLimitReached ( items . length >= pageSize ) ;
394464 } catch ( err ) {
395- console . error ( "ListView data fetch error:" , err ) ;
465+ // Only log errors from the latest request
466+ if ( requestId === fetchRequestIdRef . current ) {
467+ console . error ( "ListView data fetch error:" , err ) ;
468+ }
396469 } finally {
397- if ( isMounted ) setLoading ( false ) ;
470+ if ( isMounted && requestId === fetchRequestIdRef . current ) {
471+ setLoading ( false ) ;
472+ }
398473 }
399474 } ;
400475
401476 fetchData ( ) ;
402477
403478 return ( ) => { isMounted = false ; } ;
404- } , [ schema . objectName , dataSource , schema . filters , currentSort , currentFilters , activeQuickFilters , userFilterConditions , refreshKey ] ) ; // Re-fetch on filter/sort change
479+ } , [ schema . objectName , dataSource , schema . filters , schema . pagination ?. pageSize , currentSort , currentFilters , activeQuickFilters , userFilterConditions , refreshKey ] ) ; // Re-fetch on filter/sort change
405480
406481 // Available view types based on schema configuration
407482 const availableViews = React . useMemo ( ( ) => {
@@ -494,21 +569,26 @@ export const ListView: React.FC<ListViewProps> = ({
494569 // Apply hiddenFields and fieldOrder to produce effective fields
495570 const effectiveFields = React . useMemo ( ( ) => {
496571 let fields = schema . fields || [ ] ;
572+
573+ // Defensive: ensure fields is an array of strings/objects
574+ if ( ! Array . isArray ( fields ) ) {
575+ fields = [ ] ;
576+ }
497577
498578 // Remove hidden fields
499579 if ( hiddenFields . size > 0 ) {
500580 fields = fields . filter ( ( f : any ) => {
501- const fieldName = typeof f === 'string' ? f : ( f . name || f . fieldName || f . field ) ;
502- return ! hiddenFields . has ( fieldName ) ;
581+ const fieldName = typeof f === 'string' ? f : ( f ? .name || f ? .fieldName || f ? .field ) ;
582+ return fieldName != null && ! hiddenFields . has ( fieldName ) ;
503583 } ) ;
504584 }
505585
506586 // Apply field order
507587 if ( schema . fieldOrder && schema . fieldOrder . length > 0 ) {
508588 const orderMap = new Map ( schema . fieldOrder . map ( ( f , i ) => [ f , i ] ) ) ;
509589 fields = [ ...fields ] . sort ( ( a : any , b : any ) => {
510- const nameA = typeof a === 'string' ? a : ( a . name || a . fieldName || a . field ) ;
511- const nameB = typeof b === 'string' ? b : ( b . name || b . fieldName || b . field ) ;
590+ const nameA = typeof a === 'string' ? a : ( a ? .name || a ? .fieldName || a ? .field ) ;
591+ const nameB = typeof b === 'string' ? b : ( b ? .name || b ? .fieldName || b ? .field ) ;
512592 const orderA = orderMap . get ( nameA ) ?? Infinity ;
513593 const orderB = orderMap . get ( nameB ) ?? Infinity ;
514594 return orderA - orderB ;
@@ -656,8 +736,23 @@ export const ListView: React.FC<ListViewProps> = ({
656736 exportData . forEach ( record => {
657737 rows . push ( fields . map ( ( f : string ) => {
658738 const val = record [ f ] ;
659- const str = val == null ? '' : String ( val ) ;
660- return str . includes ( ',' ) || str . includes ( '"' ) || str . includes ( '\n' ) || str . includes ( '\r' ) ? `"${ str . replace ( / " / g, '""' ) } "` : str ;
739+ // Type-safe serialization: handle arrays, objects, null/undefined
740+ let str : string ;
741+ if ( val == null ) {
742+ str = '' ;
743+ } else if ( Array . isArray ( val ) ) {
744+ str = val . map ( v =>
745+ ( v != null && typeof v === 'object' ) ? JSON . stringify ( v ) : String ( v ?? '' ) ,
746+ ) . join ( '; ' ) ;
747+ } else if ( typeof val === 'object' ) {
748+ str = JSON . stringify ( val ) ;
749+ } else {
750+ str = String ( val ) ;
751+ }
752+ // Escape CSV special characters
753+ const needsQuoting = str . includes ( ',' ) || str . includes ( '"' )
754+ || str . includes ( '\n' ) || str . includes ( '\r' ) ;
755+ return needsQuoting ? `"${ str . replace ( / " / g, '""' ) } "` : str ;
661756 } ) . join ( ',' ) ) ;
662757 } ) ;
663758 const blob = new Blob ( [ rows . join ( '\n' ) ] , { type : 'text/csv;charset=utf-8;' } ) ;
@@ -1047,10 +1142,15 @@ export const ListView: React.FC<ListViewProps> = ({
10471142 { /* Record count status bar (Airtable-style) */ }
10481143 { ! loading && data . length > 0 && (
10491144 < div
1050- className = "border-t px-4 py-1.5 flex items-center text-xs text-muted-foreground bg-background shrink-0"
1145+ className = "border-t px-4 py-1.5 flex items-center gap-2 text-xs text-muted-foreground bg-background shrink-0"
10511146 data-testid = "record-count-bar"
10521147 >
1053- { data . length === 1 ? t ( 'list.recordCountOne' , { count : data . length } ) : t ( 'list.recordCount' , { count : data . length } ) }
1148+ < span > { data . length === 1 ? t ( 'list.recordCountOne' , { count : data . length } ) : t ( 'list.recordCount' , { count : data . length } ) } </ span >
1149+ { dataLimitReached && (
1150+ < span className = "text-amber-600" data-testid = "data-limit-warning" >
1151+ { t ( 'list.dataLimitReached' , { limit : schema . pagination ?. pageSize || 100 } ) }
1152+ </ span >
1153+ ) }
10541154 </ div >
10551155 ) }
10561156
0 commit comments