Skip to content

Commit d34403e

Browse files
Copilothotlong
andcommitted
fix: normalize filter AST, safe export, request debounce, data limit warning, i18n fallback
- Issue #1: Normalize `in`/`not in` operators to backend-compatible `or`/`and` of `=`/`!=` - Issue #2: Filter merging now validates and filters empty conditions - Issue #3: CSV export safely serializes arrays (semicolon-separated) and objects (JSON) - Issue #5: Request counter prevents stale data from overwriting latest results - Issue #6: PullToRefresh resets pull distance immediately to prevent UI lock - Issue #7: $top configurable via schema.pagination, data limit warning shown - Issue #8: Extended i18n fallback translations for all ListView labels - Issue #9: Defensive null checks in effectiveFields for mismatched objectDef - Issue #10: Added FilterNormalization, Export, and DataFetch test suites Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent a5097ba commit d34403e

6 files changed

Lines changed: 596 additions & 23 deletions

File tree

packages/mobile/src/usePullToRefresh.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,17 @@ export function usePullToRefresh<T extends HTMLElement = HTMLElement>(
5555

5656
const handleTouchEnd = useCallback(async () => {
5757
if (!enabled || isRefreshing) return;
58-
if (pullDistance >= threshold) {
58+
const distance = pullDistance;
59+
setPullDistance(0);
60+
startYRef.current = 0;
61+
if (distance >= threshold) {
5962
setIsRefreshing(true);
6063
try {
6164
await onRefresh();
6265
} finally {
6366
setIsRefreshing(false);
6467
}
6568
}
66-
setPullDistance(0);
67-
startYRef.current = 0;
6869
}, [enabled, isRefreshing, pullDistance, threshold, onRefresh]);
6970

7071
useEffect(() => {

packages/plugin-list/src/ListView.tsx

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5697
function 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(
132176
const 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

Comments
 (0)