@@ -53,6 +53,47 @@ 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 . map ( f => Array . isArray ( f ) ? normalizeFilterCondition ( f ) : f ) . filter ( f => Array . isArray ( f ) && f . length > 0 ) ;
95+ }
96+
5697function convertFilterGroupToAST ( group : FilterGroup ) : any [ ] {
5798 if ( ! group || ! group . conditions || group . conditions . length === 0 ) return [ ] ;
5899
@@ -62,9 +103,12 @@ function convertFilterGroupToAST(group: FilterGroup): any[] {
62103 return [ c . field , mapOperator ( c . operator ) , c . value ] ;
63104 } ) ;
64105
65- if ( conditions . length === 1 ) return conditions [ 0 ] ;
106+ // Normalize in/not-in conditions for backend compatibility
107+ const normalized = normalizeFilters ( conditions ) ;
108+ if ( normalized . length === 0 ) return [ ] ;
109+ if ( normalized . length === 1 ) return normalized [ 0 ] ;
66110
67- return [ group . logic , ...conditions ] ;
111+ return [ group . logic , ...normalized ] ;
68112}
69113
70114/**
@@ -132,6 +176,17 @@ export function evaluateConditionalFormatting(
132176const LIST_DEFAULT_TRANSLATIONS : Record < string , string > = {
133177 'list.recordCount' : '{{count}} records' ,
134178 'list.recordCountOne' : '{{count}} record' ,
179+ 'list.noItems' : 'No items found' ,
180+ 'list.noItemsMessage' : 'There are no records to display. Try adjusting your filters or adding new data.' ,
181+ 'list.search' : 'Search' ,
182+ 'list.filter' : 'Filter' ,
183+ 'list.sort' : 'Sort' ,
184+ 'list.export' : 'Export' ,
185+ 'list.hideFields' : 'Hide fields' ,
186+ 'list.showAll' : 'Show all' ,
187+ 'list.pullToRefresh' : 'Pull to refresh' ,
188+ 'list.refreshing' : 'Refreshing…' ,
189+ 'list.dataLimitReached' : 'Showing first {{limit}} records. More data may be available.' ,
135190} ;
136191
137192/**
@@ -224,6 +279,10 @@ export const ListView: React.FC<ListViewProps> = ({
224279 const [ loading , setLoading ] = React . useState ( false ) ;
225280 const [ objectDef , setObjectDef ] = React . useState < any > ( null ) ;
226281 const [ refreshKey , setRefreshKey ] = React . useState ( 0 ) ;
282+ const [ dataLimitReached , setDataLimitReached ] = React . useState ( false ) ;
283+
284+ // Request counter for debounce — only the latest request writes data
285+ const fetchRequestIdRef = React . useRef ( 0 ) ;
227286
228287 // Quick Filters State
229288 const [ activeQuickFilters , setActiveQuickFilters ] = React . useState < Set < string > > ( ( ) => {
@@ -328,6 +387,7 @@ export const ListView: React.FC<ListViewProps> = ({
328387 // Fetch data effect
329388 React . useEffect ( ( ) => {
330389 let isMounted = true ;
390+ const requestId = ++ fetchRequestIdRef . current ;
331391
332392 const fetchData = async ( ) => {
333393 if ( ! dataSource || ! schema . objectName ) return ;
@@ -349,13 +409,16 @@ export const ListView: React.FC<ListViewProps> = ({
349409 } ) ;
350410 }
351411
352- // Merge base filters, user filters, quick filters, and user filter bar conditions
412+ // Normalize userFilter conditions (convert `in` to `or` of `=`)
413+ const normalizedUserFilterConditions = normalizeFilters ( userFilterConditions ) ;
414+
415+ // Merge all filter sources with consistent structure
353416 const allFilters = [
354417 ...( baseFilter . length > 0 ? [ baseFilter ] : [ ] ) ,
355418 ...( userFilter . length > 0 ? [ userFilter ] : [ ] ) ,
356419 ...quickFilterConditions ,
357- ...userFilterConditions ,
358- ] ;
420+ ...normalizedUserFilterConditions ,
421+ ] . filter ( f => Array . isArray ( f ) && f . length > 0 ) ;
359422
360423 if ( allFilters . length > 1 ) {
361424 finalFilter = [ 'and' , ...allFilters ] ;
@@ -371,11 +434,17 @@ export const ListView: React.FC<ListViewProps> = ({
371434 . map ( item => ( { field : item . field , order : item . order } ) )
372435 : undefined ;
373436
437+ // Configurable page size from schema.pagination, default 100
438+ const pageSize = schema . pagination ?. pageSize || 100 ;
439+
374440 const results = await dataSource . find ( schema . objectName , {
375441 $filter : finalFilter ,
376442 $orderby : sort ,
377- $top : 100 // Default pagination limit
443+ $top : pageSize ,
378444 } ) ;
445+
446+ // Stale request guard: only apply the latest request's results
447+ if ( ! isMounted || requestId !== fetchRequestIdRef . current ) return ;
379448
380449 let items : any [ ] = [ ] ;
381450 if ( Array . isArray ( results ) ) {
@@ -388,20 +457,24 @@ export const ListView: React.FC<ListViewProps> = ({
388457 }
389458 }
390459
391- if ( isMounted ) {
392- setData ( items ) ;
393- }
460+ setData ( items ) ;
461+ setDataLimitReached ( items . length >= pageSize ) ;
394462 } catch ( err ) {
395- console . error ( "ListView data fetch error:" , err ) ;
463+ // Only log errors from the latest request
464+ if ( requestId === fetchRequestIdRef . current ) {
465+ console . error ( "ListView data fetch error:" , err ) ;
466+ }
396467 } finally {
397- if ( isMounted ) setLoading ( false ) ;
468+ if ( isMounted && requestId === fetchRequestIdRef . current ) {
469+ setLoading ( false ) ;
470+ }
398471 }
399472 } ;
400473
401474 fetchData ( ) ;
402475
403476 return ( ) => { isMounted = false ; } ;
404- } , [ schema . objectName , dataSource , schema . filters , currentSort , currentFilters , activeQuickFilters , userFilterConditions , refreshKey ] ) ; // Re-fetch on filter/sort change
477+ } , [ schema . objectName , dataSource , schema . filters , schema . pagination ?. pageSize , currentSort , currentFilters , activeQuickFilters , userFilterConditions , refreshKey ] ) ; // Re-fetch on filter/sort change
405478
406479 // Available view types based on schema configuration
407480 const availableViews = React . useMemo ( ( ) => {
@@ -494,21 +567,26 @@ export const ListView: React.FC<ListViewProps> = ({
494567 // Apply hiddenFields and fieldOrder to produce effective fields
495568 const effectiveFields = React . useMemo ( ( ) => {
496569 let fields = schema . fields || [ ] ;
570+
571+ // Defensive: ensure fields is an array of strings/objects
572+ if ( ! Array . isArray ( fields ) ) {
573+ fields = [ ] ;
574+ }
497575
498576 // Remove hidden fields
499577 if ( hiddenFields . size > 0 ) {
500578 fields = fields . filter ( ( f : any ) => {
501- const fieldName = typeof f === 'string' ? f : ( f . name || f . fieldName || f . field ) ;
502- return ! hiddenFields . has ( fieldName ) ;
579+ const fieldName = typeof f === 'string' ? f : ( f ? .name || f ? .fieldName || f ? .field ) ;
580+ return fieldName != null && ! hiddenFields . has ( fieldName ) ;
503581 } ) ;
504582 }
505583
506584 // Apply field order
507585 if ( schema . fieldOrder && schema . fieldOrder . length > 0 ) {
508586 const orderMap = new Map ( schema . fieldOrder . map ( ( f , i ) => [ f , i ] ) ) ;
509587 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 ) ;
588+ const nameA = typeof a === 'string' ? a : ( a ? .name || a ? .fieldName || a ? .field ) ;
589+ const nameB = typeof b === 'string' ? b : ( b ? .name || b ? .fieldName || b ? .field ) ;
512590 const orderA = orderMap . get ( nameA ) ?? Infinity ;
513591 const orderB = orderMap . get ( nameB ) ?? Infinity ;
514592 return orderA - orderB ;
@@ -656,7 +734,17 @@ export const ListView: React.FC<ListViewProps> = ({
656734 exportData . forEach ( record => {
657735 rows . push ( fields . map ( ( f : string ) => {
658736 const val = record [ f ] ;
659- const str = val == null ? '' : String ( val ) ;
737+ // Type-safe serialization: handle arrays, objects, null/undefined
738+ let str : string ;
739+ if ( val == null ) {
740+ str = '' ;
741+ } else if ( Array . isArray ( val ) ) {
742+ str = val . map ( v => ( v != null && typeof v === 'object' ) ? JSON . stringify ( v ) : String ( v ?? '' ) ) . join ( '; ' ) ;
743+ } else if ( typeof val === 'object' ) {
744+ str = JSON . stringify ( val ) ;
745+ } else {
746+ str = String ( val ) ;
747+ }
660748 return str . includes ( ',' ) || str . includes ( '"' ) || str . includes ( '\n' ) || str . includes ( '\r' ) ? `"${ str . replace ( / " / g, '""' ) } "` : str ;
661749 } ) . join ( ',' ) ) ;
662750 } ) ;
@@ -1047,10 +1135,15 @@ export const ListView: React.FC<ListViewProps> = ({
10471135 { /* Record count status bar (Airtable-style) */ }
10481136 { ! loading && data . length > 0 && (
10491137 < div
1050- className = "border-t px-4 py-1.5 flex items-center text-xs text-muted-foreground bg-background shrink-0"
1138+ className = "border-t px-4 py-1.5 flex items-center gap-2 text-xs text-muted-foreground bg-background shrink-0"
10511139 data-testid = "record-count-bar"
10521140 >
1053- { data . length === 1 ? t ( 'list.recordCountOne' , { count : data . length } ) : t ( 'list.recordCount' , { count : data . length } ) }
1141+ < span > { data . length === 1 ? t ( 'list.recordCountOne' , { count : data . length } ) : t ( 'list.recordCount' , { count : data . length } ) } </ span >
1142+ { dataLimitReached && (
1143+ < span className = "text-amber-600" data-testid = "data-limit-warning" >
1144+ { t ( 'list.dataLimitReached' , { limit : schema . pagination ?. pageSize || 100 } ) }
1145+ </ span >
1146+ ) }
10541147 </ div >
10551148 ) }
10561149
0 commit comments