Skip to content

Commit 7eefde5

Browse files
authored
Merge pull request #654 from objectstack-ai/copilot/fix-user-filters-refresh-issue
2 parents e115e92 + 8346cf1 commit 7eefde5

5 files changed

Lines changed: 186 additions & 4 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ Full adoption of Cloud namespace, contracts/integration/security/studio modules,
217217
- [x] Implement selection and pagination spec alignment
218218
- [x] Implement `quickFilters` and `userFilters` spec properties
219219
- [x] Auto-derive `userFilters` from objectDef (select/multi-select/boolean fields) when not explicitly configured
220+
- [x] Fix `userFilters` AST filter conditions not evaluated by ValueDataSource (in-memory)
221+
- [x] Fix demo guest user missing admin role (all features now accessible when auth is disabled)
220222
- [x] Implement `hiddenFields` and `fieldOrder` spec properties
221223
- [x] Implement `emptyState` spec property
222224

packages/auth/src/AuthProvider.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,13 @@ export function AuthProvider({
105105
}
106106

107107
if (!enabled) {
108-
// When auth is disabled, set a guest user and mark as loaded
108+
// When auth is disabled, set a guest user with admin role and mark as loaded.
109+
// Admin role ensures all features are accessible in demo/dev environments.
109110
setUser({
110111
id: 'guest',
111112
email: 'guest@local',
112113
name: 'Guest User',
114+
role: 'admin',
113115
});
114116
setSession({
115117
token: 'guest-token',

packages/auth/src/__tests__/AuthProvider.disabled.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,25 @@ describe('AuthProvider with enabled prop', () => {
9696

9797
expect(screen.getByTestId('has-expiry').textContent).toBe('true');
9898
});
99+
100+
it('should assign admin role to guest user when auth is disabled', async () => {
101+
function RoleTestComponent() {
102+
const auth = useAuth();
103+
return (
104+
<div>
105+
<div data-testid="user-role">{auth.user?.role || 'null'}</div>
106+
</div>
107+
);
108+
}
109+
110+
render(
111+
<AuthProvider authUrl="/api/auth" enabled={false}>
112+
<RoleTestComponent />
113+
</AuthProvider>
114+
);
115+
116+
await waitFor(() => {
117+
expect(screen.getByTestId('user-role').textContent).toBe('admin');
118+
});
119+
});
99120
});

packages/core/src/adapters/ValueDataSource.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,69 @@ function getRecordId(record: any, idField?: string): string | number | undefined
3636
return record._id ?? record.id;
3737
}
3838

39+
/**
40+
* Evaluate an AST-format filter node against a record.
41+
* Supports conditions like ['field', 'op', value] and logical
42+
* combinations like ['and', ...conditions] or ['or', ...conditions].
43+
*/
44+
function matchesASTFilter(record: any, filterNode: any[]): boolean {
45+
if (!filterNode || filterNode.length === 0) return true;
46+
47+
const head = filterNode[0];
48+
49+
// Logical operators: ['and', ...conditions] or ['or', ...conditions]
50+
if (head === 'and') {
51+
return filterNode.slice(1).every((sub: any) => matchesASTFilter(record, sub));
52+
}
53+
if (head === 'or') {
54+
return filterNode.slice(1).some((sub: any) => matchesASTFilter(record, sub));
55+
}
56+
57+
// Condition node: [field, operator, value]
58+
if (filterNode.length === 3 && typeof head === 'string') {
59+
const [field, operator, target] = filterNode;
60+
const value = record[field];
61+
62+
switch (operator) {
63+
case '=':
64+
return value === target;
65+
case '!=':
66+
return value !== target;
67+
case '>':
68+
return value > target;
69+
case '>=':
70+
return value >= target;
71+
case '<':
72+
return value < target;
73+
case '<=':
74+
return value <= target;
75+
case 'in':
76+
return Array.isArray(target) && target.includes(value);
77+
case 'not in':
78+
case 'notin': // alias used by convertFiltersToAST
79+
return Array.isArray(target) && !target.includes(value);
80+
case 'contains': {
81+
const lv = typeof value === 'string' ? value.toLowerCase() : '';
82+
return typeof value === 'string' && lv.includes(String(target).toLowerCase());
83+
}
84+
case 'notcontains': {
85+
const lv = typeof value === 'string' ? value.toLowerCase() : '';
86+
return typeof value === 'string' && !lv.includes(String(target).toLowerCase());
87+
}
88+
case 'startswith': {
89+
const lv = typeof value === 'string' ? value.toLowerCase() : '';
90+
return typeof value === 'string' && lv.startsWith(String(target).toLowerCase());
91+
}
92+
case 'between':
93+
return Array.isArray(target) && target.length === 2 && value >= target[0] && value <= target[1];
94+
default:
95+
return true;
96+
}
97+
}
98+
99+
return true;
100+
}
101+
39102
/**
40103
* Simple in-memory filter evaluation.
41104
* Supports flat key-value equality and basic operators ($gt, $gte, $lt, $lte, $ne, $in).
@@ -177,9 +240,13 @@ export class ValueDataSource<T = any> implements DataSource<T> {
177240
async find(_resource: string, params?: QueryParams): Promise<QueryResult<T>> {
178241
let result = [...this.items];
179242

180-
// Filter
181-
if (params?.$filter && Object.keys(params.$filter).length > 0) {
182-
result = result.filter((r) => matchesFilter(r, params.$filter!));
243+
// Filter — support both MongoDB-style objects and AST-format arrays
244+
if (params?.$filter) {
245+
if (Array.isArray(params.$filter) && params.$filter.length > 0) {
246+
result = result.filter((r) => matchesASTFilter(r, params.$filter as any[]));
247+
} else if (!Array.isArray(params.$filter) && Object.keys(params.$filter).length > 0) {
248+
result = result.filter((r) => matchesFilter(r, params.$filter!));
249+
}
183250
}
184251

185252
// Search (simple text search across all string fields)

packages/core/src/adapters/__tests__/ValueDataSource.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,96 @@ describe('ValueDataSource — find', () => {
157157
});
158158
});
159159

160+
// ---------------------------------------------------------------------------
161+
// AST-format filter support
162+
// ---------------------------------------------------------------------------
163+
164+
describe('ValueDataSource — AST filter', () => {
165+
it('should filter with simple AST equality condition', async () => {
166+
const ds = createDS();
167+
const result = await ds.find('users', {
168+
$filter: ['role', '=', 'admin'] as any,
169+
});
170+
expect(result.data).toHaveLength(2);
171+
expect(result.data.every((r: any) => r.role === 'admin')).toBe(true);
172+
});
173+
174+
it('should filter with AST "in" operator', async () => {
175+
const ds = createDS();
176+
const result = await ds.find('users', {
177+
$filter: ['role', 'in', ['admin', 'guest']] as any,
178+
});
179+
expect(result.data).toHaveLength(3);
180+
});
181+
182+
it('should filter with AST "and" logical operator', async () => {
183+
const ds = createDS();
184+
const result = await ds.find('users', {
185+
$filter: ['and', ['role', '=', 'admin'], ['age', '>', 30]] as any,
186+
});
187+
expect(result.data).toHaveLength(1);
188+
expect(result.data[0].name).toBe('Charlie');
189+
});
190+
191+
it('should filter with AST "or" logical operator', async () => {
192+
const ds = createDS();
193+
const result = await ds.find('users', {
194+
$filter: ['or', ['role', '=', 'guest'], ['name', '=', 'Alice']] as any,
195+
});
196+
expect(result.data).toHaveLength(2);
197+
});
198+
199+
it('should filter with AST "!=" operator', async () => {
200+
const ds = createDS();
201+
const result = await ds.find('users', {
202+
$filter: ['role', '!=', 'admin'] as any,
203+
});
204+
expect(result.data).toHaveLength(3);
205+
});
206+
207+
it('should filter with AST "not in" operator', async () => {
208+
const ds = createDS();
209+
const result = await ds.find('users', {
210+
$filter: ['role', 'not in', ['admin', 'guest']] as any,
211+
});
212+
expect(result.data).toHaveLength(2);
213+
expect(result.data.every((r: any) => r.role === 'user')).toBe(true);
214+
});
215+
216+
it('should filter with AST "contains" operator', async () => {
217+
const ds = createDS();
218+
const result = await ds.find('users', {
219+
$filter: ['name', 'contains', 'li'] as any,
220+
});
221+
expect(result.data).toHaveLength(2); // Alice, Charlie
222+
});
223+
224+
it('should filter with nested AST (and with in operator)', async () => {
225+
const ds = createDS();
226+
const result = await ds.find('users', {
227+
$filter: ['and', ['role', 'in', ['admin', 'user']], ['age', '>=', 28]] as any,
228+
});
229+
expect(result.data).toHaveLength(3); // Alice (30, admin), Charlie (35, admin), Diana (28, user)
230+
});
231+
232+
it('should return all items with empty AST filter', async () => {
233+
const ds = createDS();
234+
const result = await ds.find('users', { $filter: [] as any });
235+
expect(result.data).toHaveLength(5);
236+
});
237+
238+
it('should combine AST filter with sort', async () => {
239+
const ds = createDS();
240+
const result = await ds.find('users', {
241+
$filter: ['role', '=', 'admin'] as any,
242+
$orderby: { age: 'asc' },
243+
});
244+
expect(result.data).toHaveLength(2);
245+
expect(result.data[0].name).toBe('Alice');
246+
expect(result.data[1].name).toBe('Charlie');
247+
});
248+
});
249+
160250
// ---------------------------------------------------------------------------
161251
// findOne
162252
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)