From: Mathieu Suen Date: Thu, 26 Aug 2021 15:12:12 +0000 (+0200) Subject: SONAR-13736 Keep search query when browsing code file X-Git-Tag: 9.1.0.47736~74 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=b96a80637b2f86bd8436e9aaef83b0c2c704fb31;p=sonarqube.git SONAR-13736 Keep search query when browsing code file --- diff --git a/server/sonar-web/src/main/js/apps/code/components/AppCode.tsx b/server/sonar-web/src/main/js/apps/code/components/AppCode.tsx deleted file mode 100644 index cd2924e5c31..00000000000 --- a/server/sonar-web/src/main/js/apps/code/components/AppCode.tsx +++ /dev/null @@ -1,377 +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 * as classNames from 'classnames'; -import { Location } from 'history'; -import { debounce } from 'lodash'; -import * as React from 'react'; -import { Helmet } from 'react-helmet-async'; -import { connect } from 'react-redux'; -import { InjectedRouter } from 'react-router'; -import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget'; -import Suggestions from '../../../app/components/embed-docs-modal/Suggestions'; -import ListFooter from '../../../components/controls/ListFooter'; -import { isPullRequest, isSameBranchLike } from '../../../helpers/branch-like'; -import { translate } from '../../../helpers/l10n'; -import { getCodeUrl, getProjectUrl } from '../../../helpers/urls'; -import { fetchBranchStatus, fetchMetrics } from '../../../store/rootActions'; -import { getMetrics } from '../../../store/rootReducer'; -import { BranchLike } from '../../../types/branch-like'; -import { addComponent, addComponentBreadcrumbs, clearBucket } from '../bucket'; -import '../code.css'; -import { loadMoreChildren, retrieveComponent, retrieveComponentChildren } from '../utils'; -import Breadcrumbs from './Breadcrumbs'; -import Components from './Components'; -import Search from './Search'; -import SourceViewerWrapper from './SourceViewerWrapper'; - -interface StateToProps { - metrics: T.Dict; -} - -interface DispatchToProps { - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise; - fetchMetrics: () => void; -} - -interface OwnProps { - branchLike?: BranchLike; - component: T.Component; - location: Pick; - router: Pick; -} - -type Props = StateToProps & DispatchToProps & OwnProps; - -interface State { - baseComponent?: T.ComponentMeasure; - breadcrumbs: T.Breadcrumb[]; - components?: T.ComponentMeasure[]; - highlighted?: T.ComponentMeasure; - loading: boolean; - page: number; - searchResults?: T.ComponentMeasure[]; - sourceViewer?: T.ComponentMeasure; - total: number; -} - -export class AppCode extends React.PureComponent { - mounted = false; - state: State; - - constructor(props: Props) { - super(props); - this.state = { - breadcrumbs: [], - loading: true, - page: 0, - total: 0 - }; - this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); - } - - componentDidMount() { - this.mounted = true; - this.props.fetchMetrics(); - this.handleComponentChange(); - } - - componentDidUpdate(prevProps: Props) { - if ( - prevProps.component !== this.props.component || - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) - ) { - this.handleComponentChange(); - } else if (prevProps.location !== this.props.location) { - this.handleUpdate(); - } - } - - componentWillUnmount() { - clearBucket(); - this.mounted = false; - } - - loadComponent = (componentKey: string) => { - this.setState({ loading: true }); - retrieveComponent( - componentKey, - this.props.component.qualifier, - this, - this.props.branchLike - ).then(r => { - if (this.mounted) { - if (['FIL', 'UTS'].includes(r.component.qualifier)) { - this.setState({ - breadcrumbs: r.breadcrumbs, - components: r.components, - loading: false, - page: 0, - searchResults: undefined, - sourceViewer: r.component, - total: 0 - }); - } else { - this.setState({ - baseComponent: r.component, - breadcrumbs: r.breadcrumbs, - components: r.components, - loading: false, - page: r.page, - searchResults: undefined, - sourceViewer: undefined, - total: r.total - }); - } - } - }, this.stopLoading); - }; - - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; - - handleComponentChange = () => { - const { branchLike, component } = this.props; - - // we already know component's breadcrumbs, - addComponentBreadcrumbs(component.key, component.breadcrumbs); - - this.setState({ loading: true }); - retrieveComponentChildren(component.key, component.qualifier, this, branchLike).then(() => { - addComponent(component); - if (this.mounted) { - this.handleUpdate(); - } - }, this.stopLoading); - }; - - handleLoadMore = () => { - const { baseComponent, components, page } = this.state; - if (!baseComponent || !components) { - return; - } - loadMoreChildren( - baseComponent.key, - page + 1, - this.props.component.qualifier, - this, - this.props.branchLike - ).then(r => { - if (this.mounted && r.components.length) { - this.setState({ - components: [...components, ...r.components], - page: r.page, - total: r.total - }); - } - }, this.stopLoading); - }; - - handleGoToParent = () => { - const { branchLike, component } = this.props; - const { breadcrumbs = [] } = this.state; - - if (breadcrumbs.length > 1) { - const parentComponent = breadcrumbs[breadcrumbs.length - 2]; - this.props.router.push(getCodeUrl(component.key, branchLike, parentComponent.key)); - this.setState({ highlighted: breadcrumbs[breadcrumbs.length - 1] }); - } - }; - - handleHighlight = (highlighted: T.ComponentMeasure) => { - this.setState({ highlighted }); - }; - - handleIssueChange = (_: T.Issue) => { - this.refreshBranchStatus(); - }; - - handleSearchClear = () => { - this.setState({ searchResults: undefined }); - }; - - handleSearchResults = (searchResults: T.ComponentMeasure[] = []) => { - this.setState({ searchResults }); - }; - - handleSelect = (component: T.ComponentMeasure) => { - const { branchLike, component: rootComponent } = this.props; - - if (component.refKey) { - this.props.router.push(getProjectUrl(component.refKey)); - } else { - this.props.router.push(getCodeUrl(rootComponent.key, branchLike, component.key)); - } - - this.setState({ highlighted: undefined }); - }; - - handleUpdate = () => { - const { component, location } = this.props; - const { selected } = location.query; - const finalKey = selected || component.key; - - this.loadComponent(finalKey); - }; - - refreshBranchStatus = () => { - const { branchLike, component } = this.props; - if (branchLike && component && isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key); - } - }; - - render() { - const { branchLike, component, location } = this.props; - const { - baseComponent, - breadcrumbs, - components = [], - highlighted, - loading, - total, - searchResults, - sourceViewer - } = this.state; - - const showSearch = searchResults !== undefined; - - const hasNoFile = components.length === 0 && searchResults === undefined; - - const shouldShowBreadcrumbs = breadcrumbs.length > 1 && !showSearch; - const shouldShowComponentList = - sourceViewer === undefined && components.length > 0 && !showSearch; - - const componentsClassName = classNames('boxed-group', 'spacer-top', { - 'new-loading': loading, - 'search-results': showSearch - }); - - const defaultTitle = - baseComponent && ['APP', 'VW', 'SVW'].includes(baseComponent.qualifier) - ? translate('projects.page') - : translate('code.page'); - - return ( -
- - - - - {!hasNoFile && ( - - )} - -
- {hasNoFile && ( -
- - {translate( - 'code_viewer.no_source_code_displayed_due_to_empty_analysis', - component.qualifier - )} - -
- )} - {shouldShowBreadcrumbs && ( - - )} - - {shouldShowComponentList && ( - <> -
- -
- - - )} - - {showSearch && searchResults && ( -
- -
- )} - - {sourceViewer !== undefined && !showSearch && ( -
- -
- )} -
-
- ); - } -} - -const mapStateToProps = (state: any): StateToProps => ({ - metrics: getMetrics(state) -}); - -const mapDispatchToProps: DispatchToProps = { - fetchBranchStatus: fetchBranchStatus as any, - fetchMetrics -}; - -export default connect( - mapStateToProps, - mapDispatchToProps -)(AppCode); diff --git a/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx new file mode 100644 index 00000000000..fef599b7fee --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx @@ -0,0 +1,377 @@ +/* + * 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 * as classNames from 'classnames'; +import { Location } from 'history'; +import { debounce } from 'lodash'; +import * as React from 'react'; +import { Helmet } from 'react-helmet-async'; +import { connect } from 'react-redux'; +import { InjectedRouter } from 'react-router'; +import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget'; +import Suggestions from '../../../app/components/embed-docs-modal/Suggestions'; +import ListFooter from '../../../components/controls/ListFooter'; +import { isPullRequest, isSameBranchLike } from '../../../helpers/branch-like'; +import { translate } from '../../../helpers/l10n'; +import { getCodeUrl, getProjectUrl } from '../../../helpers/urls'; +import { fetchBranchStatus, fetchMetrics } from '../../../store/rootActions'; +import { getMetrics } from '../../../store/rootReducer'; +import { BranchLike } from '../../../types/branch-like'; +import { addComponent, addComponentBreadcrumbs, clearBucket } from '../bucket'; +import '../code.css'; +import { loadMoreChildren, retrieveComponent, retrieveComponentChildren } from '../utils'; +import Breadcrumbs from './Breadcrumbs'; +import Components from './Components'; +import Search from './Search'; +import SourceViewerWrapper from './SourceViewerWrapper'; + +interface StateToProps { + metrics: T.Dict; +} + +interface DispatchToProps { + fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise; + fetchMetrics: () => void; +} + +interface OwnProps { + branchLike?: BranchLike; + component: T.Component; + location: Pick; + router: Pick; +} + +type Props = StateToProps & DispatchToProps & OwnProps; + +interface State { + baseComponent?: T.ComponentMeasure; + breadcrumbs: T.Breadcrumb[]; + components?: T.ComponentMeasure[]; + highlighted?: T.ComponentMeasure; + loading: boolean; + page: number; + searchResults?: T.ComponentMeasure[]; + sourceViewer?: T.ComponentMeasure; + total: number; +} + +export class CodeApp extends React.PureComponent { + mounted = false; + state: State; + + constructor(props: Props) { + super(props); + this.state = { + breadcrumbs: [], + loading: true, + page: 0, + total: 0 + }; + this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); + } + + componentDidMount() { + this.mounted = true; + this.props.fetchMetrics(); + this.handleComponentChange(); + } + + componentDidUpdate(prevProps: Props) { + if ( + prevProps.component !== this.props.component || + !isSameBranchLike(prevProps.branchLike, this.props.branchLike) + ) { + this.handleComponentChange(); + } else if (prevProps.location !== this.props.location) { + this.handleUpdate(); + } + } + + componentWillUnmount() { + clearBucket(); + this.mounted = false; + } + + loadComponent = (componentKey: string) => { + this.setState({ loading: true }); + retrieveComponent( + componentKey, + this.props.component.qualifier, + this, + this.props.branchLike + ).then(r => { + if (this.mounted) { + if (['FIL', 'UTS'].includes(r.component.qualifier)) { + this.setState({ + breadcrumbs: r.breadcrumbs, + components: r.components, + loading: false, + page: 0, + searchResults: undefined, + sourceViewer: r.component, + total: 0 + }); + } else { + this.setState({ + baseComponent: r.component, + breadcrumbs: r.breadcrumbs, + components: r.components, + loading: false, + page: r.page, + searchResults: undefined, + sourceViewer: undefined, + total: r.total + }); + } + } + }, this.stopLoading); + }; + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + handleComponentChange = () => { + const { branchLike, component } = this.props; + + // we already know component's breadcrumbs, + addComponentBreadcrumbs(component.key, component.breadcrumbs); + + this.setState({ loading: true }); + retrieveComponentChildren(component.key, component.qualifier, this, branchLike).then(() => { + addComponent(component); + if (this.mounted) { + this.handleUpdate(); + } + }, this.stopLoading); + }; + + handleLoadMore = () => { + const { baseComponent, components, page } = this.state; + if (!baseComponent || !components) { + return; + } + loadMoreChildren( + baseComponent.key, + page + 1, + this.props.component.qualifier, + this, + this.props.branchLike + ).then(r => { + if (this.mounted && r.components.length) { + this.setState({ + components: [...components, ...r.components], + page: r.page, + total: r.total + }); + } + }, this.stopLoading); + }; + + handleGoToParent = () => { + const { branchLike, component } = this.props; + const { breadcrumbs = [] } = this.state; + + if (breadcrumbs.length > 1) { + const parentComponent = breadcrumbs[breadcrumbs.length - 2]; + this.props.router.push(getCodeUrl(component.key, branchLike, parentComponent.key)); + this.setState({ highlighted: breadcrumbs[breadcrumbs.length - 1] }); + } + }; + + handleHighlight = (highlighted: T.ComponentMeasure) => { + this.setState({ highlighted }); + }; + + handleIssueChange = (_: T.Issue) => { + this.refreshBranchStatus(); + }; + + handleSearchClear = () => { + this.setState({ searchResults: undefined }); + }; + + handleSearchResults = (searchResults: T.ComponentMeasure[] = []) => { + this.setState({ searchResults }); + }; + + handleSelect = (component: T.ComponentMeasure) => { + const { branchLike, component: rootComponent } = this.props; + + if (component.refKey) { + this.props.router.push(getProjectUrl(component.refKey)); + } else { + this.props.router.push(getCodeUrl(rootComponent.key, branchLike, component.key)); + } + + this.setState({ highlighted: undefined }); + }; + + handleUpdate = () => { + const { component, location } = this.props; + const { selected } = location.query; + const finalKey = selected || component.key; + + this.loadComponent(finalKey); + }; + + refreshBranchStatus = () => { + const { branchLike, component } = this.props; + if (branchLike && component && isPullRequest(branchLike)) { + this.props.fetchBranchStatus(branchLike, component.key); + } + }; + + render() { + const { branchLike, component, location } = this.props; + const { + baseComponent, + breadcrumbs, + components = [], + highlighted, + loading, + total, + searchResults, + sourceViewer + } = this.state; + + const showSearch = searchResults !== undefined; + + const hasNoFile = components.length === 0 && searchResults === undefined; + + const shouldShowBreadcrumbs = breadcrumbs.length > 1 && !showSearch; + const shouldShowComponentList = + sourceViewer === undefined && components.length > 0 && !showSearch; + + const componentsClassName = classNames('boxed-group', 'spacer-top', { + 'new-loading': loading, + 'search-results': showSearch + }); + + const defaultTitle = + baseComponent && ['APP', 'VW', 'SVW'].includes(baseComponent.qualifier) + ? translate('projects.page') + : translate('code.page'); + + return ( +
+ + + + + {!hasNoFile && ( + + )} + +
+ {hasNoFile && ( +
+ + {translate( + 'code_viewer.no_source_code_displayed_due_to_empty_analysis', + component.qualifier + )} + +
+ )} + {shouldShowBreadcrumbs && ( + + )} + + {shouldShowComponentList && ( + <> +
+ +
+ + + )} + + {showSearch && searchResults && ( +
+ +
+ )} + + {sourceViewer !== undefined && !showSearch && ( +
+ +
+ )} +
+
+ ); + } +} + +const mapStateToProps = (state: any): StateToProps => ({ + metrics: getMetrics(state) +}); + +const mapDispatchToProps: DispatchToProps = { + fetchBranchStatus: fetchBranchStatus as any, + fetchMetrics +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(CodeApp); diff --git a/server/sonar-web/src/main/js/apps/code/components/Search.tsx b/server/sonar-web/src/main/js/apps/code/components/Search.tsx index 00b51655ab1..a680e771c18 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Search.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Search.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 { omit } from 'lodash'; import * as React from 'react'; import { getTree } from '../../../api/components'; import SearchBox from '../../../components/controls/SearchBox'; @@ -32,7 +33,7 @@ interface Props { location: Location; onSearchClear: () => void; onSearchResults: (results?: T.ComponentMeasure[]) => void; - router: Pick; + router: Router; } interface State { @@ -40,7 +41,7 @@ interface State { loading: boolean; } -class Search extends React.PureComponent { +export class Search extends React.PureComponent { mounted = false; state: State = { query: '', @@ -49,11 +50,14 @@ class Search extends React.PureComponent { componentDidMount() { this.mounted = true; + if (this.props.location.query.search) { + this.handleQueryChange(this.props.location.query.search); + } } - componentWillReceiveProps(nextProps: Props) { - // if the url has change, reset the current state - if (nextProps.location !== this.props.location) { + componentDidUpdate(nextProps: Props) { + // if the component has change, reset the current state + if (nextProps.location.query.id !== this.props.location.query.id) { this.setState({ query: '', loading: false @@ -79,8 +83,9 @@ class Search extends React.PureComponent { handleSearch = (query: string) => { if (this.mounted) { - const { branchLike, component } = this.props; + const { branchLike, component, router, location } = this.props; this.setState({ loading: true }); + router.replace({ pathname: location.pathname, query: { ...location.query, search: query } }); const isPortfolio = ['VW', 'SVW', 'APP'].includes(component.qualifier); const qualifiers = isPortfolio ? 'SVW,TRK' : 'BRC,UTS,FIL'; @@ -109,8 +114,10 @@ class Search extends React.PureComponent { }; handleQueryChange = (query: string) => { + const { router, location } = this.props; this.setState({ query }); if (query.length === 0) { + router.replace({ pathname: location.pathname, query: omit(location.query, 'search') }); this.props.onSearchClear(); } else { this.handleSearch(query); diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/AppCode-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/AppCode-test.tsx deleted file mode 100644 index 346e5028355..00000000000 --- a/server/sonar-web/src/main/js/apps/code/components/__tests__/AppCode-test.tsx +++ /dev/null @@ -1,194 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; -import { mockComponent, mockComponentMeasure } from '../../../../helpers/mocks/component'; -import { mockIssue, mockRouter } from '../../../../helpers/testMocks'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; -import { ComponentQualifier } from '../../../../types/component'; -import { loadMoreChildren, retrieveComponent } from '../../utils'; -import { AppCode } from '../AppCode'; - -jest.mock('../../utils', () => ({ - loadMoreChildren: jest.fn().mockResolvedValue({}), - retrieveComponent: jest.fn().mockResolvedValue({ - breadcrumbs: [], - component: { qualifier: 'APP' }, - components: [], - page: 0, - total: 1 - }), - retrieveComponentChildren: () => Promise.resolve() -})); - -const METRICS = { - coverage: { id: '2', key: 'coverage', type: 'PERCENT', name: 'Coverage', domain: 'Coverage' }, - new_bugs: { id: '4', key: 'new_bugs', type: 'INT', name: 'New Bugs', domain: 'Reliability' } -}; - -beforeEach(() => { - (retrieveComponent as jest.Mock).mockClear(); -}); - -it.each([ - [ComponentQualifier.Application], - [ComponentQualifier.Project], - [ComponentQualifier.Portfolio], - [ComponentQualifier.SubPortfolio] -])('should render correclty when no sub component for %s', async qualifier => { - const component = { breadcrumbs: [], name: 'foo', key: 'foo', qualifier }; - (retrieveComponent as jest.Mock).mockResolvedValueOnce({ - breadcrumbs: [], - component, - components: [], - page: 0, - total: 1 - }); - const wrapper = shallowRender({ component }); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); - wrapper.instance().handleSearchResults([]); - expect(wrapper).toMatchSnapshot('no search'); - (retrieveComponent as jest.Mock).mockResolvedValueOnce({ - breadcrumbs: [], - component, - components: [mockComponent({ qualifier: ComponentQualifier.File })], - page: 0, - total: 1 - }); - wrapper.instance().loadComponent(component.key); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot('with sub component'); -}); - -it('should refresh branch status if issues are updated', async () => { - const fetchBranchStatus = jest.fn(); - const branchLike = mockPullRequest(); - const wrapper = shallowRender({ branchLike, fetchBranchStatus }); - const instance = wrapper.instance(); - await waitAndUpdate(wrapper); - - instance.handleIssueChange(mockIssue()); - expect(fetchBranchStatus).toBeCalledWith(branchLike, 'foo'); -}); - -it('should load more behave correctly', async () => { - const component1 = mockComponent(); - const component2 = mockComponent(); - (retrieveComponent as jest.Mock).mockResolvedValueOnce({ - breadcrumbs: [], - component: mockComponent(), - components: [component1], - page: 0, - total: 1 - }); - let wrapper = shallowRender(); - await waitAndUpdate(wrapper); - - (loadMoreChildren as jest.Mock).mockResolvedValueOnce({ - components: [component2], - page: 0, - total: 1 - }); - - wrapper.instance().handleLoadMore(); - expect(wrapper.state().components).toContainEqual(component1); - expect(wrapper.state().components).toContainEqual(component2); - - (retrieveComponent as jest.Mock).mockRejectedValueOnce({}); - wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleLoadMore(); - expect(wrapper.state().components).toBeUndefined(); -}); - -it('should handle go to parent correctly', async () => { - const router = mockRouter(); - (retrieveComponent as jest.Mock).mockResolvedValueOnce({ - breadcrumbs: [], - component: mockComponent(), - components: [], - page: 0, - total: 1 - }); - let wrapper = shallowRender(); - await waitAndUpdate(wrapper); - wrapper.instance().handleGoToParent(); - expect(wrapper.state().highlighted).toBeUndefined(); - - const breadcrumb = { key: 'key2', name: 'name2', qualifier: ComponentQualifier.Directory }; - (retrieveComponent as jest.Mock).mockResolvedValueOnce({ - breadcrumbs: [ - { key: 'key1', name: 'name1', qualifier: ComponentQualifier.Directory }, - breadcrumb - ], - component: mockComponent(), - components: [], - page: 0, - total: 1 - }); - wrapper = shallowRender({ router }); - await waitAndUpdate(wrapper); - wrapper.instance().handleGoToParent(); - expect(wrapper.state().highlighted).toBe(breadcrumb); - expect(router.push).toHaveBeenCalledWith({ - pathname: '/code', - query: { id: 'foo', line: undefined, selected: 'key1' } - }); -}); - -it('should handle select correctly', () => { - const router = mockRouter(); - const wrapper = shallowRender({ router }); - wrapper.setState({ highlighted: mockComponentMeasure() }); - - wrapper.instance().handleSelect(mockComponentMeasure(true, { refKey: 'test' })); - expect(router.push).toHaveBeenCalledWith({ - pathname: '/dashboard', - query: { branch: undefined, id: 'test' } - }); - expect(wrapper.state().highlighted).toBeUndefined(); - - wrapper.instance().handleSelect(mockComponentMeasure()); - expect(router.push).toHaveBeenCalledWith({ - pathname: '/dashboard', - query: { branch: undefined, id: 'test' } - }); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/CodeApp-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/CodeApp-test.tsx new file mode 100644 index 00000000000..20645e522f2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/CodeApp-test.tsx @@ -0,0 +1,194 @@ +/* + * 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; +import { mockComponent, mockComponentMeasure } from '../../../../helpers/mocks/component'; +import { mockIssue, mockRouter } from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { ComponentQualifier } from '../../../../types/component'; +import { loadMoreChildren, retrieveComponent } from '../../utils'; +import { CodeApp } from '../CodeApp'; + +jest.mock('../../utils', () => ({ + loadMoreChildren: jest.fn().mockResolvedValue({}), + retrieveComponent: jest.fn().mockResolvedValue({ + breadcrumbs: [], + component: { qualifier: 'APP' }, + components: [], + page: 0, + total: 1 + }), + retrieveComponentChildren: () => Promise.resolve() +})); + +const METRICS = { + coverage: { id: '2', key: 'coverage', type: 'PERCENT', name: 'Coverage', domain: 'Coverage' }, + new_bugs: { id: '4', key: 'new_bugs', type: 'INT', name: 'New Bugs', domain: 'Reliability' } +}; + +beforeEach(() => { + (retrieveComponent as jest.Mock).mockClear(); +}); + +it.each([ + [ComponentQualifier.Application], + [ComponentQualifier.Project], + [ComponentQualifier.Portfolio], + [ComponentQualifier.SubPortfolio] +])('should render correclty when no sub component for %s', async qualifier => { + const component = { breadcrumbs: [], name: 'foo', key: 'foo', qualifier }; + (retrieveComponent as jest.Mock).mockResolvedValueOnce({ + breadcrumbs: [], + component, + components: [], + page: 0, + total: 1 + }); + const wrapper = shallowRender({ component }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + wrapper.instance().handleSearchResults([]); + expect(wrapper).toMatchSnapshot('no search'); + (retrieveComponent as jest.Mock).mockResolvedValueOnce({ + breadcrumbs: [], + component, + components: [mockComponent({ qualifier: ComponentQualifier.File })], + page: 0, + total: 1 + }); + wrapper.instance().loadComponent(component.key); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('with sub component'); +}); + +it('should refresh branch status if issues are updated', async () => { + const fetchBranchStatus = jest.fn(); + const branchLike = mockPullRequest(); + const wrapper = shallowRender({ branchLike, fetchBranchStatus }); + const instance = wrapper.instance(); + await waitAndUpdate(wrapper); + + instance.handleIssueChange(mockIssue()); + expect(fetchBranchStatus).toBeCalledWith(branchLike, 'foo'); +}); + +it('should load more behave correctly', async () => { + const component1 = mockComponent(); + const component2 = mockComponent(); + (retrieveComponent as jest.Mock).mockResolvedValueOnce({ + breadcrumbs: [], + component: mockComponent(), + components: [component1], + page: 0, + total: 1 + }); + let wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + (loadMoreChildren as jest.Mock).mockResolvedValueOnce({ + components: [component2], + page: 0, + total: 1 + }); + + wrapper.instance().handleLoadMore(); + expect(wrapper.state().components).toContainEqual(component1); + expect(wrapper.state().components).toContainEqual(component2); + + (retrieveComponent as jest.Mock).mockRejectedValueOnce({}); + wrapper = shallowRender(); + await waitAndUpdate(wrapper); + wrapper.instance().handleLoadMore(); + expect(wrapper.state().components).toBeUndefined(); +}); + +it('should handle go to parent correctly', async () => { + const router = mockRouter(); + (retrieveComponent as jest.Mock).mockResolvedValueOnce({ + breadcrumbs: [], + component: mockComponent(), + components: [], + page: 0, + total: 1 + }); + let wrapper = shallowRender(); + await waitAndUpdate(wrapper); + wrapper.instance().handleGoToParent(); + expect(wrapper.state().highlighted).toBeUndefined(); + + const breadcrumb = { key: 'key2', name: 'name2', qualifier: ComponentQualifier.Directory }; + (retrieveComponent as jest.Mock).mockResolvedValueOnce({ + breadcrumbs: [ + { key: 'key1', name: 'name1', qualifier: ComponentQualifier.Directory }, + breadcrumb + ], + component: mockComponent(), + components: [], + page: 0, + total: 1 + }); + wrapper = shallowRender({ router }); + await waitAndUpdate(wrapper); + wrapper.instance().handleGoToParent(); + expect(wrapper.state().highlighted).toBe(breadcrumb); + expect(router.push).toHaveBeenCalledWith({ + pathname: '/code', + query: { id: 'foo', line: undefined, selected: 'key1' } + }); +}); + +it('should handle select correctly', () => { + const router = mockRouter(); + const wrapper = shallowRender({ router }); + wrapper.setState({ highlighted: mockComponentMeasure() }); + + wrapper.instance().handleSelect(mockComponentMeasure(true, { refKey: 'test' })); + expect(router.push).toHaveBeenCalledWith({ + pathname: '/dashboard', + query: { branch: undefined, id: 'test' } + }); + expect(wrapper.state().highlighted).toBeUndefined(); + + wrapper.instance().handleSelect(mockComponentMeasure()); + expect(router.push).toHaveBeenCalledWith({ + pathname: '/dashboard', + query: { branch: undefined, id: 'test' } + }); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/Search-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/Search-test.tsx new file mode 100644 index 00000000000..829fb65f7a4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/Search-test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { getTree } from '../../../../api/components'; +import { mockComponent } from '../../../../helpers/mocks/component'; +import { mockLocation, mockRouter } from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { Search } from '../Search'; + +jest.mock('../../../../api/components', () => { + const { mockTreeComponent, mockComponent } = jest.requireActual( + '../../../../helpers/mocks/component' + ); + + return { + getTree: jest.fn().mockResolvedValue({ + baseComponent: mockTreeComponent(), + components: [mockComponent()], + paging: { pageIndex: 0, pageSize: 5, total: 20 } + }) + }; +}); + +it('should render correcly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should search correct query on mount', async () => { + const onSearchResults = jest.fn(); + const wrapper = shallowRender({ + location: mockLocation({ query: { id: 'foo', search: 'bar' } }), + onSearchResults + }); + await waitAndUpdate(wrapper); + expect(getTree).toHaveBeenCalledWith({ + component: 'my-project', + q: 'bar', + qualifiers: 'BRC,UTS,FIL', + s: 'qualifier,name' + }); + expect(onSearchResults).toHaveBeenCalledWith([ + { + breadcrumbs: [], + key: 'my-project', + name: 'MyProject', + qualifier: 'TRK', + qualityGate: { isDefault: true, key: '30', name: 'Sonar way' }, + qualityProfiles: [{ deleted: false, key: 'my-qp', language: 'ts', name: 'Sonar way' }], + tags: [] + } + ]); +}); + +it('should handle search correctly', async () => { + const router = mockRouter(); + const onSearchClear = jest.fn(); + const wrapper = shallowRender({ router, onSearchClear }); + wrapper.instance().handleQueryChange('foo'); + await waitAndUpdate(wrapper); + expect(router.replace).toHaveBeenCalledWith({ + pathname: '/path', + query: { + search: 'foo' + } + }); + expect(getTree).toHaveBeenCalledWith({ + component: 'my-project', + q: 'foo', + qualifiers: 'BRC,UTS,FIL', + s: 'qualifier,name' + }); + + wrapper.instance().handleQueryChange(''); + await waitAndUpdate(wrapper); + expect(router.replace).toHaveBeenCalledWith({ + pathname: '/path', + query: {} + }); + expect(onSearchClear).toHaveBeenCalledWith(); +}); + +function shallowRender(props?: Partial) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/AppCode-test.tsx.snap b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/AppCode-test.tsx.snap deleted file mode 100644 index 233b60a1e9f..00000000000 --- a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/AppCode-test.tsx.snap +++ /dev/null @@ -1,765 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correclty when no sub component for APP 1`] = ` -
- - - -
-
- - code_viewer.no_source_code_displayed_due_to_empty_analysis.APP - -
-
-
-`; - -exports[`should render correclty when no sub component for APP: no search 1`] = ` -
- - - - -
-
- -
-
-
-`; - -exports[`should render correclty when no sub component for APP: with sub component 1`] = ` -
- - - - -
-
- -
- -
-
-`; - -exports[`should render correclty when no sub component for SVW 1`] = ` -
- - - -
-
- - code_viewer.no_source_code_displayed_due_to_empty_analysis.SVW - -
-
-
-`; - -exports[`should render correclty when no sub component for SVW: no search 1`] = ` -
- - - - -
-
- -
-
-
-`; - -exports[`should render correclty when no sub component for SVW: with sub component 1`] = ` -
- - - - -
-
- -
- -
-
-`; - -exports[`should render correclty when no sub component for TRK 1`] = ` -
- - - -
-
- - code_viewer.no_source_code_displayed_due_to_empty_analysis.TRK - -
-
-
-`; - -exports[`should render correclty when no sub component for TRK: no search 1`] = ` -
- - - - -
-
- -
-
-
-`; - -exports[`should render correclty when no sub component for TRK: with sub component 1`] = ` -
- - - - -
-
- -
- -
-
-`; - -exports[`should render correclty when no sub component for VW 1`] = ` -
- - - -
-
- - code_viewer.no_source_code_displayed_due_to_empty_analysis.VW - -
-
-
-`; - -exports[`should render correclty when no sub component for VW: no search 1`] = ` -
- - - - -
-
- -
-
-
-`; - -exports[`should render correclty when no sub component for VW: with sub component 1`] = ` -
- - - - -
-
- -
- -
-
-`; diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/CodeApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/CodeApp-test.tsx.snap new file mode 100644 index 00000000000..233b60a1e9f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/CodeApp-test.tsx.snap @@ -0,0 +1,765 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correclty when no sub component for APP 1`] = ` +
+ + + +
+
+ + code_viewer.no_source_code_displayed_due_to_empty_analysis.APP + +
+
+
+`; + +exports[`should render correclty when no sub component for APP: no search 1`] = ` +
+ + + + +
+
+ +
+
+
+`; + +exports[`should render correclty when no sub component for APP: with sub component 1`] = ` +
+ + + + +
+
+ +
+ +
+
+`; + +exports[`should render correclty when no sub component for SVW 1`] = ` +
+ + + +
+
+ + code_viewer.no_source_code_displayed_due_to_empty_analysis.SVW + +
+
+
+`; + +exports[`should render correclty when no sub component for SVW: no search 1`] = ` +
+ + + + +
+
+ +
+
+
+`; + +exports[`should render correclty when no sub component for SVW: with sub component 1`] = ` +
+ + + + +
+
+ +
+ +
+
+`; + +exports[`should render correclty when no sub component for TRK 1`] = ` +
+ + + +
+
+ + code_viewer.no_source_code_displayed_due_to_empty_analysis.TRK + +
+
+
+`; + +exports[`should render correclty when no sub component for TRK: no search 1`] = ` +
+ + + + +
+
+ +
+
+
+`; + +exports[`should render correclty when no sub component for TRK: with sub component 1`] = ` +
+ + + + +
+
+ +
+ +
+
+`; + +exports[`should render correclty when no sub component for VW 1`] = ` +
+ + + +
+
+ + code_viewer.no_source_code_displayed_due_to_empty_analysis.VW + +
+
+
+`; + +exports[`should render correclty when no sub component for VW: no search 1`] = ` +
+ + + + +
+
+ +
+
+
+`; + +exports[`should render correclty when no sub component for VW: with sub component 1`] = ` +
+ + + + +
+
+ +
+ +
+
+`; diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Search-test.tsx.snap new file mode 100644 index 00000000000..a1d47cde013 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Search-test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correcly 1`] = ` + +`; diff --git a/server/sonar-web/src/main/js/apps/code/routes.ts b/server/sonar-web/src/main/js/apps/code/routes.ts index f35e7d14861..7a0eac5b17c 100644 --- a/server/sonar-web/src/main/js/apps/code/routes.ts +++ b/server/sonar-web/src/main/js/apps/code/routes.ts @@ -21,7 +21,7 @@ import { lazyLoadComponent } from '../../components/lazyLoadComponent'; const routes = [ { - indexRoute: { component: lazyLoadComponent(() => import('./components/AppCode')) } + indexRoute: { component: lazyLoadComponent(() => import('./components/CodeApp')) } } ]; diff --git a/server/sonar-web/src/main/js/helpers/mocks/component.ts b/server/sonar-web/src/main/js/helpers/mocks/component.ts index 990ad003a5c..19149a524c2 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/component.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/component.ts @@ -17,7 +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 { ComponentQualifier } from '../../types/component'; +import { ComponentQualifier, TreeComponent } from '../../types/component'; import { MetricKey } from '../../types/metrics'; import { mockMeasureEnhanced } from '../testMocks'; @@ -41,6 +41,16 @@ export function mockComponent(overrides: Partial = {}): T.Component }; } +export function mockTreeComponent(overrides: Partial): TreeComponent { + return { + key: 'my-key', + qualifier: ComponentQualifier.Project, + name: 'component', + visibility: 'public', + ...overrides + }; +} + export function mockComponentMeasure( file = false, overrides: Partial = {}