Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/mobile/src/usePullToRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,18 @@ export function usePullToRefresh<T extends HTMLElement = HTMLElement>(

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();
} finally {
setIsRefreshing(false);
}
}
setPullDistance(0);
startYRef.current = 0;
}, [enabled, isRefreshing, pullDistance, threshold, onRefresh]);

useEffect(() => {
Expand Down
140 changes: 120 additions & 20 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +61 to +97
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter normalization functions use any[] for the condition parameter and return type. While this is pragmatic for heterogeneous filter AST structures, consider creating a type alias to make the intent clearer and improve maintainability:

type FilterCondition = any[]; // or more specific: [string, string, any] | [string, ...FilterCondition[]]

This would make the function signatures more self-documenting and allow for gradual type refinement in the future without breaking changes to the API.

Copilot uses AI. Check for mistakes.

function convertFilterGroupToAST(group: FilterGroup): any[] {
if (!group || !group.conditions || group.conditions.length === 0) return [];

Expand All @@ -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];
}

/**
Expand Down Expand Up @@ -132,6 +178,17 @@ export function evaluateConditionalFormatting(
const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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…',
Comment on lines +183 to +190
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several new i18n translation keys are defined but may not all be used: 'list.search', 'list.filter', 'list.sort', 'list.export', 'list.hideFields', 'list.showAll', 'list.pullToRefresh', 'list.refreshing'.

While 'list.dataLimitReached' is properly used (line 1151), the other newly added translations should be verified against their usage points. If they're not used in the changed code sections, consider whether they should be added in this PR or deferred to a future PR when those UI elements are actually implemented/updated.

Suggested change
'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…',

Copilot uses AI. Check for mistakes.
'list.dataLimitReached': 'Showing first {{limit}} records. More data may be available.',
};

/**
Expand Down Expand Up @@ -224,6 +281,10 @@ export const ListView: React.FC<ListViewProps> = ({
const [loading, setLoading] = React.useState(false);
const [objectDef, setObjectDef] = React.useState<any>(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<Set<string>>(() => {
Expand Down Expand Up @@ -328,6 +389,7 @@ export const ListView: React.FC<ListViewProps> = ({
// Fetch data effect
React.useEffect(() => {
let isMounted = true;
const requestId = ++fetchRequestIdRef.current;

const fetchData = async () => {
if (!dataSource || !schema.objectName) return;
Expand All @@ -349,13 +411,16 @@ export const ListView: React.FC<ListViewProps> = ({
});
}

// 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];
Expand All @@ -371,11 +436,17 @@ export const ListView: React.FC<ListViewProps> = ({
.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)) {
Expand All @@ -388,20 +459,24 @@ export const ListView: React.FC<ListViewProps> = ({
}
}

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(() => {
Expand Down Expand Up @@ -494,21 +569,26 @@ export const ListView: React.FC<ListViewProps> = ({
// 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);
});
}

// Apply field order
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;
Expand Down Expand Up @@ -656,8 +736,23 @@ export const ListView: React.FC<ListViewProps> = ({
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;' });
Expand Down Expand Up @@ -1047,10 +1142,15 @@ export const ListView: React.FC<ListViewProps> = ({
{/* Record count status bar (Airtable-style) */}
{!loading && data.length > 0 && (
<div
className="border-t px-4 py-1.5 flex items-center text-xs text-muted-foreground bg-background shrink-0"
className="border-t px-4 py-1.5 flex items-center gap-2 text-xs text-muted-foreground bg-background shrink-0"
data-testid="record-count-bar"
>
{data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })}
<span>{data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })}</span>
{dataLimitReached && (
<span className="text-amber-600" data-testid="data-limit-warning">
{t('list.dataLimitReached', { limit: schema.pagination?.pageSize || 100 })}
</span>
)}
</div>
)}

Expand Down
Loading