From bd48df7ad63eb513a8e923ca55b0eb74bcf15b70 Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Thu, 17 Jun 2021 17:15:11 +0200 Subject: [PATCH] SONAR-14938 Fallback on the Create Project page on brand new instance --- .../components/DefaultPageSelector.tsx | 111 +++++++-------- .../DefaultPageSelectorContainer.tsx | 28 ---- .../__tests__/DefaultPageSelector-test.tsx | 128 +++++++++++++----- .../DefaultPageSelector-test.tsx.snap | 7 + .../src/main/js/apps/projects/routes.ts | 14 +- 5 files changed, 157 insertions(+), 131 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelectorContainer.tsx create mode 100644 server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/DefaultPageSelector-test.tsx.snap diff --git a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx b/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx index 8668c78e9c5..56ef87faafb 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelector.tsx @@ -20,8 +20,9 @@ import * as React from 'react'; import { get } from 'sonar-ui-common/helpers/storage'; import { searchProjects } from '../../../api/components'; +import { withCurrentUser } from '../../../components/hoc/withCurrentUser'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import { isLoggedIn } from '../../../helpers/users'; +import { hasGlobalPermission, isLoggedIn } from '../../../helpers/users'; import { PROJECTS_ALL, PROJECTS_DEFAULT_FILTER, PROJECTS_FAVORITE } from '../utils'; import AllProjectsContainer from './AllProjectsContainer'; @@ -32,84 +33,68 @@ interface Props { } interface State { - shouldBeRedirected?: boolean; - shouldForceSorting?: string; + checking: boolean; } export class DefaultPageSelector extends React.PureComponent { - state: State = {}; + state: State = { checking: true }; componentDidMount() { - this.defineIfShouldBeRedirected(); + this.checkIfNeedsRedirecting(); } - componentDidUpdate(prevProps: Props) { - if (prevProps.location !== this.props.location) { - this.defineIfShouldBeRedirected(); - } else if (this.state.shouldBeRedirected === true) { - this.props.router.replace({ ...this.props.location, pathname: '/projects/favorite' }); - } else if (this.state.shouldForceSorting != null) { - this.props.router.replace({ - ...this.props.location, - query: { - ...this.props.location.query, - sort: this.state.shouldForceSorting - } - }); - } - } - - isFavoriteSet = (): boolean => { - const setting = get(PROJECTS_DEFAULT_FILTER); - return setting === PROJECTS_FAVORITE; - }; - - isAllSet = (): boolean => { + checkIfNeedsRedirecting = async () => { + const { currentUser, router, location } = this.props; const setting = get(PROJECTS_DEFAULT_FILTER); - return setting === PROJECTS_ALL; - }; - defineIfShouldBeRedirected() { - if (Object.keys(this.props.location.query).length > 0) { - // show ALL projects when there are some filters - this.setState({ shouldBeRedirected: false, shouldForceSorting: undefined }); - } else if (!isLoggedIn(this.props.currentUser)) { - // show ALL projects if user is anonymous - if (!this.props.location.query || !this.props.location.query.sort) { - // force default sorting to last analysis date - this.setState({ shouldBeRedirected: false, shouldForceSorting: '-analysis_date' }); - } else { - this.setState({ shouldBeRedirected: false, shouldForceSorting: undefined }); - } - } else if (this.isFavoriteSet()) { - // show FAVORITE projects if "favorite" setting is explicitly set - this.setState({ shouldBeRedirected: true, shouldForceSorting: undefined }); - } else if (this.isAllSet()) { - // show ALL projects if "all" setting is explicitly set - this.setState({ shouldBeRedirected: false, shouldForceSorting: undefined }); - } else { - // otherwise, request favorites - this.setState({ shouldBeRedirected: undefined, shouldForceSorting: undefined }); - searchProjects({ filter: 'isFavorite', ps: 1 }).then(r => { - // show FAVORITE projects if there are any - this.setState({ shouldBeRedirected: r.paging.total > 0, shouldForceSorting: undefined }); - }); + // 1. Don't have to redirect if: + // 1.1 User is anonymous + // 1.2 There's a query, which means the user is interacting with the current page + // 1.3 The last interaction with the filter was to set it to "all" + if ( + !isLoggedIn(currentUser) || + Object.keys(location.query).length > 0 || + setting === PROJECTS_ALL + ) { + this.setState({ checking: false }); + return; } - } - render() { - const { shouldBeRedirected, shouldForceSorting } = this.state; + // 2. Redirect to the favorites page if: + // 2.1 The last interaction with the filter was to set it to "favorites" + // 2.2 The user has starred some projects + if ( + setting === PROJECTS_FAVORITE || + (await searchProjects({ filter: 'isFavorite', ps: 1 })).paging.total > 0 + ) { + router.replace('/projects/favorite'); + return; + } + // 3. Redirect to the create project page if: + // 3.1 The user has permission to provision projects, AND there are 0 projects on the instance if ( - shouldBeRedirected !== undefined && - shouldBeRedirected !== true && - shouldForceSorting === undefined + hasGlobalPermission(currentUser, 'provisioning') && + (await searchProjects({ ps: 1 })).paging.total === 0 ) { - return ; + this.props.router.replace('/projects/create'); + } + + // None of the above apply. Do not redirect, and stay on this page. + this.setState({ checking: false }); + }; + + render() { + const { checking } = this.state; + + if (checking) { + // We don't return a loader here, on purpose. We don't want to show anything + // just yet. + return null; } - return null; + return ; } } -export default withRouter(DefaultPageSelector); +export default withCurrentUser(withRouter(DefaultPageSelector)); diff --git a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelectorContainer.tsx b/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelectorContainer.tsx deleted file mode 100644 index 6f69359cbe8..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/components/DefaultPageSelectorContainer.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2021 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 { connect } from 'react-redux'; -import { getCurrentUser, Store } from '../../../store/rootReducer'; -import DefaultPageSelector from './DefaultPageSelector'; - -const stateToProps = (state: Store) => ({ - currentUser: getCurrentUser(state) -}); - -export default connect(stateToProps)(DefaultPageSelector); diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/DefaultPageSelector-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/DefaultPageSelector-test.tsx index 9cb1171eaaa..1445bf40746 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/DefaultPageSelector-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/DefaultPageSelector-test.tsx @@ -17,11 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { mount } from 'enzyme'; +import { shallow } from 'enzyme'; import * as React from 'react'; import { get } from 'sonar-ui-common/helpers/storage'; -import { doAsync } from 'sonar-ui-common/helpers/testUtils'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; import { searchProjects } from '../../../../api/components'; +import { + mockCurrentUser, + mockLocation, + mockLoggedInUser, + mockRouter +} from '../../../../helpers/testMocks'; +import { hasGlobalPermission } from '../../../../helpers/users'; import { DefaultPageSelector } from '../DefaultPageSelector'; jest.mock('../AllProjectsContainer', () => ({ @@ -32,63 +39,116 @@ jest.mock('../AllProjectsContainer', () => ({ })); jest.mock('sonar-ui-common/helpers/storage', () => ({ - get: jest.fn() + get: jest.fn().mockReturnValue(undefined) +})); + +jest.mock('../../../../helpers/users', () => ({ + hasGlobalPermission: jest.fn().mockReturnValue(false), + isLoggedIn: jest.fn((u: T.CurrentUser) => u.isLoggedIn) })); jest.mock('../../../../api/components', () => ({ - searchProjects: jest.fn() + searchProjects: jest.fn().mockResolvedValue({ paging: { total: 0 } }) })); -beforeEach(() => { - (get as jest.Mock).mockImplementation(() => '').mockClear(); +beforeEach(jest.clearAllMocks); + +it('renders correctly', () => { + expect(shallowRender({ currentUser: mockLoggedInUser() }).type()).toBeNull(); // checking + expect(shallowRender({ currentUser: mockCurrentUser() })).toMatchSnapshot('default'); +}); + +it("1.1 doesn't redirect for anonymous users", async () => { + const replace = jest.fn(); + const wrapper = shallowRender({ + currentUser: mockCurrentUser(), + router: mockRouter({ replace }) + }); + await waitAndUpdate(wrapper); + expect(replace).not.toBeCalled(); +}); + +it("1.2 doesn't redirect if there's an existing filter in location", async () => { + const replace = jest.fn(); + const wrapper = shallowRender({ + location: mockLocation({ query: { size: '1' } }), + router: mockRouter({ replace }) + }); + + await waitAndUpdate(wrapper); + + expect(replace).not.toBeCalled(); }); -it('shows all projects with existing filter', () => { +it("1.3 doesn't redirect if the user previously used the 'all' filter", async () => { + (get as jest.Mock).mockReturnValueOnce('all'); const replace = jest.fn(); - mountRender(undefined, { size: '1' }, replace); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + expect(replace).not.toBeCalled(); }); -it('shows all projects sorted by analysis date for anonymous', () => { +it('2.1 redirects to favorites if the user previously used the "favorites" filter', async () => { + (get as jest.Mock).mockReturnValueOnce('favorite'); + const replace = jest.fn(); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + + expect(replace).toBeCalledWith('/projects/favorite'); +}); + +it('2.2 redirects to favorites if the user has starred projects', async () => { + (searchProjects as jest.Mock).mockResolvedValueOnce({ paging: { total: 3 } }); const replace = jest.fn(); - mountRender({ isLoggedIn: false }, undefined, replace); - expect(replace).lastCalledWith({ pathname: '/projects', query: { sort: '-analysis_date' } }); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + + expect(searchProjects).toHaveBeenLastCalledWith({ filter: 'isFavorite', ps: 1 }); + expect(replace).toBeCalledWith('/projects/favorite'); }); -it('shows favorite projects', () => { - (get as jest.Mock).mockImplementation(() => 'favorite'); +it('3.1 redirects to create project page, if user has correct permissions AND there are 0 projects', async () => { + (hasGlobalPermission as jest.Mock).mockReturnValueOnce(true); const replace = jest.fn(); - mountRender(undefined, undefined, replace); - expect(replace).lastCalledWith({ pathname: '/projects/favorite', query: {} }); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + + expect(replace).toBeCalledWith('/projects/create'); }); -it('shows all projects', () => { - (get as jest.Mock).mockImplementation(() => 'all'); +it("3.1 doesn't redirect to create project page, if user has no permissions", async () => { const replace = jest.fn(); - mountRender(undefined, undefined, replace); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + expect(replace).not.toBeCalled(); }); -it('fetches favorites', () => { - (searchProjects as jest.Mock).mockImplementation(() => Promise.resolve({ paging: { total: 3 } })); +it("3.1 doesn't redirect to create project page, if there's existing projects", async () => { + (searchProjects as jest.Mock) + .mockResolvedValueOnce({ paging: { total: 0 } }) // no favorites + .mockResolvedValueOnce({ paging: { total: 3 } }); // existing projects const replace = jest.fn(); - mountRender(undefined, undefined, replace); - return doAsync().then(() => { - expect(searchProjects).toHaveBeenLastCalledWith({ filter: 'isFavorite', ps: 1 }); - expect(replace).toBeCalledWith({ pathname: '/projects/favorite', query: {} }); - }); + const wrapper = shallowRender({ router: mockRouter({ replace }) }); + + await waitAndUpdate(wrapper); + + expect(replace).not.toBeCalled(); }); -function mountRender( - currentUser: T.CurrentUser = { isLoggedIn: true }, - query: any = {}, - replace: any = jest.fn() -) { - return mount( +function shallowRender(props: Partial = {}) { + return shallow( ); } diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/DefaultPageSelector-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/DefaultPageSelector-test.tsx.snap new file mode 100644 index 00000000000..92729bd4dbd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/DefaultPageSelector-test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly: default 1`] = ` + +`; diff --git a/server/sonar-web/src/main/js/apps/projects/routes.ts b/server/sonar-web/src/main/js/apps/projects/routes.ts index 609943818ac..e1d22ef0daf 100644 --- a/server/sonar-web/src/main/js/apps/projects/routes.ts +++ b/server/sonar-web/src/main/js/apps/projects/routes.ts @@ -20,13 +20,12 @@ import { RedirectFunction, RouterState } from 'react-router'; import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent'; import { save } from 'sonar-ui-common/helpers/storage'; -import { isDefined } from 'sonar-ui-common/helpers/types'; -import DefaultPageSelectorContainer from './components/DefaultPageSelectorContainer'; -import FavoriteProjectsContainer from './components/FavoriteProjectsContainer'; import { PROJECTS_ALL, PROJECTS_DEFAULT_FILTER } from './utils'; const routes = [ - { indexRoute: { component: DefaultPageSelectorContainer } }, + { + indexRoute: { component: lazyLoadComponent(() => import('./components/DefaultPageSelector')) } + }, { path: 'all', onEnter(_: RouterState, replace: RedirectFunction) { @@ -34,11 +33,14 @@ const routes = [ replace('/projects'); } }, - { path: 'favorite', component: FavoriteProjectsContainer }, + { + path: 'favorite', + component: lazyLoadComponent(() => import('./components/FavoriteProjectsContainer')) + }, { path: 'create', component: lazyLoadComponent(() => import('../create/project/CreateProjectPage')) } -].filter(isDefined); +]; export default routes; -- 2.39.5