diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2019-02-14 18:22:10 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-03-06 11:30:42 +0100 |
commit | f79ab22a93ead12949592ec5095401db718af63e (patch) | |
tree | fee128d48a20e2847c245695393b6134af91736c /server/sonar-web/src/main/js | |
parent | 980cb9cb6854597e5adbad6fe2536f87cb519bd1 (diff) | |
download | sonarqube-f79ab22a93ead12949592ec5095401db718af63e.tar.gz sonarqube-f79ab22a93ead12949592ec5095401db718af63e.zip |
SONARCLOUD-413 Add organizations list in onboarding modal
Diffstat (limited to 'server/sonar-web/src/main/js')
26 files changed, 733 insertions, 108 deletions
diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx index c5d84e6c8eb..cbc48881b23 100644 --- a/server/sonar-web/src/main/js/app/components/StartupModal.tsx +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -58,7 +58,7 @@ interface WithRouterProps { type Props = StateProps & DispatchProps & OwnProps & WithRouterProps; -enum ModalKey { +export enum ModalKey { license, onboarding } @@ -153,6 +153,7 @@ export class StartupModal extends React.PureComponent<Props, State> { <OnboardingModal onClose={this.closeOnboarding} onOpenProjectOnboarding={this.openProjectOnboarding} + skipOnboarding={this.props.skipOnboarding} /> )} </OnboardingContext.Provider> diff --git a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx index b2476e2d50c..e61ab79265a 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx @@ -19,13 +19,14 @@ */ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { StartupModal } from '../StartupModal'; +import { StartupModal, ModalKey } from '../StartupModal'; import { showLicense } from '../../../api/marketplace'; import { save, get } from '../../../helpers/storage'; import { hasMessage } from '../../../helpers/l10n'; import { waitAndUpdate } from '../../../helpers/testUtils'; import { differenceInDays, toShortNotSoISOString } from '../../../helpers/dates'; import { EditionKey } from '../../../apps/marketplace/utils'; +import { mockOrganization, mockRouter } from '../../../helpers/testMocks'; jest.mock('../../../api/marketplace', () => ({ showLicense: jest.fn().mockResolvedValue(undefined) @@ -110,6 +111,36 @@ it('should render license prompt', async () => { await shouldDisplayLicense(getWrapper()); }); +describe('closeOnboarding', () => { + it('should set state and skip onboarding', () => { + const skipOnboarding = jest.fn(); + const wrapper = getWrapper({ skipOnboarding }); + + wrapper.setState({ modal: ModalKey.onboarding }); + wrapper.instance().closeOnboarding(); + + expect(wrapper.state('modal')).toBe(undefined); + + expect(skipOnboarding).toHaveBeenCalledTimes(1); + }); +}); + +describe('openProjectOnboarding', () => { + it('should set state and redirect', () => { + const push = jest.fn(); + const wrapper = getWrapper({ router: mockRouter({ push }) }); + + wrapper.instance().openProjectOnboarding(mockOrganization()); + + expect(wrapper.state('modal')).toBe(undefined); + + expect(push).toHaveBeenCalledWith({ + pathname: `/projects/create`, + state: { organization: 'foo', tab: 'manual' } + }); + }); +}); + async function shouldNotHaveModals(wrapper: ShallowWrapper) { await waitAndUpdate(wrapper); expect(wrapper.find('LicensePromptModal').exists()).toBeFalsy(); @@ -121,7 +152,7 @@ async function shouldDisplayLicense(wrapper: ShallowWrapper) { } function getWrapper(props: Partial<StartupModal['props']> = {}) { - return shallow( + return shallow<StartupModal>( <StartupModal canAdmin={true} currentEdition={EditionKey.enterprise} diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js index 25ce5c25956..4fd00b72e36 100644 --- a/server/sonar-web/src/main/js/app/theme.js +++ b/server/sonar-web/src/main/js/app/theme.js @@ -95,6 +95,7 @@ module.exports = { bigFontSize: '16px', hugeFontSize: '24px', + hugeControlHeight: `${5 * grid}px`, largeControlHeight: `${4 * grid}px`, controlHeight: `${3 * grid}px`, smallControlHeight: `${2.5 * grid}px`, diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx index 8871a1933de..9015c4ba8b2 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx @@ -37,6 +37,7 @@ interface Props { loading: boolean; members?: T.OrganizationMember[]; organization: T.Organization; + refreshMembers: () => Promise<void>; setCurrentUserSetting: (setting: T.CurrentUserSetting) => void; } @@ -50,7 +51,7 @@ export class MembersPageHeader extends React.PureComponent<Props> { }; render() { - const { dismissSyncNotifOrg, members, organization } = this.props; + const { dismissSyncNotifOrg, members, organization, refreshMembers } = this.props; const memberLogins = members ? members.map(member => member.login) : []; const isAdmin = organization.actions && organization.actions.admin; const almKey = organization.alm && sanitizeAlmId(organization.alm.key); @@ -67,7 +68,10 @@ export class MembersPageHeader extends React.PureComponent<Props> { <DeferredSpinner loading={this.props.loading} /> {isAdmin && ( <div className="page-actions text-right"> - {almKey && !showSyncNotif && <SyncMemberForm organization={organization} />} + {almKey && + !showSyncNotif && ( + <SyncMemberForm organization={organization} refreshMembers={refreshMembers} /> + )} {!hasMemberSync && ( <div className="display-inline-block spacer-left spacer-bottom"> <AddMemberForm @@ -93,7 +97,11 @@ export class MembersPageHeader extends React.PureComponent<Props> { 'organization.members.auto_sync_with_x', translate(almKey) )}> - <SyncMemberForm organization={organization} /> + <SyncMemberForm + dismissSyncNotif={this.handleDismissSyncNotif} + organization={organization} + refreshMembers={refreshMembers} + /> </NewInfoBox> )} </div> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx index 46e1162372b..da2ea484d97 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx @@ -151,6 +151,18 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat ); }; + refreshMembers = () => { + return searchMembers({ + organization: this.props.organization.key, + ps: PAGE_SIZE, + q: this.state.query || undefined + }).then(({ paging, users }) => { + if (this.mounted) { + this.setState({ members: users, paging }); + } + }); + }; + updateGroup = ( login: string, updater: (member: T.OrganizationMember) => T.OrganizationMember @@ -195,6 +207,7 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat loading={loading} members={members} organization={organization} + refreshMembers={this.refreshMembers} /> {members !== undefined && paging !== undefined && ( diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx index 1a7a5a8400e..9ebb9f57e21 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx @@ -30,8 +30,10 @@ import { translate, translateWithParameters } from '../../helpers/l10n'; import { fetchOrganization } from '../../store/rootActions'; interface Props { + dismissSyncNotif?: () => void; fetchOrganization: (key: string) => void; organization: T.Organization; + refreshMembers: () => Promise<void>; } interface State { @@ -47,15 +49,20 @@ export class SyncMemberForm extends React.PureComponent<Props, State> { } handleConfirm = () => { - const { organization } = this.props; + const { dismissSyncNotif, organization } = this.props; const { membersSync } = this.state; + + if (dismissSyncNotif) { + dismissSyncNotif(); + } + return setOrganizationMemberSync({ organization: organization.key, enabled: membersSync }).then(() => { this.props.fetchOrganization(organization.key); if (membersSync && isGithub(organization.alm && organization.alm.key)) { - return syncMembers(organization.key); + return this.handleMemberSync(); } return Promise.resolve(); }); @@ -69,6 +76,10 @@ export class SyncMemberForm extends React.PureComponent<Props, State> { this.setState({ membersSync: true }); }; + handleMemberSync = () => { + return syncMembers(this.props.organization.key).then(this.props.refreshMembers); + }; + renderModalDescription = () => { return ( <p className="spacer-top"> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx index 7f74a8eff3c..bb2a10ad805 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx @@ -63,6 +63,7 @@ function shallowRender(props: Partial<MembersPageHeader['props']> = {}) { loading={false} members={[]} organization={mockOrganization()} + refreshMembers={jest.fn()} setCurrentUserSetting={jest.fn()} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx index 7861581aa2e..eac606f79c0 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx @@ -54,8 +54,7 @@ jest.mock('../../../api/user_groups', () => ({ })); beforeEach(() => { - (searchMembers as jest.Mock).mockClear(); - (searchUsersGroups as jest.Mock).mockClear(); + jest.clearAllMocks(); }); it('should fetch members and render for non-admin', async () => { @@ -88,6 +87,28 @@ it('should load more members', async () => { expect(searchMembers).lastCalledWith({ organization: 'foo', p: 2, ps: 50, q: undefined }); }); +it('should refresh members', async () => { + const paging = { pageIndex: 1, pageSize: 5, total: 3 }; + const users = [ + { login: 'admin', name: 'Admin Istrator', avatar: '', groupCount: 3 }, + { login: 'john', name: 'John Doe', avatar: '7daf6c79d4802916d83f6266e24850af', groupCount: 1 }, + { login: 'stan', name: 'Stan Marsh', avatar: '', groupCount: 7 } + ]; + + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + (searchMembers as jest.Mock).mockResolvedValueOnce({ + paging, + users + }); + + await wrapper.instance().refreshMembers(); + expect(searchMembers).toBeCalled(); + expect(wrapper.state('members')).toEqual(users); + expect(wrapper.state('paging')).toEqual(paging); +}); + it('should add new member', async () => { const wrapper = shallowRender(); await waitAndUpdate(wrapper); @@ -140,7 +161,7 @@ it('should update groups', async () => { }); function shallowRender(props: Partial<OrganizationMembers['props']> = {}) { - return shallow( + return shallow<OrganizationMembers>( <OrganizationMembers currentUser={mockCurrentUser()} organization={mockOrganizationWithAdminActions()} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx index 4410d45c301..78dd230299a 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx @@ -35,8 +35,10 @@ beforeEach(() => { }); it('should allow to switch to automatic mode with github', async () => { + const dismissSyncNotif = jest.fn(); const fetchOrganization = jest.fn(); - const wrapper = shallowRender({ fetchOrganization }); + const refreshMembers = jest.fn().mockResolvedValue({}); + const wrapper = shallowRender({ dismissSyncNotif, fetchOrganization, refreshMembers }); expect(wrapper).toMatchSnapshot(); wrapper.setState({ membersSync: true }); @@ -46,6 +48,8 @@ it('should allow to switch to automatic mode with github', async () => { await waitAndUpdate(wrapper); expect(fetchOrganization).toHaveBeenCalledWith('foo'); expect(syncMembers).toHaveBeenCalledWith('foo'); + expect(refreshMembers).toBeCalledTimes(1); + expect(dismissSyncNotif).toBeCalledTimes(1); }); it('should allow to switch to automatic mode with bitbucket', async () => { @@ -87,6 +91,7 @@ function shallowRender(props: Partial<SyncMemberForm['props']> = {}) { <SyncMemberForm fetchOrganization={jest.fn()} organization={mockOrganizationWithAlm()} + refreshMembers={jest.fn().mockResolvedValue({})} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap index 93646b3ecc1..0225976a5a6 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap @@ -143,6 +143,7 @@ exports[`should render for bound organization without sync 1`] = ` title="organization.members.auto_sync_with_x.github" > <Connect(SyncMemberForm) + dismissSyncNotif={[Function]} organization={ Object { "actions": Object { @@ -157,6 +158,7 @@ exports[`should render for bound organization without sync 1`] = ` "name": "Foo", } } + refreshMembers={[MockFunction]} /> </NewInfoBox> </div> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap index dd26a5928f4..2d451e1086c 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap @@ -21,6 +21,7 @@ exports[`should fetch members and render for non-admin 1`] = ` "name": "Foo", } } + refreshMembers={[Function]} /> </div> `; @@ -62,6 +63,7 @@ exports[`should fetch members and render for non-admin 2`] = ` "name": "Foo", } } + refreshMembers={[Function]} /> <MembersListHeader currentUser={ diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx index adf205bd0ea..1163399e78b 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx @@ -18,67 +18,79 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { connect } from 'react-redux'; -import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; +import OrganizationsShortList from './OrganizationsShortList'; import Modal from '../../../components/controls/Modal'; import OnboardingProjectIcon from '../../../components/icons-components/OnboardingProjectIcon'; +import OnboardingTeamIcon from '../../../components/icons-components/OnboardingTeamIcon'; +import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; +import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations'; import { Button, ResetButtonLink } from '../../../components/ui/buttons'; import { translate } from '../../../helpers/l10n'; -import { getCurrentUser, Store } from '../../../store/rootReducer'; -import { isLoggedIn } from '../../../helpers/users'; import '../styles.css'; -interface OwnProps { +export interface Props { + currentUser: T.LoggedInUser; onClose: () => void; onOpenProjectOnboarding: () => void; + skipOnboarding: () => void; + userOrganizations: T.Organization[]; } -interface StateProps { - currentUser: T.CurrentUser; -} - -type Props = OwnProps & StateProps; - -export class OnboardingModal extends React.PureComponent<Props> { - componentDidMount() { - if (!isLoggedIn(this.props.currentUser)) { - handleRequiredAuthentication(); - } - } +export function OnboardingModal(props: Props) { + const { + currentUser, + onClose, + onOpenProjectOnboarding, + skipOnboarding, + userOrganizations + } = props; - render() { - if (!isLoggedIn(this.props.currentUser)) { - return null; - } + const organizations = userOrganizations.filter(o => o.key !== currentUser.personalOrganization); - const header = translate('onboarding.header'); - return ( - <Modal - contentLabel={header} - medium={true} - onRequestClose={this.props.onClose} - shouldCloseOnOverlayClick={false}> - <div className="modal-head"> - <h2>{translate('onboarding.header')}</h2> - <p className="spacer-top">{translate('onboarding.header.description')}</p> - </div> - <div className="modal-body text-center huge-spacer-top huge-spacer-bottom"> + const header = translate('onboarding.header'); + return ( + <Modal + contentLabel={header} + medium={true} + onRequestClose={onClose} + shouldCloseOnOverlayClick={false}> + <div className="modal-head"> + <h2>{translate('onboarding.header')}</h2> + <p className="spacer-top">{translate('onboarding.header.description')}</p> + </div> + <div className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom"> + <div className="flex-1"> <OnboardingProjectIcon className="big-spacer-bottom" /> <h6 className="onboarding-choice-name big-spacer-bottom"> {translate('onboarding.analyze_your_code')} </h6> - <Button onClick={this.props.onOpenProjectOnboarding}> + <Button onClick={onOpenProjectOnboarding}> {translate('onboarding.project.create')} </Button> </div> - <div className="modal-foot text-right"> - <ResetButtonLink onClick={this.props.onClose}>{translate('not_now')}</ResetButtonLink> - </div> - </Modal> - ); - } + {organizations.length > 0 && ( + <> + <div className="vertical-pipe-separator"> + <div className="vertical-separator" /> + </div> + <div className="flex-1"> + <OnboardingTeamIcon className="big-spacer-bottom" /> + <h6 className="onboarding-choice-name big-spacer-bottom"> + {translate('onboarding.browse_your_organizations')} + </h6> + <OrganizationsShortList + organizations={organizations} + skipOnboarding={skipOnboarding} + /> + </div> + </> + )} + </div> + <div className="modal-foot text-right"> + <ResetButtonLink onClick={onClose}>{translate('not_now')}</ResetButtonLink> + </div> + </Modal> + ); } -const mapStateToProps = (state: Store): StateProps => ({ currentUser: getCurrentUser(state) }); - -export default connect(mapStateToProps)(OnboardingModal); +export default withUserOrganizations(whenLoggedIn(OnboardingModal)); diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx index 9533287403b..5bda42d861b 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx @@ -19,44 +19,30 @@ */ import * as React from 'react'; import { connect } from 'react-redux'; -import { InjectedRouter } from 'react-router'; import OnboardingModal from './OnboardingModal'; import { skipOnboarding } from '../../../store/users'; import { OnboardingContext } from '../../../app/components/OnboardingContext'; +import { Router } from '../../../components/hoc/withRouter'; -interface DispatchProps { +interface Props { + router: Router; skipOnboarding: () => void; } -interface OwnProps { - router: InjectedRouter; -} - -interface State { - open: boolean; -} - -export class OnboardingPage extends React.PureComponent<OwnProps & DispatchProps, State> { - state: State = { open: false }; - +export class OnboardingPage extends React.PureComponent<Props> { closeOnboarding = () => { this.props.skipOnboarding(); this.props.router.replace('/'); }; render() { - const { open } = this.state; - - if (!open) { - return null; - } - return ( <OnboardingContext.Consumer> {openProjectOnboarding => ( <OnboardingModal onClose={this.closeOnboarding} onOpenProjectOnboarding={openProjectOnboarding} + skipOnboarding={this.props.skipOnboarding} /> )} </OnboardingContext.Consumer> @@ -64,7 +50,7 @@ export class OnboardingPage extends React.PureComponent<OwnProps & DispatchProps } } -const mapDispatchToProps: DispatchProps = { skipOnboarding }; +const mapDispatchToProps = { skipOnboarding }; export default connect( null, diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortList.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortList.tsx new file mode 100644 index 00000000000..a7eecc85f6f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortList.tsx @@ -0,0 +1,64 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 React from 'react'; +import { sortBy } from 'lodash'; +import { Link } from 'react-router'; +import OrganizationsShortListItem from './OrganizationsShortListItem'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; + +export interface Props { + organizations: T.Organization[]; + skipOnboarding: () => void; +} + +export default function OrganizationsShortList({ organizations, skipOnboarding }: Props) { + if (organizations.length === 0) { + return null; + } + + const organizationsShown = sortBy(organizations, organization => + organization.name.toLocaleLowerCase() + ).slice(0, 3); + + return ( + <div> + <ul className="account-projects-list"> + {organizationsShown.map(organization => ( + <li key={organization.key}> + <OrganizationsShortListItem + organization={organization} + skipOnboarding={skipOnboarding} + /> + </li> + ))} + </ul> + <div className="big-spacer-top"> + <span className="big-spacer-right"> + {translateWithParameters('x_of_y_shown', organizationsShown.length, organizations.length)} + </span> + {organizations.length > 3 && ( + <Link className="small" onClick={skipOnboarding} to="/account/organizations"> + {translate('see_all')} + </Link> + )} + </div> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortListItem.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortListItem.tsx new file mode 100644 index 00000000000..49be2296cb9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortListItem.tsx @@ -0,0 +1,52 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 React from 'react'; +import OrganizationAvatar from '../../../components/common/OrganizationAvatar'; +import { ListButton } from '../../../components/ui/buttons'; +import { withRouter, Router } from '../../../components/hoc/withRouter'; +import { getOrganizationUrl } from '../../../helpers/urls'; + +interface Props { + organization: T.Organization; + router: Router; + skipOnboarding: () => void; +} + +export class OrganizationsShortListItem extends React.PureComponent<Props> { + handleClick = () => { + const { organization, router, skipOnboarding } = this.props; + skipOnboarding(); + router.push(getOrganizationUrl(organization.key)); + }; + + render() { + const { organization } = this.props; + return ( + <ListButton className="abs-width-300" onClick={this.handleClick}> + <div className="display-flex-center"> + <OrganizationAvatar className="spacer-right" organization={organization} /> + <span>{organization.name}</span> + </div> + </ListButton> + ); + } +} + +export default withRouter(OrganizationsShortListItem); diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx index 7ae1c13dec6..2521fc0cba8 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx @@ -19,31 +19,18 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import { OnboardingModal } from '../OnboardingModal'; +import { OnboardingModal, Props } from '../OnboardingModal'; import { click } from '../../../../helpers/testUtils'; +import { mockCurrentUser, mockOrganization } from '../../../../helpers/testMocks'; it('renders correctly', () => { - expect( - shallow( - <OnboardingModal - currentUser={{ isLoggedIn: true }} - onClose={jest.fn()} - onOpenProjectOnboarding={jest.fn()} - /> - ) - ).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); -it('should correctly open the different tutorials', () => { +it('should open project create page', () => { const onClose = jest.fn(); const onOpenProjectOnboarding = jest.fn(); - const wrapper = shallow( - <OnboardingModal - currentUser={{ isLoggedIn: true }} - onClose={onClose} - onOpenProjectOnboarding={onOpenProjectOnboarding} - /> - ); + const wrapper = shallowRender({ onClose, onOpenProjectOnboarding }); click(wrapper.find('ResetButtonLink')); expect(onClose).toHaveBeenCalled(); @@ -51,3 +38,30 @@ it('should correctly open the different tutorials', () => { wrapper.find('Button').forEach(button => click(button)); expect(onOpenProjectOnboarding).toHaveBeenCalled(); }); + +it('should display organization list if any', () => { + const wrapper = shallowRender({ + currentUser: mockCurrentUser({ personalOrganization: 'personal' }), + userOrganizations: [ + mockOrganization({ key: 'a', name: 'Arthur' }), + mockOrganization({ key: 'd', name: 'Daniel Inc' }), + mockOrganization({ key: 'personal', name: 'Personal' }) + ] + }); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('OrganizationsShortList').prop('organizations')).toHaveLength(2); +}); + +function shallowRender(props: Partial<Props> = {}) { + return shallow( + <OnboardingModal + currentUser={mockCurrentUser()} + onClose={jest.fn()} + onOpenProjectOnboarding={jest.fn()} + skipOnboarding={jest.fn()} + userOrganizations={[]} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortList-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortList-test.tsx new file mode 100644 index 00000000000..f0e9b24e4d3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortList-test.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 React from 'react'; +import { shallow } from 'enzyme'; +import OrganizationsShortList, { Props } from '../OrganizationsShortList'; +import { mockOrganization } from '../../../../helpers/testMocks'; + +it('should render null with no orgs', () => { + expect(shallowRender().getElement()).toBe(null); +}); + +it('should render correctly', () => { + const wrapper = shallowRender({ + organizations: [mockOrganization(), mockOrganization({ key: 'bar', name: 'Bar' })] + }); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('li')).toHaveLength(2); +}); + +it('should limit displayed orgs to the first three', () => { + const wrapper = shallowRender({ + organizations: [ + mockOrganization(), + mockOrganization({ key: 'zoo', name: 'Zoological' }), + mockOrganization({ key: 'bar', name: 'Bar' }), + mockOrganization({ key: 'kor', name: 'Kor' }) + ] + }); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('li')).toHaveLength(3); +}); + +function shallowRender(props: Partial<Props> = {}) { + return shallow( + <OrganizationsShortList organizations={[]} skipOnboarding={jest.fn()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortListItem-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortListItem-test.tsx new file mode 100644 index 00000000000..bf84c7895ba --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortListItem-test.tsx @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 React from 'react'; +import { shallow } from 'enzyme'; +import { OrganizationsShortListItem } from '../OrganizationsShortListItem'; +import { mockRouter, mockOrganization } from '../../../../helpers/testMocks'; +import { click } from '../../../../helpers/testUtils'; + +it('renders correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('calls skiponboarding and redirects to org page', () => { + const skipOnboarding = jest.fn(); + const push = jest.fn(); + const wrapper = shallowRender({ skipOnboarding, router: mockRouter({ push }) }); + + click(wrapper); + + expect(skipOnboarding).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/organizations/foo'); +}); + +function shallowRender(props: Partial<OrganizationsShortListItem['props']> = {}) { + return shallow( + <OrganizationsShortListItem + organization={mockOrganization()} + router={mockRouter()} + skipOnboarding={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap index daf332ad6a2..1c9eb96f30e 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap @@ -20,21 +20,111 @@ exports[`renders correctly 1`] = ` </p> </div> <div - className="modal-body text-center huge-spacer-top huge-spacer-bottom" + className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom" > - <OnboardingProjectIcon - className="big-spacer-bottom" - /> - <h6 - className="onboarding-choice-name big-spacer-bottom" - > - onboarding.analyze_your_code - </h6> - <Button + <div + className="flex-1" + > + <OnboardingProjectIcon + className="big-spacer-bottom" + /> + <h6 + className="onboarding-choice-name big-spacer-bottom" + > + onboarding.analyze_your_code + </h6> + <Button + onClick={[MockFunction]} + > + onboarding.project.create + </Button> + </div> + </div> + <div + className="modal-foot text-right" + > + <ResetButtonLink onClick={[MockFunction]} > - onboarding.project.create - </Button> + not_now + </ResetButtonLink> + </div> +</Modal> +`; + +exports[`should display organization list if any 1`] = ` +<Modal + contentLabel="onboarding.header" + medium={true} + onRequestClose={[MockFunction]} + shouldCloseOnOverlayClick={false} +> + <div + className="modal-head" + > + <h2> + onboarding.header + </h2> + <p + className="spacer-top" + > + onboarding.header.description + </p> + </div> + <div + className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom" + > + <div + className="flex-1" + > + <OnboardingProjectIcon + className="big-spacer-bottom" + /> + <h6 + className="onboarding-choice-name big-spacer-bottom" + > + onboarding.analyze_your_code + </h6> + <Button + onClick={[MockFunction]} + > + onboarding.project.create + </Button> + </div> + <div + className="vertical-pipe-separator" + > + <div + className="vertical-separator" + /> + </div> + <div + className="flex-1" + > + <OnboardingTeamIcon + className="big-spacer-bottom" + /> + <h6 + className="onboarding-choice-name big-spacer-bottom" + > + onboarding.browse_your_organizations + </h6> + <OrganizationsShortList + organizations={ + Array [ + Object { + "key": "a", + "name": "Arthur", + }, + Object { + "key": "d", + "name": "Daniel Inc", + }, + ] + } + skipOnboarding={[MockFunction]} + /> + </div> </div> <div className="modal-foot text-right" diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortList-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortList-test.tsx.snap new file mode 100644 index 00000000000..5410482f395 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortList-test.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should limit displayed orgs to the first three 1`] = ` +<div> + <ul + className="account-projects-list" + > + <li + key="bar" + > + <withRouter(OrganizationsShortListItem) + organization={ + Object { + "key": "bar", + "name": "Bar", + } + } + skipOnboarding={[MockFunction]} + /> + </li> + <li + key="foo" + > + <withRouter(OrganizationsShortListItem) + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } + skipOnboarding={[MockFunction]} + /> + </li> + <li + key="kor" + > + <withRouter(OrganizationsShortListItem) + organization={ + Object { + "key": "kor", + "name": "Kor", + } + } + skipOnboarding={[MockFunction]} + /> + </li> + </ul> + <div + className="big-spacer-top" + > + <span + className="big-spacer-right" + > + x_of_y_shown.3.4 + </span> + <Link + className="small" + onClick={[MockFunction]} + onlyActiveOnIndex={false} + style={Object {}} + to="/account/organizations" + > + see_all + </Link> + </div> +</div> +`; + +exports[`should render correctly 1`] = ` +<div> + <ul + className="account-projects-list" + > + <li + key="bar" + > + <withRouter(OrganizationsShortListItem) + organization={ + Object { + "key": "bar", + "name": "Bar", + } + } + skipOnboarding={[MockFunction]} + /> + </li> + <li + key="foo" + > + <withRouter(OrganizationsShortListItem) + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } + skipOnboarding={[MockFunction]} + /> + </li> + </ul> + <div + className="big-spacer-top" + > + <span + className="big-spacer-right" + > + x_of_y_shown.2.2 + </span> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortListItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortListItem-test.tsx.snap new file mode 100644 index 00000000000..ab088a9991b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortListItem-test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +<ListButton + className="abs-width-300" + onClick={[Function]} +> + <div + className="display-flex-center" + > + <OrganizationAvatar + className="spacer-right" + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } + /> + <span> + Foo + </span> + </div> +</ListButton> +`; diff --git a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx index 2fd85afbfbb..c9b44d0f7bd 100644 --- a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx +++ b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx @@ -23,7 +23,7 @@ import { withCurrentUser } from './withCurrentUser'; import { isLoggedIn } from '../../helpers/users'; import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthentication'; -export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { +export function whenLoggedIn<P>(WrappedComponent: React.ComponentType<P>) { class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> { static displayName = getWrappedDisplayName(WrappedComponent, 'whenLoggedIn'); diff --git a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx index 991005055e2..c016ce0ffa5 100644 --- a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx +++ b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx @@ -29,7 +29,7 @@ interface OwnProps { } export function withUserOrganizations<P>( - WrappedComponent: React.ComponentClass<P & Partial<OwnProps>> + WrappedComponent: React.ComponentType<P & Partial<OwnProps>> ) { class Wrapper extends React.Component<P & OwnProps> { static displayName = getWrappedDisplayName(WrappedComponent, 'withUserOrganizations'); diff --git a/server/sonar-web/src/main/js/components/icons-components/OnboardingTeamIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/OnboardingTeamIcon.tsx new file mode 100644 index 00000000000..ebedfeb2745 --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/OnboardingTeamIcon.tsx @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 React from 'react'; +import Icon, { IconProps } from './Icon'; +import * as theme from '../../app/theme'; + +export default function OnboardingTeamIcon({ className, fill = theme.darkBlue, size }: IconProps) { + return ( + <Icon className={className} size={size || 64} viewBox="0 0 64 64"> + <g fill="none" fillRule="evenodd" stroke={fill} strokeWidth="2"> + <path d="M32 9v5M11.5195 43.0898l7.48-4.091m33.481-18.0994l-7.48 4.1m-33.481-4.1l7.48 4.1M45 38.999l7.48 4.101M32 50v5m15-23c0 8.284-6.715 15-15 15s-15-6.716-15-15c0-8.285 6.715-15 15-15s15 6.715 15 15z" /> + <path d="M40 38c0 1.656-3.58 2-8 2s-8-.344-8-2m16 0v-3l-5-3-1-1m-10 7v-3l5-3 1-1m6-4c0 2.2-1.8 4-4 4s-4-1.8-4-4v-1c0-2.2 1.8-4 4-4s4 1.8 4 4v1zm-.0098-21.71c7.18 1.069 13.439 4.96 17.609 10.51m-17.609 42.91c7.18-1.07 13.439-4.96 17.609-10.51M6.6299 41.25c-1.06-2.88-1.63-6-1.63-9.25s.57-6.37 1.63-9.25m3.7705-6.9502c4.17-5.55 10.43-9.44 17.609-10.51m-17.609 42.9104c4.17 5.55 10.43 9.439 17.609 10.51M57.3701 22.75c1.06 2.88 1.63 6 1.63 9.25s-.57 6.37-1.63 9.25" /> + <path d="M36 5c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M12 19c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2m51 0c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M12 45c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2m51 0c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M36 59c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2" /> + </g> + </Icon> + ); +} diff --git a/server/sonar-web/src/main/js/components/ui/buttons.css b/server/sonar-web/src/main/js/components/ui/buttons.css index 05df1afe181..cede4ec4d10 100644 --- a/server/sonar-web/src/main/js/components/ui/buttons.css +++ b/server/sonar-web/src/main/js/components/ui/buttons.css @@ -79,6 +79,7 @@ .button-red:focus { box-shadow: 0 0 0 3px rgba(212, 51, 63, 0.25); } + /* #endregion */ /* #region .button-success */ @@ -96,6 +97,7 @@ .button-success:focus { box-shadow: 0 0 0 3px rgba(0, 170, 0, 0.25); } + /* #endregion */ /* #region .button-grey */ @@ -113,12 +115,14 @@ .button-grey:focus { box-shadow: 0 0 0 3px rgba(180, 180, 180, 0.25); } + /* #endregion */ /* #region .button-link */ .button-link { display: inline-flex; - height: auto; /* Keep this to not inherit the height from .button */ + height: auto; + /* Keep this to not inherit the height from .button */ line-height: 1; margin: 0; padding: 0; @@ -154,6 +158,7 @@ background: transparent !important; cursor: default; } + /* #endregion */ .button-small { @@ -217,6 +222,7 @@ margin: 0 8px; font-size: var(--smallFontSize); } + /* #endregion */ /* #region .button-icon */ @@ -261,4 +267,23 @@ .button-icon:focus svg { color: #fff; } + /* #endregion */ + +.button-list { + height: auto; + border: 1px solid var(--barBorderColor); + padding: var(--gridSize); + margin: calc(var(--gridSize) / 2); + text-align: left; + justify-content: space-between; + color: var(--secondFontColor); + font-weight: normal; +} + +.button-list:hover { + background-color: white; + border-color: var(--blue); + color: var(--darkBlue); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.175); +} diff --git a/server/sonar-web/src/main/js/components/ui/buttons.tsx b/server/sonar-web/src/main/js/components/ui/buttons.tsx index a0dd58c1455..99e4c327a62 100644 --- a/server/sonar-web/src/main/js/components/ui/buttons.tsx +++ b/server/sonar-web/src/main/js/components/ui/buttons.tsx @@ -24,6 +24,7 @@ import ClearIcon from '../icons-components/ClearIcon'; import EditIcon from '../icons-components/EditIcon'; import Tooltip from '../controls/Tooltip'; import './buttons.css'; +import ChevronRightIcon from '../icons-components/ChevronRightcon'; type AllowedButtonAttributes = Pick< React.ButtonHTMLAttributes<HTMLButtonElement>, @@ -138,3 +139,12 @@ export function EditButton(props: ActionButtonProps) { </ButtonIcon> ); } + +export function ListButton({ className, children, ...props }: ButtonProps) { + return ( + <Button className={classNames('button-list', className)} {...props}> + {children} + <ChevronRightIcon /> + </Button> + ); +} |