Skip to content

Commit 49cdc74

Browse files
committed
chore: add tests
1 parent 2c5511e commit 49cdc74

7 files changed

Lines changed: 1060 additions & 401 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@
154154
"react-select": "^2.4.3",
155155
"react-star-ratings": "^2.3.0",
156156
"react-tooltip": "^3.11.1",
157-
"redux": "^3.7.2",
157+
"redux": "^3.7.2 || ^4.0.0",
158158
"redux-persist": "^5.10.0",
159159
"redux-thunk": "^2.3.0",
160160
"spark-md5": "^3.0.2",

src/components/mui/GridFilter/readme.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { GridFilter, OPERATORS } from "components/GridFilter";
3333
{ value: 1, label: "OpenStack" },
3434
{ value: 2, label: "FnTech" }
3535
],
36-
multi: true,
36+
multiple: true,
3737
placeholder: "Select Tracks"
3838
}
3939
}
@@ -138,7 +138,9 @@ The hook reads from `allGridFiltersState` in the Redux store, so it stays in syn
138138

139139
# hideJoinOperators
140140

141-
By default the dialog shows an **All / Any** toggle that lets the user choose whether filters are ANDed or ORed together. Pass `hideJoinOperators` to hide it — useful when you always want a fixed join behavior (the default remains `"all"`).
141+
By default the dialog shows an **All / Any** toggle that lets the user choose whether filters are ANDed or ORed together. Pass `hideJoinOperators` to hide the toggle UI — but note that this **only removes the control from the dialog; it does not change the active join operator**. The dialog always initializes from the join operator last persisted in the Redux store (which defaults to `"all"` on first load). If the user previously applied filters with `"any"` and that value is still in the store, it will continue to be used when the toggle is hidden — silently producing OR-joined results.
142+
143+
If you need a guaranteed fixed join behavior after hiding the toggle, call `resetFilters()` (from `useGridFilter`) before or after mounting the component to flush the store back to `"all"`.
142144

143145
```jsx
144146
<GridFilter

src/components/mui/__tests__/GridFilter.test.jsx

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import "@testing-library/jest-dom";
77
import { Provider } from "react-redux";
88
import configureStore from "redux-mock-store";
99
import thunk from "redux-thunk";
10-
import { GridFilter, OPERATORS } from "../GridFilter";
10+
import { GridFilter, OPERATORS, JOIN_OPERATORS, SAVE_FILTERS } from "../GridFilter";
1111
import Filter from "../GridFilter/components/Filter";
1212

1313
jest.mock("i18n-react/dist/i18n-react", () => ({
@@ -234,3 +234,154 @@ describe("Filter - options as a function", () => {
234234
);
235235
});
236236
});
237+
238+
// ─── parseFilter / handleSubmit ──────────────────────────────────────────────
239+
//
240+
// These are private closures; exercised by seeding the Redux store with filter
241+
// values so useGridFilter returns them, opening the dialog, and clicking Apply.
242+
243+
describe("GridFilter – parseFilter / handleSubmit", () => {
244+
// Build a mock store that makes useGridFilter("test-filter") return the given values.
245+
const makeFilterStore = (filterValues, joinOperator = JOIN_OPERATORS.ALL) =>
246+
mockStore({
247+
allGridFiltersState: {
248+
allFilters: [{ id: "test-filter", filterValues, joinOperator, parsedFilter: [] }]
249+
}
250+
});
251+
252+
const renderWithFilters = (filterValues, opts = {}) => {
253+
const { joinOperator = JOIN_OPERATORS.ALL, onApply = jest.fn(), criterias: c } = opts;
254+
const store = makeFilterStore(filterValues, joinOperator);
255+
render(
256+
<Provider store={store}>
257+
<GridFilter
258+
id="test-filter"
259+
criterias={c ?? criterias}
260+
onApply={onApply}
261+
/>
262+
</Provider>
263+
);
264+
return { onApply, store };
265+
};
266+
267+
// FilterButton renders a Chip when filterCount > 0; its label span contains
268+
// "${n} grid_filter.filters". Clicking the span bubbles to the Chip's onClick.
269+
const openFilterDialog = () =>
270+
fireEvent.click(screen.getByText(/\d+ grid_filter\.filters/));
271+
272+
const applyFilters = () =>
273+
fireEvent.click(screen.getByText("grid_filter.apply_filters"));
274+
275+
describe("filter serialization", () => {
276+
test("single value → criteria+operator+value", () => {
277+
const { onApply } = renderWithFilters([
278+
{ criteria: "sponsor", operator: "==", value: "alice" }
279+
]);
280+
281+
openFilterDialog();
282+
applyFilters();
283+
284+
const [filters, joinOp] = onApply.mock.calls[0];
285+
expect(filters).toHaveLength(1);
286+
expect(filters[0].parsed).toEqual(["sponsor==alice"]);
287+
expect(joinOp).toBe(JOIN_OPERATORS.ALL);
288+
});
289+
290+
test("array value → items joined with ||", () => {
291+
const { onApply } = renderWithFilters([
292+
{ criteria: "track", operator: "==", value: [1, 2, 3] }
293+
]);
294+
295+
openFilterDialog();
296+
applyFilters();
297+
298+
const [filters] = onApply.mock.calls[0];
299+
expect(filters[0].parsed).toEqual(["track==1||2||3"]);
300+
});
301+
302+
test("delegates to customParser and uses its return value as parsed", () => {
303+
const customParser = jest.fn().mockReturnValue(["custom==result"]);
304+
const c = [
305+
{
306+
key: "status",
307+
label: "Status",
308+
operators: [OPERATORS.IS],
309+
values: { type: "text", props: {} },
310+
customParser
311+
}
312+
];
313+
314+
const { onApply } = renderWithFilters(
315+
[{ criteria: "status", operator: "==", value: "active" }],
316+
{ criterias: c }
317+
);
318+
319+
openFilterDialog();
320+
applyFilters();
321+
322+
expect(customParser).toHaveBeenCalledWith(
323+
expect.objectContaining({ criteria: "status", operator: "==", value: "active" })
324+
);
325+
const [filters] = onApply.mock.calls[0];
326+
expect(filters[0].parsed).toEqual(["custom==result"]);
327+
});
328+
});
329+
330+
describe("handleSubmit filter predicate", () => {
331+
test.each([
332+
// value: null avoids a render crash: with criteria=null there is no type, so ValueInput
333+
// falls back to Dropdown with options=null; a non-null value would hit options.find(null).
334+
["null criteria", { criteria: null, operator: "==", value: null }],
335+
["null operator", { criteria: "sponsor", operator: null, value: "v" }],
336+
["null value", { criteria: "sponsor", operator: "==", value: null }],
337+
["empty string", { criteria: "sponsor", operator: "==", value: "" }],
338+
["empty array", { criteria: "track", operator: "==", value: [] }]
339+
])("strips %s; valid sibling survives", (_, badFilter) => {
340+
const { onApply } = renderWithFilters([
341+
badFilter,
342+
{ criteria: "sponsor", operator: "==", value: "valid" }
343+
]);
344+
345+
openFilterDialog();
346+
applyFilters();
347+
348+
const [filters] = onApply.mock.calls[0];
349+
expect(filters).toHaveLength(1);
350+
expect(filters[0].value).toBe("valid");
351+
});
352+
});
353+
354+
describe("action contracts", () => {
355+
test("saveFilters and onApply both receive the parsed payload and active join operator", () => {
356+
const onApply = jest.fn();
357+
const { store } = renderWithFilters(
358+
[{ criteria: "sponsor", operator: "=@", value: "bob" }],
359+
{ joinOperator: JOIN_OPERATORS.ANY, onApply }
360+
);
361+
362+
openFilterDialog();
363+
applyFilters();
364+
365+
const expectedFilter = expect.objectContaining({
366+
criteria: "sponsor",
367+
operator: "=@",
368+
value: "bob",
369+
parsed: ["sponsor=@bob"]
370+
});
371+
372+
// The saveFilters thunk dispatches {type: SAVE_FILTERS, payload: {...}} to the store.
373+
const savedAction = store.getActions().find((a) => a.type === SAVE_FILTERS);
374+
expect(savedAction).toBeDefined();
375+
expect(savedAction.payload).toMatchObject({
376+
id: "test-filter",
377+
joinOperator: JOIN_OPERATORS.ANY,
378+
filters: expect.arrayContaining([expectedFilter])
379+
});
380+
381+
expect(onApply).toHaveBeenCalledWith(
382+
expect.arrayContaining([expectedFilter]),
383+
JOIN_OPERATORS.ANY
384+
);
385+
});
386+
});
387+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
jest.mock("i18n-react/dist/i18n-react", () => ({
2+
__esModule: true,
3+
default: { translate: (key) => key }
4+
}));
5+
6+
import allFiltersReducer from "../GridFilter/reducers/all-filters-reducer";
7+
import { JOIN_OPERATORS } from "../GridFilter/utils";
8+
import { SAVE_FILTERS } from "../GridFilter/actions/filter-actions";
9+
import { LOGOUT_USER } from "../../security/actions";
10+
11+
// ─── helpers ─────────────────────────────────────────────────────────────────
12+
13+
const saveAction = (id, filters = [], joinOperator = JOIN_OPERATORS.ALL) => ({
14+
type: SAVE_FILTERS,
15+
payload: { id, filters, joinOperator }
16+
});
17+
18+
const withFilters = [
19+
{ criteria: "name", operator: "==", value: "alice", parsed: ["name==alice"] },
20+
{ criteria: "track", operator: "==", value: [1, 2], parsed: ["track==1||2"] }
21+
];
22+
23+
// ─── add new entry ────────────────────────────────────────────────────────────
24+
25+
describe("allFiltersReducer – SAVE_FILTERS: add new entry", () => {
26+
test("appends an entry when the id is not yet in allFilters", () => {
27+
const next = allFiltersReducer(undefined, saveAction("grid-a", withFilters, JOIN_OPERATORS.ALL));
28+
29+
expect(next.allFilters).toHaveLength(1);
30+
expect(next.allFilters[0]).toMatchObject({
31+
id: "grid-a",
32+
filterValues: withFilters,
33+
joinOperator: JOIN_OPERATORS.ALL,
34+
parsedFilter: ["name==alice", "track==1||2"]
35+
});
36+
});
37+
38+
test("new entry is created via filterReducer(null, …): null bypasses INITIAL_STATE default and spreads as {}", () => {
39+
// Default parameters only substitute for `undefined`, not `null`.
40+
// allFiltersReducer passes null (not undefined) to filterReducer for new entries,
41+
// so `...state` inside filterReducer is `...null`, which is a no-op in JS.
42+
// The entry should contain exactly the four keys set by the return statement —
43+
// no stale id: null or other INITIAL_STATE properties leaking through.
44+
const { allFilters } = allFiltersReducer(
45+
undefined,
46+
saveAction("new-id", withFilters, JOIN_OPERATORS.ALL)
47+
);
48+
const entry = allFilters[0];
49+
50+
expect(entry.id).toBe("new-id");
51+
expect(entry.filterValues).toBe(withFilters);
52+
expect(Object.keys(entry).sort()).toEqual([
53+
"filterValues",
54+
"id",
55+
"joinOperator",
56+
"parsedFilter"
57+
]);
58+
});
59+
});
60+
61+
// ─── update existing entry ────────────────────────────────────────────────────
62+
63+
describe("allFiltersReducer – SAVE_FILTERS: update existing entry", () => {
64+
const initial = {
65+
allFilters: [
66+
{ id: "grid-a", filterValues: [], joinOperator: JOIN_OPERATORS.ALL, parsedFilter: [] },
67+
{ id: "grid-b", filterValues: [], joinOperator: JOIN_OPERATORS.ALL, parsedFilter: [] }
68+
]
69+
};
70+
71+
test("replaces the matching entry and passes current state into filterReducer", () => {
72+
const updated = [{ criteria: "sponsor", operator: "=@", value: "bob", parsed: ["sponsor=@bob"] }];
73+
const next = allFiltersReducer(initial, saveAction("grid-a", updated, JOIN_OPERATORS.ANY));
74+
75+
expect(next.allFilters).toHaveLength(2);
76+
expect(next.allFilters.find((f) => f.id === "grid-a")).toMatchObject({
77+
filterValues: updated,
78+
joinOperator: JOIN_OPERATORS.ANY,
79+
parsedFilter: ["or(sponsor=@bob)"]
80+
});
81+
});
82+
83+
test("leaves other entries untouched (same object reference)", () => {
84+
const next = allFiltersReducer(initial, saveAction("grid-a", withFilters));
85+
86+
expect(next.allFilters.find((f) => f.id === "grid-b")).toBe(initial.allFilters[1]);
87+
});
88+
});
89+
90+
// ─── empty reset via saveFilters(id) ─────────────────────────────────────────
91+
92+
describe("allFiltersReducer – SAVE_FILTERS: empty reset", () => {
93+
test("saveFilters(id) with no args clears filterValues, parsedFilter, and resets joinOperator to ALL", () => {
94+
// saveFilters(id) calls saveFilters(id, [], JOIN_OPERATORS.ALL) — the no-arg defaults.
95+
const initial = {
96+
allFilters: [
97+
{
98+
id: "grid-a",
99+
filterValues: withFilters,
100+
joinOperator: JOIN_OPERATORS.ANY,
101+
parsedFilter: ["or(name==alice)", "or(track==1||2)"]
102+
}
103+
]
104+
};
105+
106+
const next = allFiltersReducer(initial, saveAction("grid-a")); // ← no filters, no joinOperator
107+
const entry = next.allFilters[0];
108+
109+
expect(entry.filterValues).toEqual([]);
110+
expect(entry.parsedFilter).toEqual([]);
111+
expect(entry.joinOperator).toBe(JOIN_OPERATORS.ALL);
112+
});
113+
});
114+
115+
// ─── LOGOUT_USER ─────────────────────────────────────────────────────────────
116+
117+
describe("allFiltersReducer – LOGOUT_USER", () => {
118+
test("resets to DEFAULT_STATE regardless of how much was stored", () => {
119+
const populated = {
120+
allFilters: [
121+
{ id: "grid-a", filterValues: withFilters, joinOperator: JOIN_OPERATORS.ANY, parsedFilter: [] }
122+
]
123+
};
124+
125+
expect(allFiltersReducer(populated, { type: LOGOUT_USER })).toEqual({ allFilters: [] });
126+
});
127+
});
128+
129+
// ─── default case ─────────────────────────────────────────────────────────────
130+
131+
describe("allFiltersReducer – default case", () => {
132+
test("returns the same state reference for unknown action types", () => {
133+
const state = allFiltersReducer(undefined, { type: "@@INIT" });
134+
expect(allFiltersReducer(state, { type: "UNKNOWN" })).toBe(state);
135+
});
136+
});

0 commit comments

Comments
 (0)