import React from 'react'; import ReactDOM from 'react-dom'; import {components, StylesConfig} from 'react-select'; import styled from '@emotion/styled'; import createReactClass from 'create-react-class'; import Reflux from 'reflux'; import {ModalRenderProps} from 'app/actionCreators/modal'; import SelectControl from 'app/components/forms/selectControl'; import IdBadge from 'app/components/idBadge'; import Link from 'app/components/links/link'; import LoadingIndicator from 'app/components/loadingIndicator'; import {t, tct} from 'app/locale'; import OrganizationsStore from 'app/stores/organizationsStore'; import OrganizationStore from 'app/stores/organizationStore'; import space from 'app/styles/space'; import {Organization, Project} from 'app/types'; import Projects from 'app/utils/projects'; import replaceRouterParams from 'app/utils/replaceRouterParams'; type Props = ModalRenderProps & { /** * The destination route */ nextPath: string; /** * List of available organizations */ organizations: Organization[]; /** * Does modal need to prompt for organization. * TODO(billy): This can be derived from `nextPath` */ needOrg: boolean; /** * Does modal need to prompt for project */ needProject: boolean; /** * Organization slug */ organization: string; projects: Project[]; loading: boolean; /** * Finish callback */ onFinish: (path: string) => void; /** * Callback for when organization is selected */ onSelectOrganization: (orgSlug: string) => void; /** * Id of the project (most likely from the URL) * on which the modal was opened */ comingFromProjectId?: string; }; const selectStyles = { menu: (provided: StylesConfig) => ({ ...provided, position: 'auto', boxShadow: 'none', marginBottom: 0, }), }; class ContextPickerModal extends React.Component { componentDidMount() { const {organization, projects, organizations} = this.props; // Don't make any assumptions if there are multiple organizations if (organizations.length !== 1) { return; } // If there is an org in context (and there's only 1 org available), // attempt to see if we need more info from user and redirect otherwise if (organization) { // This will handle if we can intelligently move the user forward this.navigateIfFinish([{slug: organization}], projects); return; } } componentDidUpdate(prevProps: Props) { // Component may be mounted before projects is fetched, check if we can finish when // component is updated with projects if (JSON.stringify(prevProps.projects) !== JSON.stringify(this.props.projects)) { this.navigateIfFinish(this.props.organizations, this.props.projects); } } // TODO(ts) The various generics in react-select types make getting this // right hard. orgSelect: any | null = null; projectSelect: any | null = null; // Performs checks to see if we need to prompt user // i.e. When there is only 1 org and no project is needed or // there is only 1 org and only 1 project (which should be rare) navigateIfFinish = ( organizations: Array<{slug: string}>, projects: Array<{slug: string}>, latestOrg: string = this.props.organization ) => { const {needProject, onFinish, nextPath} = this.props; // If no project is needed and theres only 1 org OR // if we need a project and there's only 1 project // then return because we can't navigate anywhere yet if ( (!needProject && organizations.length !== 1) || (needProject && projects.length !== 1) ) { return; } // If there is only one org and we dont need a project slug, then call finish callback if (!needProject) { onFinish( replaceRouterParams(nextPath, { orgId: organizations[0].slug, }) ); return; } // Use latest org or if only 1 org, use that let org = latestOrg; if (!org && organizations.length === 1) { org = organizations[0].slug; } onFinish( replaceRouterParams(nextPath, { orgId: org, projectId: projects[0].slug, project: this.props.projects.find(p => p.slug === projects[0].slug)?.id, }) ); }; doFocus = (ref: any | null) => { if (!ref || this.props.loading) { return; } // eslint-disable-next-line react/no-find-dom-node const el = ReactDOM.findDOMNode(ref) as HTMLElement; if (el !== null) { const input = el.querySelector('input'); input && input.focus(); } }; focusProjectSelector = () => { this.doFocus(this.projectSelect); }; focusOrganizationSelector = () => { this.doFocus(this.orgSelect); }; handleSelectOrganization = ({value}: {value: string}) => { // If we do not need to select a project, we can early return after selecting an org // No need to fetch org details if (!this.props.needProject) { this.navigateIfFinish([{slug: value}], []); return; } this.props.onSelectOrganization(value); }; handleSelectProject = ({value}: {value: string}) => { const {organization} = this.props; if (!value || !organization) { return; } this.navigateIfFinish([{slug: organization}], [{slug: value}]); }; onProjectMenuOpen = () => { const {projects, comingFromProjectId} = this.props; // Hacky way to pre-focus to an item with newer versions of react select // See https://github.com/JedWatson/react-select/issues/3648 setTimeout(() => { const ref = this.projectSelect; if (ref) { const projectChoices = ref.select.state.menuOptions.focusable; const projectToBeFocused = projects.find(({id}) => id === comingFromProjectId); const selectedIndex = projectChoices.findIndex( option => option.value === projectToBeFocused?.slug ); if (selectedIndex >= 0 && projectToBeFocused) { // Focusing selected option only if it exists ref.select.scrollToFocusedOptionOnUpdate = true; ref.select.inputIsHiddenAfterUpdate = false; ref.select.setState({ focusedValue: null, focusedOption: projectChoices[selectedIndex], }); } } }); }; //TODO(TS): Fix typings customOptionProject = ({label, ...props}: any) => { const project = this.props.projects.find(({slug}) => props.value === slug); if (!project) { return null; } return ( ); }; get headerText() { const {needOrg, needProject} = this.props; if (needOrg && needProject) { return t('Select an organization and a project to continue'); } if (needOrg) { return t('Select an organization to continue'); } if (needProject) { return t('Select a project to continue'); } //if neither project nor org needs to be selected, nothing will render anyways return ''; } renderProjectSelectOrMessage() { const {organization, projects} = this.props; // only show projects the user is a part of const memberProjects = projects.filter(project => project.isMember); const projectOptions = memberProjects.map(({slug}) => ({label: slug, value: slug})); if (!projects.length) { return (
{tct('You have no projects. Click [link] to make one.', { link: ( {t('here')} ), })}
); } return ( { this.projectSelect = ref; this.focusProjectSelector(); }} placeholder={t('Select a Project to continue')} name="project" options={projectOptions} onChange={this.handleSelectProject} onMenuOpen={this.onProjectMenuOpen} components={{Option: this.customOptionProject, DropdownIndicator: null}} styles={selectStyles} menuIsOpen /> ); } render() { const { needOrg, needProject, organization, organizations, loading, Header, Body, } = this.props; const shouldShowPicker = needOrg || needProject; if (!shouldShowPicker) { return null; } const shouldShowProjectSelector = organization && needProject && !loading; const orgChoices = organizations .filter(({status}) => status.id !== 'pending_deletion') .map(({slug}) => ({label: slug, value: slug})); return (
{this.headerText}
{loading && } {needOrg && ( { this.orgSelect = ref; if (shouldShowProjectSelector) { return; } this.focusOrganizationSelector(); }} placeholder={t('Select an Organization')} name="organization" options={orgChoices} value={organization} onChange={this.handleSelectOrganization} components={{DropdownIndicator: null}} styles={selectStyles} menuIsOpen /> )} {shouldShowProjectSelector && this.renderProjectSelectOrMessage()}
); } } type ContainerProps = Omit< Props, 'projects' | 'loading' | 'organizations' | 'organization' | 'onSelectOrganization' > & { /** * List of slugs we want to be able to choose from */ projectSlugs?: string[]; }; type ContainerState = { selectedOrganization?: string; organizations?: Organization[]; }; const ContextPickerModalContainer = createReactClass({ displayName: 'ContextPickerModalContainer', mixins: [Reflux.connect(OrganizationsStore, 'organizations') as any], getInitialState() { const storeState = OrganizationStore.get(); return { selectedOrganization: storeState.organization?.slug, }; }, handleSelectOrganization(organizationSlug: string) { this.setState({selectedOrganization: organizationSlug}); }, renderModal({projects, initiallyLoaded}) { return ( ); }, render() { const {projectSlugs} = this.props; // eslint-disable-line react/prop-types if (this.state.selectedOrganization) { return ( {renderProps => this.renderModal(renderProps)} ); } return this.renderModal({}); }, }); export default ContextPickerModalContainer; const StyledSelectControl = styled(SelectControl)` margin-top: ${space(1)}; `; const StyledLoadingIndicator = styled(LoadingIndicator)` z-index: 1; `;