Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add host patches page #2649

Merged
merged 12 commits into from
May 27, 2024
1 change: 1 addition & 0 deletions assets/js/lib/test-utils/factories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './hosts';
export * from './sapSystems';
export * from './clusters';
export * from './databases';
export * from './relevantPatches';

export const randomObjectFactory = Factory.define(({ transientParams }) => {
const depth = transientParams.depth || 2;
Expand Down
73 changes: 73 additions & 0 deletions assets/js/pages/HostRelevantPatches/HostRelevantPatches.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react';
import { EOS_SEARCH } from 'eos-icons-react';

import PageHeader from '@common/PageHeader';
import PatchList from '@common/PatchList';
import Input from '@common/Input';
import Select from '@common/Select';
import Button from '@common/Button';

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

const filterPatchesByAdvisoryType = (patches, advisoryType) =>
patches.filter(({ advisory_type }) =>
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));

const [displayedAdvisories, setDisplayedAdvisories] = useState('all');
const [search, setSearch] = useState('');

const [displayedPatches, setDisplayedPatches] = useState(patches);

useEffect(() => {
setDisplayedPatches(
filterPatchesBySynopsis(
filterPatchesByAdvisoryType(patches, displayedAdvisories),
search
)
);
}, [patches, displayedAdvisories, search]);

return (
<>
<div className="flex flex-wrap">
<div className="flex w-1/2 overflow-ellipsis break-words">
<PageHeader>
Relevant Patches: <span className="font-bold">{hostName}</span>
</PageHeader>
</div>
<div className="flex w-1/2 gap-2 justify-end">
<Select
className=""
onChange={setDisplayedAdvisories}
options={advisoryTypes}
optionsName="optionz"
value={displayedAdvisories}
/>
<Input
className="flex"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by Synopsis"
prefix={<EOS_SEARCH size="l" />}
/>
<Button type="primary-white">Download CSV</Button>
</div>
</div>
<PatchList onNavigate={onNavigate} patches={displayedPatches} />
</>
);
}

export default HostRelevanPatches;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { action } from '@storybook/addon-actions';

import { relevantPatchFactory } from '@lib/test-utils/factories/relevantPatches';
import { hostFactory } from '@lib/test-utils/factories/hosts';

import HostRelevanPatches from './HostRelevantPatches';

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

export const HasPatches = {
args: {
hostName: hostFactory.build().hostname,
patches: relevantPatchFactory.buildList(5),
onNavigate: action('onNavigate'),
},
};
116 changes: 116 additions & 0 deletions assets/js/pages/HostRelevantPatches/HostRelevantPatches.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 { hostFactory, relevantPatchFactory } from '@lib/test-utils/factories';

import HostRelevantPatches from './HostRelevantPatches';

const enhancePachesWithAdvisoryType = (
patches,
{ amount = 2, prefix = 'adv' } = {}
) =>
patches.map((patch, index) => ({
...patch,
advisory_type: `${prefix}-${index % Math.floor(patches.length / amount)}`,
}));

describe('HostRelevantPatches', () => {
describe('renders relevant information correctly', () => {
it('displays the hostname', () => {
const host = hostFactory.build();

render(<HostRelevantPatches hostName={host.hostname} patches={[]} />);
expect(
screen.getByRole('heading', {
name: `Relevant Patches: ${host.hostname}`,
})
).toBeVisible();
});

it('shows all unique advisory types to select from', async () => {
const host = hostFactory.build();
const patches = enhancePachesWithAdvisoryType(
relevantPatchFactory.buildList(8)
);
const user = userEvent.setup();

render(
<HostRelevantPatches hostName={host.hostname} patches={patches} />
);

const advisorySelect = screen.getByRole('button', { name: 'all' });
await user.click(advisorySelect);

expect(screen.getByRole('option', { name: 'all' })).toBeVisible();

Array.from(new Set(patches.map((patch) => patch.advisory_type))).forEach(
(advisoryType) => {
// This tests for uniqeness as well.
expect(
screen.getByRole('option', { name: advisoryType })
).toBeVisible();
}
);
});

it('shows an input for searching the patches', () => {
render(<HostRelevantPatches patches={[]} />);

expect(screen.getByRole('textbox')).toBeVisible();
});

it('shows a button for downloading the data as CSV', () => {
render(<HostRelevantPatches patches={[]} />);

expect(
screen.getByRole('button', { name: 'Download CSV' })
).toBeVisible();
});

it('shows the relevant patches component', () => {
render(<HostRelevantPatches patches={[]} />);

expect(
screen.getByRole('row', { name: 'Type Advisory Synopsis Updated' })
).toBeVisible();
});
});

describe('filters according to criteria', () => {
it('shows all patches by default', () => {
const patches = relevantPatchFactory.buildList(8);

render(<HostRelevantPatches patches={patches} />);

patches.forEach((patch) => {
expect(screen.getByText(patch.advisory_synopsis)).toBeVisible();
});
});
it('only shows the selected patch kind', async () => {
const user = userEvent.setup();

const patches = enhancePachesWithAdvisoryType(
relevantPatchFactory.buildList(8)
);

const filteredType = 'adv-0';
const expectedPatches = patches.filter(
(patch) => patch.advisory_type === filteredType
);

render(<HostRelevantPatches patches={patches} />);

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();
});
});
});
});
32 changes: 32 additions & 0 deletions assets/js/pages/HostRelevantPatches/Page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';

import BackButton from '@common/BackButton';

import { getHost } from '@state/selectors/host';
import { getSoftwareUpdatesPatches } from '@state/selectors/softwareUpdates';
import HostRelevantPatches from './HostRelevantPatches';

export default function Page() {
const { hostID } = useParams();

const host = useSelector(getHost(hostID));
const patches = useSelector((state) =>
getSoftwareUpdatesPatches(state, hostID)
);

if (!host || !patches) {
return <div>Retrieving data</div>;
}

const { hostname: hostName } = host;

// TODO(janvhs): Handle navigation
return (
<>
<BackButton url={`/hosts/${hostID}`}>Back to Host Details</BackButton>
<HostRelevantPatches patches={patches} hostName={hostName} />
</>
);
}
3 changes: 3 additions & 0 deletions assets/js/pages/HostRelevantPatches/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Page from './Page';

export default Page;
6 changes: 6 additions & 0 deletions assets/js/state/selectors/softwareUpdates.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export const getSoftwareUpdatesStats = createSelector(
})
);

// TODO(janvhs): Add a test
export const getSoftwareUpdatesPatches = createSelector(
[(state, id) => getSoftwareUpdatesForHost(id)(state)],
(softwareUpdates) => get(softwareUpdates, ['relevant_patches'], [])
);

export const getSoftwareUpdatesLoading = createSelector(
[(state, id) => getSoftwareUpdatesForHost(id)(state)],
(softwareUpdates) => get(softwareUpdates, ['loading'], false)
Expand Down
5 changes: 5 additions & 0 deletions assets/js/trento.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Guard from '@pages/Guard';
import Home from '@pages/Home';
import HostDetailsPage from '@pages/HostDetailsPage';
import HostSettingsPage from '@pages/HostSettingsPage';
import HostRelevanPatchesPage from '@pages/HostRelevantPatches';
import HostsList from '@pages/HostsList';
import Layout from '@pages/Layout';
import Login from '@pages/Login';
Expand Down Expand Up @@ -69,6 +70,10 @@ function App() {
path="hosts/:hostID/saptune"
element={<SaptuneDetailsPage />}
/>
<Route
path="hosts/:hostID/patches"
element={<HostRelevanPatchesPage />}
/>
<Route path="clusters" element={<ClustersList />} />
<Route
path="sap_systems"
Expand Down
Loading