Skip to content

Commit

Permalink
Add search to table component (#2684)
Browse files Browse the repository at this point in the history
* Add search to table component

This commit adds a search capability to the table component.
The search is case insensitive and respects normalises Unicode character
differences. However, it is not fuzzy or can accommodate spelling
mistakes.

Furthermore, the change is supported via a story and several test cases,
that describe the behaviour.

* Functional JavaScript aka. fix ESLint

Prefer array iteration methods over for loops

* Implement changes proposed by Jamie

Thanks for the feedback. I agree with all of it.

* Ripping search out of the Table

This commit removes the search API from the Table component and adds a
reduced version to the @lib folder.

The idea is that the components themselves are now responsible for
handling search and the library just provides utilities and functions to
make this simpler.

* Move HostRelevantPatchesPage to new search API

* Add search to UpgradablePackagesPage

This commit adds search to the UpgradablePackagesPage. Furthermore, it
adds a story for the page and splits the Page into a navigation and
Redux independent part for easier design work.

* Add inspect function

This commit adds a Elixir inspired inspect function. Using this
function, one can log data to the console, without breaking up long call
chains.

It's literally the identity function plus a "console.dir()".

* Add test fro search

* Add test for filtering in pages

This commit adds tests for filtering the UpgradablePackagesPage and
HostRelevantPatchesPage by their content via search.

* Remove dead code

This commit completely removes the search API from the Table.

* Add search to UpgradablePackagesPage

This commit adds search to the UpgradablePackagesPage. Furthermore, it
adds a story for the page and splits the Page into a navigation and
Redux independent part for easier design work.

* Keep consistent casing

* Fix case

* Rename Page and UpgradablePackages

* Fix ESLint

* Implement changes proposed by Jamie
  • Loading branch information
janvhs authored and dottorblaster committed Jun 27, 2024
1 parent af9e959 commit 2465cf4
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 21 deletions.
3 changes: 2 additions & 1 deletion assets/js/common/Table/Table.stories.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { createStringSortingPredicate } from './sorting';

import Table from '.';

import { createStringSortingPredicate } from './sorting';

export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
Expand Down
4 changes: 4 additions & 0 deletions assets/js/lib/filter/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const regularizeString = (str) => str.normalize().trim().toLowerCase();

export const containsSubstring = (str = '', substring = '') =>
regularizeString(str).includes(regularizeString(substring));
40 changes: 40 additions & 0 deletions assets/js/lib/filter/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { faker } from '@faker-js/faker';

import { containsSubstring } from '.';

describe('search', () => {
it('should always match with an empty search string', () => {
expect(containsSubstring('', '')).toBe(true);
expect(containsSubstring(faker.word.words(10), '')).toBe(true);
});

it('should match strings case in an insensitive fashion', () => {
const original = faker.word.words(1);
const upper = original.toUpperCase();
const lower = original.toLowerCase();

expect(containsSubstring(original, upper)).toBe(true);
expect(containsSubstring(original, lower)).toBe(true);
});

it('should match substrings', () => {
const original = faker.word.words(1);
const sub = original.substring(original.length / 2);

expect(containsSubstring(original, sub)).toBe(true);
});

it('should not match not included words', () => {
const words = faker.word.words(2).split(' ');

expect(containsSubstring(words[0], words[1])).toBe(false);
expect(containsSubstring('', words[1])).toBe(false);
});

it('should match unicode in different forms', () => {
const name1 = '\u0041\u006d\u00e9\u006c\u0069\u0065';
const name2 = '\u0041\u006d\u0065\u0301\u006c\u0069\u0065';

expect(containsSubstring(name1, name2)).toBe(true);
});
});
5 changes: 5 additions & 0 deletions assets/js/lib/test-utils/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,8 @@ export async function recordSaga(

return dispatched;
}

export const inspect = (val) => {
console.dir(val); // eslint-disable-line no-console
return val;
};
23 changes: 10 additions & 13 deletions assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import Input from '@common/Input';
import Select from '@common/Select';
import Button from '@common/Button';

import { containsSubstring } from '@lib/filter';

const advisoryTypesFromPatches = (patches) =>
Array.from(new Set(patches.map(({ advisory_type }) => advisory_type))).sort();

Expand All @@ -15,14 +17,6 @@ const filterPatchesByAdvisoryType = (patches, advisoryType) =>
advisoryType === 'all' ? true : advisory_type === advisoryType
);

// TODO(janvhs): Fuzzy, case insensitive search, input delay?
const filterPatchesBySynopsis = (patches, synopsis) =>
patches.filter(({ advisory_synopsis }) =>
synopsis.trim() === ''
? true
: advisory_synopsis.trim().startsWith(synopsis.trim())
);

function HostRelevantPatches({ hostName, onNavigate, patches }) {
const advisoryTypes = ['all'].concat(advisoryTypesFromPatches(patches));

Expand All @@ -32,12 +26,15 @@ function HostRelevantPatches({ hostName, onNavigate, patches }) {
const [displayedPatches, setDisplayedPatches] = useState(patches);

useEffect(() => {
setDisplayedPatches(
filterPatchesBySynopsis(
filterPatchesByAdvisoryType(patches, displayedAdvisories),
search
)
const filteredByAdvisoryType = filterPatchesByAdvisoryType(
patches,
displayedAdvisories
);
const searchResult = filteredByAdvisoryType.filter(
({ advisory_synopsis }) =>
advisory_synopsis ? containsSubstring(advisory_synopsis, search) : false
);
setDisplayedPatches(searchResult);
}, [patches, displayedAdvisories, search]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,24 @@ describe('HostRelevantPatchesPage', () => {
expect(screen.getByText(patch.advisory_synopsis)).toBeVisible();
});
});

it('should filter patch by content', async () => {
const user = userEvent.setup();

const patches = relevantPatchFactory.buildList(8);
const searchTerm = patches[0].advisory_synopsis;

const { container } = render(
<HostRelevantPatchesPage patches={patches} />
);

const searchInput = screen.getByRole('textbox');
await user.click(searchInput);
await user.type(searchInput, searchTerm);

const tableRows = container.querySelectorAll('tbody > tr');

expect(tableRows.length).toBe(1);
});
});
});
40 changes: 40 additions & 0 deletions assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { EOS_SEARCH } from 'eos-icons-react';

import UpgradablePackagesList from '@common/UpgradablePackagesList';
import PageHeader from '@common/PageHeader';
import Input from '@common/Input';
import { containsSubstring } from '@lib/filter';

export default function UpgradablePackages({ hostName, upgradablePackages }) {
const [search, setSearch] = useState('');

const displayedPackages = upgradablePackages.filter(
({ name, patches }) =>
containsSubstring(name, search) ||
patches
.map(({ advisory }) => containsSubstring(advisory, search))
.includes(true)
);

return (
<>
<div className="flex flex-wrap">
<div className="flex w-2/3 h-auto overflow-ellipsis break-words">
<PageHeader>
Upgradable packages: <span className="font-bold">{hostName}</span>
</PageHeader>
</div>
<div className="flex w-1/3 justify-end">
<Input
className="flex"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by Name or Patch"
prefix={<EOS_SEARCH size="l" />}
/>
</div>
</div>
<UpgradablePackagesList upgradablePackages={displayedPackages} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage';
import { hostFactory } from '@lib/test-utils/factories/hosts';

import UpgradablePackages from './UpgradablePackages';

export default {
title: 'Layouts/UpgradablePackages',
components: UpgradablePackages,
argTypes: {},
render: (args) => <UpgradablePackages {...args} />,
};

export const Default = {
args: {
hostName: hostFactory.build().hostname,
upgradablePackages: upgradablePackageFactory.buildList(5),
},
};
42 changes: 42 additions & 0 deletions assets/js/pages/UpgradablePackagesPage/UpgradablePackages.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import { renderWithRouter as render } from '@lib/test-utils';
import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage';

import UpgradablePackages from './UpgradablePackages';

describe('UpgradablePackages', () => {
it('shows all packages by default', () => {
const packages = upgradablePackageFactory.buildList(8);

const { container } = render(
<UpgradablePackages upgradablePackages={packages} />
);

const tableRows = container.querySelectorAll('tbody > tr');

expect(tableRows.length).toBe(8);
});

it('should filter package by its name', async () => {
const user = userEvent.setup();

const packages = upgradablePackageFactory.buildList(8);
const searchTerm = packages[0].name;

const { container } = render(
<UpgradablePackages upgradablePackages={packages} />
);

const searchInput = screen.getByRole('textbox');
await user.click(searchInput);
await user.type(searchInput, searchTerm);

const tableRows = container.querySelectorAll('tbody > tr');

expect(tableRows.length).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ import {
fetchUpgradablePackagesPatches,
} from '@state/softwareUpdates';

import PageHeader from '@common/PageHeader';
import BackButton from '@common/BackButton';
import UpgradablePackagesList from '@common/UpgradablePackagesList';
import UpgradablePackages from './UpgradablePackages';

function UpgradablePackagesPage() {
const { hostID } = useParams();
Expand Down Expand Up @@ -43,10 +42,10 @@ function UpgradablePackagesPage() {
return (
<>
<BackButton url={`/hosts/${hostID}`}>Back to Host Details</BackButton>
<PageHeader>
Upgradable packages: <span className="font-bold">{hostname}</span>
</PageHeader>
<UpgradablePackagesList upgradablePackages={upgradablePackages} />
<UpgradablePackages
hostName={hostname}
upgradablePackages={upgradablePackages}
/>
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage';
import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches';

import UpgradablePackagesPage from './UpgradablePackagesPage';
import UpgradablePackagesPage from '.';

describe('UpgradablePackagesPage', () => {
it('should render correctly', () => {
Expand Down

0 comments on commit 2465cf4

Please sign in to comment.