diff --git a/.gitignore b/.gitignore index 2ec1b6e1a..e2daf2298 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ client/resources #temp files for import-tests server/scripts/import-tests/tmp + +#IDE settings +.vscode/settings.json \ No newline at end of file diff --git a/client/components/ReportRerun/ReportRerun.module.css b/client/components/ReportRerun/ReportRerun.module.css new file mode 100644 index 000000000..80994fc5f --- /dev/null +++ b/client/components/ReportRerun/ReportRerun.module.css @@ -0,0 +1,445 @@ +.test-plan-refresh { + max-width: 1200px; +} + +.events-header, +.rerun-header { + font-size: var(--28px); + margin-bottom: var(--32px); + color: var(--font-black); + font-weight: var(--font-weight-medium); +} + +.rerun-dashboard { + display: grid; + grid-template-columns: 1fr; + gap: var(--section-gap); + margin-bottom: var(--section-gap); + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +@media (min-width: 768px) { + .rerun-dashboard { + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + } +} + +.rerun-opportunity { + height: min-content; + background-color: white; + border: 1px solid var(--border-gray); + border-radius: 0.5rem; + padding: var(--section-gap); + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px var(--box-shadow-gray); + transition: box-shadow 0.2s ease-in-out; + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +@media (min-width: 768px) { + .rerun-opportunity { + padding: 1.5rem; + } +} + +.opportunity-header { + background-color: #f8f9fa; + border-bottom: 1px solid var(--border-gray); + padding: var(--section-gap); + display: flex; + align-items: center; +} + +.bot-name { + font-size: 1.25rem; + font-weight: 600; + color: var(--darkest-gray); + margin: 0 0 var(--section-gap) 0; +} + +.version-badge { + display: inline-block; + background-color: #e9ecef; + color: var(--dark-gray); + font-size: var(--nav-font-size-sm); + padding: var(--text-gap) var(--el-gap); + border-radius: 4px; + margin-top: var(--el-gap); +} + +.version-update { + display: flex; + flex-direction: column; + align-items: flex-start; + margin: var(--section-gap) 0; + padding: var(--section-gap); + background-color: #f8f9fa; + border-radius: 0.75rem; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 768px) { + .version-update { + margin: 1.25rem 0; + padding: 1.5rem; + overflow-x: visible; + } +} + +.version-info { + display: flex; + align-items: center; + width: fit-content; + min-width: 100%; + position: relative; + min-height: 90px; + padding: 0 55px 0 0; +} + +@media (min-width: 768px) { + .version-info { + min-height: 110px; + } +} + +.version-groups-container { + display: flex; + align-items: center; + gap: 0; + position: relative; + z-index: 1; +} + +/* Simple line connecting versions */ +.version-info::after { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + background-color: #64748b; + opacity: 0.3; + z-index: 0; +} + +/* Hide the line when there are no previous versions */ +.version-info:not(:has(.version-groups-container > *))::after { + display: none; +} + +/* Center the highlight box when there are no previous versions */ +.version-info:not(:has(.version-groups-container > *)) .version-box.highlight { + position: relative; + left: 50%; + transform: translate(-25%, 0); + width: 120px; +} + +.version-box { + background: white; + width: 90px; + height: 90px; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-right: -2rem; + position: relative; + box-shadow: 0 2px 4px var(--box-shadow-gray); + transition: transform 0.2s ease, box-shadow 0.2s ease; + padding: 0.75rem; + text-align: center; + z-index: 1; + cursor: default; +} + +@media (min-width: 768px) { + .version-box { + width: 80px; + height: 80px; + min-width: 80px; + margin-right: -1rem; + padding: var(--section-gap); + } +} + +.version-box:hover { + transform: translateY(-2px); + z-index: 2; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); +} + +.version-box .version-number { + font-weight: 600; + font-size: var(--default-font-size); + margin: 0; + color: var(--rd-color); + line-height: 1.2; +} + +@media (min-width: 768px) { + .version-box .version-number { + font-size: var(--default-font-size); + } +} + +.version-box .version-count { + font-size: 0.75rem; + color: var(--dark-gray-text); + margin: 0; + line-height: 1.2; +} + +@media (min-width: 768px) { + .version-box .version-count { + font-size: var(--nav-font-size-sm); + } +} + +.version-box.highlight { + background-color: #f0f9ff; + border-color: var(--border-dark-blue); + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + box-shadow: 0 2px 4px var(--button-shadow); + margin-right: 0; + z-index: 1; + width: 90px; + height: 90px; +} + +.version-box.highlight .version-number { + font-size: var(--default-font-size); + color: var(--font-link); +} + +@media (min-width: 768px) { + .version-box.highlight .version-number { + font-size: var(--default-font-size); + } +} + +.version-box.highlight:hover { + transform: translateY(-50%); + box-shadow: 0 4px 6px var(--button-shadow); +} + +.version-box.highlight.no-reports { + background-color: #f8fafc; + border-color: #e2e8f0; + box-shadow: 0 2px 4px var(--box-shadow-gray); +} + +.version-box.highlight.no-reports .version-number { + color: #64748b; +} + +.version-box.highlight.no-reports:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07); +} + +.version-separator { + display: none; +} + +.test-plans-preview { + margin-top: 2rem; +} + +.version-group { + margin-bottom: 1.5rem; +} + +.version-group:last-child { + margin-bottom: 0; +} + +.plans-preview-title { + font-size: var(--default-font-size); + color: var(--dark-gray-text); + margin-bottom: var(--el-gap); +} + +.plan-summary { + margin: 1.25rem 0; + padding: var(--el-gap); + background-color: #f6f8fa; + border-radius: 0.25rem; +} + +.plan-count { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: default; + user-select: none; + justify-content: center; +} + +.plan-count-number { + font-size: 1.25rem; + font-weight: 600; + color: var(--font-link); +} + +@media (min-width: 768px) { + .plan-count-number { + font-size: 1.5rem; + } +} + +.plan-count-label { + font-size: var(--nav-font-size-sm); + color: var(--dark-gray-text); +} + +@media (min-width: 768px) { + .plan-count-label { + font-size: var(--default-font-size); + } +} + +.plans-list { + list-style-type: none; + padding: 0; + margin: 0; + overflow-y: auto; + border: 1px solid #e1e4e8; + border-radius: 0.5rem; +} + +.plans-list li { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e1e4e8; + color: var(--dark-gray-text); + cursor: default; +} + +.plans-list li:last-child { + border-bottom: none; +} + +.action-footer { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; +} + +.rerun-button { + background-color: var(--positive-green); + color: white; + border: none; + padding: 0.625rem 1.25rem; + border-radius: 4px; + font-size: var(--default-font-size); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.2s ease; + min-width: 140px; +} + +.rerun-button:disabled { + background-color: var(--border-gray); + cursor: not-allowed; +} + +.rerun-button:not(:disabled):hover { + background-color: var(--rd-color); + cursor: pointer; +} + +.rerun-button:focus { + box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.3); + outline: none; +} + +.events-section { + background-color: white; + border: 1px solid #e1e4e8; + border-radius: 0.5rem; + padding: 0; + margin-top: 2.5rem; + box-shadow: 0 1px 3px var(--box-shadow-gray); + transition: box-shadow 0.2s ease-in-out; + overflow: hidden; +} + +.rerun-header, +.events-header { + font-size: 1.25rem; + margin: 0; + padding: 1.25rem; + border-bottom: 1px solid #dee2e6; + color: #24292f; + font-weight: 600; + background-color: #f8f9fa; +} + +.events-content { + padding: 0; +} + +.empty-events-message { + padding: 1.5rem; + color: var(--dark-gray-text); + font-size: var(--default-font-size); + text-align: center; + margin: 0; +} + +.events-section .theme-table { + margin: 0; + border: none; +} + +.events-section th { + background-color: #f8f9fa; + font-weight: 600; + padding: var(--section-gap) 1.25rem; + color: #24292f; + border-bottom: 1px solid #dee2e6; +} + +.events-section td { + padding: var(--section-gap) 1.25rem; + color: var(--dark-gray-text); + line-height: 1.5; + border-top: 1px solid var(--border-gray); +} + +.timestamp-cell { + white-space: nowrap; + width: 180px; + color: var(--dark-gray-text); + font-weight: var(--font-weight-medium); +} + +.message-cell { + line-height: 1.6; + color: var(--font-black); +} + +.events-header-container { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.events-header-container h2 { + margin: 0; + font-size: inherit; + font-weight: inherit; + color: inherit; +} diff --git a/client/components/ReportRerun/RerunDashboard.jsx b/client/components/ReportRerun/RerunDashboard.jsx new file mode 100644 index 000000000..0bf48cd49 --- /dev/null +++ b/client/components/ReportRerun/RerunDashboard.jsx @@ -0,0 +1,157 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './ReportRerun.module.css'; + +const RerunDashboard = ({ activeRuns, onRerunClick }) => ( + <> +

+ Available Updates +

+
+ {activeRuns.map(run => { + const totalTestPlans = run.reportGroups.reduce( + (sum, group) => sum + group.reportCount, + 0 + ); + const versionDescription = `${run.botName} ${ + run.newVersion + } automation support has been added to the application. ${totalTestPlans} test plan versions can be re-run from ${ + run.reportGroups.length + } previous versions, including ${run.reportGroups + .map(g => g.prevVersion) + .join(', ')}.`; + + return ( +
+

+ {run.botName} {run.newVersion} +

+ +

{versionDescription}

+ + + + + +
+ {run.reportGroups.map((group, index) => ( +
+

+ {group.reportCount} from {group.prevVersion} +

+
    + {group.reports.map((report, idx) => ( +
  • + {report.testPlanVersion.title}{' '} + {report.testPlanVersion.versionString},{' '} + {report.browser.name} +
  • + ))} +
+
+ ))} +
+ +
+ +
+
+ ); + })} +
+ +); + +RerunDashboard.propTypes = { + activeRuns: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + botName: PropTypes.string.isRequired, + newVersion: PropTypes.string.isRequired, + reportGroups: PropTypes.arrayOf( + PropTypes.shape({ + prevVersion: PropTypes.string.isRequired, + reportCount: PropTypes.number.isRequired, + reports: PropTypes.arrayOf( + PropTypes.shape({ + testPlanVersion: PropTypes.shape({ + title: PropTypes.string.isRequired, + versionString: PropTypes.string.isRequired + }).isRequired, + browser: PropTypes.shape({ + name: PropTypes.string.isRequired + }).isRequired + }) + ).isRequired + }) + ).isRequired + }) + ).isRequired, + onRerunClick: PropTypes.func.isRequired +}; + +export default RerunDashboard; diff --git a/client/components/ReportRerun/UpdateEventsPanel.jsx b/client/components/ReportRerun/UpdateEventsPanel.jsx new file mode 100644 index 000000000..bccacdb12 --- /dev/null +++ b/client/components/ReportRerun/UpdateEventsPanel.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import RefreshButton from '../common/RefreshButton'; +import { convertStringFormatToAnotherFormat } from 'shared/dates'; +import { Table } from 'react-bootstrap'; +import styles from './ReportRerun.module.css'; + +const UpdateEventsPanel = ({ events = [], isAdmin, onRefresh }) => { + return ( +
+
+
+

Update Events

+ +
+
+
+ {events.length ? ( + + + + + + + + + {events.map(event => ( + + + + + ))} + +
TimeMessage
+ {convertStringFormatToAnotherFormat( + event.timestamp, + 'DD-MM-YYYY HH:mm', + 'D MMM YYYY HH:mm' + )} + {event.description}
+ ) : ( +

+ {isAdmin + ? 'No update events to display yet. Start a rerun to see events here.' + : 'No update events to display yet.'} +

+ )} +
+
+ ); +}; + +UpdateEventsPanel.propTypes = { + events: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + timestamp: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + type: PropTypes.oneOf([ + 'COLLECTION_JOB', + 'GENERAL', + 'TEST_PLAN_RUN', + 'TEST_PLAN_REPORT' + ]).isRequired + }) + ), + isAdmin: PropTypes.bool.isRequired, + onRefresh: PropTypes.func.isRequired +}; + +export default UpdateEventsPanel; diff --git a/client/components/ReportRerun/index.jsx b/client/components/ReportRerun/index.jsx new file mode 100644 index 000000000..50cd5aa87 --- /dev/null +++ b/client/components/ReportRerun/index.jsx @@ -0,0 +1,145 @@ +import React, { useMemo } from 'react'; +import { useQuery, useMutation, useApolloClient } from '@apollo/client'; +import PropTypes from 'prop-types'; +import { ME_QUERY } from '../App/queries'; +import { evaluateAuth } from '../../utils/evaluateAuth'; +import RerunDashboard from './RerunDashboard'; +import UpdateEventsPanel from './UpdateEventsPanel'; +import { utils } from 'shared'; +import styles from './ReportRerun.module.css'; +import { + GET_AUTOMATION_SUPPORTED_AT_VERSIONS, + GET_RERUNNABLE_REPORTS_QUERY, + CREATE_COLLECTION_JOBS_MUTATION, + GET_UPDATE_EVENTS +} from './queries'; + +const ReportRerun = ({ onQueueUpdate }) => { + const client = useApolloClient(); + + const { data: { me } = {} } = useQuery(ME_QUERY); + const { isAdmin } = evaluateAuth(me); + + const { data: atVersionsData } = useQuery( + GET_AUTOMATION_SUPPORTED_AT_VERSIONS, + { + skip: !isAdmin + } + ); + + const { data: { updateEvents = [] } = {}, refetch: refetchEvents } = useQuery( + GET_UPDATE_EVENTS, + { + pollInterval: 10000 + } + ); + + const automatedVersions = useMemo(() => { + if (!atVersionsData?.ats) return []; + + return atVersionsData.ats + .map(at => { + const automationVersions = at.atVersions + .filter(v => v.supportedByAutomation) + .sort((a, b) => new Date(b.releasedAt) - new Date(a.releasedAt)); + + return automationVersions[0] + ? { at, version: automationVersions[0] } + : null; + }) + .filter(Boolean); + }, [atVersionsData]); + + const rerunnableReportsQueries = automatedVersions.map(({ version }) => + useQuery(GET_RERUNNABLE_REPORTS_QUERY, { + variables: { atVersionId: version.id }, + fetchPolicy: 'cache-and-network' + }) + ); + + const activeRuns = useMemo(() => { + return automatedVersions.map(({ at, version }, index) => { + const { data: rerunnableData } = rerunnableReportsQueries[index]; + const groups = + rerunnableData?.rerunnableReports?.previousVersionGroups || []; + + const reportGroups = utils + .sortAtVersions( + groups.map(group => ({ + name: group.previousVersion.name, + releasedAt: group.previousVersion.releasedAt + })) + ) + .map(sortedVersion => { + const group = groups.find( + g => g.previousVersion.name === sortedVersion.name + ); + return { + prevVersion: group.previousVersion.name, + releasedAt: group.previousVersion.releasedAt, + reportCount: group.reports.length, + reports: group.reports + }; + }); + + return { + id: version.id, + botName: `${at.name} Bot`, + newVersion: version.name, + reportGroups + }; + }); + }, [automatedVersions, rerunnableReportsQueries]); + + const [createCollectionJobs] = useMutation(CREATE_COLLECTION_JOBS_MUTATION); + + const handleRerunClick = async run => { + try { + await createCollectionJobs({ + variables: { atVersionId: run.id } + }); + + client.query({ + query: GET_RERUNNABLE_REPORTS_QUERY, + variables: { atVersionId: run.id }, + fetchPolicy: 'network-only' + }); + + client.query({ + query: GET_UPDATE_EVENTS, + variables: { type: 'COLLECTION_JOB' }, + fetchPolicy: 'network-only' + }); + onQueueUpdate(); + } catch (error) { + console.error('Error creating collection jobs:', error); + } + }; + + const handleRefreshEvents = async () => { + await refetchEvents(); + }; + + return ( +
+ {isAdmin && ( + + )} + + +
+ ); +}; + +ReportRerun.propTypes = { + onQueueUpdate: PropTypes.func.isRequired +}; + +export default ReportRerun; diff --git a/client/components/ReportRerun/queries.js b/client/components/ReportRerun/queries.js new file mode 100644 index 000000000..4a2ef5f83 --- /dev/null +++ b/client/components/ReportRerun/queries.js @@ -0,0 +1,73 @@ +import { gql } from '@apollo/client'; + +export const GET_AUTOMATION_SUPPORTED_AT_VERSIONS = gql` + query GetAutomationSupportedAtVersions { + ats { + id + name + atVersions { + id + name + releasedAt + supportedByAutomation + } + } + } +`; + +export const GET_RERUNNABLE_REPORTS_QUERY = gql` + query GetRerunnableReports($atVersionId: ID!) { + rerunnableReports(atVersionId: $atVersionId) { + currentVersion { + id + name + } + previousVersionGroups { + previousVersion { + id + name + releasedAt + } + reports { + id + testPlanVersion { + id + title + versionString + } + browser { + id + name + } + at { + id + name + } + } + } + } + } +`; + +export const CREATE_COLLECTION_JOBS_MUTATION = gql` + mutation CreateCollectionJobs($atVersionId: ID!) { + createCollectionJobsFromPreviousAtVersion(atVersionId: $atVersionId) { + collectionJobs { + id + status + } + message + } + } +`; + +export const GET_UPDATE_EVENTS = gql` + query GetUpdateEvents($type: UpdateEventType) { + updateEvents(type: $type) { + id + timestamp + description + type + } + } +`; diff --git a/client/components/TestQueue/TestQueue.module.css b/client/components/TestQueue/TestQueue.module.css index e60c44bfa..bcaeff54c 100644 --- a/client/components/TestQueue/TestQueue.module.css +++ b/client/components/TestQueue/TestQueue.module.css @@ -48,6 +48,10 @@ table.test-queue { } } +h1.test-queue-heading { + border-bottom: none !important; +} + .status-container { display: flex; flex-direction: column; diff --git a/client/components/TestQueue/index.jsx b/client/components/TestQueue/index.jsx index ca417a88e..1f5a7a36a 100644 --- a/client/components/TestQueue/index.jsx +++ b/client/components/TestQueue/index.jsx @@ -20,6 +20,8 @@ import ProgressBar from '../common/ProgressBar'; import AssignTesters from './AssignTesters'; import Actions from './Actions'; import BotRunTestStatusList from '../BotRunTestStatusList'; +import ReportRerun from '../ReportRerun'; +import Tabs from '../common/Tabs'; import styles from './TestQueue.module.css'; import commonStyles from '../common/styles.module.css'; @@ -287,12 +289,8 @@ const TestQueue = () => { const hasTestPlanReports = !!testPlans.length; - return ( - - - Test Queue | ARIA-AT - -

Test Queue

+ const renderQueueContent = () => ( + <> {hasTestPlanReports && (

{isAdmin @@ -316,7 +314,6 @@ const TestQueue = () => { {testPlans.length ? testPlans.map(testPlan => ( - {/* ID needed for recovering focus after deleting a report */}

{testPlan.title}

@@ -324,6 +321,27 @@ const TestQueue = () => { )) : null} + + ); + + const tabs = [ + { + label: 'Manual Test Queue', + content: renderQueueContent() + }, + { + label: 'Automated Report Updates', + content: + } + ]; + + return ( + + + Test Queue | ARIA-AT + +

Test Queue

+
); }; diff --git a/client/components/common/RefreshButton/RefreshButton.module.css b/client/components/common/RefreshButton/RefreshButton.module.css new file mode 100644 index 000000000..e17d057df --- /dev/null +++ b/client/components/common/RefreshButton/RefreshButton.module.css @@ -0,0 +1,21 @@ +.refresh-button { + background: none; + border: none; + padding: 0.25rem; + cursor: pointer; + color: inherit; + font-size: 0.875rem; +} + +.refresh-button:hover { + opacity: 0.8; +} + +.refresh-button:disabled { + cursor: default; + opacity: 0.7; +} + +.refresh-button .fa-spin { + animation-duration: 1.5s; +} diff --git a/client/components/common/RefreshButton/index.jsx b/client/components/common/RefreshButton/index.jsx new file mode 100644 index 000000000..76383aaf0 --- /dev/null +++ b/client/components/common/RefreshButton/index.jsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import styles from './RefreshButton.module.css'; + +const RefreshButton = ({ onRefresh }) => { + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + const startTime = Date.now(); + try { + await onRefresh(); + const elapsedTime = Date.now() - startTime; + const minimumDuration = 750; + // Allows for a full revolution of the spinner + if (elapsedTime < minimumDuration) { + await new Promise(resolve => + setTimeout(resolve, minimumDuration - elapsedTime) + ); + } + } finally { + setIsRefreshing(false); + } + }; + + return ( +