From 0f42b1d8d9471e31ff0405a2e917b70361ae511a Mon Sep 17 00:00:00 2001 From: Howard Edwards Date: Thu, 20 Jun 2024 16:46:25 -0400 Subject: [PATCH] feat: Test Queue v2 (#1124) Addresses https://github.com/w3c/aria-at-app/issues/791#issuecomment-2002610093 --------- Co-authored-by: Paul Clue <67766160+Paul-Clue@users.noreply.github.com> Co-authored-by: alflennik --- .../AddTestToQueueWithConfirmation/index.jsx | 92 ++- .../AddTestToQueueWithConfirmation/queries.js | 2 + .../components/BotRunTestStatusList/index.js | 115 ++- .../GraphQLProvider/GraphQLProvider.jsx | 1 + .../ManageBotRunDialog/WithButton.jsx | 33 +- .../ManageTestQueue/AddTestPlans.jsx | 343 ++++++++ .../ManageTestQueue/ManageAtVersions.jsx | 451 ++++++++++ client/components/ManageTestQueue/index.jsx | 781 +----------------- .../Reports/SummarizeTestPlanVersion.jsx | 19 +- .../TestPlanReportStatusDialog/WithButton.jsx | 17 +- .../TestPlanReportStatusDialog/index.jsx | 146 +--- .../components/TestPlanVersionsPage/index.jsx | 13 +- client/components/TestQueue2/Actions.jsx | 349 ++++++++ .../components/TestQueue2/AssignTesters.jsx | 382 +++++++++ .../CompletionStatusListItem/index.jsx | 88 ++ .../components/TestQueue2/TestQueue2.test.js | 27 + client/components/TestQueue2/index.jsx | 416 ++++++++++ client/components/TestQueue2/queries.js | 175 ++++ .../BotTestCompletionStatus/index.js | 39 +- .../index.js | 20 +- .../common/AtBrowserVersion/index.jsx | 54 ++ .../common/ClippedProgressBar/index.jsx | 18 +- .../common/DisclosureComponent/index.jsx | 24 +- client/components/common/RadioBox/index.jsx | 1 - .../common/ReportStatusSummary/index.jsx | 97 +++ client/hooks/useConfirmationModal.js | 43 + client/hooks/useForceUpdate.js | 10 + client/hooks/useThemedModal.js | 44 +- client/index.js | 10 +- client/routes/index.js | 5 +- client/tests/AtVersions.test.js | 9 +- .../TestPlanReportStatusDialogMock.js | 7 +- .../GraphQLMocks/TestQueuePageBaseMock.js | 7 +- ...st.js => calculatePercentComplete.test.js} | 18 +- client/tests/smokeTest.test.js | 57 +- .../calculatePercentComplete.js} | 5 +- client/utils/evaluateAuth.js | 12 +- server/graphql-schema.js | 2 +- server/handlebars/embed/public/style.css | 2 +- ...ics.js => 20240516195950-updateMetrics.js} | 0 server/models/loaders/AtLoader.js | 2 +- server/models/loaders/BrowserLoader.js | 20 +- .../resolvers/createTestPlanReportResolver.js | 2 +- server/resolvers/testPlansResolver.js | 53 +- 44 files changed, 2916 insertions(+), 1095 deletions(-) create mode 100644 client/components/ManageTestQueue/AddTestPlans.jsx create mode 100644 client/components/ManageTestQueue/ManageAtVersions.jsx create mode 100644 client/components/TestQueue2/Actions.jsx create mode 100644 client/components/TestQueue2/AssignTesters.jsx create mode 100644 client/components/TestQueue2/CompletionStatusListItem/index.jsx create mode 100644 client/components/TestQueue2/TestQueue2.test.js create mode 100644 client/components/TestQueue2/index.jsx create mode 100644 client/components/TestQueue2/queries.js create mode 100644 client/components/common/AtBrowserVersion/index.jsx create mode 100644 client/components/common/ReportStatusSummary/index.jsx create mode 100644 client/hooks/useConfirmationModal.js create mode 100644 client/hooks/useForceUpdate.js rename client/tests/{calculateTestPlanReportCompletionPercentage.test.js => calculatePercentComplete.test.js} (73%) rename client/{components/TestPlanReportStatusDialog/calculateTestPlanReportCompletionPercentage.js => utils/calculatePercentComplete.js} (84%) rename server/migrations/{20240319195950-updateMetrics.js => 20240516195950-updateMetrics.js} (100%) diff --git a/client/components/AddTestToQueueWithConfirmation/index.jsx b/client/components/AddTestToQueueWithConfirmation/index.jsx index 4ce0d400c..9f8670edf 100644 --- a/client/components/AddTestToQueueWithConfirmation/index.jsx +++ b/client/components/AddTestToQueueWithConfirmation/index.jsx @@ -14,6 +14,9 @@ import { SCHEDULE_COLLECTION_JOB_MUTATION, EXISTING_TEST_PLAN_REPORTS } from './queries'; +import { TEST_QUEUE_PAGE_QUERY } from '../TestQueue2/queries'; +import { TEST_PLAN_REPORT_STATUS_DIALOG_QUERY } from '../TestPlanReportStatusDialog/queries'; +import { ME_QUERY } from '../App/queries'; function AddTestToQueueWithConfirmation({ testPlanVersion, @@ -29,8 +32,27 @@ function AddTestToQueueWithConfirmation({ useState(false); const [showConfirmation, setShowConfirmation] = useState(false); const [canUseOldResults, setCanUseOldResults] = useState(false); - const [addTestPlanReport] = useMutation(ADD_TEST_QUEUE_MUTATION); - const [scheduleCollection] = useMutation(SCHEDULE_COLLECTION_JOB_MUTATION); + + const [addTestPlanReport] = useMutation(ADD_TEST_QUEUE_MUTATION, { + refetchQueries: [ + ME_QUERY, + EXISTING_TEST_PLAN_REPORTS, + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true + }); + + const [scheduleCollection] = useMutation(SCHEDULE_COLLECTION_JOB_MUTATION, { + refetchQueries: [ + ME_QUERY, + EXISTING_TEST_PLAN_REPORTS, + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true + }); + const { data: existingTestPlanReportsData } = useQuery( EXISTING_TEST_PLAN_REPORTS, { @@ -49,13 +71,23 @@ function AddTestToQueueWithConfirmation({ let latestOldVersion; let oldReportToCopyResultsFrom; - // Check if any results data available from a previous result - if (existingTestPlanReportsData?.oldTestPlanVersions?.length) { - latestOldVersion = - existingTestPlanReportsData?.oldTestPlanVersions?.reduce((a, b) => - new Date(a.updatedAt) > new Date(b.updatedAt) ? a : b - ); + // Check if any results data available from a previous result using the + // same testFormatVersion + const oldTestPlanVersions = + existingTestPlanReportsData?.oldTestPlanVersions?.filter( + ({ metadata }) => { + return ( + metadata.testFormatVersion === + existingTestPlanReportsData?.existingTestPlanVersion + ?.metadata.testFormatVersion + ); + } + ) || []; + if (oldTestPlanVersions?.length) { + latestOldVersion = oldTestPlanVersions?.reduce((a, b) => + new Date(a.updatedAt) > new Date(b.updatedAt) ? a : b + ); if ( new Date(latestOldVersion?.updatedAt) < new Date(testPlanVersion?.updatedAt) @@ -122,23 +154,8 @@ function AddTestToQueueWithConfirmation({ actions.push({ label: 'Add and run later', onClick: async () => { - await addTestToQueue( - canUseOldResults - ? { - copyResultsFromTestPlanVersionId: - latestOldVersion.id - } - : {} - ); - await closeWithUpdate(); - } - }); - - if (!alreadyHasBotInTestPlanReport) { - actions.push({ - label: 'Add and run with bot', - onClick: async () => { - const testPlanReport = await addTestToQueue( + try { + await addTestToQueue( canUseOldResults ? { copyResultsFromTestPlanVersionId: @@ -146,8 +163,31 @@ function AddTestToQueueWithConfirmation({ } : {} ); - await scheduleCollectionJob(testPlanReport); await closeWithUpdate(); + } catch (e) { + console.error(e); + } + } + }); + + if (!alreadyHasBotInTestPlanReport) { + actions.push({ + label: 'Add and run with bot', + onClick: async () => { + try { + const testPlanReport = await addTestToQueue( + canUseOldResults + ? { + copyResultsFromTestPlanVersionId: + latestOldVersion.id + } + : {} + ); + await scheduleCollectionJob(testPlanReport); + await closeWithUpdate(); + } catch (e) { + console.error(e); + } } }); } diff --git a/client/components/AddTestToQueueWithConfirmation/queries.js b/client/components/AddTestToQueueWithConfirmation/queries.js index e7d7b40d3..8aafafdf6 100644 --- a/client/components/AddTestToQueueWithConfirmation/queries.js +++ b/client/components/AddTestToQueueWithConfirmation/queries.js @@ -30,6 +30,7 @@ export const EXISTING_TEST_PLAN_REPORTS = gql` id } } + metadata } oldTestPlanVersions: testPlanVersions( phases: [CANDIDATE, RECOMMENDED] @@ -46,6 +47,7 @@ export const EXISTING_TEST_PLAN_REPORTS = gql` id } } + metadata } } `; diff --git a/client/components/BotRunTestStatusList/index.js b/client/components/BotRunTestStatusList/index.js index 6d1873c50..9a5bf389e 100644 --- a/client/components/BotRunTestStatusList/index.js +++ b/client/components/BotRunTestStatusList/index.js @@ -5,6 +5,7 @@ import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; import ReportStatusDot from '../common/ReportStatusDot'; +// TODO: Remove when Test Queue v1 is removed const BotRunTestStatusUnorderedList = styled.ul` list-style-type: none; background-color: #f6f8fa; @@ -14,6 +15,21 @@ const BotRunTestStatusUnorderedList = styled.ul` white-space: nowrap; `; +const BotRunTestContainer = styled.div` + font-size: 0.875rem !important; + padding: 0.5rem 0; + margin: 0.5rem 0; + + background: #f5f5f5; + border-radius: 0.25rem; + + white-space: nowrap; +`; + +const BotRunTestStatusUnorderedListV2 = styled.ul` + list-style-type: none; +`; + /** * Generate a string describing the status of some number of "Tests" where the * word "Test" is pluralized appropriately and qualified with the provided @@ -33,7 +49,10 @@ const testCountString = (count, status) => const pollInterval = 2000; -const BotRunTestStatusList = ({ testPlanReportId }) => { +const BotRunTestStatusList = ({ + testPlanReportId, + fromTestQueueV2 = false // TODO: Remove when Test Queue v1 is removed +}) => { const { data: testPlanRunsQueryResult, startPolling, @@ -84,40 +103,78 @@ const BotRunTestStatusList = ({ testPlanReportId }) => { ) { return null; } + return ( - - {RUNNING > 0 && ( -
  • - - {testCountString(RUNNING, 'Running')} -
  • - )} - {ERROR > 0 && ( -
  • - - {testCountString(ERROR, 'Error')} -
  • - )} -
  • - - {testCountString(COMPLETED, 'Completed')} -
  • -
  • - - {testCountString(QUEUED, 'Queued')} -
  • - {CANCELLED > 0 && ( -
  • - - {testCountString(CANCELLED, 'Cancelled')} -
  • + <> + {fromTestQueueV2 ? ( + + Bot Status: + + {RUNNING > 0 && ( +
  • + + {testCountString(RUNNING, 'Running')} +
  • + )} + {ERROR > 0 && ( +
  • + + {testCountString(ERROR, 'Error')} +
  • + )} +
  • + + {testCountString(COMPLETED, 'Completed')} +
  • +
  • + + {testCountString(QUEUED, 'Queued')} +
  • + {CANCELLED > 0 && ( +
  • + + {testCountString(CANCELLED, 'Cancelled')} +
  • + )} +
    +
    + ) : ( + + {RUNNING > 0 && ( +
  • + + {testCountString(RUNNING, 'Running')} +
  • + )} + {ERROR > 0 && ( +
  • + + {testCountString(ERROR, 'Error')} +
  • + )} +
  • + + {testCountString(COMPLETED, 'Completed')} +
  • +
  • + + {testCountString(QUEUED, 'Queued')} +
  • + {CANCELLED > 0 && ( +
  • + + {testCountString(CANCELLED, 'Cancelled')} +
  • + )} +
    )} -
    + ); }; BotRunTestStatusList.propTypes = { - testPlanReportId: PropTypes.string.isRequired + testPlanReportId: PropTypes.string.isRequired, + fromTestQueueV2: PropTypes.bool }; export default BotRunTestStatusList; diff --git a/client/components/GraphQLProvider/GraphQLProvider.jsx b/client/components/GraphQLProvider/GraphQLProvider.jsx index 7631848b2..cb0fa25b1 100644 --- a/client/components/GraphQLProvider/GraphQLProvider.jsx +++ b/client/components/GraphQLProvider/GraphQLProvider.jsx @@ -31,6 +31,7 @@ const client = new ApolloClient({ typePolicies: { Query: { fields: { + me: { merge: true }, testPlanVersion: { merge: true }, testPlanVersions: { merge: false }, testPlanReport: { merge: true }, diff --git a/client/components/ManageBotRunDialog/WithButton.jsx b/client/components/ManageBotRunDialog/WithButton.jsx index 3546b117e..920cd9498 100644 --- a/client/components/ManageBotRunDialog/WithButton.jsx +++ b/client/components/ManageBotRunDialog/WithButton.jsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Button } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faRobot } from '@fortawesome/free-solid-svg-icons'; import ManageBotRunDialog from '.'; import { useTestPlanRunIsFinished } from '../../hooks/useTestPlanRunIsFinished'; @@ -9,6 +11,7 @@ const ManageBotRunDialogWithButton = ({ testPlanReportId, runnableTestsLength, testers, + includeIcon = false, onChange }) => { const { runIsFinished } = useTestPlanRunIsFinished(testPlanRun.id); @@ -24,22 +27,25 @@ const ManageBotRunDialogWithButton = ({ onClick={async () => { setShowDialog(true); }} - className="mb-2" > + {/* TODO: Include by default after removing Test Queue v1 content */} + {includeIcon ? : null} Manage {testPlanRun?.tester?.username} Run - { - await onChange(); - setShowDialog(false); - }} - /> + {showDialog ? ( + { + await onChange(); + setShowDialog(false); + }} + /> + ) : null} ); }; @@ -49,6 +55,7 @@ ManageBotRunDialogWithButton.propTypes = { testPlanReportId: PropTypes.string.isRequired, runnableTestsLength: PropTypes.number.isRequired, testers: PropTypes.array.isRequired, + includeIcon: PropTypes.bool, onChange: PropTypes.func.isRequired }; diff --git a/client/components/ManageTestQueue/AddTestPlans.jsx b/client/components/ManageTestQueue/AddTestPlans.jsx new file mode 100644 index 000000000..d0ee8458b --- /dev/null +++ b/client/components/ManageTestQueue/AddTestPlans.jsx @@ -0,0 +1,343 @@ +import React, { useEffect, useState } from 'react'; +import { Form } from 'react-bootstrap'; +import RadioBox from '@components/common/RadioBox'; +import AddTestToQueueWithConfirmation from '@components/AddTestToQueueWithConfirmation'; +import { DisclosureContainer } from '@components/ManageTestQueue/index'; +import { gitUpdatedDateToString } from '@client/utils/gitUtils'; +import PropTypes from 'prop-types'; + +const AddTestPlans = ({ + ats = [], + testPlanVersions = [], + triggerUpdate = () => {} +}) => { + const [allTestPlans, setAllTestPlans] = useState([]); + const [allTestPlanVersions, setAllTestPlanVersions] = useState([]); + + const [selectedTestPlanVersionId, setSelectedTestPlanVersionId] = + useState(''); + const [matchingTestPlanVersions, setMatchingTestPlanVersions] = useState( + [] + ); + + const [selectedAtId, setSelectedAtId] = useState(''); + const [selectedBrowserId, setSelectedBrowserId] = useState(''); + const [ + selectedAtVersionExactOrMinimum, + setSelectedAtVersionExactOrMinimum + ] = useState('Exact Version'); + const [selectedReportAtVersionId, setSelectedReportAtVersionId] = + useState(null); + const [ + showMinimumAtVersionErrorMessage, + setShowMinimumAtVersionErrorMessage + ] = useState(false); + + useEffect(() => { + // Prevent allTestPlanVersions and filteredTestPlanVersions from being unnecessarily overwritten + if (allTestPlanVersions.length) return; + + const _allTestPlanVersions = testPlanVersions + .map(version => ({ ...version })) + .flat(); + + // Get valid test plans by removing duplicate entries from different + // test plan versions of the same test plan being imported multiple times + const _allTestPlans = _allTestPlanVersions + .filter( + (v, i, a) => + a.findIndex( + t => + t.title === v.title && + t.testPlan.directory === v.testPlan.directory + ) === i + ) + .map(({ id, title, testPlan }) => ({ + id, + title, + directory: testPlan.directory + })) + // sort by the testPlanVersion titles + .sort((a, b) => (a.title < b.title ? -1 : 1)); + + // mark the first testPlanVersion as selected + if (_allTestPlans.length) { + const plan = _allTestPlans[0]; + updateMatchingTestPlanVersions(plan.id, _allTestPlanVersions); + } + + setAllTestPlans(_allTestPlans); + setAllTestPlanVersions(_allTestPlanVersions); + }, [testPlanVersions]); + + const updateMatchingTestPlanVersions = (value, allTestPlanVersions) => { + // update test plan versions based on selected test plan + const retrievedTestPlanVersion = allTestPlanVersions.find( + item => item.id === value + ); + + // find the versions that apply and pre-set these + const matchingTestPlanVersions = allTestPlanVersions + .filter( + item => + item.title === retrievedTestPlanVersion.title && + item.testPlan.directory === + retrievedTestPlanVersion.testPlan.directory && + item.phase !== 'DEPRECATED' && + item.phase !== 'RD' + ) + .sort((a, b) => + new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1 + ); + setMatchingTestPlanVersions(matchingTestPlanVersions); + + if (matchingTestPlanVersions.length) + setSelectedTestPlanVersionId(matchingTestPlanVersions[0].id); + else setSelectedTestPlanVersionId(null); + }; + + const onTestPlanVersionChange = e => { + const { value } = e.target; + setShowMinimumAtVersionErrorMessage(false); + setSelectedAtVersionExactOrMinimum('Exact Version'); + setSelectedTestPlanVersionId(value); + }; + + const onAtChange = e => { + const { value } = e.target; + setShowMinimumAtVersionErrorMessage(false); + setSelectedAtId(value); + setSelectedReportAtVersionId(null); + }; + + const onReportAtVersionIdChange = e => { + const { value } = e.target; + setSelectedReportAtVersionId(value); + }; + + const onBrowserChange = e => { + const { value } = e.target; + setSelectedBrowserId(value); + }; + + const selectedTestPlanVersion = allTestPlanVersions.find( + ({ id }) => id === selectedTestPlanVersionId + ); + + const exactOrMinimumAtVersion = ats + .find(item => item.id === selectedAtId) + ?.atVersions.find(item => item.id === selectedReportAtVersionId); + + return ( + + + Select a test plan, assistive technology and browser to add a + new test plan report to the test queue. + +
    + + + Test Plan + + { + const { value } = e.target; + setShowMinimumAtVersionErrorMessage(false); + setSelectedAtVersionExactOrMinimum('Exact Version'); + updateMatchingTestPlanVersions( + value, + allTestPlanVersions + ); + }} + > + {allTestPlans.map(item => ( + + ))} + + + + + Test Plan Version + + + {matchingTestPlanVersions.length ? ( + matchingTestPlanVersions.map(item => ( + + )) + ) : ( + + )} + + +
    {/* blank grid cell */}
    + + + Assistive Technology + + + + {ats.map(item => ( + + ))} + + + + + Assistive Technology Version + +
    + { + if ( + selectedTestPlanVersion?.phase === + 'RECOMMENDED' && + exactOrMinimum === 'Minimum Version' + ) { + setShowMinimumAtVersionErrorMessage(true); + return; + } + + setSelectedAtVersionExactOrMinimum( + exactOrMinimum + ); + }} + /> + + + {ats + .find(at => at.id === selectedAtId) + ?.atVersions.map(item => ( + + ))} + + {showMinimumAtVersionErrorMessage && + selectedTestPlanVersion?.phase === 'RECOMMENDED' ? ( +
    + The selected test plan version is in the + recommended phase and only exact versions can be + chosen. +
    + ) : null} +
    +
    + + + Browser + + + + {ats + .find(at => at.id === selectedAtId) + ?.browsers.map(item => ( + + ))} + + +
    + item.id === selectedTestPlanVersionId + )} + at={ats.find(item => item.id === selectedAtId)} + exactAtVersion={ + selectedAtVersionExactOrMinimum === 'Exact Version' + ? exactOrMinimumAtVersion + : null + } + minimumAtVersion={ + selectedAtVersionExactOrMinimum === 'Minimum Version' + ? exactOrMinimumAtVersion + : null + } + browser={ats + .find(at => at.id === selectedAtId) + ?.browsers.find( + browser => browser.id === selectedBrowserId + )} + triggerUpdate={triggerUpdate} + disabled={ + !selectedTestPlanVersionId || + !selectedAtId || + !selectedReportAtVersionId || + !selectedBrowserId + } + /> +
    + ); +}; + +AddTestPlans.propTypes = { + ats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + browsers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }) + ).isRequired + }) + ).isRequired, + testPlanVersions: PropTypes.array, + triggerUpdate: PropTypes.func +}; + +export default AddTestPlans; diff --git a/client/components/ManageTestQueue/ManageAtVersions.jsx b/client/components/ManageTestQueue/ManageAtVersions.jsx new file mode 100644 index 000000000..c97211ae5 --- /dev/null +++ b/client/components/ManageTestQueue/ManageAtVersions.jsx @@ -0,0 +1,451 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Form } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEdit, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { DisclosureContainer } from '@components/ManageTestQueue/index'; +import BasicModal from '@components/common/BasicModal'; +import UpdateVersionModal from '@components/common/UpdateVersionModal'; +import { convertStringToDate } from '@client/utils/formatter'; +import { useMutation } from '@apollo/client'; +import { + ADD_AT_VERSION_MUTATION, + DELETE_AT_VERSION_MUTATION, + EDIT_AT_VERSION_MUTATION +} from '@components/TestQueue/queries'; +import { useTriggerLoad } from '@components/common/LoadingStatus'; +import { THEMES, useThemedModal } from '@client/hooks/useThemedModal'; +import PropTypes from 'prop-types'; + +const ManageAtVersions = ({ ats = [], triggerUpdate = () => {} }) => { + const { triggerLoad } = useTriggerLoad(); + const { + themedModal, + showThemedModal, + setShowThemedModal, + setThemedModalTitle, + setThemedModalContent, + setThemedModalType, + setThemedModalActions, + setThemedModalShowCloseAction, + focus, + setFocusRef, + hideThemedModal + } = useThemedModal({ + type: THEMES.WARNING, + title: 'Error Updating Assistive Technology Version' + }); + + const loadedAts = useRef(false); + + const [selectedAtId, setSelectedAtId] = useState('1'); + const [selectedAtVersions, setSelectedAtVersions] = useState([]); + const [selectedAtVersionId, setSelectedAtVersionId] = useState(''); + + const [addAtVersion] = useMutation(ADD_AT_VERSION_MUTATION); + const [editAtVersion] = useMutation(EDIT_AT_VERSION_MUTATION); + const [deleteAtVersion] = useMutation(DELETE_AT_VERSION_MUTATION); + + // Update modal state values + const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false); + const [updateVersionModalTitle, setUpdateVersionModalTitle] = useState(''); + const [updateVersionModalType, setUpdateVersionModalType] = useState('add'); + const [updateVersionModalVersionText, setUpdateVersionModalVersionText] = + useState(''); + const [ + updateVersionModalModalDateText, + setUpdateVersionModalModalDateText + ] = useState(''); + + // Feedback modal state values + const [showFeedbackModal, setShowFeedbackModal] = useState(false); + const [feedbackModalTitle, setFeedbackModalTitle] = useState(''); + const [feedbackModalContent, setFeedbackModalContent] = useState(<>); + + useEffect(() => { + if (ats.length) { + if (!loadedAts.current) setSelectedAtId(ats[0].id); + + // Required during refetch logic around managing AT Versions + if (!loadedAts.current) setSelectedAtVersions(ats[0].atVersions); + else { + setSelectedAtVersions( + ats.find(item => item.id === selectedAtId).atVersions + ); + } + + if (!loadedAts.current) + setSelectedAtVersionId(ats[0]?.atVersions[0]?.id); + loadedAts.current = true; + } + }, [ats]); + + const getAtVersionFromId = id => + selectedAtVersions.find(item => id === item.id); + + const showThemedMessage = ({ + title, + content, + theme, + actions = null, + showCloseAction = false + }) => { + setThemedModalTitle(title); + setThemedModalContent(content); + setThemedModalType(theme); + setThemedModalActions(actions); + setThemedModalShowCloseAction(showCloseAction); + setShowThemedModal(true); + }; + + const showFeedbackMessage = (title, content) => { + setFeedbackModalTitle(title); + setFeedbackModalContent(content); + setShowFeedbackModal(true); + }; + + const onAtChange = e => { + const { value } = e.target; + if (selectedAtId !== value) { + setSelectedAtId(value); + const at = ats.find(item => item.id === value); + setSelectedAtVersions(at.atVersions); + setSelectedAtVersionId(at.atVersions[0].id); + } + }; + + const onAtVersionChange = e => { + const { value } = e.target; + setSelectedAtVersionId(value); + }; + + const onOpenAtVersionModalClick = type => { + if (type === 'add') { + const selectedAt = ats.find(item => item.id === selectedAtId); + setUpdateVersionModalTitle( + `Add a New Version for ${selectedAt.name}` + ); + setUpdateVersionModalType('add'); + setUpdateVersionModalVersionText(''); + setUpdateVersionModalModalDateText(''); + setShowUpdateVersionModal(true); + } + + if (type === 'edit') { + const selectedAt = ats.find(item => item.id === selectedAtId); + setUpdateVersionModalTitle( + `Edit ${selectedAt.name} Version ${ + getAtVersionFromId(selectedAtVersionId)?.name + }` + ); + setUpdateVersionModalType('edit'); + setUpdateVersionModalVersionText( + getAtVersionFromId(selectedAtVersionId)?.name + ); + setUpdateVersionModalModalDateText( + getAtVersionFromId(selectedAtVersionId)?.releasedAt + ); + setShowUpdateVersionModal(true); + } + + if (type === 'delete') { + const theme = 'danger'; + + const selectedAt = ats.find(item => item.id === selectedAtId); + showThemedMessage({ + title: `Remove ${selectedAt.name} Version ${ + getAtVersionFromId(selectedAtVersionId)?.name + }`, + content: ( + <> + You are about to remove{' '} + + {selectedAt.name} Version{' '} + {getAtVersionFromId(selectedAtVersionId)?.name} + {' '} + from the ARIA-AT App. + + ), + actions: [ + { + text: 'Remove', + action: () => onUpdateAtVersionAction('delete', {}) + } + ], + showCloseAction: true, + theme + }); + } + }; + + const onUpdateAtVersionAction = async ( + actionType, + { updatedVersionText, updatedDateAvailabilityText } + ) => { + const selectedAt = ats.find(item => item.id === selectedAtId); + + if (actionType === 'add') { + const existingAtVersion = selectedAtVersions.find( + item => item.name.trim() === updatedVersionText.trim() + ); + if (existingAtVersion) { + setSelectedAtVersionId(existingAtVersion.id); + + onUpdateModalClose(); + showFeedbackMessage( + 'Existing Assistive Technology Version', + <> + + {selectedAt.name} {updatedVersionText} + {' '} + already exists in the system. Please try adding a + different one. + + ); + return; + } + + onUpdateModalClose(); + await triggerLoad(async () => { + const addAtVersionData = await addAtVersion({ + variables: { + atId: selectedAtId, + name: updatedVersionText, + releasedAt: convertStringToDate( + updatedDateAvailabilityText + ) + } + }); + setSelectedAtVersionId( + addAtVersionData.data?.at?.findOrCreateAtVersion?.id + ); + await triggerUpdate(); + }, 'Adding Assistive Technology Version'); + + showFeedbackMessage( + 'Successfully Added Assistive Technology Version', + <> + Successfully added{' '} + + {selectedAt.name} {updatedVersionText} + + . + + ); + } + + if (actionType === 'edit') { + onUpdateModalClose(); + await triggerLoad(async () => { + await editAtVersion({ + variables: { + atVersionId: selectedAtVersionId, + name: updatedVersionText, + releasedAt: convertStringToDate( + updatedDateAvailabilityText + ) + } + }); + await triggerUpdate(); + }, 'Updating Assistive Technology Version'); + + showFeedbackMessage( + 'Successfully Updated Assistive Technology Version', + <> + Successfully updated{' '} + + {selectedAt.name} {updatedVersionText} + + . + + ); + } + + if (actionType === 'delete') { + const deleteAtVersionData = await deleteAtVersion({ + variables: { + atVersionId: selectedAtVersionId + } + }); + if ( + !deleteAtVersionData.data?.atVersion?.deleteAtVersion?.isDeleted + ) { + const patternName = + deleteAtVersionData.data?.atVersion?.deleteAtVersion + ?.failedDueToTestResults[0]?.testPlanVersion?.title; + const theme = 'warning'; + + // Removing an AT Version already in use + showThemedMessage({ + title: 'Assistive Technology Version already being used', + content: ( + <> + + {selectedAt.name} Version{' '} + {getAtVersionFromId(selectedAtVersionId)?.name} + {' '} + can't be removed because it is already being + used to test the {patternName} Test Plan. + + ), + theme + }); + } else { + onThemedModalClose(); + + await triggerLoad(async () => { + await triggerUpdate(); + }, 'Removing Assistive Technology Version'); + + // Show confirmation that AT has been deleted + showFeedbackMessage( + 'Successfully Removed Assistive Technology Version', + <> + Successfully removed version for{' '} + {selectedAt.name}. + + ); + + // Reset atVersion to valid existing item + setSelectedAtVersionId(selectedAt.atVersions[0]?.id); + } + } + }; + + const onUpdateModalClose = () => { + setUpdateVersionModalVersionText(''); + setUpdateVersionModalModalDateText(''); + setShowUpdateVersionModal(false); + focus(); + }; + + const onThemedModalClose = () => { + hideThemedModal(); + focus(); + }; + + return ( + <> + + + Select an assistive technology and manage its versions in + the ARIA-AT App + +
    + + + Assistive Technology + + + {ats + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map(item => ( + + ))} + + +
    + + + Available Versions + + + {selectedAtVersions.map(item => ( + + ))} + + +
    + + + +
    +
    +
    +
    + + {showThemedModal && themedModal} + {showUpdateVersionModal && ( + + )} + {showFeedbackModal && ( + { + setShowFeedbackModal(false); + }} + /> + )} + + ); +}; + +ManageAtVersions.propTypes = { + ats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + browsers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }) + ).isRequired + }) + ).isRequired, + triggerUpdate: PropTypes.func +}; + +export default ManageAtVersions; diff --git a/client/components/ManageTestQueue/index.jsx b/client/components/ManageTestQueue/index.jsx index e9aea49bb..58b85c5ab 100644 --- a/client/components/ManageTestQueue/index.jsx +++ b/client/components/ManageTestQueue/index.jsx @@ -1,26 +1,12 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { useMutation } from '@apollo/client'; -import { Form } from 'react-bootstrap'; +import React, { useState } from 'react'; import styled from '@emotion/styled'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faEdit, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import PropTypes from 'prop-types'; -import BasicModal from '../common/BasicModal'; -import UpdateVersionModal from '../common/UpdateVersionModal'; -import BasicThemedModal from '../common/BasicThemedModal'; -import { - ADD_AT_VERSION_MUTATION, - EDIT_AT_VERSION_MUTATION, - DELETE_AT_VERSION_MUTATION -} from '../TestQueue/queries'; -import { gitUpdatedDateToString } from '../../utils/gitUtils'; -import { convertStringToDate } from '../../utils/formatter'; import { LoadingStatus, useTriggerLoad } from '../common/LoadingStatus'; import DisclosureComponent from '../common/DisclosureComponent'; -import AddTestToQueueWithConfirmation from '../AddTestToQueueWithConfirmation'; -import RadioBox from '../common/RadioBox'; +import ManageAtVersions from '@components/ManageTestQueue/ManageAtVersions'; +import AddTestPlans from '@components/ManageTestQueue/AddTestPlans'; -const DisclosureContainer = styled.div` +export const DisclosureContainer = styled.div` // Following directives are related to the ManageTestQueue component > span { display: block; @@ -76,22 +62,25 @@ const DisclosureContainer = styled.div` .disclosure-row-test-plans { display: grid; - grid-template-columns: 1fr; row-gap: 0.5rem; + grid-template-columns: 2fr 2fr 1fr; + column-gap: 2rem; - & > :nth-of-type(2) { - display: none; + & > :nth-of-type(3) { + display: block; } & > :nth-of-type(5) { grid-column: span 2; } - @media (min-width: 768px) { - grid-template-columns: 2fr 2fr 1fr; - column-gap: 2rem; + @media (max-width: 768px) { + grid-template-columns: 1fr; - & > :nth-of-type(2) { - display: block; + & > :nth-of-type(3) { + display: none; + } + & > :nth-of-type(5) { + grid-column: initial; } } } @@ -121,405 +110,14 @@ const ManageTestQueue = ({ testPlanVersions = [], triggerUpdate = () => {} }) => { - const { triggerLoad, loadingMessage } = useTriggerLoad(); - - const loadedAts = useRef(false); - const focusButtonRef = useRef(); - const addAtVersionButtonRef = useRef(); - const editAtVersionButtonRef = useRef(); - const deleteAtVersionButtonRef = useRef(); + const { loadingMessage } = useTriggerLoad(); const [showManageATs, setShowManageATs] = useState(false); const [showAddTestPlans, setShowAddTestPlans] = useState(false); - const [selectedManageAtId, setSelectedManageAtId] = useState('1'); - const [selectedManageAtVersions, setSelectedManageAtVersions] = useState( - [] - ); - const [selectedManageAtVersionId, setSelectedManageAtVersionId] = - useState(''); - - const [showAtVersionModal, setShowAtVersionModal] = useState(false); - const [atVersionModalTitle, setAtVersionModalTitle] = useState(''); - const [atVersionModalType, setAtVersionModalType] = useState('add'); - const [atVersionModalVersionText, setAtVersionModalVersionText] = - useState(''); - const [atVersionModalDateText, setAtVersionModalDateText] = useState(''); - - const [showThemedModal, setShowThemedModal] = useState(false); - const [themedModalType, setThemedModalType] = useState('warning'); - const [themedModalTitle, setThemedModalTitle] = useState(''); - const [themedModalContent, setThemedModalContent] = useState(<>); - - const [showFeedbackModal, setShowFeedbackModal] = useState(false); - const [feedbackModalTitle, setFeedbackModalTitle] = useState(''); - const [feedbackModalContent, setFeedbackModalContent] = useState(<>); - - const [allTestPlanVersions, setAllTestPlanVersions] = useState([]); - const [filteredTestPlanVersions, setFilteredTestPlanVersions] = useState( - [] - ); - const [selectedTestPlanVersionId, setSelectedTestPlanVersionId] = - useState(''); - const [matchingTestPlanVersions, setMatchingTestPlanVersions] = useState( - [] - ); - - const [selectedAtId, setSelectedAtId] = useState(''); - const [selectedBrowserId, setSelectedBrowserId] = useState(''); - const [ - selectedAtVersionExactOrMinimum, - setSelectedAtVersionExactOrMinimum - ] = useState('Exact Version'); - const [selectedReportAtVersionId, setSelectedReportAtVersionId] = - useState(null); - const [ - showMinimumAtVersionErrorMessage, - setShowMinimumAtVersionErrorMessage - ] = useState(false); - - const [addAtVersion] = useMutation(ADD_AT_VERSION_MUTATION); - const [editAtVersion] = useMutation(EDIT_AT_VERSION_MUTATION); - const [deleteAtVersion] = useMutation(DELETE_AT_VERSION_MUTATION); const onManageAtsClick = () => setShowManageATs(!showManageATs); const onAddTestPlansClick = () => setShowAddTestPlans(!showAddTestPlans); - useEffect(() => { - const allTestPlanVersions = testPlanVersions - .map(version => ({ ...version })) - .flat(); - - // to remove duplicate entries from different test plan versions of the same test plan being imported multiple times - const filteredTestPlanVersions = allTestPlanVersions - .filter( - (v, i, a) => - a.findIndex( - t => - t.title === v.title && - t.testPlan.directory === v.testPlan.directory - ) === i - ) - // sort by the testPlanVersion titles - .sort((a, b) => (a.title < b.title ? -1 : 1)); - - // mark the first testPlanVersion as selected - if (filteredTestPlanVersions.length) { - const plan = filteredTestPlanVersions[0]; - updateMatchingTestPlanVersions(plan.id, allTestPlanVersions); - } - - setAllTestPlanVersions(allTestPlanVersions); - setFilteredTestPlanVersions(filteredTestPlanVersions); - }, [testPlanVersions]); - - useEffect(() => { - if (ats.length) { - if (!loadedAts.current) setSelectedManageAtId(ats[0].id); - - // Required during refetch logic around managing AT Versions - if (!loadedAts.current) - setSelectedManageAtVersions(ats[0].atVersions); - else - setSelectedManageAtVersions( - ats.find(item => item.id === selectedManageAtId).atVersions - ); - - if (!loadedAts.current) - setSelectedManageAtVersionId(ats[0]?.atVersions[0]?.id); - loadedAts.current = true; - } - }, [ats]); - - const updateMatchingTestPlanVersions = (value, allTestPlanVersions) => { - // update test plan versions based on selected test plan - const retrievedTestPlan = allTestPlanVersions.find( - item => item.id === value - ); - - // find the versions that apply and pre-set these - const matchingTestPlanVersions = allTestPlanVersions - .filter( - item => - item.title === retrievedTestPlan.title && - item.testPlan.directory === - retrievedTestPlan.testPlan.directory && - item.phase !== 'DEPRECATED' && - item.phase !== 'RD' - ) - .sort((a, b) => - new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1 - ); - setMatchingTestPlanVersions(matchingTestPlanVersions); - - if (matchingTestPlanVersions.length) - setSelectedTestPlanVersionId(matchingTestPlanVersions[0].id); - else setSelectedTestPlanVersionId(null); - }; - - const onManageAtChange = e => { - const { value } = e.target; - if (selectedManageAtId !== value) { - setSelectedManageAtId(value); - const at = ats.find(item => item.id === value); - setSelectedManageAtVersions(at.atVersions); - setSelectedManageAtVersionId(at.atVersions[0].id); - } - }; - - const onManageAtVersionChange = e => { - const { value } = e.target; - setSelectedManageAtVersionId(value); - }; - - const onOpenAtVersionModalClick = (type = 'add') => { - if (type === 'add') { - focusButtonRef.current = addAtVersionButtonRef.current; - - const selectedAt = ats.find(item => item.id === selectedManageAtId); - setAtVersionModalTitle(`Add a New Version for ${selectedAt.name}`); - setAtVersionModalType('add'); - setAtVersionModalVersionText(''); - setAtVersionModalDateText(''); - setShowAtVersionModal(true); - } - - if (type === 'edit') { - focusButtonRef.current = editAtVersionButtonRef.current; - - const selectedAt = ats.find(item => item.id === selectedManageAtId); - setAtVersionModalTitle( - `Edit ${selectedAt.name} Version ${ - getAtVersionFromId(selectedManageAtVersionId)?.name - }` - ); - setAtVersionModalType('edit'); - setAtVersionModalVersionText( - getAtVersionFromId(selectedManageAtVersionId)?.name - ); - setAtVersionModalDateText( - getAtVersionFromId(selectedManageAtVersionId)?.releasedAt - ); - setShowAtVersionModal(true); - } - }; - - const onRemoveClick = () => { - focusButtonRef.current = deleteAtVersionButtonRef.current; - - const theme = 'danger'; - const selectedAt = ats.find(item => item.id === selectedManageAtId); - - showThemedMessage( - `Remove ${selectedAt.name} Version ${ - getAtVersionFromId(selectedManageAtVersionId)?.name - }`, - <> - You are about to remove{' '} - - {selectedAt.name} Version{' '} - {getAtVersionFromId(selectedManageAtVersionId)?.name} - {' '} - from the ARIA-AT App. - , - theme - ); - }; - - const onUpdateModalClose = () => { - setAtVersionModalVersionText(''); - setAtVersionModalDateText(''); - setShowAtVersionModal(false); - focusButtonRef.current.focus(); - }; - - const onThemedModalClose = () => { - setShowThemedModal(false); - focusButtonRef.current.focus(); - }; - - const getAtVersionFromId = id => { - return selectedManageAtVersions.find(item => id === item.id); - }; - - const onAtChange = e => { - const { value } = e.target; - setShowMinimumAtVersionErrorMessage(false); - setSelectedAtId(value); - setSelectedReportAtVersionId(null); - }; - - const onBrowserChange = e => { - const { value } = e.target; - setSelectedBrowserId(value); - }; - - const onReportAtVersionIdChange = e => { - const { value } = e.target; - setSelectedReportAtVersionId(value); - }; - - const onTestPlanVersionChange = e => { - const { value } = e.target; - setShowMinimumAtVersionErrorMessage(false); - setSelectedAtVersionExactOrMinimum('Exact Version'); - setSelectedTestPlanVersionId(value); - }; - - const onUpdateAtVersionAction = async ( - actionType, - { updatedVersionText, updatedDateAvailabilityText } - ) => { - const selectedAt = ats.find(item => item.id === selectedManageAtId); - - if (actionType === 'add') { - const existingAtVersion = selectedManageAtVersions.find( - item => item.name.trim() === updatedVersionText.trim() - ); - if (existingAtVersion) { - setSelectedManageAtVersionId(existingAtVersion.id); - - onUpdateModalClose(); - showFeedbackMessage( - 'Existing Assistive Technology Version', - <> - - {selectedAt.name} {updatedVersionText} - {' '} - already exists in the system. Please try adding a - different one. - - ); - return; - } - - onUpdateModalClose(); - await triggerLoad(async () => { - const addAtVersionData = await addAtVersion({ - variables: { - atId: selectedManageAtId, - name: updatedVersionText, - releasedAt: convertStringToDate( - updatedDateAvailabilityText - ) - } - }); - setSelectedManageAtVersionId( - addAtVersionData.data?.at?.findOrCreateAtVersion?.id - ); - await triggerUpdate(); - }, 'Adding Assistive Technology Version'); - - showFeedbackMessage( - 'Successfully Added Assistive Technology Version', - <> - Successfully added{' '} - - {selectedAt.name} {updatedVersionText} - - . - - ); - } - - if (actionType === 'edit') { - onUpdateModalClose(); - await triggerLoad(async () => { - await editAtVersion({ - variables: { - atVersionId: selectedManageAtVersionId, - name: updatedVersionText, - releasedAt: convertStringToDate( - updatedDateAvailabilityText - ) - } - }); - await triggerUpdate(); - }, 'Updating Assistive Technology Version'); - - showFeedbackMessage( - 'Successfully Updated Assistive Technology Version', - <> - Successfully updated{' '} - - {selectedAt.name} {updatedVersionText} - - . - - ); - } - - if (actionType === 'delete') { - const deleteAtVersionData = await deleteAtVersion({ - variables: { - atVersionId: selectedManageAtVersionId - } - }); - if ( - !deleteAtVersionData.data?.atVersion?.deleteAtVersion?.isDeleted - ) { - const patternName = - deleteAtVersionData.data?.atVersion?.deleteAtVersion - ?.failedDueToTestResults[0]?.testPlanVersion?.title; - const theme = 'warning'; - - // Removing an AT Version already in use - showThemedMessage( - 'Assistive Technology Version already being used', - <> - - {selectedAt.name} Version{' '} - { - getAtVersionFromId(selectedManageAtVersionId) - ?.name - } - {' '} - can't be removed because it is already being used - to test the {patternName} Test Plan. - , - theme - ); - } else { - onThemedModalClose(); - await triggerLoad(async () => { - await triggerUpdate(); - }, 'Removing Assistive Technology Version'); - - // Show confirmation that AT has been deleted - showFeedbackMessage( - 'Successfully Removed Assistive Technology Version', - <> - Successfully removed version for{' '} - {selectedAt.name}. - - ); - - // Reset atVersion to valid existing item - setSelectedManageAtVersionId(selectedAt.atVersions[0]?.id); - } - } - }; - - const showFeedbackMessage = (title, content) => { - setFeedbackModalTitle(title); - setFeedbackModalContent(content); - setShowFeedbackModal(true); - }; - - const showThemedMessage = (title, content, theme) => { - setThemedModalTitle(title); - setThemedModalContent(content); - setThemedModalType(theme); - setShowThemedModal(true); - }; - - const exactOrMinimumAtVersion = ats - .find(item => item.id === selectedAtId) - ?.atVersions.find(item => item.id === selectedReportAtVersionId); - - const selectedTestPlanVersion = allTestPlanVersions.find( - ({ id }) => id === selectedTestPlanVersionId - ); - return ( - - Select an assistive technology and manage its - versions in the ARIA-AT App - -
    - - - Assistive Technology - - - {ats.map(item => ( - - ))} - - -
    - - - Available Versions - - - {selectedManageAtVersions.map(item => ( - - ))} - - -
    - - - -
    -
    -
    - , - - - Select a test plan, assistive technology and browser - to add a new test plan report to the test queue. - -
    - - - Test Plan - - { - const { value } = e.target; - setShowMinimumAtVersionErrorMessage( - false - ); - setSelectedAtVersionExactOrMinimum( - 'Exact Version' - ); - updateMatchingTestPlanVersions( - value, - allTestPlanVersions - ); - }} - > - {filteredTestPlanVersions.map(item => ( - - ))} - - - - - Test Plan Version - - - {matchingTestPlanVersions.length ? ( - matchingTestPlanVersions.map(item => ( - - )) - ) : ( - - )} - - -
    {/* blank grid cell */}
    - - - Assistive Technology - - - - {ats.map(item => ( - - ))} - - - - - Assistive Technology Version - -
    - { - if ( - selectedTestPlanVersion?.phase === - 'RECOMMENDED' && - exactOrMinimum === - 'Minimum Version' - ) { - setShowMinimumAtVersionErrorMessage( - true - ); - return; - } - - setSelectedAtVersionExactOrMinimum( - exactOrMinimum - ); - }} - /> - - - {ats - .find(at => at.id === selectedAtId) - ?.atVersions.map(item => ( - - ))} - - {showMinimumAtVersionErrorMessage && - selectedTestPlanVersion?.phase === - 'RECOMMENDED' ? ( -
    - The selected test plan version is in - the recommended phase and only exact - versions can be chosen. -
    - ) : null} -
    -
    - - - Browser - - - - {ats - .find(at => at.id === selectedAtId) - ?.browsers.map(item => ( - - ))} - - -
    - item.id === selectedTestPlanVersionId - )} - at={ats.find(item => item.id === selectedAtId)} - exactAtVersion={ - selectedAtVersionExactOrMinimum === - 'Exact Version' - ? exactOrMinimumAtVersion - : null - } - minimumAtVersion={ - selectedAtVersionExactOrMinimum === - 'Minimum Version' - ? exactOrMinimumAtVersion - : null - } - browser={ats - .find(at => at.id === selectedAtId) - ?.browsers.find( - browser => browser.id === selectedBrowserId - )} - triggerUpdate={triggerUpdate} - disabled={ - !selectedTestPlanVersionId || - !selectedAtId || - !selectedReportAtVersionId || - !selectedBrowserId - } - /> -
    + , + ]} onClick={[onManageAtsClick, onAddTestPlansClick]} expanded={[showManageATs, showAddTestPlans]} stacked /> - - {showAtVersionModal && ( - - )} - - {showThemedModal && ( - - onUpdateAtVersionAction('delete', {}) - : onThemedModalClose - } - ]} - handleClose={onThemedModalClose} - showCloseAction={themedModalType === 'danger'} - /> - )} - - {showFeedbackModal && ( - { - setShowFeedbackModal(false); - focusButtonRef.current.focus(); - }} - /> - )}
    ); }; diff --git a/client/components/Reports/SummarizeTestPlanVersion.jsx b/client/components/Reports/SummarizeTestPlanVersion.jsx index a30162e57..8997d6e46 100644 --- a/client/components/Reports/SummarizeTestPlanVersion.jsx +++ b/client/components/Reports/SummarizeTestPlanVersion.jsx @@ -19,6 +19,23 @@ const FullHeightContainer = styled(Container)` const SummarizeTestPlanVersion = ({ testPlanVersion, testPlanReports }) => { const { exampleUrl, designPatternUrl } = testPlanVersion.metadata; + + // Sort the test plan reports alphabetically by AT name first, then browser + const sortedTestPlanReports = testPlanReports.slice().sort((a, b) => { + const atNameA = a.at.name.toLowerCase(); + const atNameB = b.at.name.toLowerCase(); + const browserNameA = a.browser.name.toLowerCase(); + const browserNameB = b.browser.name.toLowerCase(); + + if (atNameA < atNameB) return -1; + if (atNameA > atNameB) return 1; + + if (browserNameA < browserNameB) return -1; + if (browserNameA > browserNameB) return 1; + + return 0; + }); + return ( @@ -80,7 +97,7 @@ const SummarizeTestPlanVersion = ({ testPlanVersion, testPlanReports }) => { ) : null} - {testPlanReports.map(testPlanReport => { + {sortedTestPlanReports.map(testPlanReport => { if (testPlanReport.status === 'DRAFT') return null; const overallMetrics = getMetrics({ testPlanReport }); diff --git a/client/components/TestPlanReportStatusDialog/WithButton.jsx b/client/components/TestPlanReportStatusDialog/WithButton.jsx index a131cfb6f..a82215b88 100644 --- a/client/components/TestPlanReportStatusDialog/WithButton.jsx +++ b/client/components/TestPlanReportStatusDialog/WithButton.jsx @@ -2,7 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { Button } from 'react-bootstrap'; import TestPlanReportStatusDialog from './index'; -import { calculateTestPlanReportCompletionPercentage } from './calculateTestPlanReportCompletionPercentage'; +import { calculatePercentComplete } from '../../utils/calculatePercentComplete'; import styled from '@emotion/styled'; import ReportStatusDot from '../common/ReportStatusDot'; import { TEST_PLAN_REPORT_STATUS_DIALOG_QUERY } from './queries'; @@ -25,7 +25,10 @@ const TestPlanReportStatusDialogButton = styled(Button)` margin-top: auto; `; -const TestPlanReportStatusDialogWithButton = ({ testPlanVersionId }) => { +const TestPlanReportStatusDialogWithButton = ({ + testPlanVersionId, + triggerUpdate: refetchOther +}) => { const { data: { testPlanVersion } = {}, refetch, @@ -52,7 +55,7 @@ const TestPlanReportStatusDialogWithButton = ({ testPlanVersionId }) => { if (testPlanReport) { const percentComplete = - calculateTestPlanReportCompletionPercentage(testPlanReport); + calculatePercentComplete(testPlanReport); if (percentComplete === 100 && testPlanReport.markedFinalAt) { counts.completed += 1; @@ -136,14 +139,18 @@ const TestPlanReportStatusDialogWithButton = ({ testPlanVersionId }) => { setShowDialog(false); buttonRef.current.focus(); }} - triggerUpdate={refetch} + triggerUpdate={async () => { + await refetch(); + if (refetchOther) await refetchOther(); + }} /> ); }; TestPlanReportStatusDialogWithButton.propTypes = { - testPlanVersionId: PropTypes.string.isRequired + testPlanVersionId: PropTypes.string.isRequired, + triggerUpdate: PropTypes.func }; export default TestPlanReportStatusDialogWithButton; diff --git a/client/components/TestPlanReportStatusDialog/index.jsx b/client/components/TestPlanReportStatusDialog/index.jsx index 1d9b40396..344e00281 100644 --- a/client/components/TestPlanReportStatusDialog/index.jsx +++ b/client/components/TestPlanReportStatusDialog/index.jsx @@ -1,41 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; import AddTestToQueueWithConfirmation from '../AddTestToQueueWithConfirmation'; import { useQuery } from '@apollo/client'; import { ME_QUERY } from '../App/queries'; import { evaluateAuth } from '../../utils/evaluateAuth'; -import { calculateTestPlanReportCompletionPercentage } from './calculateTestPlanReportCompletionPercentage'; -import { convertDateToString } from '../../utils/formatter'; import { ThemeTable } from '../common/ThemeTable'; import BasicModal from '../common/BasicModal'; import './TestPlanReportStatusDialog.css'; - -const IncompleteStatusReport = styled.span` - min-width: 5rem; - display: inline-block; -`; - -const AtInner = styled.div` - display: inline-block; - flex-wrap: wrap; - background: #f5f5f5; - border-radius: 4px; - padding: 0 5px; - font-weight: bold; - - & :last-of-type { - margin-left: 6px; - font-weight: initial; - } -`; - -const VersionBox = styled.span` - /* font-size: 15px; - color: #4a4a4a; - top: 1px; - position: relative; */ -`; +import ReportStatusSummary from '../common/ReportStatusSummary'; +import { AtVersion } from '../common/AtBrowserVersion'; const TestPlanReportStatusDialog = ({ testPlanVersion, @@ -47,87 +20,10 @@ const TestPlanReportStatusDialog = ({ fetchPolicy: 'cache-and-network' }); - const { testPlanReportStatuses } = testPlanVersion; - - const auth = evaluateAuth(me ?? {}); + const auth = evaluateAuth(me); const { isSignedIn, isAdmin } = auth; - const renderCompleteReportStatus = testPlanReport => { - const formattedDate = convertDateToString( - testPlanReport.markedFinalAt, - 'MMM D, YYYY' - ); - return ( - - Report completed on {formattedDate} - - ); - }; - - const renderPartialCompleteReportStatus = testPlanReport => { - const { metrics, draftTestPlanRuns } = testPlanReport; - const conflictsCount = metrics.conflictsCount ?? 0; - const percentComplete = - calculateTestPlanReportCompletionPercentage(testPlanReport); - switch (draftTestPlanRuns?.length) { - case 0: - return In test queue with no testers assigned.; - case 1: - return ( - - {percentComplete}% complete by  - - {draftTestPlanRuns[0].tester.username} - -  with {conflictsCount} conflicts - - ); - default: - return ( - - {percentComplete}% complete by  - {draftTestPlanRuns.length} testers with {conflictsCount} -  conflicts - - ); - } - }; - - const renderReportStatus = ({ - testPlanReport, - at, - browser, - minimumAtVersion, - exactAtVersion - }) => { - if (testPlanReport) { - const { markedFinalAt } = testPlanReport; - if (markedFinalAt) { - return renderCompleteReportStatus(testPlanReport); - } else { - return renderPartialCompleteReportStatus(testPlanReport); - } - } - return ( - <> - Missing - {isSignedIn && isAdmin ? ( - - ) : null} - - ); - }; + const { testPlanReportStatuses } = testPlanVersion; let requiredReports = 0; @@ -148,28 +44,32 @@ const TestPlanReportStatusDialog = ({ `${minimumAtVersion?.id ?? exactAtVersion?.id}-` + `${testPlanReport?.id ?? 'missing'}`; - const atVersionFormatted = minimumAtVersion - ? `${minimumAtVersion.name} or later` - : exactAtVersion.name; - return ( {isRequired ? 'Yes' : 'No'} - - {at.name} - {atVersionFormatted} - + {browser.name} - {renderReportStatus({ - testPlanReport, - at, - browser, - minimumAtVersion, - exactAtVersion - })} + + {isSignedIn && isAdmin && !testPlanReport ? ( + + ) : null} ); diff --git a/client/components/TestPlanVersionsPage/index.jsx b/client/components/TestPlanVersionsPage/index.jsx index d8aa0d6b7..25c4a1141 100644 --- a/client/components/TestPlanVersionsPage/index.jsx +++ b/client/components/TestPlanVersionsPage/index.jsx @@ -21,6 +21,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import DisclosureComponentUnstyled from '../common/DisclosureComponent'; +import useForceUpdate from '../../hooks/useForceUpdate'; const DisclosureContainer = styled.div` .timeline-for-version-table { @@ -29,9 +30,7 @@ const DisclosureContainer = styled.div` `; const DisclosureComponent = styled(DisclosureComponentUnstyled)` - & h2 { - padding: 0; - margin: 0; + h2 { font-size: 1.25em; button { @@ -77,13 +76,6 @@ const ThemeTableHeader = styled(UnstyledThemeTableHeader)` margin: 0 !important; `; -// https://stackoverflow.com/a/53215514 -const useForceUpdate = () => { - const [, updateState] = React.useState(); - const forceUpdate = React.useCallback(() => updateState({}), []); - return forceUpdate; -}; - const TestPlanVersionsPage = () => { const { testPlanDirectory } = useParams(); @@ -550,7 +542,6 @@ const TestPlanVersionsPage = () => { )} on ${getEventDate(testPlanVersion)}`} > {} +}) => { + const primaryRunIdRef = useRef({}); + + const { showConfirmationModal, hideConfirmationModal } = + useConfirmationModal(); + + const { triggerLoad, loadingMessage } = useTriggerLoad(); + + const forceUpdate = useForceUpdate(); + + const client = useApolloClient(); + + const { isAdmin, isTester } = evaluateAuth(me); + + const selfAssignedRun = + me && + testPlanReport.draftTestPlanRuns.find( + testPlanRun => testPlanRun.tester.id === me.id + ); + + const nonSelfAssignedRuns = testPlanReport.draftTestPlanRuns + .filter(testPlanRun => testPlanRun.tester.id !== me?.id) + .sort((a, b) => a.tester.username.localeCompare(b.tester.username)); + + const completedAllTests = testPlanReport.draftTestPlanRuns.every( + testPlanRun => + testPlanRun.testResultsLength === testPlanReport.runnableTestsLength + ); + + const assignedBotRun = testPlanReport.draftTestPlanRuns.find( + testPlanRun => testPlanRun.tester.isBot + ); + + const canMarkAsFinal = + !assignedBotRun && + !testPlanReport.conflictsLength && + testPlanReport.draftTestPlanRuns.length > 0 && + testPlanReport.draftTestPlanRuns[0].testResultsLength > 0 && + completedAllTests; + + const markAsFinal = () => { + const runs = testPlanReport.draftTestPlanRuns; + + primaryRunIdRef.current = runs[0].id; + + const onChangePrimary = event => { + const id = event.target.value; + primaryRunIdRef.current = id; + forceUpdate(); + }; + + const onConfirm = async () => { + await triggerLoad(async () => { + await client.mutate({ + mutation: MARK_TEST_PLAN_REPORT_AS_FINAL_MUTATION, + refetchQueries: [ + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true, + variables: { + testPlanReportId: testPlanReport.id, + primaryTestPlanRunId: primaryRunIdRef.current + } + }); + }, 'Marking as Final ...'); + + hideConfirmationModal(); + }; + + let title; + let content; + + if (runs.length === 1) { + title = + "Are you sure you want to mark as final with a single tester's results?"; + content = ( + <> +

    + Only {runs[0].tester.username}'s results are + included in this report, so their run will be marked as + the primary run. Only their output will be displayed on + report pages. +

    +

    + Their run being marked as primary may also set the + minimum required Assistive Technology Version that can + be used for subsequent reports with this Test Plan + Version and Assistive Technology combination. +

    + + ); + } else { + // Multiple testers runs to choose from + title = 'Select Primary Test Plan Run'; + content = ( + <> +

    + When a tester's run is marked as primary, it means + that their output for collected results will be + prioritized and shown on report pages. +

    +

    + A tester's run being marked as primary may also set + the minimum required Assistive Technology Version that + can be used for subsequent reports with that Test Plan + Version and Assistive Technology combination. +

    + + {runs.map(run => ( + + ))} + + + ); + } + + showConfirmationModal( + + ); + }; + + const deleteReport = () => { + const onConfirm = async () => { + await triggerLoad(async () => { + await client.mutate({ + mutation: REMOVE_TEST_PLAN_REPORT_MUTATION, + refetchQueries: [ + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true, + variables: { testPlanReportId: testPlanReport.id } + }); + }, 'Deleting...'); + + hideConfirmationModal(); + + setTimeout(() => { + const focusTarget = + document.querySelector(`h2#${testPlan.directory}`) ?? + document.querySelector('#main'); + + focusTarget.focus(); + }, 1); + }; + + showConfirmationModal( + hideConfirmationModal()} + /> + ); + }; + + return ( + + + {!isTester && ( + + )} + {isTester && ( + + )} + {isAdmin && ( + + + + Open run as...  + + + {nonSelfAssignedRuns.map(testPlanRun => ( + + {testPlanRun.tester.username} + + ))} + + + )} + {isAdmin && assignedBotRun && ( + + )} + {isAdmin && ( + + )} + {isAdmin && ( + + )} + + + ); +}; + +Actions.propTypes = { + me: PropTypes.shape({ + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired + }), + testPlan: PropTypes.shape({ + directory: PropTypes.string.isRequired + }).isRequired, + testPlanReport: PropTypes.shape({ + id: PropTypes.string.isRequired, + runnableTestsLength: PropTypes.number.isRequired, + conflictsLength: PropTypes.number.isRequired, + draftTestPlanRuns: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + testResultsLength: PropTypes.number.isRequired, + tester: PropTypes.shape({ + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + isBot: PropTypes.bool.isRequired + }) + }) + ).isRequired + }).isRequired, + testers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + isBot: PropTypes.bool.isRequired, + ats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired + }) + ) + }) + ).isRequired, + triggerUpdate: PropTypes.func.isRequired +}; + +export default Actions; diff --git a/client/components/TestQueue2/AssignTesters.jsx b/client/components/TestQueue2/AssignTesters.jsx new file mode 100644 index 000000000..60afd6098 --- /dev/null +++ b/client/components/TestQueue2/AssignTesters.jsx @@ -0,0 +1,382 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { useApolloClient } from '@apollo/client'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck, faRobot, faUser } from '@fortawesome/free-solid-svg-icons'; +import { Button, Dropdown } from 'react-bootstrap'; +import CompletionStatusListItem from './CompletionStatusListItem'; +import useConfirmationModal from '../../hooks/useConfirmationModal'; +import BasicThemedModal from '../common/BasicThemedModal'; +import { LoadingStatus, useTriggerLoad } from '../common/LoadingStatus'; +import { useAriaLiveRegion } from '../providers/AriaLiveRegionProvider'; +import { evaluateAuth } from '../../utils/evaluateAuth'; +import { isSupportedByResponseCollector } from '../../utils/automation'; +import { + ASSIGN_TESTER_MUTATION, + DELETE_TEST_PLAN_RUN, + TEST_QUEUE_PAGE_QUERY +} from './queries'; +import { SCHEDULE_COLLECTION_JOB_MUTATION } from '../AddTestToQueueWithConfirmation/queries'; +import { TEST_PLAN_REPORT_STATUS_DIALOG_QUERY } from '../TestPlanReportStatusDialog/queries'; + +const AssignTestersContainer = styled.div` + display: flex; + + [role='menu'] { + width: 250px; + max-height: 200px; + overflow-y: scroll; + } + + [role='menuitem']:active { + background-color: #0b60ab; + } +`; + +const AssignTestersDropdownButton = styled(Dropdown.Toggle)` + width: min-content !important; + margin-right: 0.5rem; +`; + +const AssignedTestersUl = styled.ul` + font-weight: normal; + padding-top: 0.5rem; + text-align: center; + + li:not(:last-of-type) { + padding-bottom: 0.25rem; + } + a, + span { + font-weight: normal; + padding-right: 0.5rem; + } + em { + color: rgb(var(--bs-secondary-rgb)); + font-style: normal; + display: inline-block; + } +`; + +const AssignTesters = ({ me, testers, testPlanReport }) => { + const { triggerLoad, loadingMessage } = useTriggerLoad(); + + const setAlertMessage = useAriaLiveRegion(); + + const { showConfirmationModal, hideConfirmationModal } = + useConfirmationModal(); + + const client = useApolloClient(); + + const dropdownButtonRef = useRef(); + const assignSelfButtonRef = useRef(); + + const { isAdmin, isTester } = evaluateAuth(me); + + const isSelfAssigned = + me && + testPlanReport.draftTestPlanRuns.some( + testPlanRun => testPlanRun.tester.id === me.id + ); + + const onToggle = isShown => { + setTimeout(() => { + if (!isShown) return; + document + .querySelector( + `#assign-testers-${testPlanReport.id} [role="menuitemcheckbox"]` + ) + .focus(); + }, 1); + }; + + const onKeyDown = event => { + const { key } = event; + if (key.match(/[0-9a-zA-Z]/)) { + const container = event.target.closest('[role=menu]'); + const matchingMenuItem = Array.from(container.children).find( + menuItem => { + return menuItem.innerText + .trim() + .toLowerCase() + .startsWith(key.toLowerCase()); + } + ); + + if (matchingMenuItem) { + matchingMenuItem.focus(); + } + } + }; + + const toggleTesterCommon = async ({ + testerId, + assignedCallback, + unassignedCallback + }) => { + const tester = testers.find(tester => tester.id === testerId); + + const isAssigned = testPlanReport.draftTestPlanRuns.some( + testPlanRun => testPlanRun.tester.id === testerId + ); + + if (isAssigned) { + const isSelfAssigned = tester.id === me.id; + const testerFormatted = isSelfAssigned + ? 'your' + : `${tester.username}'s`; + + const onConfirm = async () => { + await triggerLoad(async () => { + await client.mutate({ + mutation: DELETE_TEST_PLAN_RUN, + refetchQueries: [ + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true, + variables: { + testReportId: testPlanReport.id, + testerId: tester.id + } + }); + }, 'Deleting...'); + + hideConfirmationModal(); + + await assignedCallback?.({ tester }); + }; + + showConfirmationModal( + hideConfirmationModal()} + /> + ); + + return; + } + + await triggerLoad(async () => { + if (tester.isBot) { + await client.mutate({ + mutation: SCHEDULE_COLLECTION_JOB_MUTATION, + refetchQueries: [ + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true, + variables: { + testPlanReportId: testPlanReport.id + } + }); + } else { + await client.mutate({ + mutation: ASSIGN_TESTER_MUTATION, + refetchQueries: [ + TEST_QUEUE_PAGE_QUERY, + TEST_PLAN_REPORT_STATUS_DIALOG_QUERY + ], + awaitRefetchQueries: true, + variables: { + testReportId: testPlanReport.id, + testerId: tester.id + } + }); + } + }, 'Assigning...'); + + await unassignedCallback?.({ tester }); + }; + + const onSelect = testerId => { + const assignedCallback = ({ tester }) => { + setAlertMessage(`${tester.username}'s run has been deleted`); + + setTimeout(() => { + dropdownButtonRef.current.focus(); + }, 1); + }; + + const unassignedCallback = ({ tester }) => { + setAlertMessage(`Assigned ${tester.username}`); + + setTimeout(() => { + dropdownButtonRef.current.focus(); + }, 1); + }; + + toggleTesterCommon({ testerId, assignedCallback, unassignedCallback }); + }; + + const onToggleSelf = () => { + const assignedCallback = () => { + setTimeout(() => { + assignSelfButtonRef.current.focus(); + }, 1); + }; + + toggleTesterCommon({ + testerId: me.id, + assignedCallback + }); + }; + + const renderDropdownItem = ({ tester }) => { + const { id, username, isBot, ats } = tester; + + if (isBot) { + const foundAtForBot = ats.find( + ({ id }) => id === testPlanReport.at?.id + ); + const supportedByResponseCollector = isSupportedByResponseCollector( + { + id: testPlanReport.id, + at: testPlanReport.at, + browser: testPlanReport.browser + } + ); + if (!foundAtForBot || !supportedByResponseCollector) return null; + } + + const isAssigned = testPlanReport.draftTestPlanRuns.some( + testPlanRun => testPlanRun.tester.username === username + ); + + let icon; + if (isAssigned) icon = faCheck; + else if (isBot) icon = faRobot; + + return ( + + + {icon && } + {`${username}`} + + + ); + }; + + return ( + + + {isAdmin && ( + + + Assign Testers + + + + {testers.map(tester => + renderDropdownItem({ tester }) + )} + + + )} + {isTester && ( + + )} + + + {testPlanReport.draftTestPlanRuns + .slice() + .sort((a, b) => + a.tester.username.localeCompare(b.tester.username) + ) + .map((testPlanRun, index) => { + const tester = testPlanRun.tester; + const rowId = `plan-${testPlanReport.id}-assignee-${tester.id}-run-${testPlanRun.id}`; + return ( + + + {index === + testPlanReport.draftTestPlanRuns.length - + 1 ? null : ( +
    + )} +
    + ); + })} +
    +
    + ); +}; + +AssignTesters.propTypes = { + me: PropTypes.shape({ + id: PropTypes.string.isRequired + }), + testers: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + isBot: PropTypes.bool.isRequired, + ats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + key: PropTypes.string.isRequired + }) + ) + }) + ).isRequired, + testPlanReport: PropTypes.shape({ + id: PropTypes.string.isRequired, + runnableTestsLength: PropTypes.number.isRequired, + draftTestPlanRuns: PropTypes.arrayOf( + PropTypes.shape({ + tester: PropTypes.shape({ id: PropTypes.string.isRequired }) + .isRequired + }) + ).isRequired, + at: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + key: PropTypes.string.isRequired + }).isRequired, + browser: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + key: PropTypes.string.isRequired + }).isRequired + }).isRequired +}; + +export default AssignTesters; diff --git a/client/components/TestQueue2/CompletionStatusListItem/index.jsx b/client/components/TestQueue2/CompletionStatusListItem/index.jsx new file mode 100644 index 000000000..9e55a58a1 --- /dev/null +++ b/client/components/TestQueue2/CompletionStatusListItem/index.jsx @@ -0,0 +1,88 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faRobot } from '@fortawesome/free-solid-svg-icons'; +import BotTestCompletionStatus from '@components/TestQueueCompletionStatusListItem/BotTestCompletionStatus'; +import PreviouslyAutomatedTestCompletionStatus from '@components/TestQueueCompletionStatusListItem/PreviouslyAutomatedTestCompletionStatus'; + +const CompletionStatusListItem = ({ + rowId, + testPlanReport, + testPlanRun, + tester +}) => { + const { username, isBot } = tester; + const testPlanRunPreviouslyAutomated = useMemo( + () => testPlanRun.initiatedByAutomation, + [testPlanRun] + ); + + let info; + let completionStatus; + + if (isBot) { + info = ( + + + {username} + + ); + completionStatus = ( + + ); + } else { + info = ( + + {username} + + ); + + completionStatus = testPlanRunPreviouslyAutomated ? ( + + ) : ( + + {`${testPlanRun.testResultsLength} of ` + + `${testPlanReport.runnableTestsLength} tests complete`} + + ); + } + + return ( +
  • + {info} + {completionStatus} +
  • + ); +}; + +// TODO: Update shape for testPlanReport and tester +CompletionStatusListItem.propTypes = { + rowId: PropTypes.string.isRequired, + testPlanReport: PropTypes.object.isRequired, + testPlanRun: PropTypes.shape({ + id: PropTypes.string.isRequired, + testResultsLength: PropTypes.number.isRequired, + initiatedByAutomation: PropTypes.bool.isRequired, + tester: PropTypes.shape({ + username: PropTypes.string.isRequired, + isBot: PropTypes.bool.isRequired + }).isRequired + }).isRequired, + tester: PropTypes.object.isRequired +}; + +export default CompletionStatusListItem; diff --git a/client/components/TestQueue2/TestQueue2.test.js b/client/components/TestQueue2/TestQueue2.test.js new file mode 100644 index 000000000..3dee29794 --- /dev/null +++ b/client/components/TestQueue2/TestQueue2.test.js @@ -0,0 +1,27 @@ +import getPage from '../../tests/util/getPage'; + +describe('Test Queue', () => { + const clearEntireTestQueue = () => { + console.error('TODO: IMPLEMENT clearEntireTestQueue'); + }; + + it('renders Test Queue page h1', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + const h1Element = await page.$eval( + 'h1', + element => element.textContent + ); + expect(h1Element).toBe('Test Queue'); + }); + }); + + it.skip('renders error message when no test plan reports exist', async () => { + await getPage({ role: 'admin', url: '/test-queue' }, async page => { + await clearEntireTestQueue(); + await page.waitForSelector( + '::-p-text(There are currently no test plan reports to show.)' + ); + }); + expect(true).toBe(false); + }); +}); diff --git a/client/components/TestQueue2/index.jsx b/client/components/TestQueue2/index.jsx new file mode 100644 index 000000000..72e3b162f --- /dev/null +++ b/client/components/TestQueue2/index.jsx @@ -0,0 +1,416 @@ +import React, { Fragment, useRef } from 'react'; +import { useApolloClient, useQuery } from '@apollo/client'; +import PageStatus from '../common/PageStatus'; +import { TEST_QUEUE_PAGE_QUERY } from './queries'; +import { Container, Table as BootstrapTable } from 'react-bootstrap'; +import { Helmet } from 'react-helmet'; +import { evaluateAuth } from '../../utils/evaluateAuth'; +import ManageTestQueue from '../ManageTestQueue'; +import DisclosureComponentUnstyled from '../common/DisclosureComponent'; +import useForceUpdate from '../../hooks/useForceUpdate'; +import styled from '@emotion/styled'; +import VersionString from '../common/VersionString'; +import PhasePill from '../common/PhasePill'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'; +import TestPlanReportStatusDialogWithButton from '../TestPlanReportStatusDialog/WithButton'; +import ReportStatusSummary from '../common/ReportStatusSummary'; +import { AtVersion, BrowserVersion } from '../common/AtBrowserVersion'; +import { calculatePercentComplete } from '../../utils/calculatePercentComplete'; +import ProgressBar from '../common/ClippedProgressBar'; +import AssignTesters from './AssignTesters'; +import Actions from './Actions'; +import BotRunTestStatusList from '../BotRunTestStatusList'; + +const DisclosureComponent = styled(DisclosureComponentUnstyled)` + h3 { + font-size: 1rem; + + button { + font-size: unset; + font-weight: unset; + } + } + + [role='region'] { + padding: 0; + } +`; + +const MetadataContainer = styled.div` + display: flex; + gap: 1.25em; + margin: 0.5rem 1.25rem; + align-items: center; + min-height: 40px; /* temp because the status dialog button keeps disappearing */ + + & button { + margin-bottom: 0; + margin-top: 0; + font-size: 16px; + } + & button:hover { + color: white; + } + & button, + & button:focus { + color: #2e2f33; + } +`; + +const TableOverflowContainer = styled.div` + width: 100%; + + @media (max-width: 1080px) { + overflow-x: scroll; + } +`; + +const Table = styled(BootstrapTable)` + margin-bottom: 0; + + th { + padding: 0.75rem; + } + + th:first-of-type, + td:first-of-type { + border-left: none; + } + th:last-of-type, + td:last-of-type { + border-right: none; + } + tr:last-of-type, + tr:last-of-type td { + border-bottom: none; + } + + th:nth-of-type(1), + td:nth-of-type(1) { + min-width: 220px; + } + th:nth-of-type(2), + td:nth-of-type(2) { + min-width: 150px; + } + th:nth-of-type(3), + td:nth-of-type(3) { + min-width: 230px; + } + th:nth-of-type(4), + td:nth-of-type(4) { + width: 20%; + min-width: 125px; + } + th:nth-of-type(5), + td:nth-of-type(5) { + width: 20%; + min-width: 175px; + } +`; + +const StatusContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + text-align: center; + color: rgb(var(--bs-secondary-rgb)); +`; + +const TestQueue = () => { + const client = useApolloClient(); + const { data, error, refetch } = useQuery(TEST_QUEUE_PAGE_QUERY, { + fetchPolicy: 'cache-and-network' + }); + + const openDisclosuresRef = useRef({}); + const forceUpdate = useForceUpdate(); + + if (error) { + return ( + + ); + } + + if (!data) { + return ( + + ); + } + + const isSignedIn = !!data.me; + + const { isAdmin } = evaluateAuth(data.me); + + const testPlanVersions = []; + data.testPlans.forEach(testPlan => { + // testPlan.directory is needed by ManageTestQueue + const populatedTestPlanVersions = testPlan.testPlanVersions.map( + testPlanVersion => ({ + ...testPlanVersion, + testPlan: { directory: testPlan.directory } + }) + ); + testPlanVersions.push(...populatedTestPlanVersions); + }); + + // Remove any test plans or test plan versions without reports and sort + const sortTestPlanVersions = testPlanVersions => { + return [...testPlanVersions] + .filter(testPlanVersion => testPlanVersion.testPlanReports.length) + .sort((a, b) => { + return b.versionString.localeCompare(a.versionString); + }) + .map(testPlanVersion => { + return { + ...testPlanVersion, + testPlanReports: sortTestPlanReports( + testPlanVersion.testPlanReports + ) + }; + }); + }; + + const sortTestPlanReports = testPlanReports => { + return [...testPlanReports].sort((a, b) => { + if (a.at.name !== b.at.name) { + return a.at.name.localeCompare(b.at.name); + } + if (a.browser.name !== b.browser.name) { + return a.browser.name.localeCompare(b.browser.name); + } + const dateA = new Date( + (a.minimumAtVersion ?? a.exactAtVersion).releasedAt + ); + const dateB = new Date( + (a.minimumAtVersion ?? a.exactAtVersion).releasedAt + ); + return dateB - dateA; + }); + }; + + const testPlans = data.testPlans + .filter(testPlan => { + for (const testPlanVersion of testPlan.testPlanVersions) { + if (testPlanVersion.testPlanReports.length) return true; + } + }) + .map(testPlan => { + return { + ...testPlan, + testPlanVersions: sortTestPlanVersions( + testPlan.testPlanVersions + ) + }; + }) + .sort((a, b) => { + return a.title.localeCompare(b.title); + }); + + const testers = data.users + .filter(user => user.roles.includes('TESTER')) + .sort((a, b) => a.username.localeCompare(b.username)); + + const renderDisclosure = ({ testPlan }) => { + return ( + // TODO: fix the aria-label of this + ( + <> + + {testPlanVersion.versionString} + +   + + {testPlanVersion.phase} + + + ))} + onClick={testPlan.testPlanVersions.map( + testPlanVersion => () => { + const isOpen = + openDisclosuresRef.current[testPlanVersion.id]; + openDisclosuresRef.current[testPlanVersion.id] = + !isOpen; + forceUpdate(); + } + )} + expanded={testPlan.testPlanVersions.map( + testPlanVersion => + openDisclosuresRef.current[testPlanVersion.id] || false + )} + disclosureContainerView={testPlan.testPlanVersions.map( + testPlanVersion => + renderDisclosureContent({ testPlan, testPlanVersion }) + )} + /> + ); + }; + + const renderDisclosureContent = ({ testPlan, testPlanVersion }) => { + return ( + <> + + + + View tests in {testPlanVersion.versionString} + + + + + + + + + + + + + + + + {testPlanVersion.testPlanReports.map( + testPlanReport => + renderRow({ + testPlan, + testPlanVersion, + testPlanReport + }) + )} + +
    Assistive TechnologyBrowserTestersStatusActions
    +
    + + ); + }; + + const renderRow = ({ testPlan, testPlanVersion, testPlanReport }) => { + const percentComplete = calculatePercentComplete(testPlanReport); + const hasBotRun = testPlanReport.draftTestPlanRuns?.some( + ({ tester }) => tester.isBot + ); + + return ( + + + + + + + + + + + + + {} + + {hasBotRun ? ( + + ) : null} + + + + { + await client.refetchQueries({ + include: [ + 'TestQueuePage', + 'TestPlanReportStatusDialog' + ] + }); + + // Refocus on testers assignment dropdown button + const selector = `#assign-testers-${testPlanReport.id} button`; + document.querySelector(selector).focus(); + }} + /> + + + ); + }; + + return ( + + + Test Queue | ARIA-AT + +

    Test Queue

    +

    + {isSignedIn + ? 'Assign yourself a test plan or start executing one that is already assigned to you.' + : 'Select a test plan to view. Your results will not be saved.'} +

    + {isAdmin && ( + + )} + + {!testPlans.length + ? 'There are currently no test plan reports to show.' + : testPlans.map(testPlan => ( + + {/* ID needed for recovering focus after deleting a report */} +

    + {testPlan.title} +

    + {renderDisclosure({ testPlan })} +
    + ))} +
    + ); +}; + +export default TestQueue; diff --git a/client/components/TestQueue2/queries.js b/client/components/TestQueue2/queries.js new file mode 100644 index 000000000..7b5513d36 --- /dev/null +++ b/client/components/TestQueue2/queries.js @@ -0,0 +1,175 @@ +import { gql } from '@apollo/client'; + +export const TEST_QUEUE_PAGE_QUERY = gql` + query TestQueuePage { + me { + id + username + roles + } + users { + id + username + roles + isBot + ats { + id + key + } + } + ats { + id + key + name + atVersions { + id + name + releasedAt + } + browsers { + id + key + name + } + } + testPlans(testPlanVersionPhases: [DRAFT, CANDIDATE, RECOMMENDED]) { + directory + title + testPlanVersions { + id + title + phase + versionString + updatedAt + gitSha + gitMessage + testPlanReports(isFinal: false) { + id + at { + id + key + name + } + browser { + id + key + name + } + minimumAtVersion { + id + name + } + exactAtVersion { + id + name + } + runnableTestsLength + conflictsLength + metrics + draftTestPlanRuns { + id + testResultsLength + initiatedByAutomation + tester { + id + username + isBot + } + testResults { + completedAt + } + } + } + testPlanReportStatuses { + testPlanReport { + metrics + draftTestPlanRuns { + testResults { + completedAt + } + } + } + } + } + } + testPlanVersions { + id + title + phase + gitSha + gitMessage + testPlan { + directory + } + } + testPlanReports { + id + } + } +`; + +export const ASSIGN_TESTER_MUTATION = gql` + mutation AssignTester( + $testReportId: ID! + $testerId: ID! + $testPlanRunId: ID + ) { + testPlanReport(id: $testReportId) { + assignTester(userId: $testerId, testPlanRunId: $testPlanRunId) { + testPlanReport { + draftTestPlanRuns { + initiatedByAutomation + tester { + id + username + isBot + } + } + } + } + } + } +`; + +export const DELETE_TEST_PLAN_RUN = gql` + mutation DeleteTestPlanRun($testReportId: ID!, $testerId: ID!) { + testPlanReport(id: $testReportId) { + deleteTestPlanRun(userId: $testerId) { + testPlanReport { + id + draftTestPlanRuns { + id + tester { + id + username + isBot + } + } + } + } + } + } +`; + +export const MARK_TEST_PLAN_REPORT_AS_FINAL_MUTATION = gql` + mutation MarkTestPlanReportAsFinal( + $testPlanReportId: ID! + $primaryTestPlanRunId: ID! + ) { + testPlanReport(id: $testPlanReportId) { + markAsFinal(primaryTestPlanRunId: $primaryTestPlanRunId) { + testPlanReport { + markedFinalAt + } + } + } + } +`; + +export const REMOVE_TEST_PLAN_REPORT_MUTATION = gql` + mutation RemoveTestPlanReport($testPlanReportId: ID!) { + testPlanReport(id: $testPlanReportId) { + deleteTestPlanReport + } + } +`; diff --git a/client/components/TestQueueCompletionStatusListItem/BotTestCompletionStatus/index.js b/client/components/TestQueueCompletionStatusListItem/BotTestCompletionStatus/index.js index 7319020b1..a395cc48e 100644 --- a/client/components/TestQueueCompletionStatusListItem/BotTestCompletionStatus/index.js +++ b/client/components/TestQueueCompletionStatusListItem/BotTestCompletionStatus/index.js @@ -2,7 +2,12 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useTestPlanRunValidatedAssertionCounts } from '../../../hooks/useTestPlanRunValidatedAssertionCounts'; -const BotTestCompletionStatus = ({ testPlanRun, id, runnableTestsLength }) => { +const BotTestCompletionStatus = ({ + testPlanRun, + id, + runnableTestsLength, + fromTestQueueV2 = false // TODO: Remove when Test Queue v1 is removed +}) => { const { totalValidatedAssertions, totalPossibleAssertions, @@ -17,14 +22,27 @@ const BotTestCompletionStatus = ({ testPlanRun, id, runnableTestsLength }) => { }, [testResultsLength, stopPolling]); return ( -
      -
    • - {`Responses for ${testResultsLength} of ${runnableTestsLength} tests recorded`} -
    • -
    • - {`Verdicts for ${totalValidatedAssertions} of ${totalPossibleAssertions} assertions assigned`} -
    • -
    + <> + {fromTestQueueV2 ? ( +
      +
    • + {`Responses for ${testResultsLength} of ${runnableTestsLength} tests recorded`} +
    • +
    • + {`Verdicts for ${totalValidatedAssertions} of ${totalPossibleAssertions} assertions assigned`} +
    • +
    + ) : ( +
      +
    • + {`Responses for ${testResultsLength} of ${runnableTestsLength} tests recorded`} +
    • +
    • + {`Verdicts for ${totalValidatedAssertions} of ${totalPossibleAssertions} assertions assigned`} +
    • +
    + )} + ); }; @@ -46,7 +64,8 @@ BotTestCompletionStatus.propTypes = { ) }).isRequired, id: PropTypes.string.isRequired, - runnableTestsLength: PropTypes.number.isRequired + runnableTestsLength: PropTypes.number.isRequired, + fromTestQueueV2: PropTypes.bool }; export default BotTestCompletionStatus; diff --git a/client/components/TestQueueCompletionStatusListItem/PreviouslyAutomatedTestCompletionStatus/index.js b/client/components/TestQueueCompletionStatusListItem/PreviouslyAutomatedTestCompletionStatus/index.js index 7ec7cec7f..1ac71b589 100644 --- a/client/components/TestQueueCompletionStatusListItem/PreviouslyAutomatedTestCompletionStatus/index.js +++ b/client/components/TestQueueCompletionStatusListItem/PreviouslyAutomatedTestCompletionStatus/index.js @@ -6,7 +6,8 @@ import { TEST_PLAN_RUN_ASSERTION_RESULTS_QUERY } from '../queries'; const PreviouslyAutomatedTestCompletionStatus = ({ runnableTestsLength, testPlanRunId, - id + id, + fromTestQueueV2 = false // TODO: Remove when Test Queue v1 is removed }) => { const { data: testPlanRunAssertionsQueryResult } = useQuery( TEST_PLAN_RUN_ASSERTION_RESULTS_QUERY, @@ -38,16 +39,25 @@ const PreviouslyAutomatedTestCompletionStatus = ({ }, [testPlanRunAssertionsQueryResult]); return ( -
    - {`${numValidatedTests} of ${runnableTestsLength} tests evaluated`} -
    + <> + {fromTestQueueV2 ? ( + + {`${numValidatedTests} of ${runnableTestsLength} tests evaluated`} + + ) : ( +
    + {`${numValidatedTests} of ${runnableTestsLength} tests evaluated`} +
    + )} + ); }; PreviouslyAutomatedTestCompletionStatus.propTypes = { runnableTestsLength: PropTypes.number.isRequired, testPlanRunId: PropTypes.string.isRequired, - id: PropTypes.string.isRequired + id: PropTypes.string.isRequired, + fromTestQueueV2: PropTypes.bool }; export default PreviouslyAutomatedTestCompletionStatus; diff --git a/client/components/common/AtBrowserVersion/index.jsx b/client/components/common/AtBrowserVersion/index.jsx new file mode 100644 index 000000000..0d0e49a97 --- /dev/null +++ b/client/components/common/AtBrowserVersion/index.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; + +const VersionContainer = styled.div` + display: inline-block; + flex-wrap: wrap; + background: #f5f5f5; + border-radius: 4px; + padding: 0 5px; + font-weight: bold; + + & span { + font-weight: initial; + display: inline-block; + margin-left: 2px; + } +`; + +const AtVersion = ({ at, minimumAtVersion, exactAtVersion }) => { + const atVersionFormatted = minimumAtVersion + ? `${minimumAtVersion.name} or later` + : exactAtVersion.name; + + return ( + + {at.name}  + {atVersionFormatted} + + ); +}; + +AtVersion.propTypes = { + at: PropTypes.shape({ name: PropTypes.string.isRequired }).isRequired, + minimumAtVersion: PropTypes.shape({ name: PropTypes.string.isRequired }), + exactAtVersion: PropTypes.shape({ name: PropTypes.string.isRequired }) +}; + +const BrowserVersion = ({ browser }) => { + return ( + + {browser.name}  + Any version + + ); +}; + +BrowserVersion.propTypes = { + browser: PropTypes.shape({ + name: PropTypes.string.isRequired + }).isRequired +}; + +export { AtVersion, BrowserVersion }; diff --git a/client/components/common/ClippedProgressBar/index.jsx b/client/components/common/ClippedProgressBar/index.jsx index f78ad28ef..057251c89 100644 --- a/client/components/common/ClippedProgressBar/index.jsx +++ b/client/components/common/ClippedProgressBar/index.jsx @@ -2,7 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import './ClippedProgressBar.css'; -const ProgressBar = ({ progress = 0, label = '', clipped = true }) => { +const ProgressBar = ({ + progress = 0, + label = '', + clipped = true, + decorative +}) => { return ( <> {clipped ? ( @@ -13,9 +18,11 @@ const ProgressBar = ({ progress = 0, label = '', clipped = true }) => { clipPath: `inset(0 0 0 ${progress}%)` }} > - {progress}% + {decorative ? null : `${progress}%`} + +
    + {decorative ? null : `${progress}%`}
    -
    {progress}%
    ) : (
    @@ -25,7 +32,7 @@ const ProgressBar = ({ progress = 0, label = '', clipped = true }) => { width: `${progress}%` }} > - {progress}% + {decorative ? null : `${progress}%`}
    )} @@ -36,7 +43,8 @@ const ProgressBar = ({ progress = 0, label = '', clipped = true }) => { ProgressBar.propTypes = { progress: PropTypes.number, label: PropTypes.string, - clipped: PropTypes.bool + clipped: PropTypes.bool, + decorative: PropTypes.bool }; export default ProgressBar; diff --git a/client/components/common/DisclosureComponent/index.jsx b/client/components/common/DisclosureComponent/index.jsx index 4fcee9a3e..88ac56902 100644 --- a/client/components/common/DisclosureComponent/index.jsx +++ b/client/components/common/DisclosureComponent/index.jsx @@ -9,12 +9,10 @@ const DisclosureParent = styled.div` border-radius: 3px; width: 100%; - h1 { - margin: 0; - padding: 0; - } - - h3 { + h1, + h2, + h3, + h4 { margin: 0; padding: 0; } @@ -34,7 +32,7 @@ const DisclosureButton = styled.button` position: relative; width: 100%; margin: 0; - padding: 1.25rem; + padding: 1.25rem 40px 1.25rem 1.25rem; text-align: left; font-size: 1rem; font-weight: bold; @@ -99,6 +97,10 @@ const DisclosureComponent = ({ {title.map((_, index) => { const buttonTitle = title[index]; + const labelTitle = + typeof buttonTitle === 'string' + ? buttonTitle + : index; const buttonExpanded = expanded[index]; const buttonOnClick = onClick[index]; const buttonDisclosureContainerView = @@ -108,10 +110,10 @@ const DisclosureComponent = ({ @@ -128,8 +130,8 @@ const DisclosureComponent = ({ diff --git a/client/components/common/RadioBox/index.jsx b/client/components/common/RadioBox/index.jsx index b3684d86e..b1f8f1296 100644 --- a/client/components/common/RadioBox/index.jsx +++ b/client/components/common/RadioBox/index.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import styled from '@emotion/styled'; const ContainerDiv = styled.div` - width: max-content; display: flex; `; diff --git a/client/components/common/ReportStatusSummary/index.jsx b/client/components/common/ReportStatusSummary/index.jsx new file mode 100644 index 000000000..904f81b9e --- /dev/null +++ b/client/components/common/ReportStatusSummary/index.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { convertDateToString } from '../../../utils/formatter'; +import { calculatePercentComplete } from '../../../utils/calculatePercentComplete'; + +const IncompleteStatusReport = styled.span` + min-width: 5rem; + display: inline-block; +`; + +const ReportStatusSummary = ({ + testPlanVersion, + testPlanReport, + fromTestQueue = false +}) => { + const renderCompleteReportStatus = testPlanReport => { + const formattedDate = convertDateToString( + testPlanReport.markedFinalAt, + 'MMM D, YYYY' + ); + return ( + + Report completed on {formattedDate} + + ); + }; + + const renderPartialCompleteReportStatus = testPlanReport => { + const { metrics, draftTestPlanRuns } = testPlanReport; + + const conflictsCount = metrics.conflictsCount ?? 0; + const percentComplete = calculatePercentComplete(testPlanReport); + switch (draftTestPlanRuns?.length) { + case 0: + return fromTestQueue ? ( + No testers assigned + ) : ( + In test queue with no testers assigned + ); + case 1: + return ( + + {percentComplete}% complete by  + + {draftTestPlanRuns[0].tester.username} + +  with {conflictsCount} conflicts + + ); + default: + return ( + + {percentComplete}% complete by  + {draftTestPlanRuns.length} testers with {conflictsCount} +  conflicts + + ); + } + }; + + if (testPlanReport) { + const { markedFinalAt } = testPlanReport; + if (markedFinalAt) { + return renderCompleteReportStatus(testPlanReport); + } else { + return renderPartialCompleteReportStatus(testPlanReport); + } + } + + return Missing; +}; + +ReportStatusSummary.propTypes = { + testPlanVersion: PropTypes.shape({ + id: PropTypes.string.isRequired + }).isRequired, + testPlanReport: PropTypes.shape({ + id: PropTypes.string.isRequired, + markedFinalAt: PropTypes.string, + metrics: PropTypes.object, + draftTestPlanRuns: PropTypes.arrayOf( + PropTypes.shape({ + tester: PropTypes.shape({ + username: PropTypes.string.isRequired + }).isRequired + }) + ).isRequired + }), + fromTestQueue: PropTypes.bool +}; + +export default ReportStatusSummary; diff --git a/client/hooks/useConfirmationModal.js b/client/hooks/useConfirmationModal.js new file mode 100644 index 000000000..a14bbe56e --- /dev/null +++ b/client/hooks/useConfirmationModal.js @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useRef } from 'react'; +import PropTypes from 'prop-types'; +import useForceUpdate from './useForceUpdate'; + +const ConfirmationContext = createContext(); + +const ConfirmationModalProvider = ({ children }) => { + const forceUpdate = useForceUpdate(); + const modalContent = useRef(); + + const showConfirmationModal = newModalContent => { + modalContent.current = newModalContent; + forceUpdate(); + }; + + const hideConfirmationModal = async () => { + modalContent.current = null; + forceUpdate(); + }; + + return ( + + {children} + {modalContent.current} + + ); +}; + +ConfirmationModalProvider.propTypes = { + children: PropTypes.node.isRequired +}; + +const useConfirmationModal = () => { + const { showConfirmationModal, hideConfirmationModal } = + useContext(ConfirmationContext); + + return { showConfirmationModal, hideConfirmationModal }; +}; + +export { ConfirmationModalProvider }; +export default useConfirmationModal; diff --git a/client/hooks/useForceUpdate.js b/client/hooks/useForceUpdate.js new file mode 100644 index 000000000..923186af9 --- /dev/null +++ b/client/hooks/useForceUpdate.js @@ -0,0 +1,10 @@ +import React from 'react'; + +// https://stackoverflow.com/a/53215514 +const useForceUpdate = () => { + const [, updateState] = React.useState(); + const forceUpdate = React.useCallback(() => updateState({}), []); + return forceUpdate; +}; + +export default useForceUpdate; diff --git a/client/hooks/useThemedModal.js b/client/hooks/useThemedModal.js index a85b27b65..f058f474b 100644 --- a/client/hooks/useThemedModal.js +++ b/client/hooks/useThemedModal.js @@ -13,6 +13,9 @@ function useThemedModal({ show, type, title, content }) { const [themedModalType, setThemedModalType] = useState(THEMES.WARNING); const [themedModalTitle, setThemedModalTitle] = useState(''); const [themedModalContent, setThemedModalContent] = useState(<>); + const [themedModalActions, setThemedModalActions] = useState(null); + const [themedModalShowCloseAction, setThemedModalShowCloseAction] = + useState(false); useEffect(() => { setShowThemedModal(showThemedModal || show); @@ -21,13 +24,26 @@ function useThemedModal({ show, type, title, content }) { setThemedModalContent(themedModalContent || content); }); + const hideThemedModal = () => { + setShowThemedModal(false); + setThemedModalType(THEMES.WARNING); + setThemedModalTitle(''); + setThemedModalContent(<>); + setThemedModalActions(null); + setThemedModalShowCloseAction(false); + }; + const onThemedModalClose = () => { setShowThemedModal(false); if (focusElementRef.current) focusElementRef.current.focus(); }; const setFocusRef = focusElement => - (focusElementRef.current = focusElement); + (focusElementRef.current = focusElement?.current || focusElement); + + const focus = () => { + if (focusElementRef.current) focusElementRef.current.focus(); + }; const themedModal = ( ); return { - setFocusRef, themedModal, showThemedModal, setShowThemedModal, setThemedModalType, setThemedModalTitle, - setThemedModalContent + setThemedModalContent, + setThemedModalActions, + setThemedModalShowCloseAction, + focus, + setFocusRef, + hideThemedModal }; } diff --git a/client/index.js b/client/index.js index 57d21061f..537cd22a8 100644 --- a/client/index.js +++ b/client/index.js @@ -7,15 +7,19 @@ import App from './components/App'; import GraphQLProvider from './components/GraphQLProvider'; import { AriaLiveRegionProvider } from './components/providers/AriaLiveRegionProvider'; import { resetCache } from './components/GraphQLProvider/GraphQLProvider'; +import { ConfirmationModalProvider } from './hooks/useConfirmationModal'; const container = document.getElementById('root'); const root = createRoot(container); + root.render( - - - + + + + + ); diff --git a/client/routes/index.js b/client/routes/index.js index a98bc63de..cab1ab6f7 100644 --- a/client/routes/index.js +++ b/client/routes/index.js @@ -8,6 +8,7 @@ import { Reports, Report } from '@components/Reports'; import CandidateReview from '@components/CandidateReview'; import SignupInstructions from '@components/SignupInstructions'; import TestQueue from '@components/TestQueue'; +import TestQueue2 from '@components/TestQueue2'; import TestRun from '@components/TestRun'; import UserSettings from '@components/UserSettings'; import CandidateTestPlanRun from '@components/CandidateReview/CandidateTestPlanRun'; @@ -41,7 +42,9 @@ export default () => ( } /> - } /> + {/* TODO: Deprecate and remove */} + } /> + } /> { await page.type('.modal-body .form-group:nth-child(1) input', '99.0.1'); await page.type('.modal-body .form-group:nth-child(2) input', '01-01-2000'); await page.click('.modal-footer button ::-p-text(Add Version)'); - await page.waitForNetworkIdle(); - await openTrayIfClosed(); + 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.select('.at-versions-container select', optionValue); @@ -32,15 +32,14 @@ describe('AT Version UI', () => { } await page.type('.modal-body .form-group:nth-child(1) input', '99.0.99'); await page.click('.modal-footer button ::-p-text(Save)'); - await page.waitForNetworkIdle(); - await openTrayIfClosed(); + await page.waitForNetworkIdle({ idleTime: 5000 }); + await page.click('.modal-footer button ::-p-text(Ok)'); await page.waitForSelector('.at-versions-container option ::-p-text(99.0.99)'); await page.select('.at-versions-container select', optionValue); await page.click('.at-versions-container button ::-p-text(Remove)'); await page.waitForSelector('.modal-title ::-p-text(Remove JAWS Version 99.0.99)'); await page.click('.modal-footer button ::-p-text(Remove)'); await page.waitForNetworkIdle(); - await openTrayIfClosed(); const option = await page.$('.at-versions-container option ::-p-text(99.0.99)'); expect(option).toBeNull(); }); diff --git a/client/tests/__mocks__/GraphQLMocks/TestPlanReportStatusDialogMock.js b/client/tests/__mocks__/GraphQLMocks/TestPlanReportStatusDialogMock.js index d01592182..32a9a824b 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestPlanReportStatusDialogMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestPlanReportStatusDialogMock.js @@ -804,7 +804,12 @@ export default ( name: 'Firefox' } } - ] + ], + metadata: { + exampleUrl: 'https://fakeurl.com/exampleUrl', + designPatternUrl: 'https://fakeurl.com/designPattern', + testFormatVersion: 1 + } }, oldTestPlanVersions: [] } diff --git a/client/tests/__mocks__/GraphQLMocks/TestQueuePageBaseMock.js b/client/tests/__mocks__/GraphQLMocks/TestQueuePageBaseMock.js index 06dfcc0f6..13afebebd 100644 --- a/client/tests/__mocks__/GraphQLMocks/TestQueuePageBaseMock.js +++ b/client/tests/__mocks__/GraphQLMocks/TestQueuePageBaseMock.js @@ -103,7 +103,12 @@ export default (testPlanReportAtBrowserQuery, existingTestPlanReportsQuery) => [ key: 'chrome' } } - ] + ], + metadata: { + exampleUrl: 'https://fakeurl.com/exampleUrl', + designPatternUrl: 'https://fakeurl.com/designPattern', + testFormatVersion: 1 + } }, oldTestPlanVersions: [] } diff --git a/client/tests/calculateTestPlanReportCompletionPercentage.test.js b/client/tests/calculatePercentComplete.test.js similarity index 73% rename from client/tests/calculateTestPlanReportCompletionPercentage.test.js rename to client/tests/calculatePercentComplete.test.js index dd72bb904..03cda0adf 100644 --- a/client/tests/calculateTestPlanReportCompletionPercentage.test.js +++ b/client/tests/calculatePercentComplete.test.js @@ -1,15 +1,13 @@ -import { calculateTestPlanReportCompletionPercentage } from '../components/TestPlanReportStatusDialog/calculateTestPlanReportCompletionPercentage'; +import { calculatePercentComplete } from '../utils/calculatePercentComplete'; -describe('calculateTestPlanReportCompletionPercentage', () => { +describe('calculatePercentComplete', () => { const testResult = (id, completedAt = null) => ({ id, completedAt }); test('returns 0 when metrics or draftTestPlanRuns is not defined', () => { - expect(calculateTestPlanReportCompletionPercentage({})).toBe(0); + expect(calculatePercentComplete({})).toBe(0); + expect(calculatePercentComplete({ metrics: {} })).toBe(0); expect( - calculateTestPlanReportCompletionPercentage({ metrics: {} }) - ).toBe(0); - expect( - calculateTestPlanReportCompletionPercentage({ + calculatePercentComplete({ draftTestPlanRuns: [] }) ).toBe(0); @@ -17,7 +15,7 @@ describe('calculateTestPlanReportCompletionPercentage', () => { test('returns 0 when draftTestPlanRuns is empty', () => { expect( - calculateTestPlanReportCompletionPercentage({ + calculatePercentComplete({ metrics: { testsCount: 5 }, draftTestPlanRuns: [] }) @@ -38,7 +36,7 @@ describe('calculateTestPlanReportCompletionPercentage', () => { ]; expect( - calculateTestPlanReportCompletionPercentage({ + calculatePercentComplete({ metrics, draftTestPlanRuns }) @@ -61,7 +59,7 @@ describe('calculateTestPlanReportCompletionPercentage', () => { // (NUMBER_COMPLETED_TESTS_BY_ALL_TESTERS / (NUMBER_ASSIGNED_TESTERS * NUMBER_TESTS_IN_PLAN)) * 100 // (5 / (2 * 5)) * 100 = 50 expect( - calculateTestPlanReportCompletionPercentage({ + calculatePercentComplete({ metrics, draftTestPlanRuns }) diff --git a/client/tests/smokeTest.test.js b/client/tests/smokeTest.test.js index 9042bb094..3818b1d3b 100644 --- a/client/tests/smokeTest.test.js +++ b/client/tests/smokeTest.test.js @@ -1,14 +1,14 @@ import getPage from './util/getPage'; describe('smoke test', () => { - it('end-to-end tests can simultaneously sign in with all roles', async () => { + it('end-to-end tests can simultaneously sign in with all roles [old]', async () => { await Promise.all([ - getPage({ role: 'admin', url: '/test-queue' }, async page => { + getPage({ role: 'admin', url: '/test-queue-old' }, async page => { // Only admins can remove rows from the test queue await page.waitForSelector('td.actions ::-p-text(Remove)'); }), - getPage({ role: 'tester', url: '/test-queue' }, async page => { + getPage({ role: 'tester', url: '/test-queue-old' }, async page => { // Testers can assign themselves await page.waitForSelector('table ::-p-text(Assign Yourself)'); const adminOnlyRemoveButton = await page.$( @@ -18,7 +18,7 @@ describe('smoke test', () => { }), getPage( - { role: 'vendor', url: '/test-queue' }, + { role: 'vendor', url: '/test-queue-old' }, async (page, { baseUrl }) => { // Vendors get the same test queue as signed-out users await page.waitForSelector( @@ -30,13 +30,60 @@ describe('smoke test', () => { } ), - getPage({ role: false, url: '/test-queue' }, async page => { + getPage({ role: false, url: '/test-queue-old' }, async page => { // Signed-out users can only view tests, not run them await page.waitForSelector('td.actions ::-p-text(View tests)'); }) ]); }); + it('end-to-end tests can simultaneously sign in with all roles', async () => { + await Promise.all([ + getPage({ role: 'admin', url: '/test-queue' }, async page => { + // Only admins can remove rows from the test queue + await page.waitForSelector( + 'td [type="button"] ::-p-text(Delete Report)' + ); + }), + + getPage({ role: 'tester', url: '/test-queue' }, async page => { + // Testers can assign themselves + await page.waitForSelector('table ::-p-text(Assign Yourself)'); + const adminOnlyRemoveButton = await page.$( + 'td [type="button"] ::-p-text(Delete Report)' + ); + expect(adminOnlyRemoveButton).toBe(null); + }), + + getPage( + { role: 'vendor', url: '/test-queue' }, + async (page, { baseUrl }) => { + // Vendors get the same test queue as signed-out users + await page.waitForSelector('button ::-p-text(V22.04.14)'); + await page.click('button ::-p-text(V22.04.14)'); + + await page.waitForSelector('td [role="button"]'); + const buttonText = await page.$eval( + 'td [role="button"]', + button => button.textContent + ); + expect(buttonText).toEqual('View Tests'); + + // Unlike signed-out users, they will get tables on this page + await page.goto(`${baseUrl}/candidate-review`); + await page.waitForSelector('table'); + } + ), + + getPage({ role: false, url: '/test-queue' }, async page => { + // Signed-out users can only view tests, not run them + await page.waitForSelector( + 'td [role="button"] ::-p-text(View Tests)' + ); + }) + ]); + }); + it('loads various pages without crashing', async () => { await Promise.all([ getPage({ role: false, url: '/' }, async page => { diff --git a/client/components/TestPlanReportStatusDialog/calculateTestPlanReportCompletionPercentage.js b/client/utils/calculatePercentComplete.js similarity index 84% rename from client/components/TestPlanReportStatusDialog/calculateTestPlanReportCompletionPercentage.js rename to client/utils/calculatePercentComplete.js index 2df1ec9a0..f437e0a3c 100644 --- a/client/components/TestPlanReportStatusDialog/calculateTestPlanReportCompletionPercentage.js +++ b/client/utils/calculatePercentComplete.js @@ -1,7 +1,4 @@ -export const calculateTestPlanReportCompletionPercentage = ({ - metrics, - draftTestPlanRuns -}) => { +export const calculatePercentComplete = ({ metrics, draftTestPlanRuns }) => { if (!metrics || !draftTestPlanRuns) return 0; const assignedUserCount = draftTestPlanRuns.length || 1; const totalTestsPossible = metrics.testsCount * assignedUserCount; diff --git a/client/utils/evaluateAuth.js b/client/utils/evaluateAuth.js index 6c41f5c40..34fcc145a 100644 --- a/client/utils/evaluateAuth.js +++ b/client/utils/evaluateAuth.js @@ -9,19 +9,21 @@ * @param {string[]} data.roles - currently logged in user's assigned roles * @returns {Auth} - evaluated auth object */ -export const evaluateAuth = (data = {}) => { - const roles = data.roles || []; +export const evaluateAuth = user => { + if (!user) user = {}; + + let roles = user.roles ?? []; return { // calculated booleans isAdmin: roles.includes('ADMIN'), isTester: roles.includes('TESTER'), isVendor: roles.includes('VENDOR'), - isSignedIn: !!data.username, + isSignedIn: !!user.username, // user object values - id: data.id || null, - username: data.username || null, + id: user.id ?? null, + username: user.username ?? null, roles }; }; diff --git a/server/graphql-schema.js b/server/graphql-schema.js index d4df23ef6..440c04f9f 100644 --- a/server/graphql-schema.js +++ b/server/graphql-schema.js @@ -1239,7 +1239,7 @@ const graphqlSchema = gql` """ Get all TestPlans. """ - testPlans: [TestPlan]! + testPlans(testPlanVersionPhases: [TestPlanVersionPhase]): [TestPlan]! """ Load a particular TestPlan by ID. """ diff --git a/server/handlebars/embed/public/style.css b/server/handlebars/embed/public/style.css index a93bb6343..7361be738 100644 --- a/server/handlebars/embed/public/style.css +++ b/server/handlebars/embed/public/style.css @@ -30,6 +30,7 @@ a:focus-visible { details { margin-bottom: 1em; } + details summary:focus-visible { outline-offset: -2px; outline: 2px solid #3a86d1; @@ -97,7 +98,6 @@ details > summary > h4 { position: relative; padding-left: var(--left-right-padding); padding-right: var(--left-right-padding); - font-family: Arial, Helvetica, sans-serif; font-size: 1em; color: #60470c; diff --git a/server/migrations/20240319195950-updateMetrics.js b/server/migrations/20240516195950-updateMetrics.js similarity index 100% rename from server/migrations/20240319195950-updateMetrics.js rename to server/migrations/20240516195950-updateMetrics.js diff --git a/server/models/loaders/AtLoader.js b/server/models/loaders/AtLoader.js index 06ea3e671..f0a1b65a9 100644 --- a/server/models/loaders/AtLoader.js +++ b/server/models/loaders/AtLoader.js @@ -16,7 +16,7 @@ const AtLoader = () => { activePromise = getAts({ transaction }).then(ats => { // Sort date of atVersions subarray in desc order by releasedAt date - ats.forEach(item => + ats.sort((a, b) => a.name.localeCompare(b.name)).forEach(item => item.atVersions.sort((a, b) => b.releasedAt - a.releasedAt) ); diff --git a/server/models/loaders/BrowserLoader.js b/server/models/loaders/BrowserLoader.js index 59e38fbce..db04d8a4b 100644 --- a/server/models/loaders/BrowserLoader.js +++ b/server/models/loaders/BrowserLoader.js @@ -15,15 +15,17 @@ const BrowserLoader = () => { } activePromise = getBrowsers({ transaction }).then(browsers => { - browsers = browsers.map(browser => ({ - ...browser.dataValues, - candidateAts: browser.ats.filter( - at => at.AtBrowsers.isCandidate - ), - recommendedAts: browser.ats.filter( - at => at.AtBrowsers.isRecommended - ) - })); + browsers = browsers + .sort((a, b) => a.name.localeCompare(b.name)) + .map(browser => ({ + ...browser.dataValues, + candidateAts: browser.ats.filter( + at => at.AtBrowsers.isCandidate + ), + recommendedAts: browser.ats.filter( + at => at.AtBrowsers.isRecommended + ) + })); return browsers; }); diff --git a/server/resolvers/createTestPlanReportResolver.js b/server/resolvers/createTestPlanReportResolver.js index d1f599dc4..854ef6246 100644 --- a/server/resolvers/createTestPlanReportResolver.js +++ b/server/resolvers/createTestPlanReportResolver.js @@ -34,7 +34,7 @@ const createTestPlanReportResolver = async (_, { input }, context) => { context }); - if (updatedTestPlanReports) { + if (updatedTestPlanReports?.length) { // Expecting only to get back the single requested combination testPlanReport = updatedTestPlanReports[0]; } else { diff --git a/server/resolvers/testPlansResolver.js b/server/resolvers/testPlansResolver.js index 5d8273ff9..6dd8ee200 100644 --- a/server/resolvers/testPlansResolver.js +++ b/server/resolvers/testPlansResolver.js @@ -1,55 +1,22 @@ const { getTestPlans } = require('../models/services/TestPlanService'); -const retrieveAttributes = require('./helpers/retrieveAttributes'); -const { TEST_PLAN_VERSION_ATTRIBUTES } = require('../models/services/helpers'); -const testPlans = async (_, __, context, info) => { +const testPlans = async (_, { testPlanVersionPhases }, context) => { const { transaction } = context; - const requestedFields = - info.fieldNodes[0] && - info.fieldNodes[0].selectionSet.selections.map( - selection => selection.name.value - ); - const includeLatestTestPlanVersion = requestedFields.includes( - 'latestTestPlanVersion' - ); - - const { attributes: latestTestPlanVersionAttributes } = retrieveAttributes( - 'latestTestPlanVersion', - TEST_PLAN_VERSION_ATTRIBUTES, - info, - true - ); - - const { attributes: testPlanVersionsAttributes } = retrieveAttributes( - 'testPlanVersions', - TEST_PLAN_VERSION_ATTRIBUTES, - info, - true - ); - - const combinedTestPlanVersionAttributes = [ - ...new Set([ - ...latestTestPlanVersionAttributes, - ...testPlanVersionsAttributes - ]) - ]; - - const hasAssociations = combinedTestPlanVersionAttributes.length !== 0; - const plans = await getTestPlans({ - includeLatestTestPlanVersion, - testPlanVersionAttributes: combinedTestPlanVersionAttributes, - testPlanReportAttributes: hasAssociations ? null : [], - atAttributes: hasAssociations ? null : [], - browserAttributes: hasAssociations ? null : [], - testPlanRunAttributes: hasAssociations ? null : [], - userAttributes: hasAssociations ? null : [], + includeLatestTestPlanVersion: true, pagination: { order: [['testPlanVersions', 'updatedAt', 'DESC']] }, transaction }); + return plans.map(p => { - return { ...p.dataValues }; + return { + ...p.dataValues, + testPlanVersions: p.testPlanVersions?.filter(testPlanVersion => { + if (!testPlanVersionPhases) return true; + return testPlanVersionPhases.includes(testPlanVersion.phase); + }) + }; }); };