From 557a34c44e1478c69e92da09b064020d09d2bf50 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Mon, 10 Jun 2024 09:09:55 +0200 Subject: [PATCH 01/16] 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. --- assets/js/common/Table/Table.jsx | 12 ++- assets/js/common/Table/Table.stories.jsx | 47 +++++++++- assets/js/common/Table/Table.test.jsx | 108 +++++++++++++++++++++++ assets/js/common/Table/search.js | 25 ++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 assets/js/common/Table/search.js diff --git a/assets/js/common/Table/Table.jsx b/assets/js/common/Table/Table.jsx index c8b9879059..31e0de07a1 100644 --- a/assets/js/common/Table/Table.jsx +++ b/assets/js/common/Table/Table.jsx @@ -8,6 +8,7 @@ import { createFilter, TableFilters, } from './filters'; +import { searchByKey } from './search'; import { defaultRowKey } from './defaultRowKey'; import SortingIcon from './SortingIcon'; import EmptyState from './EmptyState'; @@ -59,6 +60,7 @@ function Table({ config, data = [], sortBy, + searchBy, searchParams, setSearchParams, emptyStateText = 'No data available', @@ -134,7 +136,15 @@ function Table({ }) .reduce((d, filterFunction) => d.filter(filterFunction), data); - const sortedData = sortBy ? [...filteredData].sort(sortBy) : filteredData; + const searchKeys = columns + .filter(({ searchable = false }) => searchable === true) + .map(({ key }) => key); + + const searchedData = searchBy + ? [...filteredData].filter((it) => searchByKey(it, searchBy, ...searchKeys)) + : filteredData; + + const sortedData = sortBy ? [...searchedData].sort(sortBy) : searchedData; const renderedData = pagination ? page(currentPage, sortedData) : sortedData; diff --git a/assets/js/common/Table/Table.stories.jsx b/assets/js/common/Table/Table.stories.jsx index 5ac004b33d..b33ea0f191 100644 --- a/assets/js/common/Table/Table.stories.jsx +++ b/assets/js/common/Table/Table.stories.jsx @@ -1,8 +1,10 @@ import React, { useState } from 'react'; -import { createStringSortingPredicate } from './sorting'; +import Input from '@common/Input'; 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 @@ -75,6 +77,39 @@ const filteredConfig = { ], }; +const searchConfig = { + usePadding: false, + columns: [ + { + title: 'User', + key: 'user', + searchable: true, + }, + { + title: 'Created At', + key: 'created_at', + }, + { + title: 'Role', + key: 'role', + searchable: true, + }, + { + title: 'Status', + key: 'status', + render: (content) => ( + + + ), + }, + ], +}; + const data = [ { user: 'Tony Kekw', @@ -184,6 +219,16 @@ export function WithFilters(args) { return ; } +export function WithSearch(args) { + const [search, setSearch] = useState(''); + return ( + <> + setSearch(e.target.value)} placeholder="Search" /> +
+ + ); +} + export function WithHeader(args) { return (
{ }); }); }); + + describe('search', () => { + it('should filter by the chosen column and partial search', () => { + const data = tableDataFactory.buildList(10); + + const columnsOverride = tableConfig.columns.map((it) => { + if (it.key === 'column1') { + return { + ...it, + searchable: true, + handleClick: noop, + }; + } + + return it; + }); + + const { container } = render( +
+ ); + + const tableRows = container.querySelectorAll('tbody > tr'); + + expect(tableRows.length).toBe(1); + }); + + it('should show all data on empty search', () => { + const data = tableDataFactory.buildList(10); + + const columnsOverride = tableConfig.columns.map((it) => { + if (it.key === 'column1') { + return { + ...it, + searchable: true, + handleClick: noop, + }; + } + + return it; + }); + + const { container } = render( +
+ ); + + const tableRows = container.querySelectorAll('tbody > tr'); + + expect(tableRows.length).toBe(10); + }); + + it('should filter multiple columns by input', () => { + const data = tableDataFactory + .buildList(10) + .map((it, n) => + n % 2 === 0 + ? { ...it, column2: 'even_column' } + : { ...it, column1: 'odd_column' } + ); + + const columnsOverride = tableConfig.columns.map((it) => { + if (it.key === 'column1' || it.key === 'column2') { + return { + ...it, + searchable: true, + handleClick: noop, + }; + } + + return it; + }); + + { + const { container } = render( +
+ ); + + const tableRows = container.querySelectorAll('tbody > tr'); + + expect(tableRows.length).toBe(5); + } + + { + const { container } = render( +
+ ); + + const tableRows = container.querySelectorAll('tbody > tr'); + + expect(tableRows.length).toBe(5); + } + }); + }); }); diff --git a/assets/js/common/Table/search.js b/assets/js/common/Table/search.js new file mode 100644 index 0000000000..f30fc1d43e --- /dev/null +++ b/assets/js/common/Table/search.js @@ -0,0 +1,25 @@ +const canonicalise = (val) => val.normalize().trim().toLowerCase(); + +const stringContains = (subject, predicate) => + canonicalise(subject).includes(canonicalise(predicate)); + +const arrayContains = (subject, predicate) => + subject + .map((it) => stringContains(it, predicate)) + .reduce((accumulator, currentValue) => accumulator || currentValue, false); + +const contains = (subject, predicate) => + Array.isArray(subject) + ? arrayContains(subject, predicate) + : stringContains(subject, predicate); + +export const searchByKey = (subject, predicate, ...key) => { + let found = false; + for (const it of key) { + if (Object.hasOwn(subject, it)) { + found ||= contains(subject[it] ?? '', predicate); + } + } + + return found; +}; From e5df506674c2016331ed0421fa83d6c84af995c7 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Mon, 10 Jun 2024 09:26:53 +0200 Subject: [PATCH 02/16] Functional JavaScript aka. fix ESLint Prefer array iteration methods over for loops --- assets/js/common/Table/search.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/assets/js/common/Table/search.js b/assets/js/common/Table/search.js index f30fc1d43e..e5dfefb265 100644 --- a/assets/js/common/Table/search.js +++ b/assets/js/common/Table/search.js @@ -13,13 +13,11 @@ const contains = (subject, predicate) => ? arrayContains(subject, predicate) : stringContains(subject, predicate); -export const searchByKey = (subject, predicate, ...key) => { - let found = false; - for (const it of key) { - if (Object.hasOwn(subject, it)) { - found ||= contains(subject[it] ?? '', predicate); - } - } - - return found; -}; +export const searchByKey = (subject, predicate, ...key) => + key + .map((it) => + Object.hasOwn(subject, it) + ? contains(subject[it] ?? '', predicate) + : false + ) + .reduce((accumulator, currentValue) => accumulator || currentValue, false); From 44b9b0084145cdc205ee77dc1f2daa526cba025e Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Mon, 10 Jun 2024 14:49:30 +0200 Subject: [PATCH 03/16] Implement changes proposed by Jamie Thanks for the feedback. I agree with all of it. --- assets/js/common/Table/Table.jsx | 6 +++--- assets/js/common/Table/search.js | 30 +++++++++++++----------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/assets/js/common/Table/Table.jsx b/assets/js/common/Table/Table.jsx index 31e0de07a1..05ff5cedf3 100644 --- a/assets/js/common/Table/Table.jsx +++ b/assets/js/common/Table/Table.jsx @@ -8,7 +8,7 @@ import { createFilter, TableFilters, } from './filters'; -import { searchByKey } from './search'; +import { search } from './search'; import { defaultRowKey } from './defaultRowKey'; import SortingIcon from './SortingIcon'; import EmptyState from './EmptyState'; @@ -137,11 +137,11 @@ function Table({ .reduce((d, filterFunction) => d.filter(filterFunction), data); const searchKeys = columns - .filter(({ searchable = false }) => searchable === true) + .filter(({ searchable = false }) => searchable) .map(({ key }) => key); const searchedData = searchBy - ? [...filteredData].filter((it) => searchByKey(it, searchBy, ...searchKeys)) + ? [...filteredData].filter((it) => search(it, searchBy, searchKeys)) : filteredData; const sortedData = sortBy ? [...searchedData].sort(sortBy) : searchedData; diff --git a/assets/js/common/Table/search.js b/assets/js/common/Table/search.js index e5dfefb265..c9ad35add6 100644 --- a/assets/js/common/Table/search.js +++ b/assets/js/common/Table/search.js @@ -1,23 +1,19 @@ -const canonicalise = (val) => val.normalize().trim().toLowerCase(); +const regularize = (val) => val.normalize().trim().toLowerCase(); -const stringContains = (subject, predicate) => - canonicalise(subject).includes(canonicalise(predicate)); +const stringContains = (str, substring) => + regularize(str).includes(regularize(substring)); -const arrayContains = (subject, predicate) => - subject - .map((it) => stringContains(it, predicate)) - .reduce((accumulator, currentValue) => accumulator || currentValue, false); +const arrayContains = (arr, substring) => + arr.map((str) => stringContains(str, substring)).includes(true); -const contains = (subject, predicate) => - Array.isArray(subject) - ? arrayContains(subject, predicate) - : stringContains(subject, predicate); +const contains = (str, substring) => + Array.isArray(str) + ? arrayContains(str, substring) + : stringContains(str, substring); -export const searchByKey = (subject, predicate, ...key) => - key +export const search = (row, searchTerm, columnKeys) => + columnKeys .map((it) => - Object.hasOwn(subject, it) - ? contains(subject[it] ?? '', predicate) - : false + Object.hasOwn(row, it) ? contains(row[it] ?? '', searchTerm) : false ) - .reduce((accumulator, currentValue) => accumulator || currentValue, false); + .includes(true); From def78b848bcbaafd23291899dace381913037189 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 09:57:51 +0200 Subject: [PATCH 04/16] 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. --- assets/js/common/Table/Table.jsx | 12 +-- assets/js/common/Table/Table.stories.jsx | 44 --------- assets/js/common/Table/Table.test.jsx | 108 ----------------------- assets/js/lib/filter/index.js | 4 + 4 files changed, 5 insertions(+), 163 deletions(-) create mode 100644 assets/js/lib/filter/index.js diff --git a/assets/js/common/Table/Table.jsx b/assets/js/common/Table/Table.jsx index 05ff5cedf3..c8b9879059 100644 --- a/assets/js/common/Table/Table.jsx +++ b/assets/js/common/Table/Table.jsx @@ -8,7 +8,6 @@ import { createFilter, TableFilters, } from './filters'; -import { search } from './search'; import { defaultRowKey } from './defaultRowKey'; import SortingIcon from './SortingIcon'; import EmptyState from './EmptyState'; @@ -60,7 +59,6 @@ function Table({ config, data = [], sortBy, - searchBy, searchParams, setSearchParams, emptyStateText = 'No data available', @@ -136,15 +134,7 @@ function Table({ }) .reduce((d, filterFunction) => d.filter(filterFunction), data); - const searchKeys = columns - .filter(({ searchable = false }) => searchable) - .map(({ key }) => key); - - const searchedData = searchBy - ? [...filteredData].filter((it) => search(it, searchBy, searchKeys)) - : filteredData; - - const sortedData = sortBy ? [...searchedData].sort(sortBy) : searchedData; + const sortedData = sortBy ? [...filteredData].sort(sortBy) : filteredData; const renderedData = pagination ? page(currentPage, sortedData) : sortedData; diff --git a/assets/js/common/Table/Table.stories.jsx b/assets/js/common/Table/Table.stories.jsx index b33ea0f191..f00d20645b 100644 --- a/assets/js/common/Table/Table.stories.jsx +++ b/assets/js/common/Table/Table.stories.jsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; -import Input from '@common/Input'; import Table from '.'; import { createStringSortingPredicate } from './sorting'; @@ -77,39 +76,6 @@ const filteredConfig = { ], }; -const searchConfig = { - usePadding: false, - columns: [ - { - title: 'User', - key: 'user', - searchable: true, - }, - { - title: 'Created At', - key: 'created_at', - }, - { - title: 'Role', - key: 'role', - searchable: true, - }, - { - title: 'Status', - key: 'status', - render: (content) => ( - - - ), - }, - ], -}; - const data = [ { user: 'Tony Kekw', @@ -219,16 +185,6 @@ export function WithFilters(args) { return
; } -export function WithSearch(args) { - const [search, setSearch] = useState(''); - return ( - <> - setSearch(e.target.value)} placeholder="Search" /> -
- - ); -} - export function WithHeader(args) { return (
{ }); }); }); - - describe('search', () => { - it('should filter by the chosen column and partial search', () => { - const data = tableDataFactory.buildList(10); - - const columnsOverride = tableConfig.columns.map((it) => { - if (it.key === 'column1') { - return { - ...it, - searchable: true, - handleClick: noop, - }; - } - - return it; - }); - - const { container } = render( -
- ); - - const tableRows = container.querySelectorAll('tbody > tr'); - - expect(tableRows.length).toBe(1); - }); - - it('should show all data on empty search', () => { - const data = tableDataFactory.buildList(10); - - const columnsOverride = tableConfig.columns.map((it) => { - if (it.key === 'column1') { - return { - ...it, - searchable: true, - handleClick: noop, - }; - } - - return it; - }); - - const { container } = render( -
- ); - - const tableRows = container.querySelectorAll('tbody > tr'); - - expect(tableRows.length).toBe(10); - }); - - it('should filter multiple columns by input', () => { - const data = tableDataFactory - .buildList(10) - .map((it, n) => - n % 2 === 0 - ? { ...it, column2: 'even_column' } - : { ...it, column1: 'odd_column' } - ); - - const columnsOverride = tableConfig.columns.map((it) => { - if (it.key === 'column1' || it.key === 'column2') { - return { - ...it, - searchable: true, - handleClick: noop, - }; - } - - return it; - }); - - { - const { container } = render( -
- ); - - const tableRows = container.querySelectorAll('tbody > tr'); - - expect(tableRows.length).toBe(5); - } - - { - const { container } = render( -
- ); - - const tableRows = container.querySelectorAll('tbody > tr'); - - expect(tableRows.length).toBe(5); - } - }); - }); }); diff --git a/assets/js/lib/filter/index.js b/assets/js/lib/filter/index.js new file mode 100644 index 0000000000..7dd54e08d3 --- /dev/null +++ b/assets/js/lib/filter/index.js @@ -0,0 +1,4 @@ +const regularizeString = (str) => str.normalize().trim().toLowerCase(); + +export const foundStringNaive = (str = '', substring = '') => + regularizeString(str).includes(regularizeString(substring)); From 2e41932d8e42f0137e611386803c93a8c76a58c8 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 10:03:35 +0200 Subject: [PATCH 05/16] Move HostRelevantPatchesPage to new search API --- .../HostRelevantPatchesPage.jsx | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx index 44f867f85d..919cef1f1e 100644 --- a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx +++ b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx @@ -7,6 +7,8 @@ import Input from '@common/Input'; import Select from '@common/Select'; import Button from '@common/Button'; +import { foundStringNaive } from '@lib/filter'; + const advisoryTypesFromPatches = (patches) => Array.from(new Set(patches.map(({ advisory_type }) => advisory_type))).sort(); @@ -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 HostRelevanPatches({ hostName, onNavigate, patches }) { const advisoryTypes = ['all'].concat(advisoryTypesFromPatches(patches)); @@ -32,12 +26,15 @@ function HostRelevanPatches({ hostName, onNavigate, patches }) { const [displayedPatches, setDisplayedPatches] = useState(patches); useEffect(() => { - setDisplayedPatches( - filterPatchesBySynopsis( - filterPatchesByAdvisoryType(patches, displayedAdvisories), - search - ) + const filteredByAdvisoryKind = filterPatchesByAdvisoryType( + patches, + displayedAdvisories + ); + const searchResult = filteredByAdvisoryKind.filter( + ({ advisory_synopsis }) => + advisory_synopsis ? foundStringNaive(advisory_synopsis, search) : false ); + setDisplayedPatches(searchResult); }, [patches, displayedAdvisories, search]); return ( From 54109f381b4d564d5211dcb286ad38a4dc3ec5a4 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 10:06:47 +0200 Subject: [PATCH 06/16] 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. --- .../js/pages/UpgradablePackagesPage/Page.jsx | 53 ++++++++++++++ .../UpgradablePackagesPage.jsx | 69 +++++++++---------- .../UpgradablePackagesPage.stories.jsx | 20 ++++++ .../UpgradablePackagesPage.test.jsx | 2 +- .../js/pages/UpgradablePackagesPage/index.js | 4 +- 5 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 assets/js/pages/UpgradablePackagesPage/Page.jsx create mode 100644 assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx diff --git a/assets/js/pages/UpgradablePackagesPage/Page.jsx b/assets/js/pages/UpgradablePackagesPage/Page.jsx new file mode 100644 index 0000000000..052a3d5ccd --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/Page.jsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { get } from 'lodash'; + +import { getHost } from '@state/selectors/host'; +import { getUpgradablePackages } from '@state/selectors/softwareUpdates'; +import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings'; +import { + fetchSoftwareUpdates, + fetchUpgradablePackagesPatches, +} from '@state/softwareUpdates'; + +import BackButton from '@common/BackButton'; +import UpgradablePackagesPage from './UpgradablePackagesPage'; + +function Page() { + const { hostID } = useParams(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchSoftwareUpdates(hostID)); + dispatch(fetchSoftwareUpdatesSettings()); + }, []); + + const host = useSelector(getHost(hostID)); + + const hostname = get(host, 'hostname', ''); + + const upgradablePackages = useSelector((state) => + getUpgradablePackages(state, hostID) + ); + + useEffect(() => { + const packageIDs = upgradablePackages.map( + ({ to_package_id: packageID }) => packageID + ); + + dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs })); + }, [upgradablePackages.length, hostID]); + + return ( + <> + Back to Host Details + + + ); +} + +export default Page; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx index 2b2983d7b4..f674b01fcf 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx @@ -1,44 +1,43 @@ -import React, { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useState } from 'react'; +import { EOS_SEARCH } from 'eos-icons-react'; -import { getUpgradablePackages } from '@state/selectors/softwareUpdates'; -import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings'; -import { - fetchSoftwareUpdates, - fetchUpgradablePackagesPatches, -} from '@state/softwareUpdates'; - -import BackButton from '@common/BackButton'; import UpgradablePackagesList from '@common/UpgradablePackagesList'; - -function UpgradablePackagesPage() { - const { hostID } = useParams(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(fetchSoftwareUpdates(hostID)); - dispatch(fetchSoftwareUpdatesSettings()); - }, []); - - const upgradablePackages = useSelector((state) => - getUpgradablePackages(state, hostID) +import PageHeader from '@common/PageHeader'; +import Input from '@common/Input'; +import { foundStringNaive } from '@lib/filter'; + +export default function UpgradablePackagesPage({ + hostName, + upgradablePackages, +}) { + const [search, setSearch] = useState(''); + + const displayedPackages = upgradablePackages.filter( + ({ name, patches }) => + foundStringNaive(name, search) || + patches + .map(({ advisory }) => foundStringNaive(advisory, search)) + .includes(true) ); - useEffect(() => { - const packageIDs = upgradablePackages.map( - ({ to_package_id: packageID }) => packageID - ); - - dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs })); - }, [upgradablePackages.length, hostID]); - return ( <> - Back to Host Details - +
+
+ + Upgradable packages: {hostName} + +
+
+ setSearch(e.target.value)} + placeholder="Search by Name or Patch" + prefix={} + /> +
+
+ ); } - -export default UpgradablePackagesPage; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx new file mode 100644 index 0000000000..5dd30882c0 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx @@ -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 UpgradablePackagesPage from './UpgradablePackagesPage'; + +export default { + title: 'Layouts/UpgradablePackagesPage', + components: UpgradablePackagesPage, + argTypes: {}, + render: (args) => , +}; + +export const Default = { + args: { + hostName: hostFactory.build().hostname, + upgradablePackages: upgradablePackageFactory.buildList(5), + }, +}; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx index c2818d79c0..815ee8ad46 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx @@ -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', () => { diff --git a/assets/js/pages/UpgradablePackagesPage/index.js b/assets/js/pages/UpgradablePackagesPage/index.js index 3fde2855a7..12c3418bbc 100644 --- a/assets/js/pages/UpgradablePackagesPage/index.js +++ b/assets/js/pages/UpgradablePackagesPage/index.js @@ -1,3 +1,3 @@ -import UpgradablePackagesPage from './UpgradablePackagesPage'; +import Page from './Page'; -export default UpgradablePackagesPage; +export default Page; From e562fa375f33bb7283bf79988b8169797afb7992 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 10:09:29 +0200 Subject: [PATCH 07/16] 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()". --- assets/js/lib/test-utils/index.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/js/lib/test-utils/index.jsx b/assets/js/lib/test-utils/index.jsx index 2d4b3c3194..43f2226dc6 100644 --- a/assets/js/lib/test-utils/index.jsx +++ b/assets/js/lib/test-utils/index.jsx @@ -86,3 +86,8 @@ export async function recordSaga(saga, initialAction, state = {}) { return dispatched; } + +export const inspect = (val) => { + console.dir(val); + return val; +}; From de58640ef80a22e4bbfa19b8d8d3e6175a9ecc3d Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 10:56:15 +0200 Subject: [PATCH 08/16] Add test fro search --- assets/js/lib/filter/index.test.js | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 assets/js/lib/filter/index.test.js diff --git a/assets/js/lib/filter/index.test.js b/assets/js/lib/filter/index.test.js new file mode 100644 index 0000000000..a5da2935c1 --- /dev/null +++ b/assets/js/lib/filter/index.test.js @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; + +import { foundStringNaive } from '.'; + +describe('search', () => { + it('should always match with an empty search string', () => { + expect(foundStringNaive('', '')).toBe(true); + expect(foundStringNaive(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(foundStringNaive(original, upper)).toBe(true); + expect(foundStringNaive(original, lower)).toBe(true); + }); + + it('should match substrings', () => { + const original = faker.word.words(1); + const sub = original.substring(original.length / 2); + + expect(foundStringNaive(original, sub)).toBe(true); + }); + + it('should not match not included words', () => { + const words = faker.word.words(2).split(' '); + + expect(foundStringNaive(words[0], words[1])).toBe(false); + expect(foundStringNaive('', 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(foundStringNaive(name1, name2)).toBe(true); + }); +}); From fe0b2d559c7ee5a9ff577051baf4382718526d61 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 14:11:42 +0200 Subject: [PATCH 09/16] Add test for filtering in pages This commit adds tests for filtering the UpgradablePackagesPage and HostRelevantPatchesPage by their content via search. --- .../HostRelevantPatchesPage.test.jsx | 27 ++++++-- .../UpgradablePackagesPage/Page.test.jsx | 45 ++++++++++++ .../UpgradablePackagesPage.test.jsx | 69 +++++++++---------- 3 files changed, 101 insertions(+), 40 deletions(-) create mode 100644 assets/js/pages/UpgradablePackagesPage/Page.test.jsx diff --git a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx index f7bb55067d..6634f5bc28 100644 --- a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx +++ b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx @@ -104,14 +104,33 @@ describe('HostRelevantPatchesPage', () => { render(); - const advisorySelect = screen.getByRole('button', { name: 'all' }); - await user.click(advisorySelect); - const advisoryOption = screen.getByRole('option', { name: filteredType }); - await user.click(advisoryOption); + const advisoryselect = screen.getByRole('button', { name: 'all' }); + await user.click(advisoryselect); + const advisoryoption = screen.getByRole('option', { name: filteredType }); + await user.click(advisoryoption); expectedPatches.forEach((patch) => { 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( + + ); + + 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); + }); }); }); diff --git a/assets/js/pages/UpgradablePackagesPage/Page.test.jsx b/assets/js/pages/UpgradablePackagesPage/Page.test.jsx new file mode 100644 index 0000000000..815ee8ad46 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/Page.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { faker } from '@faker-js/faker'; + +import { + renderWithRouterMatch, + withState, + defaultInitialState, +} from '@lib/test-utils'; +import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; +import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches'; + +import UpgradablePackagesPage from '.'; + +describe('UpgradablePackagesPage', () => { + it('should render correctly', () => { + const hostID = faker.string.uuid(); + const patch = patchForPackageFactory.build(); + const upgradablePackages = upgradablePackageFactory.buildList(10, { + patches: [patch], + }); + const [{ name }] = upgradablePackages; + + const [StatefulPage] = withState(, { + ...defaultInitialState, + softwareUpdates: { + softwareUpdates: { + [hostID]: { + loading: false, + errors: [], + upgradable_packages: upgradablePackages, + }, + }, + }, + }); + + renderWithRouterMatch(StatefulPage, { + path: 'hosts/:hostID/packages', + route: `/hosts/${hostID}/packages`, + }); + + expect(screen.getAllByText(name, { exact: false })).toHaveLength(2); + }); +}); diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx index 815ee8ad46..c2a5d01283 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx @@ -1,45 +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 { faker } from '@faker-js/faker'; - -import { - renderWithRouterMatch, - withState, - defaultInitialState, -} from '@lib/test-utils'; +import { renderWithRouter as render } from '@lib/test-utils'; import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; -import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches'; -import UpgradablePackagesPage from '.'; +import UpgradablePackagesPage from './UpgradablePackagesPage'; describe('UpgradablePackagesPage', () => { - it('should render correctly', () => { - const hostID = faker.string.uuid(); - const patch = patchForPackageFactory.build(); - const upgradablePackages = upgradablePackageFactory.buildList(10, { - patches: [patch], - }); - const [{ name }] = upgradablePackages; - - const [StatefulPage] = withState(, { - ...defaultInitialState, - softwareUpdates: { - softwareUpdates: { - [hostID]: { - loading: false, - errors: [], - upgradable_packages: upgradablePackages, - }, - }, - }, - }); - - renderWithRouterMatch(StatefulPage, { - path: 'hosts/:hostID/packages', - route: `/hosts/${hostID}/packages`, - }); - - expect(screen.getAllByText(name, { exact: false })).toHaveLength(2); + it('shows all packages by default', () => { + const packages = upgradablePackageFactory.buildList(8); + + const { container } = render( + + ); + + 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( + + ); + + 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); }); }); From 689f21b33cddf673490d68d142136d3c5712f864 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 14:14:49 +0200 Subject: [PATCH 10/16] Remove dead code This commit completely removes the search API from the Table. --- assets/js/common/Table/search.js | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 assets/js/common/Table/search.js diff --git a/assets/js/common/Table/search.js b/assets/js/common/Table/search.js deleted file mode 100644 index c9ad35add6..0000000000 --- a/assets/js/common/Table/search.js +++ /dev/null @@ -1,19 +0,0 @@ -const regularize = (val) => val.normalize().trim().toLowerCase(); - -const stringContains = (str, substring) => - regularize(str).includes(regularize(substring)); - -const arrayContains = (arr, substring) => - arr.map((str) => stringContains(str, substring)).includes(true); - -const contains = (str, substring) => - Array.isArray(str) - ? arrayContains(str, substring) - : stringContains(str, substring); - -export const search = (row, searchTerm, columnKeys) => - columnKeys - .map((it) => - Object.hasOwn(row, it) ? contains(row[it] ?? '', searchTerm) : false - ) - .includes(true); From cd80e1f62ea00a3a7c2ab5c77bd3ea2ceb313f3d Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 10:06:47 +0200 Subject: [PATCH 11/16] 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. --- .../UpgradablePackagesPage/UpgradablePackagesPage.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx index c2a5d01283..f769895e3c 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx @@ -6,7 +6,7 @@ import '@testing-library/jest-dom'; import { renderWithRouter as render } from '@lib/test-utils'; import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; -import UpgradablePackagesPage from './UpgradablePackagesPage'; +import UpgradablePackagesPage from '.'; describe('UpgradablePackagesPage', () => { it('shows all packages by default', () => { From a8b9e6afae5c0881a3ef2e08e0bce02ba5851793 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 15:51:53 +0200 Subject: [PATCH 12/16] Keep consistent casing --- .../HostRelevantPatches/HostRelevantPatchesPage.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx index 6634f5bc28..67363e96e9 100644 --- a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx +++ b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx @@ -104,8 +104,8 @@ describe('HostRelevantPatchesPage', () => { render(); - const advisoryselect = screen.getByRole('button', { name: 'all' }); - await user.click(advisoryselect); + const advisorySelect = screen.getByRole('button', { name: 'all' }); + await user.click(advisorySelect); const advisoryoption = screen.getByRole('option', { name: filteredType }); await user.click(advisoryoption); From d7d71a09c34a0eba9ca37848ab2029efe74a39d6 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 16:06:52 +0200 Subject: [PATCH 13/16] Fix case --- .../HostRelevantPatches/HostRelevantPatchesPage.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx index 67363e96e9..84499edc8b 100644 --- a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx +++ b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.test.jsx @@ -106,8 +106,8 @@ describe('HostRelevantPatchesPage', () => { const advisorySelect = screen.getByRole('button', { name: 'all' }); await user.click(advisorySelect); - const advisoryoption = screen.getByRole('option', { name: filteredType }); - await user.click(advisoryoption); + const advisoryOption = screen.getByRole('option', { name: filteredType }); + await user.click(advisoryOption); expectedPatches.forEach((patch) => { expect(screen.getByText(patch.advisory_synopsis)).toBeVisible(); From e61395a821f9aff044159745f7357b086db2530a Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 16:24:44 +0200 Subject: [PATCH 14/16] Rename Page and UpgradablePackages --- .../js/pages/UpgradablePackagesPage/Page.jsx | 53 ------------ .../UpgradablePackagesPage/Page.test.jsx | 45 ---------- .../UpgradablePackages.jsx | 43 ++++++++++ ...ies.jsx => UpgradablePackages.stories.jsx} | 8 +- .../UpgradablePackages.test.jsx | 42 ++++++++++ .../UpgradablePackagesPage.jsx | 82 +++++++++++-------- .../UpgradablePackagesPage.test.jsx | 67 +++++++-------- .../js/pages/UpgradablePackagesPage/index.js | 4 +- 8 files changed, 172 insertions(+), 172 deletions(-) delete mode 100644 assets/js/pages/UpgradablePackagesPage/Page.jsx delete mode 100644 assets/js/pages/UpgradablePackagesPage/Page.test.jsx create mode 100644 assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx rename assets/js/pages/UpgradablePackagesPage/{UpgradablePackagesPage.stories.jsx => UpgradablePackages.stories.jsx} (64%) create mode 100644 assets/js/pages/UpgradablePackagesPage/UpgradablePackages.test.jsx diff --git a/assets/js/pages/UpgradablePackagesPage/Page.jsx b/assets/js/pages/UpgradablePackagesPage/Page.jsx deleted file mode 100644 index 052a3d5ccd..0000000000 --- a/assets/js/pages/UpgradablePackagesPage/Page.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; -import { get } from 'lodash'; - -import { getHost } from '@state/selectors/host'; -import { getUpgradablePackages } from '@state/selectors/softwareUpdates'; -import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings'; -import { - fetchSoftwareUpdates, - fetchUpgradablePackagesPatches, -} from '@state/softwareUpdates'; - -import BackButton from '@common/BackButton'; -import UpgradablePackagesPage from './UpgradablePackagesPage'; - -function Page() { - const { hostID } = useParams(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(fetchSoftwareUpdates(hostID)); - dispatch(fetchSoftwareUpdatesSettings()); - }, []); - - const host = useSelector(getHost(hostID)); - - const hostname = get(host, 'hostname', ''); - - const upgradablePackages = useSelector((state) => - getUpgradablePackages(state, hostID) - ); - - useEffect(() => { - const packageIDs = upgradablePackages.map( - ({ to_package_id: packageID }) => packageID - ); - - dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs })); - }, [upgradablePackages.length, hostID]); - - return ( - <> - Back to Host Details - - - ); -} - -export default Page; diff --git a/assets/js/pages/UpgradablePackagesPage/Page.test.jsx b/assets/js/pages/UpgradablePackagesPage/Page.test.jsx deleted file mode 100644 index 815ee8ad46..0000000000 --- a/assets/js/pages/UpgradablePackagesPage/Page.test.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; - -import { faker } from '@faker-js/faker'; - -import { - renderWithRouterMatch, - withState, - defaultInitialState, -} from '@lib/test-utils'; -import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; -import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches'; - -import UpgradablePackagesPage from '.'; - -describe('UpgradablePackagesPage', () => { - it('should render correctly', () => { - const hostID = faker.string.uuid(); - const patch = patchForPackageFactory.build(); - const upgradablePackages = upgradablePackageFactory.buildList(10, { - patches: [patch], - }); - const [{ name }] = upgradablePackages; - - const [StatefulPage] = withState(, { - ...defaultInitialState, - softwareUpdates: { - softwareUpdates: { - [hostID]: { - loading: false, - errors: [], - upgradable_packages: upgradablePackages, - }, - }, - }, - }); - - renderWithRouterMatch(StatefulPage, { - path: 'hosts/:hostID/packages', - route: `/hosts/${hostID}/packages`, - }); - - expect(screen.getAllByText(name, { exact: false })).toHaveLength(2); - }); -}); diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx new file mode 100644 index 0000000000..cc581e0116 --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx @@ -0,0 +1,43 @@ +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 { foundStringNaive } from '@lib/filter'; + +export default function UpgradablePackages({ + hostName, + upgradablePackages, +}) { + const [search, setSearch] = useState(''); + + const displayedPackages = upgradablePackages.filter( + ({ name, patches }) => + foundStringNaive(name, search) || + patches + .map(({ advisory }) => foundStringNaive(advisory, search)) + .includes(true) + ); + + return ( + <> +
+
+ + Upgradable packages: {hostName} + +
+
+ setSearch(e.target.value)} + placeholder="Search by Name or Patch" + prefix={} + /> +
+
+ + + ); +} diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.stories.jsx similarity index 64% rename from assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx rename to assets/js/pages/UpgradablePackagesPage/UpgradablePackages.stories.jsx index 5dd30882c0..f88eea473f 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.stories.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.stories.jsx @@ -3,13 +3,13 @@ import React from 'react'; import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; import { hostFactory } from '@lib/test-utils/factories/hosts'; -import UpgradablePackagesPage from './UpgradablePackagesPage'; +import UpgradablePackages from './UpgradablePackages'; export default { - title: 'Layouts/UpgradablePackagesPage', - components: UpgradablePackagesPage, + title: 'Layouts/UpgradablePackages', + components: UpgradablePackages, argTypes: {}, - render: (args) => , + render: (args) => , }; export const Default = { diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.test.jsx new file mode 100644 index 0000000000..06e860506d --- /dev/null +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.test.jsx @@ -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( + + ); + + 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( + + ); + + 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); + }); +}); diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx index f674b01fcf..5a4661a812 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx @@ -1,43 +1,53 @@ -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 { foundStringNaive } from '@lib/filter'; - -export default function UpgradablePackagesPage({ - hostName, - upgradablePackages, -}) { - const [search, setSearch] = useState(''); - - const displayedPackages = upgradablePackages.filter( - ({ name, patches }) => - foundStringNaive(name, search) || - patches - .map(({ advisory }) => foundStringNaive(advisory, search)) - .includes(true) +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { get } from 'lodash'; + +import { getHost } from '@state/selectors/host'; +import { getUpgradablePackages } from '@state/selectors/softwareUpdates'; +import { fetchSoftwareUpdatesSettings } from '@state/softwareUpdatesSettings'; +import { + fetchSoftwareUpdates, + fetchUpgradablePackagesPatches, +} from '@state/softwareUpdates'; + +import BackButton from '@common/BackButton'; +import UpgradablePackages from './UpgradablePackages'; + +function UpgradablePackagesPage() { + const { hostID } = useParams(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchSoftwareUpdates(hostID)); + dispatch(fetchSoftwareUpdatesSettings()); + }, []); + + const host = useSelector(getHost(hostID)); + + const hostname = get(host, 'hostname', ''); + + const upgradablePackages = useSelector((state) => + getUpgradablePackages(state, hostID) ); + useEffect(() => { + const packageIDs = upgradablePackages.map( + ({ to_package_id: packageID }) => packageID + ); + + dispatch(fetchUpgradablePackagesPatches({ hostID, packageIDs })); + }, [upgradablePackages.length, hostID]); + return ( <> -
-
- - Upgradable packages: {hostName} - -
-
- setSearch(e.target.value)} - placeholder="Search by Name or Patch" - prefix={} - /> -
-
- + Back to Host Details + ); } + +export default UpgradablePackagesPage; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx index f769895e3c..815ee8ad46 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.test.jsx @@ -1,42 +1,45 @@ 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 { faker } from '@faker-js/faker'; + +import { + renderWithRouterMatch, + withState, + defaultInitialState, +} from '@lib/test-utils'; import { upgradablePackageFactory } from '@lib/test-utils/factories/upgradablePackage'; +import { patchForPackageFactory } from '@lib/test-utils/factories/relevantPatches'; import UpgradablePackagesPage from '.'; describe('UpgradablePackagesPage', () => { - it('shows all packages by default', () => { - const packages = upgradablePackageFactory.buildList(8); - - const { container } = render( - - ); - - 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( - - ); - - 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); + it('should render correctly', () => { + const hostID = faker.string.uuid(); + const patch = patchForPackageFactory.build(); + const upgradablePackages = upgradablePackageFactory.buildList(10, { + patches: [patch], + }); + const [{ name }] = upgradablePackages; + + const [StatefulPage] = withState(, { + ...defaultInitialState, + softwareUpdates: { + softwareUpdates: { + [hostID]: { + loading: false, + errors: [], + upgradable_packages: upgradablePackages, + }, + }, + }, + }); + + renderWithRouterMatch(StatefulPage, { + path: 'hosts/:hostID/packages', + route: `/hosts/${hostID}/packages`, + }); + + expect(screen.getAllByText(name, { exact: false })).toHaveLength(2); }); }); diff --git a/assets/js/pages/UpgradablePackagesPage/index.js b/assets/js/pages/UpgradablePackagesPage/index.js index 12c3418bbc..55c7616af7 100644 --- a/assets/js/pages/UpgradablePackagesPage/index.js +++ b/assets/js/pages/UpgradablePackagesPage/index.js @@ -1,3 +1,3 @@ -import Page from './Page'; +import UpgradablePackagesPage from './UpgradablePackagesPage'; -export default Page; +export default UpgradablePackagesPage From a071650ef1e6411d7eddccffd2a6b06047459498 Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 16:36:07 +0200 Subject: [PATCH 15/16] Fix ESLint --- assets/js/lib/test-utils/index.jsx | 2 +- .../js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/js/lib/test-utils/index.jsx b/assets/js/lib/test-utils/index.jsx index 43f2226dc6..95db0212b2 100644 --- a/assets/js/lib/test-utils/index.jsx +++ b/assets/js/lib/test-utils/index.jsx @@ -88,6 +88,6 @@ export async function recordSaga(saga, initialAction, state = {}) { } export const inspect = (val) => { - console.dir(val); + console.dir(val); // eslint-disable-line no-console return val; }; diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx index 8f70377c6a..5a4661a812 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackagesPage.jsx @@ -11,7 +11,6 @@ import { fetchUpgradablePackagesPatches, } from '@state/softwareUpdates'; -import PageHeader from '@common/PageHeader'; import BackButton from '@common/BackButton'; import UpgradablePackages from './UpgradablePackages'; From 6c6c6702db68b3d10deb4ae702ba6218b80cad7f Mon Sep 17 00:00:00 2001 From: Jan Fooken Date: Thu, 13 Jun 2024 17:07:36 +0200 Subject: [PATCH 16/16] Implement changes proposed by Jamie --- assets/js/lib/filter/index.js | 2 +- assets/js/lib/filter/index.test.js | 18 +++++++++--------- .../HostRelevantPatchesPage.jsx | 8 ++++---- .../UpgradablePackages.jsx | 11 ++++------- .../js/pages/UpgradablePackagesPage/index.js | 2 +- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/assets/js/lib/filter/index.js b/assets/js/lib/filter/index.js index 7dd54e08d3..36b803898d 100644 --- a/assets/js/lib/filter/index.js +++ b/assets/js/lib/filter/index.js @@ -1,4 +1,4 @@ const regularizeString = (str) => str.normalize().trim().toLowerCase(); -export const foundStringNaive = (str = '', substring = '') => +export const containsSubstring = (str = '', substring = '') => regularizeString(str).includes(regularizeString(substring)); diff --git a/assets/js/lib/filter/index.test.js b/assets/js/lib/filter/index.test.js index a5da2935c1..baffb8d219 100644 --- a/assets/js/lib/filter/index.test.js +++ b/assets/js/lib/filter/index.test.js @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker'; -import { foundStringNaive } from '.'; +import { containsSubstring } from '.'; describe('search', () => { it('should always match with an empty search string', () => { - expect(foundStringNaive('', '')).toBe(true); - expect(foundStringNaive(faker.word.words(10), '')).toBe(true); + expect(containsSubstring('', '')).toBe(true); + expect(containsSubstring(faker.word.words(10), '')).toBe(true); }); it('should match strings case in an insensitive fashion', () => { @@ -13,28 +13,28 @@ describe('search', () => { const upper = original.toUpperCase(); const lower = original.toLowerCase(); - expect(foundStringNaive(original, upper)).toBe(true); - expect(foundStringNaive(original, lower)).toBe(true); + 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(foundStringNaive(original, sub)).toBe(true); + expect(containsSubstring(original, sub)).toBe(true); }); it('should not match not included words', () => { const words = faker.word.words(2).split(' '); - expect(foundStringNaive(words[0], words[1])).toBe(false); - expect(foundStringNaive('', words[1])).toBe(false); + 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(foundStringNaive(name1, name2)).toBe(true); + expect(containsSubstring(name1, name2)).toBe(true); }); }); diff --git a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx index bb3b46b225..c44683f8c7 100644 --- a/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx +++ b/assets/js/pages/HostRelevantPatches/HostRelevantPatchesPage.jsx @@ -7,7 +7,7 @@ import Input from '@common/Input'; import Select from '@common/Select'; import Button from '@common/Button'; -import { foundStringNaive } from '@lib/filter'; +import { containsSubstring } from '@lib/filter'; const advisoryTypesFromPatches = (patches) => Array.from(new Set(patches.map(({ advisory_type }) => advisory_type))).sort(); @@ -26,13 +26,13 @@ function HostRelevantPatches({ hostName, onNavigate, patches }) { const [displayedPatches, setDisplayedPatches] = useState(patches); useEffect(() => { - const filteredByAdvisoryKind = filterPatchesByAdvisoryType( + const filteredByAdvisoryType = filterPatchesByAdvisoryType( patches, displayedAdvisories ); - const searchResult = filteredByAdvisoryKind.filter( + const searchResult = filteredByAdvisoryType.filter( ({ advisory_synopsis }) => - advisory_synopsis ? foundStringNaive(advisory_synopsis, search) : false + advisory_synopsis ? containsSubstring(advisory_synopsis, search) : false ); setDisplayedPatches(searchResult); }, [patches, displayedAdvisories, search]); diff --git a/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx index cc581e0116..bbc694a859 100644 --- a/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx +++ b/assets/js/pages/UpgradablePackagesPage/UpgradablePackages.jsx @@ -4,19 +4,16 @@ import { EOS_SEARCH } from 'eos-icons-react'; import UpgradablePackagesList from '@common/UpgradablePackagesList'; import PageHeader from '@common/PageHeader'; import Input from '@common/Input'; -import { foundStringNaive } from '@lib/filter'; +import { containsSubstring } from '@lib/filter'; -export default function UpgradablePackages({ - hostName, - upgradablePackages, -}) { +export default function UpgradablePackages({ hostName, upgradablePackages }) { const [search, setSearch] = useState(''); const displayedPackages = upgradablePackages.filter( ({ name, patches }) => - foundStringNaive(name, search) || + containsSubstring(name, search) || patches - .map(({ advisory }) => foundStringNaive(advisory, search)) + .map(({ advisory }) => containsSubstring(advisory, search)) .includes(true) ); diff --git a/assets/js/pages/UpgradablePackagesPage/index.js b/assets/js/pages/UpgradablePackagesPage/index.js index 55c7616af7..3fde2855a7 100644 --- a/assets/js/pages/UpgradablePackagesPage/index.js +++ b/assets/js/pages/UpgradablePackagesPage/index.js @@ -1,3 +1,3 @@ import UpgradablePackagesPage from './UpgradablePackagesPage'; -export default UpgradablePackagesPage +export default UpgradablePackagesPage;