Skip to content

Commit 15da90d

Browse files
authored
fix: Saved legacy filter in data browser cannot be deleted or cloned (#2944)
1 parent 8a55280 commit 15da90d

File tree

2 files changed

+314
-7
lines changed

2 files changed

+314
-7
lines changed

src/dashboard/Data/Browser/Browser.react.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,13 +1278,28 @@ class Browser extends DashboardView {
12781278
});
12791279
}
12801280
} else {
1281-
// Create new filter
1282-
newFilterId = crypto.randomUUID();
1283-
preferences.filters.push({
1284-
name,
1285-
id: newFilterId,
1286-
filter: _filters,
1287-
});
1281+
// Check if this is updating a legacy filter (no filterId but filter content matches existing filter without ID)
1282+
const existingLegacyFilterIndex = preferences.filters.findIndex(filter =>
1283+
!filter.id && filter.name === name && filter.filter === _filters
1284+
);
1285+
1286+
if (existingLegacyFilterIndex !== -1) {
1287+
// Convert legacy filter to modern filter by adding an ID
1288+
newFilterId = crypto.randomUUID();
1289+
preferences.filters[existingLegacyFilterIndex] = {
1290+
name,
1291+
id: newFilterId,
1292+
filter: _filters,
1293+
};
1294+
} else {
1295+
// Create new filter
1296+
newFilterId = crypto.randomUUID();
1297+
preferences.filters.push({
1298+
name,
1299+
id: newFilterId,
1300+
filter: _filters,
1301+
});
1302+
}
12881303
}
12891304

12901305
ClassPreferences.updatePreferences(
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
/*
5+
* Copyright (c) 2016-present, Parse, LLC
6+
* All rights reserved.
7+
*
8+
* This source code is licensed under the license found in the LICENSE file in
9+
* the root directory of this source tree.
10+
*/
11+
12+
// Mock localStorage
13+
const mockStorage = {};
14+
window.localStorage = {
15+
setItem(key, value) {
16+
mockStorage[key] = value;
17+
},
18+
getItem(key) {
19+
return mockStorage[key] || null;
20+
},
21+
};
22+
23+
// Mock crypto.randomUUID
24+
const mockRandomUUID = jest.fn(() => 'test-uuid-123');
25+
26+
// Mock the entire crypto object at module level
27+
Object.defineProperty(global, 'crypto', {
28+
value: {
29+
randomUUID: mockRandomUUID
30+
}
31+
});
32+
33+
jest.dontMock('../ClassPreferences');
34+
const ClassPreferences = require('../ClassPreferences');
35+
36+
// Create a minimal Browser-like class with just the saveFilters method
37+
class MockBrowser {
38+
constructor() {
39+
this.context = { applicationId: 'testApp' };
40+
this.props = { params: { className: 'TestClass' } };
41+
}
42+
43+
forceUpdate() {
44+
// Mock implementation
45+
}
46+
47+
saveFilters(filters, name, relativeDate, filterId = null) {
48+
const jsonFilters = filters.toJSON();
49+
if (relativeDate && jsonFilters?.length) {
50+
for (let i = 0; i < jsonFilters.length; i++) {
51+
const filter = jsonFilters[i];
52+
const compareTo = filter.get('compareTo');
53+
if (compareTo?.__type === 'Date') {
54+
compareTo.__type = 'RelativeDate';
55+
const now = new Date();
56+
const date = new Date(compareTo.iso);
57+
const diff = date.getTime() - now.getTime();
58+
compareTo.value = Math.floor(diff / 1000);
59+
delete compareTo.iso;
60+
filter.set('compareTo', compareTo);
61+
jsonFilters[i] = filter;
62+
}
63+
}
64+
}
65+
66+
const _filters = JSON.stringify(jsonFilters);
67+
const preferences = ClassPreferences.getPreferences(
68+
this.context.applicationId,
69+
this.props.params.className
70+
);
71+
72+
let newFilterId = filterId;
73+
74+
if (filterId) {
75+
// Update existing filter
76+
const existingFilterIndex = preferences.filters.findIndex(filter => filter.id === filterId);
77+
if (existingFilterIndex !== -1) {
78+
preferences.filters[existingFilterIndex] = {
79+
name,
80+
id: filterId,
81+
filter: _filters,
82+
};
83+
} else {
84+
// Fallback: if filter not found, create new one
85+
newFilterId = crypto.randomUUID();
86+
preferences.filters.push({
87+
name,
88+
id: newFilterId,
89+
filter: _filters,
90+
});
91+
}
92+
} else {
93+
// Check if this is updating a legacy filter (no filterId but filter content matches existing filter without ID)
94+
const existingLegacyFilterIndex = preferences.filters.findIndex(filter =>
95+
!filter.id && filter.name === name && filter.filter === _filters
96+
);
97+
98+
if (existingLegacyFilterIndex !== -1) {
99+
// Convert legacy filter to modern filter by adding an ID
100+
newFilterId = crypto.randomUUID();
101+
preferences.filters[existingLegacyFilterIndex] = {
102+
name,
103+
id: newFilterId,
104+
filter: _filters,
105+
};
106+
} else {
107+
// Create new filter
108+
newFilterId = crypto.randomUUID();
109+
preferences.filters.push({
110+
name,
111+
id: newFilterId,
112+
filter: _filters,
113+
});
114+
}
115+
}
116+
117+
ClassPreferences.updatePreferences(
118+
preferences,
119+
this.context.applicationId,
120+
this.props.params.className
121+
);
122+
123+
this.forceUpdate();
124+
125+
// Return the filter ID for new filters so the caller can apply them
126+
return newFilterId;
127+
}
128+
}
129+
130+
// Mock List for filters
131+
class MockList {
132+
constructor(data = []) {
133+
this.data = data;
134+
}
135+
136+
toJSON() {
137+
return this.data;
138+
}
139+
}
140+
141+
describe('Browser saveFilters - Legacy Filter Conversion', () => {
142+
let browser;
143+
144+
beforeEach(() => {
145+
browser = new MockBrowser();
146+
// Clear mock storage
147+
Object.keys(mockStorage).forEach(key => delete mockStorage[key]);
148+
// Reset the UUID mock
149+
mockRandomUUID.mockReturnValue('test-uuid-123');
150+
});
151+
152+
it('converts legacy filter to modern filter when updating', () => {
153+
const filterData = [{ field: 'name', constraint: 'eq', compareTo: 'test' }];
154+
const filters = new MockList(filterData);
155+
156+
// First, manually create a legacy filter (without ID) in preferences
157+
const preferences = {
158+
filters: [
159+
{
160+
name: 'Legacy Filter',
161+
filter: JSON.stringify(filterData)
162+
// Note: no 'id' property - this makes it a legacy filter
163+
}
164+
]
165+
};
166+
ClassPreferences.updatePreferences(
167+
preferences,
168+
'testApp',
169+
'TestClass'
170+
);
171+
172+
// Now call saveFilters to update the same filter
173+
const result = browser.saveFilters(filters, 'Legacy Filter', false);
174+
175+
// Check that the legacy filter was converted to modern filter
176+
expect(result).toBe('test-uuid-123');
177+
178+
const updatedPreferences = ClassPreferences.getPreferences('testApp', 'TestClass');
179+
expect(updatedPreferences.filters).toHaveLength(1);
180+
expect(updatedPreferences.filters[0]).toEqual({
181+
name: 'Legacy Filter',
182+
id: 'test-uuid-123',
183+
filter: JSON.stringify(filterData)
184+
});
185+
});
186+
187+
it('creates new filter when legacy filter with same name has different content', () => {
188+
const originalFilterData = [{ field: 'name', constraint: 'eq', compareTo: 'original' }];
189+
const newFilterData = [{ field: 'name', constraint: 'eq', compareTo: 'updated' }];
190+
191+
// Create a legacy filter with different content
192+
const preferences = {
193+
filters: [
194+
{
195+
name: 'My Filter',
196+
filter: JSON.stringify(originalFilterData)
197+
// No 'id' property - legacy filter
198+
}
199+
]
200+
};
201+
ClassPreferences.updatePreferences(
202+
preferences,
203+
'testApp',
204+
'TestClass'
205+
);
206+
207+
// Try to save a filter with same name but different content
208+
const filters = new MockList(newFilterData);
209+
const result = browser.saveFilters(filters, 'My Filter', false);
210+
211+
// Should create a new filter, not update the legacy one
212+
expect(result).toBe('test-uuid-123');
213+
214+
const updatedPreferences = ClassPreferences.getPreferences('testApp', 'TestClass');
215+
expect(updatedPreferences.filters).toHaveLength(2);
216+
217+
// Original legacy filter should remain unchanged
218+
expect(updatedPreferences.filters[0]).toEqual({
219+
name: 'My Filter',
220+
filter: JSON.stringify(originalFilterData)
221+
});
222+
223+
// New modern filter should be created
224+
expect(updatedPreferences.filters[1]).toEqual({
225+
name: 'My Filter',
226+
id: 'test-uuid-123',
227+
filter: JSON.stringify(newFilterData)
228+
});
229+
});
230+
231+
it('does not affect modern filters when updating', () => {
232+
const filterData = [{ field: 'name', constraint: 'eq', compareTo: 'test' }];
233+
234+
// Create a modern filter (with ID)
235+
const preferences = {
236+
filters: [
237+
{
238+
name: 'Modern Filter',
239+
id: 'existing-id',
240+
filter: JSON.stringify(filterData)
241+
}
242+
]
243+
};
244+
ClassPreferences.updatePreferences(
245+
preferences,
246+
'testApp',
247+
'TestClass'
248+
);
249+
250+
// Update the modern filter
251+
const filters = new MockList(filterData);
252+
const result = browser.saveFilters(filters, 'Modern Filter', false, 'existing-id');
253+
254+
// Should return the existing ID, not create a new one
255+
expect(result).toBe('existing-id');
256+
257+
const updatedPreferences = ClassPreferences.getPreferences('testApp', 'TestClass');
258+
expect(updatedPreferences.filters).toHaveLength(1);
259+
expect(updatedPreferences.filters[0]).toEqual({
260+
name: 'Modern Filter',
261+
id: 'existing-id',
262+
filter: JSON.stringify(filterData)
263+
});
264+
});
265+
266+
it('creates new filter when no existing filter matches', () => {
267+
const filterData = [{ field: 'name', constraint: 'eq', compareTo: 'test' }];
268+
269+
// Start with empty preferences
270+
const preferences = { filters: [] };
271+
ClassPreferences.updatePreferences(
272+
preferences,
273+
'testApp',
274+
'TestClass'
275+
);
276+
277+
// Save a new filter
278+
const filters = new MockList(filterData);
279+
const result = browser.saveFilters(filters, 'New Filter', false);
280+
281+
// Should create a new modern filter
282+
expect(result).toBe('test-uuid-123');
283+
284+
const updatedPreferences = ClassPreferences.getPreferences('testApp', 'TestClass');
285+
expect(updatedPreferences.filters).toHaveLength(1);
286+
expect(updatedPreferences.filters[0]).toEqual({
287+
name: 'New Filter',
288+
id: 'test-uuid-123',
289+
filter: JSON.stringify(filterData)
290+
});
291+
});
292+
});

0 commit comments

Comments
 (0)