diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugSection.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugSection.tsx deleted file mode 100644 index 850f0148a..000000000 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugSection.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import * as React from 'react'; -import { - EmptyState, - EmptyStateBody, - SearchInput, - TextContent, - TextVariants, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - Text, -} from '@patternfly/react-core'; -import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; -import { FieldArray, useField } from 'formik'; -import { debounce } from 'lodash-es'; -import { useSearchParam } from '../../../../../hooks/useSearchParam'; -import ActionMenu from '../../../../../shared/components/action-menu/ActionMenu'; -import FilteredEmptyState from '../../../../../shared/components/empty-state/FilteredEmptyState'; -import { AddBugModal } from './AddBugModal'; - -import './AddBugSection.scss'; - -interface AddBugSectionProps { - field: string; -} - -export interface BugsObject { - issueKey: string; - summary: string; - url?: string; - uploadDate?: string; - status?: string; -} - -export const bugsTableColumnClass = { - issueKey: 'pf-m-width-15 wrap-column', - url: 'pf-m-width-30 pf-m-width-25-on-lg', - summary: 'pf-m-width-20 pf-m-width-20-on-lg pf-m-width-15-on-xl', - uploadDate: 'pf-m-hidden pf-m-visible-on-xl pf-m-width-20', - status: 'pf-m-hidden pf-m-visible-on-xl pf-m-width-15', - kebab: 'pf-v5-c-table__action', -}; - -const AddBugSection: React.FC> = ({ field }) => { - const [nameFilter, setNameFilter] = useSearchParam('name', ''); - const [{ value: issues }, ,] = useField(field); - - const [onLoadName, setOnLoadName] = React.useState(nameFilter); - React.useEffect(() => { - if (nameFilter) { - setOnLoadName(nameFilter); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const filteredBugs = React.useMemo( - () => - issues && Array.isArray(issues) - ? issues?.filter((bug) => !nameFilter || bug.issueKey.indexOf(nameFilter) >= 0) - : [], - [issues, nameFilter], - ); - - const onClearFilters = () => { - onLoadName.length && setOnLoadName(''); - setNameFilter(''); - }; - const onNameInput = debounce((n: string) => { - n.length === 0 && onLoadName.length && setOnLoadName(''); - - setNameFilter(n); - }, 600); - - const EmptyMsg = () => - nameFilter ? ( - - ) : ( - - No Bugs found - - ); - - return ( - { - const addNewBug = (bug) => { - arrayHelper.push(bug); - }; - - return ( - <> - - - Are there any bug fixes you would like to add to this release? - - - - - - - onNameInput(n)} - value={nameFilter} - /> - - - - - - - -
- - - - - - - - - - - - {Array.isArray(filteredBugs) && filteredBugs.length > 0 && ( - - {filteredBugs.map((bug, i) => ( - - - - - - - - - ))} - - )} -
Bug issue keyURLSummaryLast updatedStatus
{bug.issueKey}{bug.url}{bug.summary}{bug.uploadDate}{bug.status} - arrayHelper.remove(i), - id: 'delete-bug', - label: 'Delete bug', - }, - ]} - /> -
-
- {!filteredBugs || - (filteredBugs?.length === 0 && ( -
{EmptyMsg()}
- ))} - - ); - }} - /> - ); -}; - -export default AddBugSection; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/__tests__/AddBugSection.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/__tests__/AddBugSection.spec.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugModal.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx similarity index 56% rename from src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugModal.tsx rename to src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx index fc96815b4..da0e124cb 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugModal.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx @@ -4,24 +4,39 @@ import { Formik } from 'formik'; import * as yup from 'yup'; import { ComponentProps } from '../../../../modal/createModalLauncher'; import BugFormContent from './BugFormContent'; +import CVEFormContent from './CVEFormContent'; +import { dateFormat } from './UploadDate'; -type AddBugModalProps = ComponentProps & { +export enum IssueType { + BUG = 'bug', + CVE = 'cve', +} + +type AddIssueModalProps = ComponentProps & { bugArrayHelper: (values) => void; + issueType: IssueType; }; -const bugFormSchema = yup.object({ +const BugFormSchema = yup.object({ issueKey: yup.string().required('Required'), url: yup.string().required('Required'), }); -export const AddBugModal: React.FC> = ({ +const CVEFormSchema = yup.object({ + issueKey: yup.string().required('Required'), +}); + +export const AddIssueModal: React.FC> = ({ onClose, bugArrayHelper, + issueType, }) => { const [isModalOpen, setIsModalOpen] = React.useState(false); const [isTimePickerOpen, setIsTimePickerOpen] = React.useState(false); const dateRef = React.useRef(null); + const isBug = issueType === IssueType.BUG; + const handleModalToggle = () => { setIsModalOpen(!isModalOpen); }; @@ -47,22 +62,36 @@ export const AddBugModal: React.FC> = return ( <> - + {isBug ? ( + + ) : ( + + )} diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugSection.scss b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.scss similarity index 100% rename from src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/AddBugSection.scss rename to src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.scss diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.tsx new file mode 100644 index 000000000..ca4d805cd --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.tsx @@ -0,0 +1,212 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + SearchInput, + TextContent, + TextVariants, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + Text, + EmptyStateVariant, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { FieldArray, useField } from 'formik'; +import { debounce } from 'lodash-es'; +import { useSearchParam } from '../../../../../hooks/useSearchParam'; +import ActionMenu from '../../../../../shared/components/action-menu/ActionMenu'; +import FilteredEmptyState from '../../../../../shared/components/empty-state/FilteredEmptyState'; +import { AddIssueModal, IssueType } from './AddIssueModal'; + +import './AddIssueSection.scss'; + +interface AddIssueSectionProps { + field: string; + issueType: IssueType; +} + +export interface IssueObject { + issueKey: string; + summary: string; + url?: string; + components?: string[]; + uploadDate?: string; + status?: string; +} + +export const issueTableColumnClass = { + issueKey: 'pf-m-width-15 wrap-column', + url: 'pf-m-width-25 pf-m-width-25-on-lg', + components: 'pf-m-width-20 pf-m-width-15-on-lg', + summary: 'pf-m-width-20 pf-m-width-15-on-xl', + uploadDate: 'pf-m-width-15 pf-m-width-10-on-xl', + status: 'pf-m-hidden pf-m-visible-on-xl pf-m-width-15', + kebab: 'pf-v5-c-table__action', +}; + +export const AddIssueSection: React.FC> = ({ + field, + issueType, +}) => { + const [nameFilter, setNameFilter] = useSearchParam(field, ''); + const [{ value: issues }, ,] = useField(field); + + const isBug = issueType === IssueType.BUG; + + const [onLoadName, setOnLoadName] = React.useState(nameFilter); + React.useEffect(() => { + if (nameFilter) { + setOnLoadName(nameFilter); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const filteredIssues = React.useMemo( + () => + issues && Array.isArray(issues) + ? issues?.filter((bug) => !nameFilter || bug.issueKey.indexOf(nameFilter) >= 0) + : [], + [issues, nameFilter], + ); + + const onClearFilters = () => { + onLoadName.length && setOnLoadName(''); + setNameFilter(''); + }; + const onNameInput = debounce((n: string) => { + n.length === 0 && onLoadName.length && setOnLoadName(''); + + setNameFilter(n); + }, 600); + + const EmptyMsg = (type) => + nameFilter ? ( + + ) : ( + + + {type === IssueType.BUG ? 'No Bugs found' : 'No CVEs found'} + + + ); + + return ( + { + const addNewBug = (bug) => { + arrayHelper.push(bug); + }; + + return ( + <> + + + {isBug + ? 'Are there any bug fixes you would like to add to this release?' + : 'Are there any CVEs you would like to add to this release?'} + + + + + + + onNameInput(n)} + value={nameFilter} + /> + + + + + + + +
+ + {isBug ? ( + + + + + + + + + + ) : ( + + + + + + + + + + + )} + + {Array.isArray(filteredIssues) && filteredIssues.length > 0 && ( + + {filteredIssues.map((issue, i) => ( + + + + {!isBug && ( + + )} + + + + + + ))} + + )} +
Bug issue keyURLSummaryLast updatedStatus
CVE keyURLComponentsSummaryLast updatedStatus
{issue.issueKey}{issue.url} + {issue.components && + Array.isArray(issue.components) && + issue.components?.map((component) => ( + + {component} + + ))} + {issue.summary}{issue.uploadDate}{issue.status} + arrayHelper.remove(i), + id: 'delete-bug', + label: isBug ? 'Delete bug' : 'Delete CVE', + }, + ]} + /> +
+ {!filteredIssues || + (filteredIssues?.length === 0 && ( +
{EmptyMsg(issueType)}
+ ))} +
+ + ); + }} + /> + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/BugFormContent.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/BugFormContent.tsx similarity index 100% rename from src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/BugFormContent.tsx rename to src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/BugFormContent.tsx diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/CVEFormContent.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/CVEFormContent.tsx new file mode 100644 index 000000000..415a1c05a --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/CVEFormContent.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { + Button, + ButtonType, + ButtonVariant, + Form, + Stack, + StackItem, + Text, + TextContent, + TextVariants, +} from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { isEmpty } from 'lodash-es'; +import { InputField, TextAreaField } from '../../../../../shared'; +import ComponentField from './ComponentField'; +import StatusDropdown from './StatusDropdown'; +import UploadDate from './UploadDate'; + +type CVEFormValues = { + CVEKey: string; + components: string[]; + uploadDate: string; + status: string; + summary: string; +}; + +type CVEFormContentProps = { + modalToggle: () => void; +}; +const CVEFormContent: React.FC = ({ modalToggle }) => { + const { handleSubmit, isSubmitting, errors, dirty } = useFormikContext(); + const formRef = React.useRef(); + + return ( +
+
+ + + + + Provide information about a Bug that has already been resolved. + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default CVEFormContent; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/ComponentField.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/ComponentField.tsx new file mode 100644 index 000000000..7e616f4a6 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/ComponentField.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + Button, + ButtonVariant, + FormGroup, + InputGroup, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { MinusCircleIcon } from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; +import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import { FieldArray, useField } from 'formik'; +import { InputField } from '../../../../../shared'; + +type ComponentFieldProps = { + name: string; +}; + +const ComponentField: React.FC> = ({ name }) => { + const [{ value: components }, ,] = useField(name); + + return ( + { + return ( + + + {Array.isArray(components) && + components.length > 0 && + components.map((val, i) => { + return ( + + + + {components.length > 1 && ( + + )} + + + ); + })} + + + + + + ); + }} + /> + ); +}; + +export default ComponentField; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/StatusDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/StatusDropdown.tsx similarity index 100% rename from src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/StatusDropdown.tsx rename to src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/StatusDropdown.tsx diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/UploadDate.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx similarity index 70% rename from src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/UploadDate.tsx rename to src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx index 8f9777647..7ea195e14 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddBugSection/UploadDate.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx @@ -6,13 +6,13 @@ type UploadDateProps = { name: string; }; -const UploadDate: React.FC> = ({ name }) => { - const [, , { setValue }] = useField(name); +export const dateFormat = (date: Date) => + date + .toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' }) + .replace(/\//g, '-'); - const dateFormat = (date: Date) => - date - .toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' }) - .replace(/\//g, '-'); +const UploadDate: React.FC> = ({ name }) => { + const [{ value = dateFormat(new Date()) }, , { setValue }] = useField(name); const dateParse = (date: string) => { const split = date.split('-'); @@ -29,11 +29,11 @@ const UploadDate: React.FC> = ({ name } return ( setValue(value)} + onChange={(event, val) => setValue(val)} /> ); }; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx index 61dd5bb83..17b7e27c4 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx @@ -6,7 +6,8 @@ import PageLayout from '../../../../components/PageLayout/PageLayout'; import { FormFooter, TextAreaField } from '../../../../shared'; import KeyValueField from '../../../../shared/components/formik-fields/key-value-input-field/KeyValueInputField'; import { useWorkspaceBreadcrumbs } from '../../../../utils/breadcrumb-utils'; -import AddBugSection from './AddBugSection/AddBugSection'; +import { IssueType } from './AddIssueSection/AddIssueModal'; +import { AddIssueSection } from './AddIssueSection/AddIssueSection'; import { TriggerReleaseFormValues } from './form-utils'; import { ReleasePlanDropdown } from './ReleasePlanDropdown'; import { SnapshotDropdown } from './SnapshotDropdown'; @@ -61,7 +62,9 @@ export const TriggerReleaseForm: React.FC = ({ helpText="The release you want to release to the environments in your target workspace." required /> - + + + ({ + useReleasePlans: jest.fn(), +})); + +const useReleasePlansMock = useReleasePlans as jest.Mock; + +describe('ReleasePlanDropdown', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show loading indicator if release plans arent loaded', () => { + useReleasePlansMock.mockReturnValue([[], false]); + formikRenderer(); + expect(screen.getByText('Loading release plans...')).toBeVisible(); + }); + + it('should show dropdown if release plans are loaded', async () => { + useReleasePlansMock.mockReturnValue([ + [{ metadata: { name: 'rp1' } }, { metadata: { name: 'rp2' } }], + true, + ]); + formikRenderer(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(screen.getByRole('menuitem', { name: 'rp1' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'rp2' })).toBeVisible(); + }); + + it('should change the release plan dropdown value', async () => { + useReleasePlansMock.mockReturnValue([ + [{ metadata: { name: 'rp1' } }, { metadata: { name: 'rp2' } }], + true, + ]); + + formikRenderer(, { + targets: { application: 'app' }, + }); + expect(screen.queryByRole('button')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByLabelText('Select release plan')); + screen.getByText('rp2'); + }); + await act(async () => { + fireEvent.click(screen.getByText('rp2')); + }); + waitFor(() => { + expect(screen.getByText('rp2')); + }); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx index e69de29bb..d668de1c5 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, configure, fireEvent, screen, waitFor } from '@testing-library/react'; +import { useSnapshots } from '../../../../../hooks/useSnapshots'; +import { formikRenderer } from '../../../../../utils/test-utils'; +import { SnapshotDropdown } from '../SnapshotDropdown'; + +jest.mock('../../../../../hooks/useSnapshots', () => ({ + useSnapshots: jest.fn(), +})); + +const useSnapshotsMock = useSnapshots as jest.Mock; + +describe('SnapshotDropdown', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show loading indicator if snapshot arent loaded', () => { + useSnapshotsMock.mockReturnValue([[], false]); + formikRenderer(); + expect(screen.getByText('Loading snapshots...')).toBeVisible(); + }); + + it('should show dropdown if snapshots are loaded', async () => { + useSnapshotsMock.mockReturnValue([ + [{ metadata: { name: 'snapshot1' } }, { metadata: { name: 'snapshot2' } }], + true, + ]); + formikRenderer(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(screen.getByRole('menuitem', { name: 'snapshot1' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'snapshot2' })).toBeVisible(); + }); + + it('should change the Snapshot dropdown value', async () => { + useSnapshotsMock.mockReturnValue([ + [{ metadata: { name: 'snapshot1' } }, { metadata: { name: 'snapshot2' } }], + true, + ]); + + formikRenderer(, { + targets: { application: 'app' }, + }); + expect(screen.queryByRole('button')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByLabelText('Select snapshot')); + screen.getByText('rp2'); + }); + await act(async () => { + fireEvent.click(screen.getByText('snapshot2')); + }); + waitFor(() => { + expect(screen.getByText('snapshot2')); + }); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseForm.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseForm.spec.tsx index 06282ce94..605bae2ea 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseForm.spec.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseForm.spec.tsx @@ -1,49 +1,69 @@ import React from 'react'; +import { screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { FormikProps } from 'formik'; +import { useReleasePlans } from '../../../../../hooks/useReleasePlans'; +import { useSnapshots } from '../../../../../hooks/useSnapshots'; import { formikRenderer } from '../../../../../utils/test-utils'; -import { WorkspaceContext } from '../../../../../utils/workspace-context-utils'; import { TriggerReleaseForm } from '../TriggerReleaseForm'; -jest.mock('react-router-dom', () => ({ - ...(jest as any).requireActual('react-router-dom'), - useLocation: jest.fn(() => ({})), - Link: (props: any) => {props.children}, - useNavigate: () => jest.fn(), +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useNavigate: () => jest.fn(), + Link: (props: any) => {props.children}, + useLocation: () => jest.fn(), + }; +}); + +jest.mock('../AddIssueSection/AddIssueSection', () => ({ + AddIssueSection: (props) => {props.name}, })); -jest.mock('../../../../../utils/workspace-context-utils', () => ({ - useWorkspaceInfo: jest.fn(() => ({ workspace: 'test-ws' })), +jest.mock('../../../../../hooks/useSnapshots', () => ({ + useSnapshots: jest.fn(), })); -jest.mock('../../../../../hooks/useApplications', () => ({ - useApplications: jest.fn(() => [[], true]), +jest.mock('../../../../../hooks/useReleasePlans', () => ({ + useReleasePlans: jest.fn(), })); jest.mock('../../../../../shared/hooks/useScrollShadows', () => ({ useScrollShadows: jest.fn().mockReturnValue('none'), })); -jest.mock('react', () => { - const actual = jest.requireActual('react'); - return { - ...actual, - useContext: jest.fn((ctx) => - ctx === WorkspaceContext - ? { namespace: 'test-ns', workspaces: [], workspacesLoaded: true } - : actual.useContext(ctx), - ), - }; -}); +const useSnapshotsMock = useSnapshots as jest.Mock; +const useReleasePlansMock = useReleasePlans as jest.Mock; -describe('ReleasePlanForm', () => { - it('should show trigger form ', () => { +describe('TriggerReleaseForm', () => { + beforeEach(() => { + useReleasePlansMock.mockReturnValue([[], false]); + useSnapshotsMock.mockReturnValue([[], false]); + }); + it('should show trigger release button and heading', () => { const values = {}; const props = { values } as FormikProps; const result = formikRenderer(, values); - expect(result.getByRole('heading', { name: 'Trigger release ' })).toBeVisible(); + expect(result.getByRole('heading', { name: 'Trigger release plan' })).toBeVisible(); expect(result.getByRole('button', { name: 'Trigger' })).toBeVisible(); - expect(result.getByRole('button', { name: 'Create' })).toBeVisible(); - expect(result.getByText('synopsis')).toBeVisible(); + }); + + it('should show trigger release input fields', () => { + const values = {}; + const props = { values } as FormikProps; + const result = formikRenderer(, values); + expect(result.getByRole('textbox', { name: 'Synopsis' })).toBeVisible(); + expect(result.getByRole('textbox', { name: 'Description' })).toBeVisible(); + expect(result.getByRole('textbox', { name: 'Topic' })).toBeVisible(); + expect(result.getByRole('textbox', { name: 'References' })).toBeVisible(); + }); + + it('should show release & snapshot dropdown in loading state', () => { + const values = {}; + const props = { values } as FormikProps; + formikRenderer(, values); + expect(screen.getByText('Loading release plans...')).toBeVisible(); + expect(screen.getByText('Loading snapshots...')).toBeVisible(); }); }); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseFormPage.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseFormPage.spec.tsx index 625c3ef26..dfc7040f4 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseFormPage.spec.tsx +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseFormPage.spec.tsx @@ -12,6 +12,7 @@ jest.mock('../../../../../utils/analytics', () => ({ })); const navigateMock = jest.fn(); + jest.mock('react-router-dom', () => ({ ...(jest as any).requireActual('react-router-dom'), useLocation: jest.fn(() => ({})), @@ -21,13 +22,12 @@ jest.mock('react-router-dom', () => ({ jest.mock('../form-utils', () => ({ ...jest.requireActual('../form-utils'), - createReleasePlan: jest.fn(), - editReleasePlan: jest.fn(), - releasePlanFormSchema: yup.object(), + createRelease: jest.fn(), + triggerReleaseFormSchema: yup.object(), })); -jest.mock('../ReleasePlanForm', () => ({ - ReleasePlanForm: ({ handleSubmit, handleReset }) => ( +jest.mock('../TriggerReleaseForm', () => ({ + TriggerReleaseForm: ({ handleSubmit, handleReset }) => ( <> @@ -35,11 +35,11 @@ jest.mock('../ReleasePlanForm', () => ({ ), })); -const createReleasePlanMock = createRelease as jest.Mock; +const triggerReleasePlanMock = createRelease as jest.Mock; -describe('ReleaseFormPage', () => { - it('should navigate on successful creation', async () => { - createReleasePlanMock.mockResolvedValue({ metadata: {}, spec: {} }); +describe('TriggerReleaseFormPage', () => { + it('should navigate on successful trigger', async () => { + triggerReleasePlanMock.mockResolvedValue({ metadata: {}, spec: {} }); namespaceRenderer(, 'test-ns', { workspace: 'test-ws', }); @@ -48,49 +48,23 @@ describe('ReleaseFormPage', () => { fireEvent.click(screen.getByRole('button', { name: 'Submit' })); }); - expect(createReleasePlanMock).toHaveBeenCalled(); - expect(createReleasePlanMock).toHaveBeenCalledWith( + expect(triggerReleasePlanMock).toHaveBeenCalled(); + expect(triggerReleasePlanMock).toHaveBeenCalledWith( expect.objectContaining({ - name: '', - application: '', - autoRelease: false, - standingAttribution: false, - data: '', - params: [], - serviceAccount: '', - target: '', - git: { - url: '', - revision: '', - path: '', - }, + description: '', + labels: [{ key: '', value: '' }], + references: '', + releasePlan: '', + snapshot: '', + synopsis: '', + topic: '', }), 'test-ns', - 'test-ws', + undefined, ); - expect(createReleasePlanMock).toHaveBeenCalledTimes(2); expect(navigateMock).toHaveBeenCalledWith('/application-pipeline/release'); }); - // it('should navigate on successful edit', async () => { - // editReleasePlanMock.mockResolvedValue({ metadata: {}, spec: {} }); - // namespaceRenderer( - // , - // 'test-ns', - // { - // workspace: 'test-ws', - // }, - // ); - - // await act(async () => { - // fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - // }); - - // expect(editReleasePlanMock).toHaveBeenCalled(); - // expect(editReleasePlanMock).toHaveBeenCalledTimes(2); - // expect(navigateMock).toHaveBeenCalledWith('/application-pipeline/release'); - // }); - it('should navigate to release list on reset', async () => { namespaceRenderer( , diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/form-utils.spec.ts b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/form-utils.spec.ts new file mode 100644 index 000000000..1cbc58996 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/form-utils.spec.ts @@ -0,0 +1,120 @@ +import { k8sCreateResource } from '@openshift/dynamic-plugin-sdk-utils'; +import '@testing-library/jest-dom'; +import { createRelease } from '../form-utils'; + +jest.mock('@openshift/dynamic-plugin-sdk-utils', () => ({ + k8sCreateResource: jest.fn(), +})); + +const k8sCreateMock = k8sCreateResource as jest.Mock; + +describe('triggerReleasePlan', () => { + beforeEach(() => { + k8sCreateMock.mockImplementation((obj) => obj.resource); + }); + + it('should add snapshot & releasePlan to spec.snapshot & spec.releasePlan', async () => { + const result = await createRelease( + { + snapshot: 'test-snapshot', + releasePlan: 'test-releasePlan', + synopsis: null, + topic: null, + references: null, + labels: [], + }, + 'test-ns', + null, + ); + expect(result.spec).toEqual( + expect.objectContaining({ + snapshot: 'test-snapshot', + releasePlan: 'test-releasePlan', + }), + ); + }); + + it('should add Synopsis, Topic, Description, Reference to spec.data', async () => { + const result = await createRelease( + { + snapshot: 'test-plan', + synopsis: 'synopsis', + releasePlan: 'test-releasePlan', + description: 'short description', + topic: 'topic of release', + references: 'references', + labels: [], + }, + 'test-ns', + null, + ); + expect(result.spec.data.advisory).toEqual( + expect.objectContaining({ + synopsis: 'synopsis', + topic: 'topic of release', + description: 'short description', + }), + ); + }); + + it('should add Bug Data to advisory', async () => { + const result = await createRelease( + { + snapshot: 'test-plan', + synopsis: 'synopsis', + releasePlan: 'test-releasePlan', + description: 'short description', + topic: 'topic of release', + references: 'references', + issues: [ + { issueKey: 'RHTAP-5560', summary: 'summary1', url: 'test-url' }, + { issueKey: 'RHTAP-5561', summary: 'summary2', url: 'test-url2' }, + { issueKey: 'RHTAP-5562', summary: 'summary3', url: 'test-url2' }, + ], + labels: [], + }, + 'test-ns', + null, + ); + + const advisoryIssues = result.spec.data.advisory.issues; + expect(advisoryIssues.length).toEqual(3); + expect(advisoryIssues[0]).toEqual( + expect.objectContaining({ + issueKey: 'RHTAP-5560', + url: 'test-url', + summary: 'summary1', + }), + ); + }); + + it('should add CVE Data to advisory', async () => { + const result = await createRelease( + { + snapshot: 'test-plan', + synopsis: 'synopsis', + releasePlan: 'test-releasePlan', + description: 'short description', + topic: 'topic of release', + references: 'references', + cves: [ + { issueKey: 'cve1', components: ['a', 'b'], url: 'test-url' }, + { issueKey: 'cve2', components: ['c', 'd'], url: 'test-url2' }, + ], + labels: [], + }, + 'test-ns', + null, + ); + + const advisoryCVE = result.spec.data.advisory.cves; + expect(advisoryCVE.length).toEqual(2); + expect(advisoryCVE[0]).toEqual( + expect.objectContaining({ + issueKey: 'cve1', + url: 'test-url', + components: ['a', 'b'], + }), + ); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts b/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts index af8a00a49..99a8b4f93 100644 --- a/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts @@ -6,7 +6,7 @@ import { RESOURCE_NAME_LENGTH_ERROR_MSG, RESOURCE_NAME_REGEX_MSG, } from '../../../../components/ImportForm/utils/validation-utils'; -import { ReleaseModel, ReleasePlanGroupVersionKind } from '../../../../models'; +import { ReleaseGroupVersionKind, ReleaseModel } from '../../../../models'; import { ReleaseKind, ReleasePlanKind } from '../../../../types/coreBuildService'; export enum ReleasePipelineLocation { @@ -22,7 +22,8 @@ export type TriggerReleaseFormValues = { description?: string; solution?: string; references: string; - issues?: string[]; + issues?: any[]; + cves?: any[]; labels?: { key: string; value: string }[]; }; @@ -47,6 +48,7 @@ export const createRelease = async ( const { releasePlan: rp, snapshot, + cves, topic, labels: labelPairs, description, @@ -58,9 +60,10 @@ export const createRelease = async ( const labels = labelPairs .filter((l) => !!l.key) .reduce((acc, o) => ({ ...acc, [o.key]: o.value }), {} as Record); + const resource: ReleaseKind = { - apiVersion: `${ReleasePlanGroupVersionKind.group}/${ReleasePlanGroupVersionKind.version}`, - kind: ReleasePlanGroupVersionKind.kind, + apiVersion: `${ReleaseGroupVersionKind.group}/${ReleaseGroupVersionKind.version}`, + kind: ReleaseGroupVersionKind.kind, metadata: { generateName: rp, namespace, @@ -75,6 +78,7 @@ export const createRelease = async ( snapshot, data: { advisory: { + cves, issues, synopsis, topic, diff --git a/src/shared/components/empty-state/FilteredEmptyState.tsx b/src/shared/components/empty-state/FilteredEmptyState.tsx index a81d0a050..f7df95061 100644 --- a/src/shared/components/empty-state/FilteredEmptyState.tsx +++ b/src/shared/components/empty-state/FilteredEmptyState.tsx @@ -19,9 +19,11 @@ const EmptyStateImg = () => ( ); const FilteredEmptyState: React.FC< - React.PropsWithChildren & { onClearFilters: () => void }> -> = ({ onClearFilters, ...props }) => ( - + React.PropsWithChildren< + Omit & { variant?: string; onClearFilters: () => void } + > +> = ({ variant = EmptyStateVariant.full, onClearFilters, ...props }) => ( + } diff --git a/src/types/coreBuildService.ts b/src/types/coreBuildService.ts index 7e9e40e17..8f3f4749c 100644 --- a/src/types/coreBuildService.ts +++ b/src/types/coreBuildService.ts @@ -70,6 +70,23 @@ export type Env = { value: string; }; +export type CVE = { + issueKey: string; + url: string; + status?: string; + summary?: string; + uploadDate?: string; + components?: string[]; +}; + +export type Issue = { + issueKey: string; + url: string; + status?: string; + summary?: string; + uploadDate?: string; +}; + export type ReleaseKind = K8sResourceCommon & { spec: ReleaseSpec; status?: ReleaseStatus; @@ -78,6 +95,17 @@ export type ReleaseKind = K8sResourceCommon & { export type ReleaseSpec = { snapshot: string; releasePlan: string; + data?: { + advisory?: { + topic?: string; + description: string; + synopsis: string; + cves: CVE[]; + issues: Issue[]; + solution?: string; + references?: string; + }; + }; }; export type ReleaseStatus = {