Skip to content

Commit 42a4384

Browse files
authored
fix(ui): query preset crash with empty filters in postgres/sqlite (#14722)
### What? Fixes a crash when creating query presets with no filters (`where`) or columns in Postgres/SQLite. ### Why? When you save a query preset without any `where` filters or `columns`, SQL databases store these as `NULL`. When the UI tries to render or sanitize these presets, it calls `Object.keys()` on null values and crashes with `TypeError: Cannot convert undefined or null to object`. This only happens in Postgres/SQLite because MongoDB doesn't store missing fields at all - they just come back as `undefined`. SQL databases explicitly store `NULL` values. ### How? Added null checks before calling `Object.keys()` in three places: - `sanitizeQuery.ts` - The main fix that prevents the crash when loading presets - `WhereField` component - Defensive check when rendering the where field - `WhereCell` component - Defensive check when rendering the where cell in tables Fixes #14719
1 parent 00d9156 commit 42a4384

File tree

6 files changed

+142
-17
lines changed

6 files changed

+142
-17
lines changed

packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ const transformWhereToNaturalLanguage = (where: Where): string => {
99
const orQuery = where.or[0]
1010
const andQuery = orQuery?.and?.[0]
1111

12-
if (!andQuery) {
12+
if (!andQuery || typeof andQuery !== 'object') {
1313
return 'No where query'
1414
}
1515

1616
const key = Object.keys(andQuery)[0]
1717

18-
if (!andQuery[key]) {
18+
if (!key || !andQuery[key] || typeof andQuery[key] !== 'object') {
1919
return 'No where query'
2020
}
2121

packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ const transformWhereToNaturalLanguage = (
2323
}
2424

2525
const renderCondition = (condition: any): React.ReactNode => {
26+
if (!condition || typeof condition !== 'object') {
27+
return 'No where query'
28+
}
29+
2630
const key = Object.keys(condition)[0]
2731

28-
if (!condition[key]) {
32+
if (!key || !condition[key] || typeof condition[key] !== 'object') {
2933
return 'No where query'
3034
}
3135

packages/ui/src/providers/ListQuery/sanitizeQuery.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ export const sanitizeQuery = (toSanitize: ListQuery): ListQuery => {
1616
delete sanitized[key]
1717
}
1818

19-
if (key === 'where' && typeof value === 'object' && !Object.keys(value as Where).length) {
19+
if (
20+
key === 'where' &&
21+
typeof value === 'object' &&
22+
value !== null &&
23+
!Object.keys(value as Where).length
24+
) {
2025
delete sanitized[key]
2126
}
2227

test/query-presets/e2e.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,65 @@ describe('Query Presets', () => {
149149
}
150150
})
151151

152+
test('can create and view preset with no filters or columns', async () => {
153+
await page.goto(pagesUrl.list)
154+
155+
const presetTitle = 'Empty Preset'
156+
157+
// Create a new preset without setting any filters or columns
158+
await page.locator('#create-new-preset').click()
159+
const modal = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
160+
await expect(modal).toBeVisible()
161+
await modal.locator('input[name="title"]').fill(presetTitle)
162+
163+
const currentURL = page.url()
164+
await saveDocAndAssert(page)
165+
await expect(modal).toBeHidden()
166+
167+
await page.waitForURL(() => page.url() !== currentURL)
168+
169+
await expect(
170+
page.locator('button#select-preset', {
171+
hasText: exactText(presetTitle),
172+
}),
173+
).toBeVisible()
174+
175+
// Open the edit modal to verify where/columns fields handle null values
176+
await page.locator('#edit-preset').click()
177+
const editModal = page.locator('[id^=doc-drawer_payload-query-presets_0_]')
178+
await expect(editModal).toBeVisible()
179+
180+
// Verify the Where field displays "No where query" instead of crashing
181+
const whereFieldContent = editModal.locator('.query-preset-where-field .value-wrapper')
182+
await expect(whereFieldContent).toBeVisible()
183+
await expect(whereFieldContent).toContainText('No where query')
184+
185+
// Verify the Columns field displays "No columns selected" instead of crashing
186+
const columnsFieldContent = editModal.locator('.query-preset-columns-field .value-wrapper')
187+
await expect(columnsFieldContent).toBeVisible()
188+
await expect(columnsFieldContent).toContainText('No columns selected')
189+
190+
await editModal.locator('button.doc-drawer__header-close').click()
191+
await expect(editModal).toBeHidden()
192+
193+
await openQueryPresetDrawer({ page })
194+
const drawer = page.locator('[id^=list-drawer_0_]')
195+
await expect(drawer).toBeVisible()
196+
197+
const presetRow = drawer.locator('tbody tr', {
198+
has: page.locator(`button:has-text("${presetTitle}")`),
199+
})
200+
201+
await expect(presetRow).toBeVisible()
202+
203+
// Column order: title (0), isShared (1), access (2), where (3), columns (4)
204+
const whereCell = presetRow.locator('td').nth(3)
205+
await expect(whereCell).toContainText('No where query')
206+
207+
const columnsCell = presetRow.locator('td').nth(4)
208+
await expect(columnsCell).toContainText('No columns selected')
209+
})
210+
152211
test('should select preset and apply filters', async () => {
153212
await page.goto(pagesUrl.list)
154213

test/query-presets/int.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,5 +803,43 @@ describe('Query Presets', () => {
803803
],
804804
})
805805
})
806+
807+
it('should handle empty where and columns fields', async () => {
808+
const result = await payload.create({
809+
collection: queryPresetsCollectionSlug,
810+
user: adminUser,
811+
overrideAccess: false,
812+
data: {
813+
title: 'Empty Where and Columns',
814+
// Not including where or columns at all
815+
access: {
816+
read: {
817+
constraint: 'everyone',
818+
},
819+
update: {
820+
constraint: 'everyone',
821+
},
822+
delete: {
823+
constraint: 'everyone',
824+
},
825+
},
826+
relatedCollection: 'pages',
827+
},
828+
})
829+
830+
expect(result.where == null).toBe(true)
831+
expect(result.columns == null).toBe(true)
832+
833+
const fetched = await payload.findByID({
834+
collection: queryPresetsCollectionSlug,
835+
depth: 0,
836+
user: adminUser,
837+
overrideAccess: false,
838+
id: result.id,
839+
})
840+
841+
expect(fetched.where == null).toBe(true)
842+
expect(fetched.columns == null).toBe(true)
843+
})
806844
})
807845
})

test/query-presets/payload-types.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface Config {
7070
pages: Page;
7171
posts: Post;
7272
users: User;
73+
'payload-kv': PayloadKv;
7374
'payload-locked-documents': PayloadLockedDocument;
7475
'payload-preferences': PayloadPreference;
7576
'payload-migrations': PayloadMigration;
@@ -80,6 +81,7 @@ export interface Config {
8081
pages: PagesSelect<false> | PagesSelect<true>;
8182
posts: PostsSelect<false> | PostsSelect<true>;
8283
users: UsersSelect<false> | UsersSelect<true>;
84+
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
8385
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
8486
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
8587
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -88,6 +90,7 @@ export interface Config {
8890
db: {
8991
defaultIDType: string;
9092
};
93+
fallbackLocale: null;
9194
globals: {};
9295
globalsSelect: {};
9396
locale: null;
@@ -164,25 +167,33 @@ export interface User {
164167
| null;
165168
password?: string | null;
166169
}
170+
/**
171+
* This interface was referenced by `Config`'s JSON-Schema
172+
* via the `definition` "payload-kv".
173+
*/
174+
export interface PayloadKv {
175+
id: string;
176+
key: string;
177+
data:
178+
| {
179+
[k: string]: unknown;
180+
}
181+
| unknown[]
182+
| string
183+
| number
184+
| boolean
185+
| null;
186+
}
167187
/**
168188
* This interface was referenced by `Config`'s JSON-Schema
169189
* via the `definition` "payload-locked-documents".
170190
*/
171191
export interface PayloadLockedDocument {
172192
id: string;
173-
document?:
174-
| ({
175-
relationTo: 'pages';
176-
value: string | Page;
177-
} | null)
178-
| ({
179-
relationTo: 'posts';
180-
value: string | Post;
181-
} | null)
182-
| ({
183-
relationTo: 'users';
184-
value: string | User;
185-
} | null);
193+
document?: {
194+
relationTo: 'users';
195+
value: string | User;
196+
} | null;
186197
globalSlug?: string | null;
187198
user: {
188199
relationTo: 'users';
@@ -318,6 +329,14 @@ export interface UsersSelect<T extends boolean = true> {
318329
expiresAt?: T;
319330
};
320331
}
332+
/**
333+
* This interface was referenced by `Config`'s JSON-Schema
334+
* via the `definition` "payload-kv_select".
335+
*/
336+
export interface PayloadKvSelect<T extends boolean = true> {
337+
key?: T;
338+
data?: T;
339+
}
321340
/**
322341
* This interface was referenced by `Config`'s JSON-Schema
323342
* via the `definition` "payload-locked-documents_select".

0 commit comments

Comments
 (0)