Skip to content

Commit 850b974

Browse files
authored
Merge pull request #659 from objectstack-ai/copilot/fix-listview-component-issues
2 parents a82074a + a904c97 commit 850b974

6 files changed

Lines changed: 605 additions & 24 deletions

File tree

packages/mobile/src/usePullToRefresh.ts

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

5656
const handleTouchEnd = useCallback(async () => {
5757
if (!enabled || isRefreshing) return;
58-
if (pullDistance >= threshold) {
58+
// Capture distance and reset UI immediately to prevent lock during async refresh
59+
const distance = pullDistance;
60+
setPullDistance(0);
61+
startYRef.current = 0;
62+
if (distance >= threshold) {
5963
setIsRefreshing(true);
6064
try {
6165
await onRefresh();
6266
} finally {
6367
setIsRefreshing(false);
6468
}
6569
}
66-
setPullDistance(0);
67-
startYRef.current = 0;
6870
}, [enabled, isRefreshing, pullDistance, threshold, onRefresh]);
6971

7072
useEffect(() => {

packages/plugin-list/src/ListView.tsx

Lines changed: 120 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
5699
function 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(
132178
const 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

Comments
 (0)