diff --git a/config/remotePlugin.js b/config/remotePlugin.js index 16ec3cc1e..644e24db7 100644 --- a/config/remotePlugin.js +++ b/config/remotePlugin.js @@ -634,6 +634,19 @@ const routeExtensions = [ required: ['SIGNUP'], }, }, + { + type: 'core.page/route', + properties: { + path: '/application-pipeline/release/workspaces/:workspaceName/release-plan/trigger', + exact: true, + component: { + $codeRef: 'TriggerReleasePlan', + }, + }, + flags: { + required: ['SIGNUP'], + }, + }, { type: 'core.page/route', properties: { @@ -761,6 +774,7 @@ module.exports = { CreateEnvironment: resolve(__dirname, '../src/pages/CreateEnvironmentPage'), ReleaseListPage: resolve(__dirname, '../src/pages/ReleaseServicesListPage'), CreateReleasePlan: resolve(__dirname, '../src/pages/CreateReleasePlanPage'), + TriggerReleasePlan: resolve(__dirname, '../src/pages/TriggerReleasePlanPage'), EditReleasePlan: resolve(__dirname, '../src/pages/EditReleasePlanPage'), WorkspaceContext: resolve(__dirname, '../src/utils/workspace-context-utils'), WorkspacedPage: resolve(__dirname, '../src/pages/WorkspacedPage'), diff --git a/src/components/ImportForm/utils/validation-utils.ts b/src/components/ImportForm/utils/validation-utils.ts index e7f17fbb4..a53c5af0d 100644 --- a/src/components/ImportForm/utils/validation-utils.ts +++ b/src/components/ImportForm/utils/validation-utils.ts @@ -11,6 +11,11 @@ export const containerImageRegex = /^(https:\/\/)?quay.io\/([a-z0-9-_]+\/)?[^/.] export const MAX_RESOURCE_NAME_LENGTH = 63; export const RESOURCE_NAME_LENGTH_ERROR_MSG = `Must be no more than ${MAX_RESOURCE_NAME_LENGTH} characters.`; +export const urlRegex = + /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i; + +export const URL_ERROR_MSG = `Invalid URL.`; + export const resourceNameRegex = /^[a-z]([-a-z0-9]*[a-z0-9])?$/; export const RESOURCE_NAME_REGEX_MSG = 'Must start with a letter and end with a letter or number. Valid characters include lowercase letters from a to z, numbers from 0 to 9, and hyphens ( - ).'; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx new file mode 100644 index 000000000..ddcc1f558 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueModal.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Button, Modal, ModalVariant } from '@patternfly/react-core'; +import { Formik } from 'formik'; +import * as yup from 'yup'; +import { URL_ERROR_MSG, urlRegex } from '../../../../ImportForm/utils/validation-utils'; +import { ComponentProps } from '../../../../modal/createModalLauncher'; +import BugFormContent from './BugFormContent'; +import CVEFormContent from './CVEFormContent'; +import { dateFormat } from './UploadDate'; + +export enum IssueType { + BUG = 'bug', + CVE = 'cve', +} + +type AddIssueModalProps = ComponentProps & { + bugArrayHelper: (values) => void; + issueType: IssueType; +}; + +const IssueFormSchema = yup.object({ + key: yup.string().required('Required'), + url: yup.string().matches(urlRegex, URL_ERROR_MSG).required('Required'), +}); + +export const AddIssueModal: React.FC> = ({ + onClose, + bugArrayHelper, + issueType, +}) => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const isBug = issueType === IssueType.BUG; + + const handleModalToggle = () => { + setIsModalOpen(!isModalOpen); + }; + + const setValues = React.useCallback( + (fields) => { + bugArrayHelper(fields); + onClose(); + }, + [onClose, bugArrayHelper], + ); + + return ( + <> + + + + {isBug ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.scss b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.scss new file mode 100644 index 000000000..ac2d4158b --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.scss @@ -0,0 +1,9 @@ +.add-bug-section { + &__emptyMsg { + padding: 0; + margin: 0; + padding-top: var(--pf-v5-global--spacer--sm); + width: 100%; + text-align: center; + } +} 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..95b68b82f --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/AddIssueSection.tsx @@ -0,0 +1,236 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + SearchInput, + TextContent, + TextVariants, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + Text, + EmptyStateVariant, + Truncate, +} 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 { + key: string; + summary: string; + url?: string; + components?: string[]; + uploadDate?: string; + status?: string; +} + +export const issueTableColumnClass = { + issueKey: 'pf-m-width-15 wrap-column ', + bugUrl: 'pf-m-width-20 ', + cveUrl: 'pf-m-width-15 ', + components: 'pf-m-width-15 ', + 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.key.toLowerCase().indexOf(nameFilter.toLowerCase()) >= 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.key ?? '-'} + + + + {issue.components && + Array.isArray(issue.components) && + issue.components.length > 0 + ? 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/AddIssueSection/BugFormContent.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/BugFormContent.tsx new file mode 100644 index 000000000..f8b27736d --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/BugFormContent.tsx @@ -0,0 +1,87 @@ +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 StatusDropdown from './StatusDropdown'; +import UploadDate from './UploadDate'; + +type BugFormValues = { + issueKey: string; + url: string; + uploadDate: string; + status: string; +}; + +type BugFormContentProps = { + modalToggle: () => void; +}; +const BugFormContent: React.FC = ({ modalToggle }) => { + const { handleSubmit, isSubmitting, errors, dirty } = useFormikContext(); + + return ( +
+ + + + + Provide information about a Bug that has already been resolved. + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default BugFormContent; 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..9260bfe3d --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/CVEFormContent.tsx @@ -0,0 +1,91 @@ +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(); + + return ( +
+ + + + + Provide information about a CVE 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..8110475bd --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/ComponentField.tsx @@ -0,0 +1,69 @@ +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 ( + + + + + + + + ); + })} + + + + + + ); + }} + /> + ); +}; + +export default ComponentField; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/StatusDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/StatusDropdown.tsx new file mode 100644 index 000000000..51c5e28c3 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/StatusDropdown.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { useField } from 'formik'; +import { DropdownField } from '../../../../../shared'; + +const dropdownItems = [ + { key: 'unresolved', value: 'Unresolved' }, + { key: 'resolved', value: 'Resolved' }, +]; + +type StatusDropdownProps = Omit< + React.ComponentProps, + 'items' | 'label' | 'placeholder' +>; + +const StatusDropdown: React.FC> = (props) => { + const [, , { setValue }] = useField(props.name); + + return ( + setValue(app)} + /> + ); +}; + +export default StatusDropdown; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx new file mode 100644 index 000000000..665eea522 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/UploadDate.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { DatePicker, FormGroup } from '@patternfly/react-core'; +import { useField } from 'formik'; + +type UploadDateProps = { + name: string; + label?: string; +}; + +export const dateFormat = (date: Date) => + date + .toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' }) + .replace(/\//g, '-'); + +const UploadDate: React.FC> = ({ name, label }) => { + const [{ value = dateFormat(new Date()) }, , { setValue }] = useField(name); + + const dateParse = (date: string) => { + const split = date.split('-'); + if (split.length !== 3) { + return new Date(); + } + const month = split[0]; + const day = split[1]; + const year = split[2]; + return new Date( + `${year.padStart(4, '0')}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`, + ); + }; + + return ( + + setValue(val)} + /> + + ); +}; + +export default UploadDate; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueModal.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueModal.spec.tsx new file mode 100644 index 000000000..42326e866 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueModal.spec.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, configure, fireEvent, act } from '@testing-library/react'; +import { AddIssueModal, IssueType } from '../AddIssueModal'; + +configure({ testIdAttribute: 'data-test' }); + +describe('AddIssueModal', () => { + it('should not show modal till Modal launcher button is clicked', () => { + render(); + expect(screen.queryByTestId('add-issue-modal')).not.toBeInTheDocument(); + }); + + it('should show modal when Modal launcher button is clicked', () => { + render(); + const launchModalBtn = screen.getByTestId('modal-launch-btn'); + act(() => { + fireEvent.click(launchModalBtn); + }); + expect(screen.queryByTestId('add-issue-modal')).toBeInTheDocument(); + }); + + it('should show Bug fields for Bug IssueType', () => { + render(); + const launchModalBtn = screen.getByTestId('modal-launch-btn'); + act(() => { + fireEvent.click(launchModalBtn); + }); + screen.getByText('Add a bug fix'); + screen.getByText('Provide information about a Bug that has already been resolved.'); + }); + + it('should show CVE fields for CVE IssueType', () => { + render(); + const launchModalBtn = screen.getByTestId('modal-launch-btn'); + act(() => { + fireEvent.click(launchModalBtn); + }); + screen.getByText('Add CVE'); + screen.getByText('Provide information about a CVE that has already been resolved.'); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueSection.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueSection.spec.tsx new file mode 100644 index 000000000..b2d933ff2 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/AddIssueSection.spec.tsx @@ -0,0 +1,204 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { configure, fireEvent, screen, waitFor } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import { IssueType } from '../AddIssueModal'; +import { AddIssueSection } from '../AddIssueSection'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + Link: (props) => ( + + {props.children} + + ), + useNavigate: () => jest.fn(), + useSearchParams: () => React.useState(() => new URLSearchParams()), + useParams: jest.fn(), + }; +}); + +describe('AddIssueSection for Bugs', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show correct heading ', async () => { + formikRenderer(); + expect( + screen.getByText('Are there any bug fixes you would like to add to this release?'), + ).toBeVisible(); + }); + + it('should show correct columns ', async () => { + formikRenderer(); + screen.getByText('Bug issue key'); + screen.getByText('URL'); + screen.getByText('Summary'); + screen.getByText('Last updated'); + screen.getByText('Status'); + }); + + it('should render correct bug list ', async () => { + formikRenderer(, { + bugs: [ + { key: 'bug-nodejs', url: 'url1' }, + { key: 'bug-java', url: 'url2' }, + { key: 'bug-python', url: 'url3' }, + ], + }); + expect(screen.getByText('bug-nodejs')).toBeInTheDocument(); + expect(screen.getByText('bug-java')).toBeInTheDocument(); + expect(screen.getByText('bug-python')).toBeInTheDocument(); + }); + + it('should have no search filter onLoad ', async () => { + formikRenderer(, { + bugs: [ + { key: 'bug-nodejs', url: 'url1' }, + { key: 'bug-java', url: 'url2' }, + { key: 'bug-python', url: 'url3' }, + ], + }); + const inputFilter = screen + .getByTestId('bugs-input-filter') + .querySelector('.pf-v5-c-text-input-group__text-input'); + expect((inputFilter as HTMLInputElement).value).toBe(''); + }); + + it('should filter bug list ', async () => { + formikRenderer(, { + bugs: [ + { key: 'bug-nodejs', url: 'url1' }, + { key: 'bug-java', url: 'url2' }, + { key: 'bug-python', url: 'url3' }, + ], + }); + const tableBody = screen.getByTestId('issue-table-body'); + expect(tableBody.children.length).toBe(3); + const inputFilter = screen + .getByTestId('bugs-input-filter') + .querySelector('.pf-v5-c-text-input-group__text-input'); + + fireEvent.change(inputFilter, { target: { value: 'java' } }); + await waitFor(() => { + expect(tableBody.children.length).toBe(1); + expect(tableBody.children[0].children[0].innerHTML).toBe('bug-java'); + }); + }); + + it('should show emptyState ', async () => { + formikRenderer(, { + bugs: [], + }); + expect(screen.getByText('No Bugs found')); + }); + + it('should show filteredEmptyState ', async () => { + formikRenderer(, { + bugs: [ + { key: 'bug-nodejs', url: 'url1' }, + { key: 'bug-java', url: 'url2' }, + { key: 'bug-python', url: 'url3' }, + ], + }); + const inputFilter = screen + .getByTestId('bugs-input-filter') + .querySelector('.pf-v5-c-text-input-group__text-input'); + + fireEvent.change(inputFilter, { target: { value: 'dotnet' } }); + await waitFor(() => { + expect( + screen.getByText('No results match this filter criteria. Clear all filters and try again.'), + ); + }); + }); + + it('should filter bug list ', async () => { + formikRenderer(, { + bugs: [ + { key: 'bug-nodejs', url: 'url1' }, + { key: 'bug-java', url: 'url2' }, + { key: 'bug-python', url: 'url3' }, + ], + }); + const tableBody = screen.getByTestId('issue-table-body'); + expect(tableBody.children.length).toBe(3); + const inputFilter = screen + .getByTestId('bugs-input-filter') + .querySelector('.pf-v5-c-text-input-group__text-input'); + + fireEvent.change(inputFilter, { target: { value: 'java' } }); + await waitFor(() => { + expect(tableBody.children.length).toBe(1); + expect(tableBody.children[0].children[0].innerHTML).toBe('bug-java'); + }); + }); +}); + +describe('AddIssueSection for CVEs', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show correct heading ', async () => { + formikRenderer(); + expect( + screen.getByText('Are there any CVEs you would like to add to this release?'), + ).toBeVisible(); + }); + + it('should show correct columns ', async () => { + formikRenderer(); + screen.getByText('CVE key'); + screen.getByText('URL'); + screen.getByText('Components'); + screen.getByText('Summary'); + screen.getByText('Last updated'); + screen.getByText('Status'); + }); + + it('should render correct cve list ', async () => { + formikRenderer(, { + cves: [ + { key: 'cve-nodejs', url: 'url1' }, + { key: 'cve-java', url: 'url2' }, + { key: 'cve-python', url: 'url3' }, + ], + }); + expect(screen.getByText('cve-nodejs')).toBeInTheDocument(); + expect(screen.getByText('cve-java')).toBeInTheDocument(); + expect(screen.getByText('cve-python')).toBeInTheDocument(); + }); + + it('should render - when data is missing', async () => { + formikRenderer(, { + cves: [{ key: 'cve-nodejs', url: 'url1' }], + }); + expect(screen.getByTestId('issue-summary').innerHTML).toBe('-'); + expect(screen.getByTestId('issue-status').innerHTML).toBe('-'); + }); + + it('should filter cves list ', async () => { + formikRenderer(, { + cves: [ + { key: 'cve-nodejs', url: 'url1' }, + { key: 'cve-java', url: 'url2' }, + { key: 'cve-python', url: 'url3' }, + ], + }); + const tableBody = screen.getByTestId('issue-table-body'); + expect(tableBody.children.length).toBe(3); + const inputFilter = screen + .getByTestId('cves-input-filter') + .querySelector('.pf-v5-c-text-input-group__text-input'); + + fireEvent.change(inputFilter, { target: { value: 'java' } }); + await waitFor(() => { + expect(tableBody.children.length).toBe(1); + expect(tableBody.children[0].children[0].innerHTML).toBe('cve-java'); + }); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/BugFormContent.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/BugFormContent.spec.tsx new file mode 100644 index 000000000..110c17896 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/BugFormContent.spec.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { configure, screen, act, fireEvent } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import BugFormContent from '../BugFormContent'; + +describe('BugFormContent', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show correct heading ', async () => { + formikRenderer(); + expect( + screen.getByText('Provide information about a Bug that has already been resolved.'), + ).toBeVisible(); + }); + + it('should show correct input fields ', async () => { + formikRenderer(); + + expect(screen.getByRole('textbox', { name: 'Bug issue key' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Summary' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'URL' })).toBeVisible(); + }); + + it('should show correct values', async () => { + formikRenderer(, { + key: 'RHTAP-120', + url: 'url1', + summary: 'summary', + }); + expect((screen.getByRole('textbox', { name: 'Bug issue key' }) as HTMLInputElement).value).toBe( + 'RHTAP-120', + ); + expect((screen.getByRole('textbox', { name: 'URL' }) as HTMLInputElement).value).toBe('url1'); + expect((screen.getByRole('textbox', { name: 'Summary' }) as HTMLInputElement).value).toBe( + 'summary', + ); + }); + + it('should have disabled Submit button when url and key not there', async () => { + formikRenderer(); + expect(screen.getByTestId('add-bug-btn')).toBeDisabled(); + }); + + it('should have disabled Submit button and error text when url is invalid', () => { + formikRenderer(); + const key = screen.getByTestId('bug-issue-key'); + const url = screen.getByTestId('bug-url'); + + act(() => { + fireEvent.change(key, { value: 'ISSUE-420' }); + fireEvent.change(url, { value: 'invalid' }); + }); + expect(screen.getByTestId('add-bug-btn')).toBeDisabled(); + screen.findByText('Invalid URL.'); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/CVEFormContent.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/CVEFormContent.spec.tsx new file mode 100644 index 000000000..cb1932a86 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/CVEFormContent.spec.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { configure, fireEvent, screen, act } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import CVEFormContent from '../CVEFormContent'; + +describe('CVEFormContent', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show correct heading ', async () => { + formikRenderer(); + expect( + screen.getByText('Provide information about a CVE that has already been resolved.'), + ).toBeVisible(); + }); + + it('should show correct input fields ', async () => { + formikRenderer(); + expect(screen.getByRole('textbox', { name: 'CVE key' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Summary' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'URL for the CVE' })).toBeVisible(); + }); + + it('should show correct values', async () => { + formikRenderer(, { + key: 'CVE-120', + url: 'url1', + summary: 'summary', + components: ['a', 'b', 'c'], + }); + expect((screen.getByRole('textbox', { name: 'CVE key' }) as HTMLInputElement).value).toBe( + 'CVE-120', + ); + expect( + (screen.getByRole('textbox', { name: 'URL for the CVE' }) as HTMLInputElement).value, + ).toBe('url1'); + expect((screen.getByRole('textbox', { name: 'Summary' }) as HTMLInputElement).value).toBe( + 'summary', + ); + }); + + it('should render component field ', async () => { + formikRenderer(, { + key: 'CVE-120', + url: 'url1', + summary: 'summary', + components: ['a', 'b', 'c'], + }); + screen.getByTestId('component-field'); + }); + + it('should have disabled Submit button when url and key not there', async () => { + formikRenderer(); + expect(screen.getByTestId('add-cve-btn')).toBeDisabled(); + }); + + it('should have disabled Submit button and error text when url is invalid', () => { + formikRenderer(); + const key = screen.getByTestId('cve-issue-key'); + const url = screen.getByTestId('cve-url'); + + act(() => { + fireEvent.change(key, { value: 'CVE-420' }); + fireEvent.change(url, { value: 'invalid' }); + }); + expect(screen.getByTestId('add-cve-btn')).toBeDisabled(); + screen.findByText('Invalid URL.'); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/ComponentField.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/ComponentField.spec.tsx new file mode 100644 index 000000000..b2724744b --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/ComponentField.spec.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, fireEvent, screen, configure } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import ComponentField from '../ComponentField'; + +configure({ testIdAttribute: 'data-test' }); + +describe('ComponentField', () => { + it('should initially load with no components and add component on button click', () => { + formikRenderer(, { components: [] }); + + const addCmpBtn = screen.queryByTestId('add-component-button'); + expect(addCmpBtn).toBeInTheDocument(); + + act(() => { + fireEvent.click(addCmpBtn); + }); + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + }); + + it('should load with formik component values', async () => { + formikRenderer(, { + components: ['cmp1', 'cmp2'], + }); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + const addCmpBtn = screen.queryByTestId('add-component-button'); + expect(addCmpBtn).toBeInTheDocument(); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + expect((screen.queryByTestId('component-0') as HTMLInputElement).value).toBe('cmp1'); + expect(screen.queryByTestId('component-1')).toBeInTheDocument(); + expect((screen.queryByTestId('component-1') as HTMLInputElement).value).toBe('cmp2'); + }); + + it('should change the value properly', async () => { + formikRenderer(, { + components: ['cmp1'], + }); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + const addCmpBtn = screen.queryByTestId('add-component-button'); + expect(addCmpBtn).toBeInTheDocument(); + + act(() => { + fireEvent.change(screen.queryByTestId('component-0'), { + target: { value: 'component2' }, + }); + }); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + expect((screen.queryByTestId('component-0') as HTMLInputElement).value).toBe('component2'); + }); + + it('should change the value properly', async () => { + formikRenderer(, { + components: ['cmp1'], + }); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + const addCmpBtn = screen.queryByTestId('add-component-button'); + expect(addCmpBtn).toBeInTheDocument(); + + act(() => { + fireEvent.change(screen.queryByTestId('component-0'), { + target: { value: 'component2' }, + }); + }); + + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + expect((screen.queryByTestId('component-0') as HTMLInputElement).value).toBe('component2'); + }); + + it('should show disabled remove button when only one component', async () => { + formikRenderer(, { + components: ['cmp1'], + }); + + expect(screen.queryByTestId('remove-component-0')).toBeInTheDocument(); + expect(screen.queryByTestId('remove-component-0')).toBeDisabled(); + }); + + it('should remove component when remove button in clicked', async () => { + formikRenderer(, { + components: ['remove-component', 'cmp2'], + }); + + expect(screen.queryByTestId('remove-component-0')).toBeInTheDocument(); + expect(screen.queryByTestId('component-0')).toBeInTheDocument(); + expect((screen.queryByTestId('component-0') as HTMLInputElement).value).toBe( + 'remove-component', + ); + + act(() => { + fireEvent.click(screen.queryByTestId('remove-component-0')); + }); + + expect((screen.queryByTestId('component-0') as HTMLInputElement).value).toBe('cmp2'); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/StatusDropdown.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/StatusDropdown.spec.tsx new file mode 100644 index 000000000..b6d272a06 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/StatusDropdown.spec.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, configure, fireEvent, screen, waitFor } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import StatusDropdown from '../StatusDropdown'; + +describe('StatusDropdown', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('should show dropdown options', async () => { + formikRenderer(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + expect(screen.getByRole('menuitem', { name: 'Resolved' })).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Unresolved' })).toBeVisible(); + }); + + it('should change the status dropdown value', async () => { + formikRenderer(, { + targets: { application: 'app' }, + }); + expect(screen.queryByRole('button')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + screen.getByText('Unresolved'); + }); + await act(async () => { + fireEvent.click(screen.getByText('Unresolved')); + }); + waitFor(() => { + expect(screen.getByText('Unresolved')); + }); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/UploadDate.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/UploadDate.spec.tsx new file mode 100644 index 000000000..2ecfdbabd --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/AddIssueSection/__tests__/UploadDate.spec.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, configure, fireEvent, screen } from '@testing-library/react'; +import { formikRenderer } from '../../../../../../utils/test-utils'; +import UploadDate, { dateFormat } from '../UploadDate'; + +describe('UploadDate', () => { + beforeEach(() => { + configure({ testIdAttribute: 'data-test' }); + }); + + it('dateFormat should format correct date', async () => { + const formattedDate = dateFormat(new Date('10-03-2024')); + expect(formattedDate).toEqual('10-03-2024'); + }); + + it('should show datepicker with correct date', async () => { + formikRenderer(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + + // node.closest('input') not working + const dateInput = + screen.getByTestId('upload-date-input').children[0].children[0].children[0].children[0] + .children[0]; + expect((dateInput as HTMLInputElement).value).toBe(dateFormat(new Date())); + }); + + it('should show label', async () => { + formikRenderer(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + screen.getByText('test-label'); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx new file mode 100644 index 000000000..853342977 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { useReleasePlans } from '../../../../hooks/useReleasePlans'; +import { DropdownField } from '../../../../shared'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; + +type ReleasePlanDropdownProps = Omit< + React.ComponentProps, + 'items' | 'label' | 'placeholder' +>; + +export const ReleasePlanDropdown: React.FC> = ( + props, +) => { + const { namespace } = useWorkspaceInfo(); + const [releasePlans, loaded] = useReleasePlans(namespace); + const [, , { setValue }] = useField(props.name); + + const dropdownItems = React.useMemo( + () => releasePlans.map((a) => ({ key: a.metadata.name, value: a.metadata.name })), + [releasePlans], + ); + + return ( + setValue(app)} + /> + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx new file mode 100644 index 000000000..3e1fd2708 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { useSnapshots } from '../../../../hooks/useSnapshots'; +import { DropdownField } from '../../../../shared'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; + +type SnapshotDropdownProps = Omit< + React.ComponentProps, + 'items' | 'label' | 'placeholder' +>; + +export const SnapshotDropdown: React.FC> = ( + props, +) => { + const { namespace } = useWorkspaceInfo(); + const [applications, loaded] = useSnapshots(namespace); + const [, , { setValue }] = useField(props.name); + + const dropdownItems = React.useMemo( + () => applications.map((a) => ({ key: a.metadata.name, value: a.metadata.name })), + [applications], + ); + + return ( + setValue(app)} + /> + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx new file mode 100644 index 000000000..6526ede5a --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { Form, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { FormikProps, useField } from 'formik'; +import isEmpty from 'lodash-es/isEmpty'; +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 { IssueType } from './AddIssueSection/AddIssueModal'; +import { AddIssueSection } from './AddIssueSection/AddIssueSection'; +import { TriggerReleaseFormValues } from './form-utils'; +import { ReleasePlanDropdown } from './ReleasePlanDropdown'; +import { SnapshotDropdown } from './SnapshotDropdown'; + +type Props = FormikProps; + +export const TriggerReleaseForm: React.FC = ({ + handleSubmit, + handleReset, + isSubmitting, + dirty, + errors, + status, +}) => { + const breadcrumbs = useWorkspaceBreadcrumbs(); + + const [{ value: labels }] = useField('labels'); + + return ( + + } + > + +
+ + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx new file mode 100644 index 000000000..e42a160b9 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Formik, FormikHelpers } from 'formik'; +import { ReleasePlanKind } from '../../../../types/coreBuildService'; +import { useTrackEvent, TrackEvents } from '../../../../utils/analytics'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; +import { TriggerReleaseFormValues, createRelease, triggerReleaseFormSchema } from './form-utils'; +import { TriggerReleaseForm } from './TriggerReleaseForm'; + +type Props = { + releasePlan?: ReleasePlanKind; +}; + +export const TriggerReleaseFormPage: React.FC = ({ releasePlan }) => { + const navigate = useNavigate(); + const track = useTrackEvent(); + const { namespace, workspace } = useWorkspaceInfo(); + + const handleSubmit = async ( + values: TriggerReleaseFormValues, + { setSubmitting, setStatus }: FormikHelpers, + ) => { + track(TrackEvents.ButtonClicked, { + link_name: 'trigger-release-plan-submit', + workspace, + }); + + try { + const newRelease = await createRelease(values, namespace, releasePlan); + track('Release plan triggered', { + // eslint-disable-next-line camelcase + release_plan_name: newRelease.metadata.name, + // eslint-disable-next-line camelcase + target_snapshot: newRelease.spec.snapshot, + releasePlan: newRelease.spec.releasePlan, + workspace, + }); + navigate('/application-pipeline/release'); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error while submitting integration test:', e); + setSubmitting(false); + setStatus({ submitError: e.message }); + } + }; + + const handleReset = () => { + track(TrackEvents.ButtonClicked, { + link_name: 'trigger-release-plan-leave', + workspace, + }); + + navigate('/application-pipeline/release'); + }; + + const initialValues: TriggerReleaseFormValues = { + releasePlan: releasePlan?.metadata?.name ?? '', + snapshot: '', + synopsis: '', + description: '', + topic: '', + references: '', + labels: releasePlan?.metadata?.labels + ? Object.entries(releasePlan?.metadata?.labels).map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }], + }; + + return ( + + {(props) => } + + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.spec.tsx new file mode 100644 index 000000000..c4fd43ba4 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.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 { useReleasePlans } from '../../../../../hooks/useReleasePlans'; +import { formikRenderer } from '../../../../../utils/test-utils'; +import { ReleasePlanDropdown } from '../ReleasePlanDropdown'; + +jest.mock('../../../../../hooks/useReleasePlans', () => ({ + 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 new file mode 100644 index 000000000..d668de1c5 --- /dev/null +++ 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 new file mode 100644 index 000000000..605bae2ea --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseForm.spec.tsx @@ -0,0 +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 { TriggerReleaseForm } from '../TriggerReleaseForm'; + +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('../../../../../hooks/useSnapshots', () => ({ + useSnapshots: jest.fn(), +})); + +jest.mock('../../../../../hooks/useReleasePlans', () => ({ + useReleasePlans: jest.fn(), +})); + +jest.mock('../../../../../shared/hooks/useScrollShadows', () => ({ + useScrollShadows: jest.fn().mockReturnValue('none'), +})); + +const useSnapshotsMock = useSnapshots as jest.Mock; +const useReleasePlansMock = useReleasePlans as jest.Mock; + +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 plan' })).toBeVisible(); + expect(result.getByRole('button', { name: 'Trigger' })).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 new file mode 100644 index 000000000..dfc7040f4 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/TriggerReleaseFormPage.spec.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, fireEvent, screen } from '@testing-library/react'; +import * as yup from 'yup'; +import { namespaceRenderer } from '../../../../../utils/test-utils'; +import { createRelease } from '../form-utils'; +import { TriggerReleaseFormPage } from '../TriggerReleaseFormPage'; + +jest.mock('../../../../../utils/analytics', () => ({ + ...jest.requireActual('../../../../../utils/analytics'), + useTrackEvent: () => jest.fn, +})); + +const navigateMock = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...(jest as any).requireActual('react-router-dom'), + useLocation: jest.fn(() => ({})), + Link: (props: any) => {props.children}, + useNavigate: jest.fn(() => navigateMock), +})); + +jest.mock('../form-utils', () => ({ + ...jest.requireActual('../form-utils'), + createRelease: jest.fn(), + triggerReleaseFormSchema: yup.object(), +})); + +jest.mock('../TriggerReleaseForm', () => ({ + TriggerReleaseForm: ({ handleSubmit, handleReset }) => ( + <> + + + + ), +})); + +const triggerReleasePlanMock = createRelease as jest.Mock; + +describe('TriggerReleaseFormPage', () => { + it('should navigate on successful trigger', async () => { + triggerReleasePlanMock.mockResolvedValue({ metadata: {}, spec: {} }); + namespaceRenderer(, 'test-ns', { + workspace: 'test-ws', + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + }); + + expect(triggerReleasePlanMock).toHaveBeenCalled(); + expect(triggerReleasePlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: '', + labels: [{ key: '', value: '' }], + references: '', + releasePlan: '', + snapshot: '', + synopsis: '', + topic: '', + }), + 'test-ns', + undefined, + ); + expect(navigateMock).toHaveBeenCalledWith('/application-pipeline/release'); + }); + + it('should navigate to release list on reset', async () => { + namespaceRenderer( + , + 'test-ns', + { + workspace: 'test-ws', + }, + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + }); + + expect(navigateMock).toHaveBeenCalledWith('/application-pipeline/release'); + }); +}); 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..4c465c8a1 --- /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.releaseNotes).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.releaseNotes.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.releaseNotes.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 new file mode 100644 index 000000000..3eca23f85 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts @@ -0,0 +1,100 @@ +import { k8sCreateResource } from '@openshift/dynamic-plugin-sdk-utils'; +import * as yup from 'yup'; +import { + MAX_RESOURCE_NAME_LENGTH, + resourceNameRegex, + RESOURCE_NAME_LENGTH_ERROR_MSG, + RESOURCE_NAME_REGEX_MSG, +} from '../../../../components/ImportForm/utils/validation-utils'; +import { ReleaseGroupVersionKind, ReleaseModel } from '../../../../models'; +import { ReleaseKind, ReleasePlanKind } from '../../../../types/coreBuildService'; + +export enum ReleasePipelineLocation { + current, + target, +} + +export type TriggerReleaseFormValues = { + releasePlan: string; + snapshot: string; + synopsis: string; + topic: string; + description?: string; + solution?: string; + references?: string; + issues?: any[]; + cves?: any[]; + labels?: { key: string; value: string }[]; +}; + +export const triggerReleaseFormSchema = yup.object({ + releasePlan: yup + .string() + .matches(resourceNameRegex, RESOURCE_NAME_REGEX_MSG) + .max(MAX_RESOURCE_NAME_LENGTH, RESOURCE_NAME_LENGTH_ERROR_MSG) + .required('Required'), + snapshot: yup + .string() + .matches(resourceNameRegex, RESOURCE_NAME_REGEX_MSG) + .max(MAX_RESOURCE_NAME_LENGTH, RESOURCE_NAME_LENGTH_ERROR_MSG) + .required('Required'), +}); + +export const createRelease = async ( + values: TriggerReleaseFormValues, + namespace: string, + releasePlan: ReleasePlanKind, +) => { + const { + releasePlan: rp, + snapshot, + cves, + topic, + labels: labelPairs, + description, + solution, + issues, + references, + synopsis, + } = values; + + const labels = labelPairs + .filter((l) => !!l.key) + .reduce((acc, o) => ({ ...acc, [o.key]: o.value }), {} as Record); + + const resource: ReleaseKind = { + apiVersion: `${ReleaseGroupVersionKind.group}/${ReleaseGroupVersionKind.version}`, + kind: ReleaseGroupVersionKind.kind, + metadata: { + generateName: rp, + namespace, + labels: { + ...releasePlan?.metadata?.labels, + ...labels, + }, + annotations: { ...releasePlan?.metadata?.annotations }, + }, + spec: { + releasePlan: rp, + snapshot, + data: { + releaseNotes: { + cves, + issues, + references, + synopsis, + topic, + description, + solution, + }, + }, + }, + }; + return k8sCreateResource({ + model: ReleaseModel, + queryOptions: { + ns: namespace, + }, + resource, + }); +}; diff --git a/src/components/ReleaseService/ReleasePlan/__tests__/releaseplan-actions.spec.tsx b/src/components/ReleaseService/ReleasePlan/__tests__/releaseplan-actions.spec.tsx new file mode 100644 index 000000000..9885b8e75 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/__tests__/releaseplan-actions.spec.tsx @@ -0,0 +1,112 @@ +import '@testing-library/jest-dom'; +import { useNavigate } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks'; +import { runStatus } from '../../../../utils/pipeline-utils'; +import { useAccessReviewForModel } from '../../../../utils/rbac'; +import { useReleasePlanActions } from '../releaseplan-actions'; + +jest.mock('../../../../utils/rbac', () => ({ + useAccessReviewForModel: jest.fn(() => [true, true]), +})); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useNavigate: jest.fn(), + }; +}); + +jest.mock('../../../../utils/workspace-context-utils', () => ({ + useWorkspaceInfo: jest.fn(() => ({ namespace: 'test-ns', workspace: 'test-ws' })), +})); + +const useAccessReviewForModelMock = useAccessReviewForModel as jest.Mock; +const useNavigateMock = useNavigate as jest.Mock; + +describe('useReleasePlanActions', () => { + let navigateMock: jest.Mock; + + beforeEach(() => { + navigateMock = jest.fn(); + useNavigateMock.mockImplementation(() => navigateMock); + useAccessReviewForModelMock.mockReturnValue([true, true]); + }); + + it('should contain trigger actions', async () => { + const { result } = renderHook(() => + useReleasePlanActions({ + metadata: { name: 'test-release-plan' }, + status: { conditions: [{ type: 'Succeeded', status: runStatus.Running }] }, + } as any), + ); + const actions = result.current; + + expect(actions[0]).toEqual( + expect.objectContaining({ + label: 'Trigger release plan', + cta: { + href: `/application-pipeline/release/workspaces/test-ws/release-plan/trigger`, + }, + }), + ); + }); + + it('should contain Edit actions', async () => { + const { result } = renderHook(() => + useReleasePlanActions({ + metadata: { name: 'test-release-plan' }, + status: { conditions: [{ type: 'Succeeded', status: runStatus.Running }] }, + } as any), + ); + const actions = result.current; + + expect(actions[1]).toEqual( + expect.objectContaining({ + label: 'Edit release plan', + cta: { + href: `/application-pipeline/release/workspaces/test-ws/release-plan/edit/test-release-plan`, + }, + }), + ); + }); + + it('should contain Delete actions', async () => { + const { result } = renderHook(() => + useReleasePlanActions({ + metadata: { name: 'test-release-plan' }, + status: { conditions: [{ type: 'Succeeded', status: runStatus.Running }] }, + } as any), + ); + const actions = result.current; + + expect(actions[2].label).toEqual('Delete release plan'); + expect(actions[2].id).toEqual('releaseplan-delete'); + }); + + it('should contain disabled actions', async () => { + useAccessReviewForModelMock.mockReturnValue([false, false]); + const { result } = renderHook(() => + useReleasePlanActions({ + metadata: { name: 'test-release-plan' }, + status: { conditions: [{ type: 'Succeeded', status: runStatus.Running }] }, + } as any), + ); + const actions = result.current; + expect(actions[0].label).toEqual('Trigger release plan'); + expect(actions[0].disabled).toEqual(true); + expect(actions[0].disabledTooltip).toEqual( + "You don't have permission to trigger this release plan", + ); + expect(actions[1].label).toEqual('Edit release plan'); + expect(actions[1].disabled).toEqual(true); + expect(actions[1].disabledTooltip).toEqual( + "You don't have permission to edit this release plan", + ); + expect(actions[2].label).toEqual('Delete release plan'); + expect(actions[2].disabled).toEqual(true); + expect(actions[2].disabledTooltip).toEqual( + "You don't have permission to delete this release plan", + ); + }); +}); diff --git a/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx b/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx index d9e4ee82f..60be9e1d9 100644 --- a/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx +++ b/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx @@ -1,4 +1,4 @@ -import { ReleasePlanModel } from '../../../models'; +import { ReleaseModel, ReleasePlanModel } from '../../../models'; import { ReleasePlanKind } from '../../../types/coreBuildService'; import { useAccessReviewForModel } from '../../../utils/rbac'; import { useWorkspaceInfo } from '../../../utils/workspace-context-utils'; @@ -10,7 +10,18 @@ export const useReleasePlanActions = (obj: ReleasePlanKind) => { const { workspace } = useWorkspaceInfo(); const [canDelete] = useAccessReviewForModel(ReleasePlanModel, 'delete'); const [canUpdate] = useAccessReviewForModel(ReleasePlanModel, 'update'); + const [canTrigger] = useAccessReviewForModel(ReleaseModel, 'create'); + return [ + { + label: 'Trigger release plan', + id: `trigger-releaseplan-${obj.metadata.name}`, + cta: { + href: `/application-pipeline/release/workspaces/${workspace}/release-plan/trigger`, + }, + disabled: !canTrigger, + disabledTooltip: "You don't have permission to trigger this release plan", + }, { label: 'Edit release plan', id: `edit-releaseplan-${obj.metadata.name}`, diff --git a/src/pages/TriggerReleasePlanPage.tsx b/src/pages/TriggerReleasePlanPage.tsx new file mode 100644 index 000000000..5b8868b0b --- /dev/null +++ b/src/pages/TriggerReleasePlanPage.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import NamespacedPage from '../components/NamespacedPage/NamespacedPage'; +import PageAccessCheck from '../components/PageAccess/PageAccessCheck'; +import { TriggerReleaseFormPage } from '../components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage'; +import { FULL_APPLICATION_TITLE } from '../consts/labels'; +import { ReleaseModel } from '../models'; +import { AccessReviewResources } from '../types'; + +const TriggerReleasePlanPage: React.FC> = () => { + const accessReviewResources: AccessReviewResources = [{ model: ReleaseModel, verb: 'create' }]; + + return ( + + Trigger release plan | ${FULL_APPLICATION_TITLE} + + + + + ); +}; + +export default TriggerReleasePlanPage; 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 33da3351e..29952ea6e 100644 --- a/src/types/coreBuildService.ts +++ b/src/types/coreBuildService.ts @@ -70,14 +70,42 @@ 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; + status?: ReleaseStatus; }; export type ReleaseSpec = { snapshot: string; releasePlan: string; + data?: { + releaseNotes?: { + topic?: string; + description: string; + synopsis: string; + cves: CVE[]; + issues: Issue[]; + solution?: string; + references?: string; + }; + }; }; export type ReleaseStatus = {