From bc2dd35394f807499ffbfba80e3a3ba5876dd7a4 Mon Sep 17 00:00:00 2001 From: Ambroise C Date: Tue, 18 Jul 2023 16:42:34 +0200 Subject: [PATCH] SONAR-19728 Lock/unlock pages in the Project space while re-indexing --- .../src/components/NavBarTabs.tsx | 11 +- .../src/main/js/api/project-management.ts | 12 -- .../js/app/components/ComponentContainer.tsx | 20 ++- .../__tests__/ComponentContainer-test.tsx | 140 +++++++++++------- .../components/project-card/ProjectCard.tsx | 35 ++--- .../__tests__/ProjectCard-test.tsx | 6 - .../src/main/js/apps/projects/types.ts | 1 - .../resources/org/sonar/l10n/core.properties | 1 + 8 files changed, 118 insertions(+), 108 deletions(-) diff --git a/server/sonar-web/design-system/src/components/NavBarTabs.tsx b/server/sonar-web/design-system/src/components/NavBarTabs.tsx index 17a2b2bff88..e3ef0ce0682 100644 --- a/server/sonar-web/design-system/src/components/NavBarTabs.tsx +++ b/server/sonar-web/design-system/src/components/NavBarTabs.tsx @@ -121,11 +121,12 @@ const NavBarTabLinkWrapper = styled.li` ${tw`sw-invisible`}; content: attr(data-text); } - & > a.disabled-link, - & > a.disabled-link:hover, - & > a.disabled-link.hover, - & > a.disabled-link[aria-expanded='true'] { - ${tw`sw-cursor-default`}; + + &:has(a.disabled-link) > a, + &:has(a.disabled-link) > a:hover, + &:has(a.disabled-link) > a.hover, + &:has(a.disabled-link)[aria-expanded='true'] { + ${tw`sw-cursor-not-allowed`}; border-bottom: ${themeBorder('xsActive', 'transparent', 1)}; color: ${themeContrast('subnavigationDisabled')}; } diff --git a/server/sonar-web/src/main/js/api/project-management.ts b/server/sonar-web/src/main/js/api/project-management.ts index c12831d1ace..bda0c5b2eac 100644 --- a/server/sonar-web/src/main/js/api/project-management.ts +++ b/server/sonar-web/src/main/js/api/project-management.ts @@ -51,18 +51,6 @@ export interface SearchProjectsParameters extends BaseSearchProjectsParameters { ps?: number; } -export interface ComponentRaw { - key: string; - name: string; - isFavorite?: boolean; - analysisDate?: string; - qualifier: ComponentQualifier; - tags: string[]; - visibility: Visibility; - leakPeriodDate?: string; - needIssueSync?: boolean; -} - export function getComponents(parameters: SearchProjectsParameters): Promise<{ components: Project[]; paging: Paging; diff --git a/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx b/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx index d9c9c9e9864..250f27e9a60 100644 --- a/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { differenceBy } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; @@ -43,7 +44,6 @@ import withAvailableFeatures, { WithAvailableFeaturesProps, } from './available-features/withAvailableFeatures'; import { ComponentContext } from './componentContext/ComponentContext'; -import PageUnavailableDueToIndexation from './indexation/PageUnavailableDueToIndexation'; import ComponentNav from './nav/component/ComponentNav'; interface Props extends WithAvailableFeaturesProps { @@ -98,6 +98,7 @@ export class ComponentContainer extends React.PureComponent { getComponentNavigation({ component: key, branch, pullRequest }), getComponentData({ component: key, branch, pullRequest }), ]); + componentWithQualifier = this.addQualifier({ ...nav, ...component }); } catch (e) { if (this.mounted) { @@ -107,6 +108,7 @@ export class ComponentContainer extends React.PureComponent { this.setState({ component: undefined, loading: false }); } } + return; } @@ -123,6 +125,7 @@ export class ComponentContainer extends React.PureComponent { } let projectBinding; + if (componentWithQualifier.qualifier === ComponentQualifier.Project) { projectBinding = await getProjectAlmBinding(key).catch(() => undefined); } @@ -144,6 +147,7 @@ export class ComponentContainer extends React.PureComponent { ({ current, queue }) => { if (this.mounted) { let shouldFetchComponent = false; + this.setState( ({ component, currentTask, tasksInProgress }) => { const newCurrentTask = this.getCurrentTask(current); @@ -161,6 +165,7 @@ export class ComponentContainer extends React.PureComponent { if (this.needsAnotherCheck(shouldFetchComponent, component, newTasksInProgress)) { // Refresh the status as long as there is tasks in progress or no analysis window.clearTimeout(this.watchStatusTimer); + this.watchStatusTimer = window.setTimeout( () => this.fetchStatus(componentKey), FETCH_STATUS_WAIT_TIME @@ -168,6 +173,7 @@ export class ComponentContainer extends React.PureComponent { } const isPending = pendingTasks.some((task) => task.status === TaskStatuses.Pending); + return { currentTask: newCurrentTask, isPending, @@ -195,6 +201,7 @@ export class ComponentContainer extends React.PureComponent { const projectBindingErrors = await validateProjectAlmBinding(component.key).catch( () => undefined ); + if (this.mounted) { this.setState({ projectBindingErrors }); } @@ -280,12 +287,15 @@ export class ComponentContainer extends React.PureComponent { if (!pullRequest && !branch) { return !task.branch && !task.pullRequest; } + if (pullRequest) { return pullRequest === task.pullRequest; } + if (branch) { return branch === task.branch; } + return false; }; @@ -296,6 +306,7 @@ export class ComponentContainer extends React.PureComponent { const newComponent: Component = { ...state.component, ...changes }; return { component: newComponent }; } + return null; }); } @@ -308,12 +319,9 @@ export class ComponentContainer extends React.PureComponent { return ; } - if (component?.needIssueSync) { - return ; - } - const { currentTask, isPending, projectBinding, projectBindingErrors, tasksInProgress } = this.state; + const isInProgress = tasksInProgress && tasksInProgress.length > 0; return ( @@ -325,6 +333,7 @@ export class ComponentContainer extends React.PureComponent { component?.name ?? '' )} /> + {component && !([ComponentQualifier.File, ComponentQualifier.TestFile] as string[]).includes( component.qualifier @@ -338,6 +347,7 @@ export class ComponentContainer extends React.PureComponent { projectBindingErrors={projectBindingErrors} /> )} + {loading ? (
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx index 9faf1d9486b..0ed48b25ac1 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { shallow } from 'enzyme'; import * as React from 'react'; import { getProjectAlmBinding, validateProjectAlmBinding } from '../../../api/alm-settings'; @@ -37,12 +38,12 @@ import { TaskStatuses, TaskTypes } from '../../../types/tasks'; import { Component } from '../../../types/types'; import handleRequiredAuthorization from '../../utils/handleRequiredAuthorization'; import { ComponentContainer } from '../ComponentContainer'; -import PageUnavailableDueToIndexation from '../indexation/PageUnavailableDueToIndexation'; jest.mock('../../../api/branches', () => { const { mockMainBranch, mockPullRequest } = jest.requireActual( '../../../helpers/mocks/branch-like' ); + return { getBranches: jest .fn() @@ -82,6 +83,7 @@ jest.mock('../../utils/handleRequiredAuthorization', () => ({ default: jest.fn(), })); +// eslint-disable-next-line react/function-component-definition const Inner = () =>
; beforeEach(() => { @@ -96,6 +98,7 @@ afterEach(() => { it('changes component', () => { const wrapper = shallowRender(); + wrapper.setState({ component: { qualifier: ComponentQualifier.Project, @@ -105,6 +108,7 @@ it('changes component', () => { }); wrapper.instance().handleComponentChange({ visibility: Visibility.Private }); + expect(wrapper.state().component).toEqual({ qualifier: ComponentQualifier.Project, visibility: Visibility.Private, @@ -115,14 +119,25 @@ it('loads the project binding, if any', async () => { const component = mockComponent({ breadcrumbs: [{ key: 'foo', name: 'foo', qualifier: ComponentQualifier.Project }], }); - (getComponentNavigation as jest.Mock).mockResolvedValueOnce({}); - (getComponentData as jest.Mock) - .mockResolvedValueOnce({ component }) - .mockResolvedValueOnce({ component }); - (getProjectAlmBinding as jest.Mock).mockResolvedValueOnce(undefined).mockResolvedValueOnce({ - alm: AlmKeys.GitHub, - key: 'foo', - }); + + jest + .mocked(getComponentNavigation) + .mockResolvedValueOnce({} as unknown as Awaited>); + + jest + .mocked(getComponentData) + .mockResolvedValueOnce({ component } as unknown as Awaited>) + .mockResolvedValueOnce({ component } as unknown as Awaited< + ReturnType + >); + + jest + .mocked(getProjectAlmBinding) + .mockResolvedValueOnce(undefined as unknown as Awaited>) + .mockResolvedValueOnce({ + alm: AlmKeys.GitHub, + key: 'foo', + } as unknown as Awaited>); const wrapper = shallowRender(); await waitAndUpdate(wrapper); @@ -147,9 +162,9 @@ it("doesn't load branches portfolio", async () => { }); it('fetches status', async () => { - (getComponentData as jest.Mock).mockResolvedValueOnce({ + jest.mocked(getComponentData).mockResolvedValueOnce({ component: {}, - }); + } as unknown as Awaited>); const wrapper = shallowRender(); await waitAndUpdate(wrapper); @@ -187,13 +202,15 @@ it('filters correctly the pending tasks for a main branch', () => { }); it('reload component after task progress finished', async () => { - (getTasksForComponent as jest.Mock) + jest + .mocked(getTasksForComponent) .mockResolvedValueOnce({ queue: [{ id: 'foo', status: TaskStatuses.InProgress, type: TaskTypes.ViewRefresh }], - }) + } as unknown as Awaited>) .mockResolvedValueOnce({ queue: [], - }); + } as unknown as Awaited>); + const wrapper = shallowRender(); // First round, there's something in the queue, and component navigation was @@ -226,15 +243,20 @@ it('reload component after task progress finished', async () => { }); it('reloads component after task progress finished, and moves straight to current', async () => { - (getComponentData as jest.Mock).mockResolvedValueOnce({ + jest.mocked(getComponentData).mockResolvedValueOnce({ component: { key: 'bar' }, - }); - (getTasksForComponent as jest.Mock) - .mockResolvedValueOnce({ queue: [] }) + } as unknown as Awaited>); + + jest + .mocked(getTasksForComponent) + .mockResolvedValueOnce({ queue: [] } as unknown as Awaited< + ReturnType + >) .mockResolvedValueOnce({ queue: [], current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.AppRefresh }, - }); + } as unknown as Awaited>); + const wrapper = shallowRender(); // First round, nothing in the queue, and component navigation was not called @@ -260,13 +282,15 @@ it('reloads component after task progress finished, and moves straight to curren }); it('only fully loads a non-empty component once', async () => { - (getComponentData as jest.Mock).mockResolvedValueOnce({ + jest.mocked(getComponentData).mockResolvedValueOnce({ component: { key: 'bar', analysisDate: '2019-01-01' }, - }); - (getTasksForComponent as jest.Mock).mockResolvedValueOnce({ + } as unknown as Awaited>); + + jest.mocked(getTasksForComponent).mockResolvedValueOnce({ queue: [], current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.Report }, - }); + } as unknown as Awaited>); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); @@ -275,17 +299,20 @@ it('only fully loads a non-empty component once', async () => { }); it('only fully reloads a non-empty component if there was previously some task in progress', async () => { - (getComponentData as jest.Mock).mockResolvedValueOnce({ + jest.mocked(getComponentData).mockResolvedValueOnce({ component: { key: 'bar', analysisDate: '2019-01-01' }, - }); - (getTasksForComponent as jest.Mock) + } as unknown as Awaited>); + + jest + .mocked(getTasksForComponent) .mockResolvedValueOnce({ queue: [{ id: 'foo', status: TaskStatuses.InProgress, type: TaskTypes.AppRefresh }], - }) + } as unknown as Awaited>) .mockResolvedValueOnce({ queue: [], current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.AppRefresh }, - }); + } as unknown as Awaited>); + const wrapper = shallowRender(); // First round, a pending task in the queue. This should trigger a reload of the @@ -309,18 +336,20 @@ it('only fully reloads a non-empty component if there was previously some task i }); it('should show component not found if it does not exist', async () => { - (getComponentNavigation as jest.Mock).mockRejectedValueOnce( - new Response(null, { status: HttpStatus.NotFound }) - ); + jest + .mocked(getComponentNavigation) + .mockRejectedValueOnce(new Response(null, { status: HttpStatus.NotFound })); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); }); it('should redirect if the user has no access', async () => { - (getComponentNavigation as jest.Mock).mockRejectedValueOnce( - new Response(null, { status: HttpStatus.Forbidden }) - ); + jest + .mocked(getComponentNavigation) + .mockRejectedValueOnce(new Response(null, { status: HttpStatus.Forbidden })); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); expect(handleRequiredAuthorization).toHaveBeenCalled(); @@ -328,9 +357,10 @@ it('should redirect if the user has no access', async () => { it('should redirect if the component is a portfolio', async () => { const componentKey = 'comp-key'; - (getComponentData as jest.Mock).mockResolvedValueOnce({ + + jest.mocked(getComponentData).mockResolvedValueOnce({ component: { key: componentKey, breadcrumbs: [{ qualifier: ComponentQualifier.Portfolio }] }, - }); + } as unknown as Awaited>); const replace = jest.fn(); @@ -338,20 +368,9 @@ it('should redirect if the component is a portfolio', async () => { location: mockLocation({ pathname: '/dashboard' }), router: mockRouter({ replace }), }); - await waitAndUpdate(wrapper); - expect(replace).toHaveBeenCalledWith({ pathname: '/portfolio', search: `?id=${componentKey}` }); -}); - -it('should display display the unavailable page if the component needs issue sync', async () => { - (getComponentData as jest.Mock).mockResolvedValueOnce({ - component: { key: 'test', qualifier: ComponentQualifier.Project, needIssueSync: true }, - }); - - const wrapper = shallowRender(); await waitAndUpdate(wrapper); - - expect(wrapper.find(PageUnavailableDueToIndexation).exists()).toBe(true); + expect(replace).toHaveBeenCalledWith({ pathname: '/portfolio', search: `?id=${componentKey}` }); }); describe('should correctly validate the project binding depending on the context', () => { @@ -365,11 +384,18 @@ describe('should correctly validate the project binding depending on the context ['has a project binding; check is OK', COMPONENT, undefined, 1], ['has a project binding; check is not OK', COMPONENT, PROJECT_BINDING_ERRORS, 1], ])('%s', async (_, component, projectBindingErrors = undefined, n = 0) => { - (getComponentNavigation as jest.Mock).mockResolvedValueOnce({}); - (getComponentData as jest.Mock).mockResolvedValueOnce({ component }); + jest + .mocked(getComponentNavigation) + .mockResolvedValueOnce({} as unknown as Awaited>); + + jest + .mocked(getComponentData) + .mockResolvedValueOnce({ component } as unknown as Awaited< + ReturnType + >); if (n > 0) { - (validateProjectAlmBinding as jest.Mock).mockResolvedValueOnce(projectBindingErrors); + jest.mocked(validateProjectAlmBinding).mockResolvedValueOnce(projectBindingErrors); } const wrapper = shallowRender({ hasFeature: () => true }); @@ -390,8 +416,16 @@ it.each([ const component = mockComponent({ breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: componentQualifier }], }); - (getComponentNavigation as jest.Mock).mockResolvedValueOnce({}); - (getComponentData as jest.Mock).mockResolvedValueOnce({ component }); + + jest + .mocked(getComponentNavigation) + .mockResolvedValueOnce({} as unknown as Awaited>); + + jest + .mocked(getComponentData) + .mockResolvedValueOnce({ component } as unknown as Awaited< + ReturnType + >); const wrapper = shallowRender(); await waitAndUpdate(wrapper); diff --git a/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx b/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx index 519288d19a5..c3e7ac009fb 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx @@ -63,17 +63,7 @@ function renderFirstLine( handleFavorite: Props['handleFavorite'], isNewCode: boolean ) { - const { - analysisDate, - tags, - qualifier, - isFavorite, - needIssueSync, - key, - name, - measures, - visibility, - } = project; + const { analysisDate, isFavorite, key, measures, name, qualifier, tags, visibility } = project; const formatted = formatMeasure(measures[MetricKey.alert_status], MetricType.Level); const qualityGateLabel = translateWithParameters('overview.quality_gate_x', formatted); return ( @@ -92,7 +82,7 @@ function renderFirstLine( )}

- {needIssueSync ? name : {name}} + {name}

{qualifier === ComponentQualifier.Application && ( @@ -211,7 +201,7 @@ function renderSecondLine( project: Props['project'], isNewCode: boolean ) { - const { measures, analysisDate, needIssueSync, leakPeriodDate, qualifier, key } = project; + const { analysisDate, key, leakPeriodDate, measures, qualifier } = project; if (analysisDate && (!isNewCode || leakPeriodDate)) { return ( @@ -230,14 +220,11 @@ function renderSecondLine( ? translate('projects.no_new_code_period', qualifier) : translate('projects.not_analyzed', qualifier)} - {qualifier !== ComponentQualifier.Application && - !analysisDate && - isLoggedIn(currentUser) && - !needIssueSync && ( - - {translate('projects.configure_analysis')} - - )} + {qualifier !== ComponentQualifier.Application && !analysisDate && isLoggedIn(currentUser) && ( + + {translate('projects.configure_analysis')} + + )}
); } @@ -249,12 +236,8 @@ export default function ProjectCard(props: Props) { return ( {renderFirstLine(project, props.handleFavorite, isNewCode)} diff --git a/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx index 77d82ca6162..b1da5b7ce96 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx @@ -46,12 +46,6 @@ const PROJECT: Project = { const USER_LOGGED_OUT = mockCurrentUser(); const USER_LOGGED_IN = mockLoggedInUser(); -it('should display correclty when project need issue sync and not setup', () => { - renderProjectCard({ ...PROJECT, needIssueSync: true }); - expect(screen.getByLabelText('overview.quality_gate_x.OK')).toBeInTheDocument(); - expect(screen.getByText('overview.project.main_branch_empty')).toBeInTheDocument(); -}); - it('should not display the quality gate', () => { const project = { ...PROJECT, analysisDate: undefined }; renderProjectCard(project); diff --git a/server/sonar-web/src/main/js/apps/projects/types.ts b/server/sonar-web/src/main/js/apps/projects/types.ts index a56e9d81e00..e31c013859a 100644 --- a/server/sonar-web/src/main/js/apps/projects/types.ts +++ b/server/sonar-web/src/main/js/apps/projects/types.ts @@ -31,7 +31,6 @@ export interface Project { qualifier: ComponentQualifier; tags: string[]; visibility: Visibility; - needIssueSync?: boolean; } export interface Facet { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 04564923538..62c85b682af 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -544,6 +544,7 @@ layout.security_reports=Security Reports layout.nav.home_logo_alt=Logo, link to homepage layout.must_be_configured=This will be available once your project is configured and analyzed. layout.all_project_must_be_accessible=You need access to all projects within this {0} to access it. +layout.component_must_be_reindexed=This will be available once your project has been reindexed. sidebar.projects=Projects sidebar.project_settings=Configuration -- 2.39.5