Skip to content

Commit 850c442

Browse files
authored
Merge pull request #31 from caugello/KFLUXUI-193
feat(KFLUXUI-193): filter on Applications
2 parents a203b1e + d4298fb commit 850c442

File tree

2 files changed

+193
-11
lines changed

2 files changed

+193
-11
lines changed

src/components/Applications/ApplicationListView.tsx

+46-10
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@ import {
55
EmptyStateBody,
66
PageSection,
77
PageSectionVariants,
8+
SearchInput,
89
Spinner,
910
Toolbar,
1011
ToolbarContent,
1112
ToolbarItem,
1213
} from '@patternfly/react-core';
14+
import { debounce } from 'lodash-es';
1315
import emptyStateImgUrl from '../../assets/Application.svg';
1416
import { useApplications } from '../../hooks/useApplications';
17+
import { useSearchParam } from '../../hooks/useSearchParam';
1518
import { ApplicationModel, ComponentModel } from '../../models';
1619
import { Table } from '../../shared';
1720
import AppEmptyState from '../../shared/components/empty-state/AppEmptyState';
21+
import FilteredEmptyState from '../../shared/components/empty-state/FilteredEmptyState';
1822
import { ApplicationKind } from '../../types';
1923
import { useApplicationBreadcrumbs } from '../../utils/breadcrumb-utils';
2024
import { useAccessReviewForModel } from '../../utils/rbac';
@@ -29,12 +33,29 @@ const ApplicationListView: React.FC<React.PropsWithChildren<unknown>> = () => {
2933
const applicationBreadcrumbs = useApplicationBreadcrumbs();
3034
const [canCreateApplication] = useAccessReviewForModel(ApplicationModel, 'create');
3135
const [canCreateComponent] = useAccessReviewForModel(ComponentModel, 'create');
36+
const [nameFilter, setNameFilter] = useSearchParam('name', '');
3237

3338
const [applications, loaded] = useApplications(namespace, workspace);
3439
applications?.sort(
3540
(app1, app2) =>
3641
+new Date(app2.metadata?.creationTimestamp) - +new Date(app1.metadata?.creationTimestamp),
3742
);
43+
const filteredApplications = React.useMemo(() => {
44+
const lowerCaseNameFilter = nameFilter.toLowerCase();
45+
return applications?.filter(
46+
(app) =>
47+
app.spec.displayName?.toLowerCase().includes(lowerCaseNameFilter) ??
48+
app.metadata.name.includes(lowerCaseNameFilter),
49+
);
50+
}, [nameFilter, applications]);
51+
52+
const onClearFilters = () => {
53+
setNameFilter('');
54+
};
55+
56+
const onNameInput = debounce((n: string) => {
57+
setNameFilter(n);
58+
}, 600);
3859

3960
if (!loaded) {
4061
return (
@@ -86,6 +107,17 @@ const ApplicationListView: React.FC<React.PropsWithChildren<unknown>> = () => {
86107
<>
87108
<Toolbar usePageInsets>
88109
<ToolbarContent>
110+
<ToolbarItem className="pf-v5-u-ml-0">
111+
<SearchInput
112+
name="nameInput"
113+
data-test="name-input-filter"
114+
type="search"
115+
aria-label="name filter"
116+
placeholder="Filter by name..."
117+
onChange={(_, n) => onNameInput(n)}
118+
value={nameFilter}
119+
/>
120+
</ToolbarItem>
89121
<ToolbarItem>
90122
<ButtonWithAccessTooltip
91123
variant="primary"
@@ -104,16 +136,20 @@ const ApplicationListView: React.FC<React.PropsWithChildren<unknown>> = () => {
104136
</ToolbarItem>
105137
</ToolbarContent>
106138
</Toolbar>
107-
<Table
108-
data={applications}
109-
aria-label="Application List"
110-
Header={ApplicationListHeader}
111-
Row={ApplicationListRow}
112-
loaded
113-
getRowProps={(obj: ApplicationKind) => ({
114-
id: obj.metadata?.name,
115-
})}
116-
/>
139+
{filteredApplications.length !== 0 ? (
140+
<Table
141+
data={filteredApplications}
142+
aria-label="Application List"
143+
Header={ApplicationListHeader}
144+
Row={ApplicationListRow}
145+
loaded
146+
getRowProps={(obj: ApplicationKind) => ({
147+
id: obj.metadata?.name,
148+
})}
149+
/>
150+
) : (
151+
<FilteredEmptyState onClearFilters={onClearFilters} />
152+
)}
117153
</>
118154
)}
119155
</PageSection>

src/components/Applications/__tests__/ApplicationListView.spec.tsx

+147-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { screen } from '@testing-library/react';
1+
import React from 'react';
2+
import { useSearchParams } from 'react-router-dom';
3+
import { Table as PfTable, TableHeader } from '@patternfly/react-table/deprecated';
4+
import { screen, fireEvent, waitFor } from '@testing-library/react';
25
import { ApplicationKind } from '../../../types';
36
import {
47
createK8sWatchResourceMock,
58
createUseWorkspaceInfoMock,
69
renderWithQueryClient,
710
} from '../../../utils/test-utils';
11+
import ApplicationListRow from '../ApplicationListRow';
812
import ApplicationListView from '../ApplicationListView';
913

1014
jest.mock('react-i18next', () => ({
@@ -16,6 +20,7 @@ jest.mock('react-router-dom', () => {
1620
return {
1721
...actual,
1822
useLocation: jest.fn(() => ({})),
23+
useSearchParams: jest.fn(),
1924
Link: (props) => <a href={props.to}>{props.children}</a>,
2025
useNavigate: jest.fn(),
2126
};
@@ -25,6 +30,30 @@ jest.mock('../../../utils/rbac', () => ({
2530
useAccessReviewForModel: jest.fn(() => [true, true]),
2631
}));
2732

33+
jest.mock('../../../shared/components/table', () => {
34+
const actual = jest.requireActual('../../../shared/components/table');
35+
return {
36+
...actual,
37+
Table: (props) => {
38+
const { data, filters, selected, match, kindObj } = props;
39+
const cProps = { data, filters, selected, match, kindObj };
40+
const columns = props.Header(cProps);
41+
return (
42+
<PfTable role="table" aria-label="table" cells={columns} variant="compact" borders={false}>
43+
<TableHeader role="rowgroup" />
44+
<tbody>
45+
{props.data.map((d, i) => (
46+
<tr key={i}>
47+
<ApplicationListRow columns={null} obj={d} />
48+
</tr>
49+
))}
50+
</tbody>
51+
</PfTable>
52+
);
53+
},
54+
};
55+
});
56+
2857
const applications: ApplicationKind[] = [
2958
{
3059
kind: 'Application',
@@ -88,12 +117,17 @@ const applications: ApplicationKind[] = [
88117
},
89118
];
90119

120+
const useSearchParamsMock = useSearchParams as jest.Mock;
91121
const watchResourceMock = createK8sWatchResourceMock();
92122
createUseWorkspaceInfoMock({ namespace: 'test-ns', workspace: 'test-ws' });
93123

94124
const ApplicationList = ApplicationListView;
95125

96126
describe('Application List', () => {
127+
beforeEach(() => {
128+
useSearchParamsMock.mockImplementation(() => React.useState(new URLSearchParams()));
129+
});
130+
97131
it('should render spinner if application data is not loaded', () => {
98132
watchResourceMock.mockReturnValue([[], false]);
99133
renderWithQueryClient(<ApplicationList />);
@@ -130,4 +164,116 @@ describe('Application List', () => {
130164
renderWithQueryClient(<ApplicationList />);
131165
expect(screen.queryByTestId('applications-breadcrumb-link')).not.toBeInTheDocument();
132166
});
167+
168+
it('should apply query params to the filter', async () => {
169+
watchResourceMock.mockReturnValue([applications, true]);
170+
useSearchParamsMock.mockImplementation(() =>
171+
React.useState(new URLSearchParams('name=xyz-app')),
172+
);
173+
renderWithQueryClient(<ApplicationList />);
174+
await waitFor(() => {
175+
expect(screen.queryByText('mno-app')).not.toBeInTheDocument();
176+
expect(screen.queryByText('mno-app1')).not.toBeInTheDocument();
177+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
178+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
179+
});
180+
});
181+
182+
it('should filter applications by name', async () => {
183+
watchResourceMock.mockReturnValue([applications, true]);
184+
const { rerender } = renderWithQueryClient(<ApplicationList />);
185+
await waitFor(() => {
186+
expect(screen.queryByText('mno-app')).toBeInTheDocument();
187+
expect(screen.queryByText('mno-app1')).toBeInTheDocument();
188+
expect(screen.queryByText('mno-app2')).toBeInTheDocument();
189+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
190+
});
191+
192+
const filter = screen.getByPlaceholderText('Filter by name...');
193+
fireEvent.change(filter, { target: { value: 'xyz-app' } });
194+
rerender(<ApplicationList />);
195+
await waitFor(() => {
196+
expect(screen.queryByText('mno-app')).not.toBeInTheDocument();
197+
expect(screen.queryByText('mno-app1')).not.toBeInTheDocument();
198+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
199+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
200+
});
201+
});
202+
203+
it('should use fallback filter value of metadata.name', async () => {
204+
const alteredApplications = applications.map((app) => ({
205+
...app,
206+
spec: { displayName: undefined },
207+
}));
208+
watchResourceMock.mockReturnValue([alteredApplications, true]);
209+
const { rerender } = renderWithQueryClient(<ApplicationList />);
210+
await waitFor(() => {
211+
expect(screen.queryByText('mno-app')).toBeInTheDocument();
212+
expect(screen.queryByText('mno-app1')).toBeInTheDocument();
213+
expect(screen.queryByText('mno-app2')).toBeInTheDocument();
214+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
215+
});
216+
217+
const filter = screen.getByPlaceholderText('Filter by name...');
218+
fireEvent.change(filter, { target: { value: 'xyz-app' } });
219+
rerender(<ApplicationList />);
220+
await waitFor(() => {
221+
expect(screen.queryByText('mno-app')).not.toBeInTheDocument();
222+
expect(screen.queryByText('mno-app1')).not.toBeInTheDocument();
223+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
224+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
225+
});
226+
});
227+
228+
it('should filter case insensitive', async () => {
229+
watchResourceMock.mockReturnValue([applications, true]);
230+
const { rerender } = renderWithQueryClient(<ApplicationList />);
231+
await waitFor(() => {
232+
expect(screen.queryByText('mno-app')).toBeInTheDocument();
233+
expect(screen.queryByText('mno-app1')).toBeInTheDocument();
234+
expect(screen.queryByText('mno-app2')).toBeInTheDocument();
235+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
236+
});
237+
238+
const filter = screen.getByPlaceholderText('Filter by name...');
239+
fireEvent.change(filter, { target: { value: 'XYZ' } });
240+
rerender(<ApplicationList />);
241+
await waitFor(() => {
242+
expect(screen.queryByText('mno-app')).not.toBeInTheDocument();
243+
expect(screen.queryByText('mno-app1')).not.toBeInTheDocument();
244+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
245+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
246+
});
247+
});
248+
249+
it('should clear the filter when clear button is clicked', async () => {
250+
watchResourceMock.mockReturnValue([applications, true]);
251+
const { rerender } = renderWithQueryClient(<ApplicationList />);
252+
await waitFor(() => {
253+
expect(screen.queryByText('mno-app')).toBeInTheDocument();
254+
expect(screen.queryByText('mno-app1')).toBeInTheDocument();
255+
expect(screen.queryByText('mno-app2')).toBeInTheDocument();
256+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
257+
});
258+
259+
const filter = screen.getByPlaceholderText('Filter by name...');
260+
fireEvent.change(filter, { target: { value: 'invalid-app' } });
261+
rerender(<ApplicationList />);
262+
await waitFor(() => {
263+
expect(screen.queryByText('mno-app')).not.toBeInTheDocument();
264+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
265+
expect(screen.queryByText('mno-app2')).not.toBeInTheDocument();
266+
expect(screen.queryByText('xyz-app')).not.toBeInTheDocument();
267+
expect(screen.queryByText('No results found')).toBeInTheDocument();
268+
});
269+
270+
fireEvent.click(screen.getByRole('button', { name: 'Clear all filters' }));
271+
rerender(<ApplicationList />);
272+
await waitFor(() => {
273+
expect(screen.queryByText('mno-app')).toBeInTheDocument();
274+
expect(screen.queryByText('mno-app1')).toBeInTheDocument();
275+
expect(screen.queryByText('mno-app2')).toBeInTheDocument();
276+
expect(screen.queryByText('xyz-app')).toBeInTheDocument();
277+
});
278+
});
133279
});

0 commit comments

Comments
 (0)