diff --git a/client/components/ManageTestQueue/index.jsx b/client/components/ManageTestQueue/index.jsx index 3bf82420d..4dc4d941e 100644 --- a/client/components/ManageTestQueue/index.jsx +++ b/client/components/ManageTestQueue/index.jsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; -import styled from '@emotion/styled'; import PropTypes from 'prop-types'; import { LoadingStatus, useTriggerLoad } from '../common/LoadingStatus'; import DisclosureComponent from '../common/DisclosureComponent'; import ManageAtVersions from '@components/ManageTestQueue/ManageAtVersions'; import AddTestPlans from '@components/ManageTestQueue/AddTestPlans'; import { AtPropType, TestPlanVersionPropType } from '../common/proptypes'; +import ManageRequiredReportsDisclosure from '../common/ManageRequiredReportsDisclosure'; +import styled from '@emotion/styled'; export const DisclosureContainer = styled.div` // Following directives are related to the ManageTestQueue component @@ -91,7 +92,6 @@ export const DisclosureContainer = styled.div` flex-wrap: wrap; column-gap: 1rem; row-gap: 0.75rem; - select { width: inherit; @media (max-width: 767px) { @@ -100,6 +100,14 @@ export const DisclosureContainer = styled.div` } } + .disclosure-row-controls { + display: grid; + grid-auto-flow: column; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 1rem; + align-items: end; + } + .disclosure-form-label { font-weight: bold; font-size: 1rem; @@ -109,39 +117,64 @@ export const DisclosureContainer = styled.div` const ManageTestQueue = ({ ats = [], testPlanVersions = [], - triggerUpdate = () => {} + triggerUpdate = () => {}, + browsers = [], + includeManageRequiredReports = false }) => { const { loadingMessage } = useTriggerLoad(); - const [showManageATs, setShowManageATs] = useState(false); const [showAddTestPlans, setShowAddTestPlans] = useState(false); + const [showManageReqReports, setShowManageReqReports] = useState(false); const onManageAtsClick = () => setShowManageATs(!showManageATs); const onAddTestPlansClick = () => setShowAddTestPlans(!showAddTestPlans); + const onManageReqReportsClick = () => + setShowManageReqReports(!showManageReqReports); + + const titles = [ + 'Manage Assistive Technology Versions', + 'Add Test Plans to the Test Queue' + ]; + + const disclosureViews = [ + , + + ]; + + const onClickHandlers = [onManageAtsClick, onAddTestPlansClick]; + const expandedStates = [showManageATs, showAddTestPlans]; + + if (includeManageRequiredReports) { + titles.push('Manage Required Reports'); + disclosureViews.push( + + ); + onClickHandlers.push(onManageReqReportsClick); + expandedStates.push(showManageReqReports); + } return ( , - - ]} - onClick={[onManageAtsClick, onAddTestPlansClick]} - expanded={[showManageATs, showAddTestPlans]} + title={titles} + disclosureContainerView={disclosureViews} + onClick={onClickHandlers} + expanded={expandedStates} stacked /> @@ -151,7 +184,9 @@ const ManageTestQueue = ({ ManageTestQueue.propTypes = { ats: PropTypes.arrayOf(AtPropType).isRequired, testPlanVersions: PropTypes.arrayOf(TestPlanVersionPropType), - triggerUpdate: PropTypes.func + triggerUpdate: PropTypes.func, + browsers: PropTypes.array, + includeManageRequiredReports: PropTypes.bool }; export default ManageTestQueue; diff --git a/client/components/ManageTestQueue/queries.js b/client/components/ManageTestQueue/queries.js index 82ed8a3da..bebe8f4a3 100644 --- a/client/components/ManageTestQueue/queries.js +++ b/client/components/ManageTestQueue/queries.js @@ -53,3 +53,53 @@ export const DELETE_AT_VERSION_MUTATION = gql` } } `; + +export const CREATE_REQUIRED_REPORT_MUTATION = gql` + mutation CreateRequiredReport( + $atId: ID! + $browserId: ID! + $phase: TestPlanVersionPhase! + ) { + requiredReport(atId: $atId, browserId: $browserId, phase: $phase) { + createRequiredReport { + atId + browserId + phase + } + } + } +`; + +export const UPDATE_REQUIRED_REPORT_MUTATION = gql` + mutation UpdateRequiredReport( + $atId: ID! + $browserId: ID! + $phase: TestPlanVersionPhase! + $updateAtId: ID! + $updateBrowserId: ID! + ) { + requiredReport(atId: $atId, browserId: $browserId, phase: $phase) { + updateRequiredReport(atId: $updateAtId, browserId: $updateBrowserId) { + atId + browserId + phase + } + } + } +`; + +export const DELETE_REQUIRED_REPORT_MUTATION = gql` + mutation DeleteRequiredReport( + $atId: ID! + $browserId: ID! + $phase: TestPlanVersionPhase! + ) { + requiredReport(atId: $atId, browserId: $browserId, phase: $phase) { + deleteRequiredReport { + atId + browserId + phase + } + } + } +`; diff --git a/client/components/TestQueue/index.jsx b/client/components/TestQueue/index.jsx index 3cec38396..61d882fb5 100644 --- a/client/components/TestQueue/index.jsx +++ b/client/components/TestQueue/index.jsx @@ -406,6 +406,8 @@ const TestQueue = () => { ats={data.ats} testPlanVersions={testPlanVersions} triggerUpdate={refetch} + includeManageRequiredReports={true} + browsers={data.browsers} /> )} diff --git a/client/components/TestQueue/queries.js b/client/components/TestQueue/queries.js index 7dd6f7411..156b90667 100644 --- a/client/components/TestQueue/queries.js +++ b/client/components/TestQueue/queries.js @@ -44,6 +44,15 @@ export const TEST_QUEUE_PAGE_QUERY = gql` browsers { ...BrowserFields } + candidateBrowsers { + ...BrowserFields + } + recommendedBrowsers { + ...BrowserFields + } + } + browsers { + ...BrowserFields } testPlans(testPlanVersionPhases: [DRAFT, CANDIDATE, RECOMMENDED]) { ...TestPlanFields diff --git a/client/components/common/ManageRequiredReportsDisclosure/CreateRequiredReportForm.jsx b/client/components/common/ManageRequiredReportsDisclosure/CreateRequiredReportForm.jsx new file mode 100644 index 000000000..8f342148b --- /dev/null +++ b/client/components/common/ManageRequiredReportsDisclosure/CreateRequiredReportForm.jsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Form } from 'react-bootstrap'; +import styled from '@emotion/styled'; +import { AtPropType } from '../proptypes'; + +const StyledSelect = styled(Form.Select)` + appearance: none; + background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27%3e%3cpath fill=%27none%27 stroke=%27%23343a40%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27 stroke-width=%272%27 d=%27m2 5 6 6 6-6%27/%3e%3c/svg%3e'); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 16px 12px; + padding: 0.375rem 2.25rem 0.375rem 0.75rem; + + ${props => + props.value && + ` + background-color: ${props.value === 'Candidate' ? '#ff6c00' : '#8441de'}; + color: white; + border-radius: 1.5rem; + padding-left: 1rem; + `} +`; + +const FormGroup = ({ label, children }) => ( + + {label} + {children} + +); + +export const CreateRequiredReportForm = ({ ats, handleCreate }) => { + const [formState, setFormState] = useState({ + phase: '', + at: '', + browser: '' + }); + + const handleInputChange = (field, value) => { + setFormState(prev => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async () => { + await handleCreate({ + atId: formState.at, + browserId: formState.browser, + phase: formState.phase.toUpperCase() + }); + setFormState({ phase: '', at: '', browser: '' }); + }; + + return ( +
+ + handleInputChange('phase', e.target.value)} + required + > + + {['Candidate', 'Recommended'].map(phase => ( + + ))} + + + + handleInputChange('at', e.target.value)} + required + > + + {ats.map(item => ( + + ))} + + + + handleInputChange('browser', e.target.value)} + required + > + + {ats + .find(at => at.id === formState.at) + ?.browsers.map(item => ( + + ))} + + + + + +
+ ); +}; + +FormGroup.propTypes = { + label: PropTypes.string, + children: PropTypes.node.isRequired +}; + +CreateRequiredReportForm.propTypes = { + ats: PropTypes.arrayOf(AtPropType).isRequired, + handleCreate: PropTypes.func.isRequired +}; + +CreateRequiredReportForm.propTypes = {}; diff --git a/client/components/common/ManageRequiredReportsDisclosure/DeleteRequiredReportModal.jsx b/client/components/common/ManageRequiredReportsDisclosure/DeleteRequiredReportModal.jsx new file mode 100644 index 000000000..d61d2d4df --- /dev/null +++ b/client/components/common/ManageRequiredReportsDisclosure/DeleteRequiredReportModal.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import BasicModal from '../BasicModal'; +import PhasePill from '../PhasePill'; +import PropTypes from 'prop-types'; +import { AtPropType, BrowserPropType } from '../proptypes'; + +const DeleteRequiredReportModal = ({ + at, + browser, + phase, + handleClose, + handleDeleteReqReport +}) => { + const handleDelete = () => { + handleDeleteReqReport({ atId: at.id, browserId: browser.id, phase }); + handleClose(); + }; + + return ( + + Delete {`${at.name} and ${browser.name} pair for `} + + {phase} + + {' required reports'} +

+ } + dialogClassName="modal-50w" + actions={[ + { + label: 'Confirm Delete', + onClick: handleDelete + } + ]} + handleClose={handleClose} + staticBackdrop={true} + /> + ); +}; + +DeleteRequiredReportModal.propTypes = { + at: AtPropType.isRequired, + browser: BrowserPropType.isRequired, + phase: PropTypes.string.isRequired, + handleClose: PropTypes.func.isRequired, + handleDeleteReqReport: PropTypes.func.isRequired +}; + +export default DeleteRequiredReportModal; diff --git a/client/components/common/ManageRequiredReportsDisclosure/UpdateRequiredReportModal.jsx b/client/components/common/ManageRequiredReportsDisclosure/UpdateRequiredReportModal.jsx new file mode 100644 index 000000000..fea184e74 --- /dev/null +++ b/client/components/common/ManageRequiredReportsDisclosure/UpdateRequiredReportModal.jsx @@ -0,0 +1,137 @@ +import React, { useMemo, useState } from 'react'; +import PhasePill from '../PhasePill'; +import styled from '@emotion/styled'; +import BasicModal from '../BasicModal'; +import { Form } from 'react-bootstrap'; +import PropTypes from 'prop-types'; +import { AtPropType } from '../proptypes'; + +const ModalInnerSectionContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const Row = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 1rem; +`; + +const UpdateRequiredReportModal = ({ + atId, + browserId, + ats, + phase, + handleClose, + handleUpdate +}) => { + const [atSelection, setAtSelection] = useState(atId); + const [browserSelection, setBrowserSelection] = useState(browserId); + + const handleAtChange = e => { + const value = e.target.value; + setAtSelection(value); + }; + + const handleBrowserChange = e => { + const value = e.target.value; + setBrowserSelection(value); + }; + + const filteredBrowsers = useMemo( + () => ats.find(at => at.id === atSelection)?.browsers || [], + [ats, atSelection] + ); + + return ( + + Edit the following AT/Browser pair for{' '} + + {phase} + {' '} + required reports +

+ } + dialogClassName="modal-50w" + content={ + + { + + + Assistive Technology + + {ats.map(item => { + return ( + + ); + })} + + + + Browser + + + {filteredBrowsers.map(item => ( + + ))} + + + + } + + } + actions={[ + { + label: 'Save Changes', + onClick: () => { + // If the user has changed the AT or Browser, update the required report + if (atSelection !== atId || browserSelection !== browserId) { + handleUpdate({ + atId, + browserId, + phase, + updateAtId: atSelection, + updateBrowserId: browserSelection + }); + } + handleClose(); + } + } + ]} + handleClose={handleClose} + staticBackdrop={true} + /> + ); +}; + +UpdateRequiredReportModal.propTypes = { + atId: PropTypes.string.isRequired, + browserId: PropTypes.string.isRequired, + ats: PropTypes.arrayOf(AtPropType).isRequired, + phase: PropTypes.string.isRequired, + handleClose: PropTypes.func.isRequired, + handleUpdate: PropTypes.func.isRequired +}; + +export default UpdateRequiredReportModal; diff --git a/client/components/common/ManageRequiredReportsDisclosure/index.jsx b/client/components/common/ManageRequiredReportsDisclosure/index.jsx new file mode 100644 index 000000000..80321e45c --- /dev/null +++ b/client/components/common/ManageRequiredReportsDisclosure/index.jsx @@ -0,0 +1,297 @@ +import { faEdit, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTriggerLoad } from '../LoadingStatus'; +import styled from '@emotion/styled'; +import PhasePill from '../PhasePill'; +import { ThemeTable, ThemeTableHeaderH2 } from '../ThemeTable'; +import { useMutation } from '@apollo/client'; +import { + CREATE_REQUIRED_REPORT_MUTATION, + DELETE_REQUIRED_REPORT_MUTATION, + UPDATE_REQUIRED_REPORT_MUTATION +} from '../../ManageTestQueue/queries'; +import BasicThemedModal from '../BasicThemedModal'; +import PropTypes from 'prop-types'; +import { DisclosureContainer } from '../../ManageTestQueue'; +import UpdateRequiredReportModal from './UpdateRequiredReportModal'; +import DeleteRequiredReportModal from './DeleteRequiredReportModal'; +import { AtPropType, BrowserPropType } from '../proptypes'; +import { CreateRequiredReportForm } from './CreateRequiredReportForm'; +import { + filterAtBrowserCombinations, + sortAtBrowserCombinations +} from './utils'; + +const TransparentButton = styled.button` + border: none; + background-color: transparent; +`; + +const ManageRequiredReportsDisclosure = ({ + ats, + browsers, + triggerUpdate = () => {} +}) => { + const { triggerLoad } = useTriggerLoad(); + + const [pendingUpdateReport, setPendingUpdateReport] = useState(null); + const [pendingDeleteReport, setPendingDeleteReport] = useState(null); + + const [modalState, setModalState] = useState({ + show: false, + title: '', + content: '' + }); + + const [createRequiredReport] = useMutation(CREATE_REQUIRED_REPORT_MUTATION); + const [updateRequiredReport] = useMutation(UPDATE_REQUIRED_REPORT_MUTATION); + const [deleteRequiredReport] = useMutation(DELETE_REQUIRED_REPORT_MUTATION); + + useEffect(() => { + setAtBrowserCombinations(getAtBrowserCombos()); + }, [ats, browsers]); + + const showErrorModal = useCallback((title, message) => { + setModalState({ show: true, title, content: <>{message} }); + }, []); + + const getAtBrowserCombos = () => { + return [ + ...ats.flatMap(at => + at.candidateBrowsers?.map(browser => ({ + at, + browser, + phase: 'CANDIDATE' + })) + ), + ...ats.flatMap(at => + at.recommendedBrowsers?.map(browser => ({ + at, + browser, + phase: 'RECOMMENDED' + })) + ) + ]; + }; + + const [atBrowserCombinations, setAtBrowserCombinations] = useState( + getAtBrowserCombos() + ); + + const updateAtBrowserCombinations = useCallback( + (updatedReport, action) => { + setAtBrowserCombinations(prevCombinations => { + let newCombinations = [...prevCombinations]; + + if (action === 'add') { + newCombinations.push({ + at: ats.find(at => at.id === updatedReport.atId), + browser: browsers.find( + browser => browser.id === updatedReport.browserId + ), + phase: updatedReport.phase.replace('IS_', '') + }); + } + + newCombinations = filterAtBrowserCombinations( + newCombinations, + updatedReport + ); + return sortAtBrowserCombinations(newCombinations); + }); + }, + [ats, browsers] + ); + + const handleRequiredReportAction = useCallback( + async (action, actionData, mutationFn, successMessage) => { + try { + await triggerLoad(async () => { + const { data } = await mutationFn({ + variables: { + ...actionData, + phase: actionData.phase + } + }); + + const updatedReport = data.requiredReport[`${action}RequiredReport`]; + + if (updatedReport) { + updateAtBrowserCombinations(updatedReport, action); + } + + await triggerUpdate(); + }, successMessage); + } catch (error) { + showErrorModal( + `Error ${ + action.charAt(0).toUpperCase() + action.slice(1) + }ing Required Report`, + error.message + ); + } + }, + [triggerLoad, triggerUpdate, updateAtBrowserCombinations, showErrorModal] + ); + + const handleCreateRequiredReport = useCallback( + createData => + handleRequiredReportAction( + 'create', + { ...createData, phase: createData.phase.toUpperCase() }, + createRequiredReport, + 'Adding Phase requirement to the required reports table' + ), + [handleRequiredReportAction, createRequiredReport] + ); + + const handleDeleteReqReport = useCallback( + deletionData => { + handleRequiredReportAction( + 'delete', + deletionData, + deleteRequiredReport, + 'Deleting Phase requirement from the required reports table' + ), + handleCloseDeleteModal(); + }, + [handleRequiredReportAction, deleteRequiredReport, handleCloseDeleteModal] + ); + + const handleUpdateReport = useCallback( + updateData => { + handleRequiredReportAction( + 'update', + updateData, + updateRequiredReport, + 'Updating Phase requirement in the required reports table' + ), + handleCloseUpdateModal(); + }, + [handleRequiredReportAction, updateRequiredReport, handleCloseUpdateModal] + ); + + const handleCloseUpdateModal = () => { + setPendingUpdateReport(null); + }; + + const handleCloseDeleteModal = () => { + setPendingDeleteReport(null); + }; + + return ( + <> + + + Update which assistive technology and browser combinations require + reports for the Candidate and Recommended phases + + + + Required Reports + + + + + Phase + AT + Browser + Edit + + + + {atBrowserCombinations?.map(({ at, browser, phase }) => { + return ( + + + + {phase.toUpperCase()} + + + {at.name} + {browser.name} + + { + setPendingUpdateReport({ + atId: at.id, + browserId: browser.id, + phase: phase + }); + }} + > + + Edit + + { + setPendingDeleteReport({ + at, + browser, + phase + }); + }} + > + + Remove + + + + ); + })} + + + + {pendingUpdateReport && ( + + )} + {pendingDeleteReport && ( + + )} + + setModalState({ show: false, title: '', content: null }) + } + ]} + handleClose={() => + setModalState({ show: false, title: '', content: null }) + } + /> + + ); +}; + +ManageRequiredReportsDisclosure.propTypes = { + ats: PropTypes.arrayOf(AtPropType), + browsers: PropTypes.arrayOf(BrowserPropType), + triggerUpdate: PropTypes.func +}; + +export default ManageRequiredReportsDisclosure; diff --git a/client/components/common/ManageRequiredReportsDisclosure/utils.js b/client/components/common/ManageRequiredReportsDisclosure/utils.js new file mode 100644 index 000000000..6e7592844 --- /dev/null +++ b/client/components/common/ManageRequiredReportsDisclosure/utils.js @@ -0,0 +1,20 @@ +export const sortAtBrowserCombinations = combinations => { + return combinations.sort((a, b) => { + if (a.phase < b.phase) return -1; + if (a.phase > b.phase) return 1; + return a.at.name.localeCompare(b.at.name); + }); +}; + +export const filterAtBrowserCombinations = ( + combinations, + { atId, browserId, phase } +) => { + return combinations.filter(row => { + return !( + row.at.id === atId && + row.browser.id === browserId && + row.phase === phase + ); + }); +}; diff --git a/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js b/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js index 32c8de0cf..c7de62598 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestQueuePageAdminNotPopulatedMock.js @@ -40,7 +40,8 @@ export default testQueuePageQuery => [ ats: [], testPlans: [], testPlanVersions: [], - testPlanReports: [] + testPlanReports: [], + browsers: [] } } } diff --git a/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js b/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js index 186f6ac4f..83a112fb2 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestQueuePageTesterNotPopulatedMock.js @@ -40,7 +40,8 @@ export default testQueuePageQuery => [ ats: [], testPlans: [], testPlanVersions: [], - testPlanReports: [] + testPlanReports: [], + browsers: [] } } } diff --git a/client/tests/e2e/AtVersions.e2e.test.js b/client/tests/e2e/AtVersions.e2e.test.js index 518a2f217..894076344 100644 --- a/client/tests/e2e/AtVersions.e2e.test.js +++ b/client/tests/e2e/AtVersions.e2e.test.js @@ -22,8 +22,8 @@ describe('AT Version UI', () => { await page.click('.modal-footer button ::-p-text(Add Version)'); await page.waitForNetworkIdle({ idleTime: 5000 }); await page.click('.modal-footer button ::-p-text(Ok)'); - await page.waitForSelector('.at-versions-container option:nth-child(2) ::-p-text(99.0.1)'); - const optionValue = await page.$eval('.at-versions-container option:nth-child(2)', option => option.value); + await page.waitForSelector('.at-versions-container option:nth-child(1) ::-p-text(99.0.1)'); + const optionValue = await page.$eval('.at-versions-container option:nth-child(1)', option => option.value); await page.select('.at-versions-container select', optionValue); await page.click('.at-versions-container button ::-p-text(Edit)'); const input = await page.waitForSelector('.modal-body .form-group:nth-child(1) input'); diff --git a/client/tests/e2e/TestQueue.e2e.test.js b/client/tests/e2e/TestQueue.e2e.test.js index f8168bed3..a94c96e24 100644 --- a/client/tests/e2e/TestQueue.e2e.test.js +++ b/client/tests/e2e/TestQueue.e2e.test.js @@ -486,3 +486,186 @@ describe('Test Queue tester traits when reports exist', () => { }); }); }); + +describe('Manage Required Reports Disclosure', () => { + const disclosureSelector = + '#disclosure-btn-manage-test-queue-Manage\\ Required\\ Reports'; + + it('renders the disclosure container and can be expanded', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + await page.waitForSelector(disclosureSelector); + + const disclosureButton = await page.$(disclosureSelector); + expect(disclosureButton).toBeTruthy(); + + await disclosureButton.click(); + + const expandedContent = await page.$( + '#disclosure-container-manage-test-queue-Manage\\ Required\\ Reports' + ); + expect(expandedContent).toBeTruthy(); + }); + }); + + it('can create a new required report', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + await page.waitForSelector(disclosureSelector); + await page.click(disclosureSelector); + + // Wait for the disclosure row controls to be rendered + await page.waitForSelector('.disclosure-row-controls'); + + // Find the phase select and choose Candidate + const phaseSelectSelector = + '.disclosure-row-controls .form-group:first-child select'; + await page.waitForSelector(phaseSelectSelector); + await page.select(phaseSelectSelector, 'Candidate'); + + // Find the Assistive Technology dropdown and select VoiceOver + const atSelectSelector = + '.disclosure-row-controls .form-group:nth-child(2) select.form-select'; + await page.waitForSelector(atSelectSelector); + await page.select(atSelectSelector, '3'); + + // Find the Browser dropdown and select Chrome + const browserSelectSelector = + '.disclosure-row-controls .form-group:nth-child(3) select.form-select'; + await page.waitForSelector(browserSelectSelector); + await page.select(browserSelectSelector, '2'); + + await page.waitForSelector( + '.disclosure-row-controls .form-group:nth-child(4) button' + ); + + // Click the "Add Required Reports" button + await page.click( + '.disclosure-row-controls .form-group:nth-child(4) button' + ); + + // Wait for the row to be added + await page.waitForFunction(() => { + const rows = document.querySelectorAll('tbody tr'); + return Array.from(rows).some( + row => + row.textContent.includes('VoiceOver for macOS') && + row.textContent.includes('Chrome') && + row.textContent.includes('Candidate') + ); + }, 1000); + }); + }); + + it('can update an existing required report', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + await page.waitForSelector(disclosureSelector); + await page.click(disclosureSelector); + + // Find the edit button for the NVDA/Chrome/Candidate row + const editButtonSelector = await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('tbody tr')); + const targetRow = rows.find( + row => + row.textContent.includes('NVDA') && + row.textContent.includes('Chrome') && + row.textContent.includes('Candidate') + ); + if (targetRow) { + const editButton = targetRow.querySelector( + '.edit-required-report-button' + ); + return editButton + ? `tbody tr:nth-child(${ + rows.indexOf(targetRow) + 1 + }) .edit-required-report-button` + : null; + } + return null; + }); + + if (editButtonSelector) { + await page.click(editButtonSelector); + } else { + throw new Error('Edit button not found'); + } + + await page.waitForSelector('div[role="dialog"].modal.show'); + const modalTitle = await page.$eval('.modal-title', el => el.textContent); + expect(modalTitle).toContain('Edit the following AT/Browser pair'); + + const browserSelectSelector = + '.modal-body select[data-testid="required-report-browser-select"]'; + await page.waitForSelector(browserSelectSelector); + await page.select(browserSelectSelector, '1'); // Firefox + + // Save changes + await page.click('.modal-footer button.btn-primary'); + + await page.waitForFunction( + () => !document.querySelector('div[role="dialog"].modal.show') + ); + + // Wait for the table to update + await page.waitForFunction(() => { + const rows = document.querySelectorAll('tbody tr'); + return Array.from(rows).some( + row => + row.textContent.includes('NVDA') && + row.textContent.includes('Firefox') && + row.textContent.includes('Candidate') + ); + }, 1000); + }); + }); + + it('can delete an existing required report', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + await page.waitForSelector(disclosureSelector); + await page.click(disclosureSelector); + + // Find and click the delete button for the NVDA/Chrome/Candidate row + const deleteButtonSelector = await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('tbody tr')); + const targetRow = rows.find( + row => + row.textContent.includes('NVDA') && + row.textContent.includes('Chrome') && + row.textContent.includes('Candidate') + ); + if (targetRow) { + const deleteButton = targetRow.querySelector( + '.delete-required-report-button' + ); + return deleteButton + ? `tbody tr:nth-child(${ + rows.indexOf(targetRow) + 1 + }) .delete-required-report-button` + : null; + } + return null; + }); + + if (deleteButtonSelector) { + await page.click(deleteButtonSelector); + } else { + throw new Error('Delete button not found'); + } + + await page.waitForSelector( + 'text=Delete NVDA and Chrome pair for Candidate required reports' + ); + // Confirm deletion + await page.click('.modal-footer button.btn-primary'); + + // Ensure the row is deleted + await page.waitForFunction(() => { + const rows = document.querySelectorAll('tbody tr'); + return !Array.from(rows).some( + row => + row.textContent.includes('NVDA') && + row.textContent.includes('Chrome') && + row.textContent.includes('Candidate') + ); + }, 1000); + }); + }); +}); diff --git a/client/tests/e2e/snapshots/saved/_data-management.html b/client/tests/e2e/snapshots/saved/_data-management.html index 496638791..4f7539081 100644 --- a/client/tests/e2e/snapshots/saved/_data-management.html +++ b/client/tests/e2e/snapshots/saved/_data-management.html @@ -114,7 +114,7 @@

id="disclosure-container-manage-test-queue-Manage Assistive Technology Versions" aria-labelledby="disclosure-btn-manage-test-queue-Manage Assistive Technology Versions" class="css-19fsyrg"> -
+
Select an assistive technology and manage its versions in the ARIA-AT App id="disclosure-container-manage-test-queue-Add Test Plans to the Test Queue" aria-labelledby="disclosure-btn-manage-test-queue-Add Test Plans to the Test Queue" class="css-19fsyrg"> -
+
Select a test plan, assistive technology and browser to add a new test plan report to the test queue. id="disclosure-container-manage-test-queue-Manage Assistive Technology Versions" aria-labelledby="disclosure-btn-manage-test-queue-Manage Assistive Technology Versions" class="css-19fsyrg"> -
+
Select an assistive technology and manage its versions in the ARIA-AT App id="disclosure-container-manage-test-queue-Add Test Plans to the Test Queue" aria-labelledby="disclosure-btn-manage-test-queue-Add Test Plans to the Test Queue" class="css-19fsyrg"> -
+
Select a test plan, assistive technology and browser to add a new test plan report to the test queue.
+

+ +

+
+
+ Update which assistive technology and browser combinations + require reports for the Candidate and Recommended phases +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

+ Required Reports +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseATBrowserEdit
+ Candidate + JAWSChrome + +
+ Candidate + NVDAChrome + +
+ Candidate + VoiceOver for macOSSafari + +
+ Recommended + JAWSFirefox + +
+ Recommended + JAWSChrome + +
+ Recommended + NVDAFirefox + +
+ Recommended + NVDAChrome + +
+ Recommended + VoiceOver for macOSSafari + +
+ Recommended + VoiceOver for macOSChrome + +
+
+
+

Alert Example

diff --git a/server/graphql-schema.js b/server/graphql-schema.js index b6b50adfa..00b2db807 100644 --- a/server/graphql-schema.js +++ b/server/graphql-schema.js @@ -786,6 +786,31 @@ const graphqlSchema = gql` unexpectedBehaviors: [UnexpectedBehavior] } + """ + See TestResult type for more information. + The return type for createRequiredReport. + """ + type RequiredReport { + atId: ID! + browserId: ID! + phase: TestPlanVersionPhase! + } + + """ + The fields on the RequiredReportOperations type which can be used or update the + RequiredReports. + """ + input RequiredReportOperationsInput { + """ + See AtVersion type for more information. + """ + inputAtId: ID! + """ + See AtVersion type for more information. + """ + inputBrowserId: ID! + } + """ Minimal plain representation of a ScenarioResult. """ @@ -1316,6 +1341,12 @@ const graphqlSchema = gql` findOrCreateAtVersion(input: AtVersionInput!): AtVersion! } + type RequiredReportOperations { + createRequiredReport: RequiredReport! + updateRequiredReport(atId: ID!, browserId: ID!): RequiredReport! + deleteRequiredReport: RequiredReport! + } + """ Mutations scoped to an existing AtVersion. """ @@ -1571,6 +1602,14 @@ const graphqlSchema = gql` Delete a CollectionJob """ deleteCollectionJob(id: ID!): NoResponse! + """ + Get the available mutations for the given RequiredReport. + """ + requiredReport( + atId: ID! + browserId: ID! + phase: TestPlanVersionPhase! + ): RequiredReportOperations! } `; diff --git a/server/models/services/AtBrowserService b/server/models/services/AtBrowserService new file mode 100644 index 000000000..f3de0767e --- /dev/null +++ b/server/models/services/AtBrowserService @@ -0,0 +1,25 @@ +const ModelService = require('./ModelService.js'); +const { AtBrowsers } = require('../'); +const { AT_BROWSERS_ATTRIBUTES } = require('./helpers'); + +const updateAtBrowser = async ({ + atId, + browserId, + updateParams = {}, + atBrowsersAttributes = AT_BROWSERS_ATTRIBUTES, + transaction +}) => { + await ModelService.update(AtBrowsers, { + where: { atId, browserId }, + values: updateParams, + transaction + }); + + return ModelService.getByQuery(AtBrowsers, { + where: { atId, browserId }, + attributes: atBrowsersAttributes, + transaction + }); +}; + +module.exports = { updateAtBrowser }; diff --git a/server/models/services/helpers.js b/server/models/services/helpers.js index 402787495..3db63d195 100644 --- a/server/models/services/helpers.js +++ b/server/models/services/helpers.js @@ -12,7 +12,8 @@ const { UserRoles, UserAts, CollectionJob, - CollectionJobTestStatus + CollectionJobTestStatus, + AtBrowsers } = require('../index'); /** @@ -32,6 +33,7 @@ module.exports = { AT_VERSION_ATTRIBUTES: getSequelizeModelAttributes(AtVersion), BROWSER_ATTRIBUTES: getSequelizeModelAttributes(Browser), BROWSER_VERSION_ATTRIBUTES: getSequelizeModelAttributes(BrowserVersion), + AT_BROWSERS_ATTRIBUTES: getSequelizeModelAttributes(AtBrowsers), ROLE_ATTRIBUTES: getSequelizeModelAttributes(Role), TEST_PLAN_ATTRIBUTES: getSequelizeModelAttributes(TestPlan), TEST_PLAN_VERSION_ATTRIBUTES: getSequelizeModelAttributes(TestPlanVersion), diff --git a/server/resolvers/RequiredReportOperations/createRequiredReportResolver.js b/server/resolvers/RequiredReportOperations/createRequiredReportResolver.js new file mode 100644 index 000000000..2bf27341d --- /dev/null +++ b/server/resolvers/RequiredReportOperations/createRequiredReportResolver.js @@ -0,0 +1,28 @@ +const { AuthenticationError } = require('apollo-server'); +const { updateAtBrowser } = require('../../models/services/AtBrowserService'); + +const createRequiredReportResolver = async ( + { parentContext: { atId, browserId, phase } }, + _, + context +) => { + const { user, transaction } = context; + if (!user?.roles.find(role => role.name === 'ADMIN')) { + throw new AuthenticationError(); + } + + let updateParams = {}; + + if (phase === 'CANDIDATE') { + updateParams = { isCandidate: true }; + } + if (phase === 'RECOMMENDED') { + updateParams = { isRecommended: true }; + } + + await updateAtBrowser({ atId, browserId, updateParams, transaction }); + + return { atId, browserId, phase }; +}; + +module.exports = createRequiredReportResolver; diff --git a/server/resolvers/RequiredReportOperations/deleteRequiredReportResolver.js b/server/resolvers/RequiredReportOperations/deleteRequiredReportResolver.js new file mode 100644 index 000000000..d1e97f410 --- /dev/null +++ b/server/resolvers/RequiredReportOperations/deleteRequiredReportResolver.js @@ -0,0 +1,28 @@ +const { AuthenticationError } = require('apollo-server'); +const { updateAtBrowser } = require('../../models/services/AtBrowserService'); + +const deleteRequiredReportResolver = async ( + { parentContext: { atId, browserId, phase } }, + _, + context +) => { + const { user, transaction } = context; + if (!user?.roles.find(role => role.name === 'ADMIN')) { + throw new AuthenticationError(); + } + + let updateParams = {}; + + if (phase === 'CANDIDATE') { + updateParams = { isCandidate: false }; + } + if (phase === 'RECOMMENDED') { + updateParams = { isRecommended: false }; + } + + await updateAtBrowser({ atId, browserId, updateParams, transaction }); + + return { atId, browserId, phase }; +}; + +module.exports = deleteRequiredReportResolver; diff --git a/server/resolvers/RequiredReportOperations/index.js b/server/resolvers/RequiredReportOperations/index.js new file mode 100644 index 000000000..1a1a91c11 --- /dev/null +++ b/server/resolvers/RequiredReportOperations/index.js @@ -0,0 +1,9 @@ +const createRequiredReport = require('./createRequiredReportResolver'); +const updateRequiredReport = require('./updateRequiredReportResolver'); +const deleteRequiredReport = require('./deleteRequiredReportResolver'); + +module.exports = { + createRequiredReport, + updateRequiredReport, + deleteRequiredReport +}; diff --git a/server/resolvers/RequiredReportOperations/updateRequiredReportResolver.js b/server/resolvers/RequiredReportOperations/updateRequiredReportResolver.js new file mode 100644 index 000000000..a27c3e1f4 --- /dev/null +++ b/server/resolvers/RequiredReportOperations/updateRequiredReportResolver.js @@ -0,0 +1,46 @@ +const { AuthenticationError } = require('apollo-server'); +const { updateAtBrowser } = require('../../models/services/AtBrowserService'); + +const updateRequiredReportResolver = async ( + { parentContext: { atId, browserId, phase } }, + { atId: inputAtId, browserId: inputBrowserId }, + context +) => { + const { user, transaction } = context; + if (!user?.roles.find(role => role.name === 'ADMIN')) { + throw new AuthenticationError(); + } + + let updateParams = {}; + + // These conditionals will change values in the At/Browsers table + // in the database. Each updateAtBrowser() call changes the boolean value + // for a particular row in the database. The booleans for two row need to be + // changed. So we call updateAtBrowser() twice. + if (phase === 'CANDIDATE') { + updateParams = { isCandidate: false }; + await updateAtBrowser({ atId, browserId, updateParams, transaction }); + updateParams = { isCandidate: true }; + await updateAtBrowser({ + atId: inputAtId, + browserId: inputBrowserId, + updateParams, + transaction + }); + } + if (phase === 'RECOMMENDED') { + updateParams = { isRecommended: false }; + await updateAtBrowser({ atId, browserId, updateParams, transaction }); + updateParams = { isRecommended: true }; + await updateAtBrowser({ + atId: inputAtId, + browserId: inputBrowserId, + updateParams, + transaction + }); + } + + return { atId: inputAtId, browserId: inputBrowserId, phase }; +}; + +module.exports = updateRequiredReportResolver; diff --git a/server/resolvers/index.js b/server/resolvers/index.js index 58bfd1d80..cf7364cd4 100644 --- a/server/resolvers/index.js +++ b/server/resolvers/index.js @@ -15,6 +15,7 @@ const addViewer = require('./addViewerResolver'); const mutateAt = require('./mutateAtResolver'); const mutateAtVersion = require('./mutateAtVersionResolver'); const mutateBrowser = require('./mutateBrowserResolver'); +const mutateRequiredReport = require('./mutateRequiredReportResolver'); const mutateTestPlanReport = require('./mutateTestPlanReportResolver'); const mutateTestPlanRun = require('./mutateTestPlanRunResolver'); const mutateTestResult = require('./mutateTestResultResolver'); @@ -34,6 +35,7 @@ const AtOperations = require('./AtOperations'); const AtVersionOperations = require('./AtVersionOperations'); const BrowserOperations = require('./BrowserOperations'); const TestPlan = require('./TestPlan'); +const RequiredReportOperations = require('./RequiredReportOperations'); const TestPlanVersion = require('./TestPlanVersion'); const TestPlanReport = require('./TestPlanReport'); const TestPlanReportOperations = require('./TestPlanReportOperations'); @@ -75,6 +77,7 @@ const resolvers = { testResult: mutateTestResult, testPlanVersion: mutateTestPlanVersion, collectionJob: mutateCollectionJob, + requiredReport: mutateRequiredReport, createTestPlanReport, updateMe, addViewer, @@ -99,6 +102,7 @@ const resolvers = { TestResultOperations, TestPlanVersionOperations, CollectionJobOperations, + RequiredReportOperations, AtVersion }; diff --git a/server/resolvers/mutateRequiredReportResolver.js b/server/resolvers/mutateRequiredReportResolver.js new file mode 100644 index 000000000..e0b2b8533 --- /dev/null +++ b/server/resolvers/mutateRequiredReportResolver.js @@ -0,0 +1,9 @@ +const mutateRequiredReportResolver = ( + _, + { atId, browserId, phase }, + context // eslint-disable-line no-unused-vars +) => { + return { parentContext: { atId, browserId, phase } }; +}; + +module.exports = mutateRequiredReportResolver; diff --git a/server/tests/integration/graphql.test.js b/server/tests/integration/graphql.test.js index 7ffbf8c69..93a8b777f 100644 --- a/server/tests/integration/graphql.test.js +++ b/server/tests/integration/graphql.test.js @@ -801,6 +801,27 @@ describe('graphql', () => { } } deleteCollectionJob(id: 1) + requiredReport(atId: 1, browserId: 1, phase: CANDIDATE) { + __typename + createRequiredReport { + __typename + atId + browserId + phase + } + updateRequiredReport(atId: 1, browserId: 1) { + __typename + atId + browserId + phase + } + deleteRequiredReport { + __typename + atId + browserId + phase + } + } } `, {