From 9865012e7dd0c424c34a2c3914a57b678ed48357 Mon Sep 17 00:00:00 2001 From: Sarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:54:41 +0200 Subject: [PATCH] SONAR-22313 Fix A11y issues on import/export page (#11650) --- .../js/apps/projectDump/ProjectDumpApp.tsx | 204 +++--------------- .../js/apps/projectDump/components/Export.tsx | 159 +++++++------- .../js/apps/projectDump/components/Import.tsx | 79 +++++-- .../src/main/js/apps/projectDump/utils.ts | 36 ++++ server/sonar-web/src/main/js/queries/ce.ts | 30 +++ .../src/main/js/queries/project-dump.ts | 39 ++++ 6 files changed, 271 insertions(+), 276 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/projectDump/utils.ts create mode 100644 server/sonar-web/src/main/js/queries/ce.ts create mode 100644 server/sonar-web/src/main/js/queries/project-dump.ts diff --git a/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx b/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx index 5b30997db41..7c000544a0f 100644 --- a/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx @@ -17,199 +17,59 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - BasicSeparator, - LargeCenteredLayout, - PageContentFontWrapper, - Spinner, - Title, -} from 'design-system'; +import { BasicSeparator, LargeCenteredLayout, PageContentFontWrapper, Title } from 'design-system'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; -import { throwGlobalError } from '~sonar-aligned/helpers/error'; -import { getActivity } from '../../api/ce'; -import { getStatus } from '../../api/project-dump'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../app/components/available-features/withAvailableFeatures'; import withComponentContext from '../../app/components/componentContext/withComponentContext'; import { translate } from '../../helpers/l10n'; import { Feature } from '../../types/features'; -import { DumpStatus, DumpTask } from '../../types/project-dump'; -import { ActivityRequestParameters, TaskStatuses, TaskTypes } from '../../types/tasks'; import { Component } from '../../types/types'; import Export from './components/Export'; import Import from './components/Import'; import './styles.css'; -const POLL_INTERNAL = 5000; - interface Props extends WithAvailableFeaturesProps { component: Component; } -interface State { - lastAnalysisTask?: DumpTask; - lastExportTask?: DumpTask; - lastImportTask?: DumpTask; - status?: DumpStatus; -} - -export class ProjectDumpApp extends React.Component { - mounted = false; - state: State = {}; - - componentDidMount() { - this.mounted = true; - this.loadStatus(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.component.key !== this.props.component.key) { - this.loadStatus(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - getLastTask(component: string, type: TaskTypes) { - const data: ActivityRequestParameters = { - type, - component, - ps: 1, - status: [ - TaskStatuses.Pending, - TaskStatuses.InProgress, - TaskStatuses.Success, - TaskStatuses.Failed, - TaskStatuses.Canceled, - ].join(','), - }; - return getActivity(data) - .then(({ tasks }) => (tasks.length > 0 ? tasks[0] : undefined), throwGlobalError) - .catch(() => undefined); - } - - getLastTaskOfEachType(componentKey: string) { - const projectImportFeatureEnabled = this.props.hasFeature(Feature.ProjectImport); - const all = projectImportFeatureEnabled - ? [ - this.getLastTask(componentKey, TaskTypes.ProjectExport), - this.getLastTask(componentKey, TaskTypes.ProjectImport), - this.getLastTask(componentKey, TaskTypes.Report), - ] - : [ - this.getLastTask(componentKey, TaskTypes.ProjectExport), - Promise.resolve(), - this.getLastTask(componentKey, TaskTypes.Report), - ]; - return Promise.all(all).then(([lastExportTask, lastImportTask, lastAnalysisTask]) => ({ - lastExportTask, - lastImportTask, - lastAnalysisTask, - })); - } +export function ProjectDumpApp({ component, hasFeature }: Readonly) { + const projectImportFeatureEnabled = hasFeature(Feature.ProjectImport); - loadStatus = () => { - const { component } = this.props; - return Promise.all([getStatus(component.key), this.getLastTaskOfEachType(component.key)]).then( - ([status, { lastExportTask, lastImportTask, lastAnalysisTask }]) => { - if (this.mounted) { - this.setState({ - status, - lastExportTask, - lastImportTask, - lastAnalysisTask, - }); - } - return { - status, - lastExportTask, - lastImportTask, - lastAnalysisTask, - }; - }, - ); - }; - - poll = () => { - this.loadStatus().then( - ({ lastExportTask, lastImportTask }) => { - if (this.mounted) { - const progressStatus = [TaskStatuses.Pending, TaskStatuses.InProgress]; - const exportNotFinished = - lastExportTask === undefined || progressStatus.includes(lastExportTask.status); - const importNotFinished = - lastImportTask === undefined || progressStatus.includes(lastImportTask.status); - if (exportNotFinished || importNotFinished) { - setTimeout(this.poll, POLL_INTERNAL); - } else { - // Since we fetch status separate from task we could not get an up to date status. - // even if we detect that export / import is finish. - // Doing a last call will make sur we get the latest status. - this.loadStatus(); - } - } - }, - () => { - /* no catch needed */ - }, - ); - }; - - render() { - const { component } = this.props; - const projectImportFeatureEnabled = this.props.hasFeature(Feature.ProjectImport); - const { lastAnalysisTask, lastExportTask, lastImportTask, status } = this.state; - - return ( - - -
- - {translate('project_dump.page')} -
- {projectImportFeatureEnabled ? ( - <> -

{translate('project_dump.page.description1')}

-

{translate('project_dump.page.description2')}

- - ) : ( - <> -

{translate('project_dump.page.description_without_import1')}

-

{translate('project_dump.page.description_without_import2')}

- - )} -
-
- - - {status && ( + return ( + + +
+ + {translate('project_dump.page')} +
+ {projectImportFeatureEnabled ? ( <> - - - +

{translate('project_dump.page.description1')}

+

{translate('project_dump.page.description2')}

+ + ) : ( + <> +

{translate('project_dump.page.description_without_import1')}

+

{translate('project_dump.page.description_without_import2')}

)} - - - - ); - } +
+
+ + <> +
+

{translate('project_dump.export')}

+
+ + + + +
+
+ ); } export default withComponentContext(withAvailableFeatures(ProjectDumpApp)); diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx index 9b0175fc0e5..2431737c7e2 100644 --- a/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx +++ b/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx @@ -17,115 +17,113 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Button } from '@sonarsource/echoes-react'; -import { FlagMessage, Link, Spinner } from 'design-system'; +import { Button, Spinner } from '@sonarsource/echoes-react'; +import { FlagMessage, Link } from 'design-system'; +import { noop } from 'lodash'; import * as React from 'react'; -import { doExport } from '../../../api/project-dump'; import DateFromNow from '../../../components/intl/DateFromNow'; import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { DumpStatus, DumpTask } from '../../../types/project-dump'; +import { useLastActivityQuery } from '../../../queries/ce'; +import { useProjectDumpStatusQuery, useProjectExportMutation } from '../../../queries/project-dump'; +import { DumpTask } from '../../../types/project-dump'; +import { TaskStatuses, TaskTypes } from '../../../types/tasks'; +import { getImportExportActivityParams } from '../utils'; interface Props { componentKey: string; - loadStatus: () => void; - status: DumpStatus; - task?: DumpTask; } +const PROGRESS_STATUS = [TaskStatuses.Pending, TaskStatuses.InProgress]; +const REFRESH_INTERVAL = 5000; + +export default function Export({ componentKey }: Readonly) { + const { data: task, refetch: refetchLastActivity } = useLastActivityQuery( + getImportExportActivityParams(componentKey, TaskTypes.ProjectExport), + { + refetchInterval: ({ state: { data } }) => { + return data && PROGRESS_STATUS.includes(data.status) ? REFRESH_INTERVAL : undefined; + }, + }, + ); + const { data: status, refetch: refetchDumpStatus } = useProjectDumpStatusQuery(componentKey, { + refetchInterval: () => { + return task && PROGRESS_STATUS.includes(task.status) ? REFRESH_INTERVAL : undefined; + }, + }); + const { mutateAsync: doExport } = useProjectExportMutation(); + + const isDumpAvailable = Boolean(status?.exportedDump); -export default function Export(props: Readonly) { const handleExport = async () => { try { - await doExport(props.componentKey); - props.loadStatus(); - } catch (error) { - /* no catch needed */ + await doExport(componentKey); + refetchLastActivity(); + refetchDumpStatus(); + } catch (_) { + noop(); } }; - function renderHeader() { - return ( -
- {translate('project_dump.export')} -
- ); - } - function renderWhenCanNotExport() { return ( - <> - {renderHeader()} - - {translate('project_dump.can_not_export')} - - + + {translate('project_dump.can_not_export')} + ); } function renderWhenExportPending(task: DumpTask) { return ( - <> - {renderHeader()} -
- - - {(formatted) => ( - {translateWithParameters('project_dump.pending_export', formatted)} - )} - -
- +
+ + + {(formatted) => ( + {translateWithParameters('project_dump.pending_export', formatted)} + )} + +
); } function renderWhenExportInProgress(task: DumpTask) { return ( - <> - {renderHeader()} -
- - {task.startedAt && ( - - {(fromNow) => ( - {translateWithParameters('project_dump.in_progress_export', fromNow)} - )} - - )} -
- +
+ + {task.startedAt && ( + + {(fromNow) => ( + {translateWithParameters('project_dump.in_progress_export', fromNow)} + )} + + )} +
); } function renderWhenExportFailed() { - const { componentKey } = props; const detailsUrl = `/project/background_tasks?id=${encodeURIComponent( componentKey, )}&status=FAILED&taskType=PROJECT_EXPORT`; return ( - <> - {renderHeader()} -
- - {translate('project_dump.failed_export')} - - {translate('project_dump.see_details')} - - - - {renderExport()} -
- +
+ + {translate('project_dump.failed_export')} + + {translate('project_dump.see_details')} + + + + {renderExport()} +
); } - function renderDump(task?: DumpTask) { - const { status } = props; - + function renderDump(task?: DumpTask | null) { return (
- {task && task.executedAt && ( + {task?.executedAt && ( {(formatted) => (
@@ -137,7 +135,7 @@ export default function Export(props: Readonly) {
{!task &&
{translate('project_dump.export_available')}
} -

{status.exportedDump}

+

{status?.exportedDump}

@@ -159,33 +157,30 @@ export default function Export(props: Readonly) { ); } - const { task, status } = props; + if (status === undefined || task === undefined) { + return ; + } if (!status.canBeExported) { return renderWhenCanNotExport(); } - if (task && task.status === 'PENDING') { + if (task?.status === TaskStatuses.Pending) { return renderWhenExportPending(task); } - if (task && task.status === 'IN_PROGRESS') { + if (task?.status === TaskStatuses.InProgress) { return renderWhenExportInProgress(task); } - if (task && task.status === 'FAILED') { + if (task?.status === TaskStatuses.Failed) { return renderWhenExportFailed(); } - const isDumpAvailable = Boolean(status.exportedDump); - return ( - <> - {renderHeader()} -
- {isDumpAvailable && renderDump(task)} - {renderExport()} -
- +
+ {isDumpAvailable && renderDump(task)} + {renderExport()} +
); } diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx index 09126a9c59b..165815cefc3 100644 --- a/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx +++ b/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx @@ -17,31 +17,66 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Button } from '@sonarsource/echoes-react'; -import { FlagMessage, Link, Spinner } from 'design-system'; +import { Button, Spinner } from '@sonarsource/echoes-react'; +import { FlagMessage, Link } from 'design-system'; +import { noop } from 'lodash'; import * as React from 'react'; -import { doImport } from '../../../api/project-dump'; import DateFromNow from '../../../components/intl/DateFromNow'; import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getComponentBackgroundTaskUrl } from '../../../helpers/urls'; -import { DumpStatus, DumpTask } from '../../../types/project-dump'; +import { useLastActivityQuery } from '../../../queries/ce'; +import { StaleTime } from '../../../queries/common'; +import { useProjectDumpStatusQuery, useProjectImportMutation } from '../../../queries/project-dump'; +import { DumpTask } from '../../../types/project-dump'; import { TaskStatuses, TaskTypes } from '../../../types/tasks'; +import { getImportExportActivityParams } from '../utils'; interface Props { - analysis?: DumpTask; componentKey: string; importEnabled: boolean; - loadStatus: () => void; - status: DumpStatus; - task?: DumpTask; } +const PROGRESS_STATUS = [TaskStatuses.Pending, TaskStatuses.InProgress]; +const REFRESH_INTERVAL = 5000; export default function Import(props: Readonly) { - const handleImport = () => { - doImport(props.componentKey).then(props.loadStatus, () => { - /* no catch needed */ - }); + const { componentKey, importEnabled } = props; + + const { + data: task, + refetch: refetchImportActivity, + isLoading: isTaskLoading, + } = useLastActivityQuery(getImportExportActivityParams(componentKey, TaskTypes.ProjectImport), { + refetchInterval: ({ state: { data } }) => { + return data && PROGRESS_STATUS.includes(data.status) ? REFRESH_INTERVAL : undefined; + }, + }); + const { data: analysis, isLoading: isAnalysisActivityLoading } = useLastActivityQuery( + getImportExportActivityParams(componentKey, TaskTypes.Report), + { + staleTime: StaleTime.SHORT, + }, + ); + const { + data: status, + isLoading: isStatusLoading, + refetch: refetchDumpStatus, + } = useProjectDumpStatusQuery(componentKey, { + refetchInterval: () => { + return task && PROGRESS_STATUS.includes(task.status) ? REFRESH_INTERVAL : undefined; + }, + }); + const { mutateAsync: doImport } = useProjectImportMutation(); + const isLoading = isTaskLoading || isAnalysisActivityLoading || isStatusLoading; + + const handleImport = async () => { + try { + await doImport(componentKey); + refetchImportActivity(); + refetchDumpStatus(); + } catch (_) { + noop(); + } }; function renderWhenCanNotImport() { @@ -135,8 +170,6 @@ export default function Import(props: Readonly) { ); } - const { importEnabled, status, task, analysis } = props; - function renderContent(): React.ReactNode { switch (task?.status) { case TaskStatuses.Success: @@ -151,9 +184,9 @@ export default function Import(props: Readonly) { case TaskStatuses.Failed: return renderWhenImportFailed(); default: - if (!status.canBeImported) { + if (status && !status.canBeImported) { return renderWhenCanNotImport(); - } else if (!status.dumpToImport) { + } else if (status && status.dumpToImport === undefined) { return renderWhenNoDump(); } return
{renderImportForm()}
; @@ -163,14 +196,16 @@ export default function Import(props: Readonly) { return ( <>
- {translate('project_dump.import')} +

{translate('project_dump.import')}

- {importEnabled ? ( - renderContent() - ) : ( -
{translate('project_dump.import_form_description_disabled')}
- )} + + {importEnabled ? ( + renderContent() + ) : ( +
{translate('project_dump.import_form_description_disabled')}
+ )} +
); } diff --git a/server/sonar-web/src/main/js/apps/projectDump/utils.ts b/server/sonar-web/src/main/js/apps/projectDump/utils.ts new file mode 100644 index 00000000000..73e85452acd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectDump/utils.ts @@ -0,0 +1,36 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { TaskStatuses, TaskTypes } from '../../types/tasks'; + +export const getImportExportActivityParams = (componentKey: string, taskType: TaskTypes) => { + return { + component: componentKey, + type: taskType, + ps: 1, + status: [ + TaskStatuses.Pending, + TaskStatuses.InProgress, + TaskStatuses.Success, + TaskStatuses.Failed, + TaskStatuses.Canceled, + ].join(','), + }; +}; diff --git a/server/sonar-web/src/main/js/queries/ce.ts b/server/sonar-web/src/main/js/queries/ce.ts new file mode 100644 index 00000000000..8cc36db566c --- /dev/null +++ b/server/sonar-web/src/main/js/queries/ce.ts @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { queryOptions } from '@tanstack/react-query'; +import { getActivity } from '../api/ce'; +import { ActivityRequestParameters } from '../types/tasks'; +import { createQueryHook } from './common'; + +export const useLastActivityQuery = createQueryHook((data: ActivityRequestParameters) => { + return queryOptions({ + queryKey: ['ce', 'activity', data.component, data.type, data.status], + queryFn: () => getActivity(data).then(({ tasks }) => (tasks.length > 0 ? tasks[0] : null)), + }); +}); diff --git a/server/sonar-web/src/main/js/queries/project-dump.ts b/server/sonar-web/src/main/js/queries/project-dump.ts new file mode 100644 index 00000000000..5f5d7ff00f0 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/project-dump.ts @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { queryOptions, useMutation } from '@tanstack/react-query'; +import { doExport, doImport, getStatus } from '../api/project-dump'; +import { createQueryHook } from './common'; + +export const useProjectDumpStatusQuery = createQueryHook((componentKey: string) => { + return queryOptions({ + queryKey: ['project-dump', componentKey], + queryFn: () => getStatus(componentKey), + }); +}); + +export const useProjectExportMutation = () => + useMutation({ + mutationFn: (componentKey: string) => doExport(componentKey), + }); + +export const useProjectImportMutation = () => + useMutation({ + mutationFn: (componentKey: string) => doImport(componentKey), + }); -- 2.39.5