projectContext.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import {Component, createContext} from 'react';
  2. import styled from '@emotion/styled';
  3. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  4. import {setActiveProject} from 'sentry/actionCreators/projects';
  5. import type {Client} from 'sentry/api';
  6. import Alert from 'sentry/components/alert';
  7. import * as Layout from 'sentry/components/layouts/thirds';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import MissingProjectMembership from 'sentry/components/projects/missingProjectMembership';
  11. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  12. import {t} from 'sentry/locale';
  13. import MemberListStore from 'sentry/stores/memberListStore';
  14. import ProjectsStore from 'sentry/stores/projectsStore';
  15. import {space} from 'sentry/styles/space';
  16. import type {Organization} from 'sentry/types/organization';
  17. import type {Project} from 'sentry/types/project';
  18. import type {User} from 'sentry/types/user';
  19. import {
  20. addProjectFeaturesHandler,
  21. buildSentryFeaturesHandler,
  22. } from 'sentry/utils/featureFlags';
  23. import withApi from 'sentry/utils/withApi';
  24. import withOrganization from 'sentry/utils/withOrganization';
  25. import withProjects from 'sentry/utils/withProjects';
  26. enum ErrorTypes {
  27. MISSING_MEMBERSHIP = 'MISSING_MEMBERSHIP',
  28. PROJECT_NOT_FOUND = 'PROJECT_NOT_FOUND',
  29. UNKNOWN = 'UNKNOWN',
  30. }
  31. type ChildFuncProps = {
  32. project: Project;
  33. };
  34. type Props = {
  35. api: Client;
  36. children: ((props: ChildFuncProps) => React.ReactNode) | React.ReactNode;
  37. loadingProjects: boolean;
  38. organization: Organization;
  39. projectSlug: string;
  40. projects: Project[];
  41. /**
  42. * If true, this will not change `state.loading` during `fetchData` phase
  43. */
  44. skipReload?: boolean;
  45. };
  46. type State = {
  47. error: boolean;
  48. errorType: ErrorTypes | null;
  49. loading: boolean;
  50. memberList: User[];
  51. project: Project | null;
  52. };
  53. const ProjectContext = createContext<Project | null>(null);
  54. /**
  55. * Higher-order component that sets `project` as a child context
  56. * value to be accessed by child elements.
  57. *
  58. * Additionally delays rendering of children until project XHR has finished
  59. * and context is populated.
  60. */
  61. class ProjectContextProvider extends Component<Props, State> {
  62. state = this.getInitialState();
  63. getInitialState(): State {
  64. return {
  65. loading: true,
  66. error: false,
  67. errorType: null,
  68. memberList: [],
  69. project: null,
  70. };
  71. }
  72. componentDidMount() {
  73. // Wait for withProjects to fetch projects before making request
  74. // Once loaded we can fetchData in componentDidUpdate
  75. const {loadingProjects} = this.props;
  76. if (!loadingProjects) {
  77. this.fetchData();
  78. }
  79. }
  80. UNSAFE_componentWillReceiveProps(nextProps: Props) {
  81. if (nextProps.projectSlug === this.props.projectSlug) {
  82. return;
  83. }
  84. if (!nextProps.skipReload) {
  85. this.remountComponent();
  86. }
  87. }
  88. componentDidUpdate(prevProps: Props, _prevState: State) {
  89. if (prevProps.projectSlug !== this.props.projectSlug) {
  90. this.fetchData();
  91. }
  92. // Project list has changed. Likely indicating that a new project has been
  93. // added. Re-fetch project details in case that the new project is the active
  94. // project.
  95. //
  96. // For now, only compare lengths. It is possible that project slugs within
  97. // the list could change, but it doesn't seem to be broken anywhere else at
  98. // the moment that would require deeper checks.
  99. if (prevProps.projects.length !== this.props.projects.length) {
  100. this.fetchData();
  101. }
  102. }
  103. componentWillUnmount() {
  104. this.unsubscribeMembers();
  105. this.unsubscribeProjects();
  106. }
  107. unsubscribeProjects = ProjectsStore.listen(
  108. (projectIds: Set<string>) => this.onProjectChange(projectIds),
  109. undefined
  110. );
  111. unsubscribeMembers = MemberListStore.listen(
  112. ({members}: typeof MemberListStore.state) => this.setState({memberList: members}),
  113. undefined
  114. );
  115. remountComponent() {
  116. this.setState(this.getInitialState());
  117. }
  118. getTitle() {
  119. return this.state.project?.slug ?? 'Sentry';
  120. }
  121. onProjectChange(projectIds: Set<string>) {
  122. if (!this.state.project) {
  123. return;
  124. }
  125. if (!projectIds.has(this.state.project.id)) {
  126. return;
  127. }
  128. this.setState({
  129. project: {...ProjectsStore.getById(this.state.project.id)} as Project,
  130. });
  131. }
  132. identifyProject() {
  133. const {projects, projectSlug} = this.props;
  134. return projects.find(({slug}) => slug === projectSlug) || null;
  135. }
  136. async fetchData() {
  137. const {organization, projectSlug, skipReload} = this.props;
  138. // we fetch core access/information from the global organization data
  139. const activeProject = this.identifyProject();
  140. const hasAccess = activeProject?.hasAccess;
  141. this.setState((state: State) => ({
  142. // if `skipReload` is true, then don't change loading state
  143. loading: skipReload ? state.loading : true,
  144. // we bind project initially, but it'll rebind
  145. project: activeProject,
  146. }));
  147. if (activeProject && hasAccess) {
  148. setActiveProject(null);
  149. const projectRequest = this.props.api.requestPromise(
  150. `/projects/${organization.slug}/${projectSlug}/`
  151. );
  152. try {
  153. const project = await projectRequest;
  154. this.setState({
  155. loading: false,
  156. project,
  157. error: false,
  158. errorType: null,
  159. });
  160. // assuming here that this means the project is considered the active project
  161. setActiveProject(project);
  162. addProjectFeaturesHandler({
  163. project,
  164. handler: buildSentryFeaturesHandler('feature.projects:'),
  165. });
  166. } catch (error) {
  167. this.setState({
  168. loading: false,
  169. error: false,
  170. errorType: ErrorTypes.UNKNOWN,
  171. });
  172. }
  173. fetchOrgMembers(this.props.api, organization.slug, [activeProject.id]);
  174. return;
  175. }
  176. // User is not a memberof the active project
  177. if (activeProject && !activeProject.isMember) {
  178. this.setState({
  179. loading: false,
  180. error: true,
  181. errorType: ErrorTypes.MISSING_MEMBERSHIP,
  182. });
  183. return;
  184. }
  185. // There is no active project. This likely indicates either the project
  186. // *does not exist* or the project has not yet been added to the store.
  187. // Either way, make a request to check for existence of the project.
  188. try {
  189. await this.props.api.requestPromise(
  190. `/projects/${organization.slug}/${projectSlug}/`
  191. );
  192. } catch (error) {
  193. this.setState({
  194. loading: false,
  195. error: true,
  196. errorType: ErrorTypes.PROJECT_NOT_FOUND,
  197. });
  198. }
  199. }
  200. renderBody() {
  201. const {children, organization} = this.props;
  202. const {error, errorType, loading, project} = this.state;
  203. if (loading) {
  204. return (
  205. <div className="loading-full-layout">
  206. <LoadingIndicator />
  207. </div>
  208. );
  209. }
  210. if (!error && project) {
  211. return (
  212. <ProjectContext.Provider value={project}>
  213. {typeof children === 'function' ? children({project}) : children}
  214. </ProjectContext.Provider>
  215. );
  216. }
  217. switch (errorType) {
  218. case ErrorTypes.PROJECT_NOT_FOUND:
  219. // TODO(chrissy): use scale for margin values
  220. return (
  221. <Layout.Page withPadding>
  222. <Alert type="warning">
  223. {t('The project you were looking for was not found.')}
  224. </Alert>
  225. </Layout.Page>
  226. );
  227. case ErrorTypes.MISSING_MEMBERSHIP:
  228. // TODO(dcramer): add various controls to improve this flow and break it
  229. // out into a reusable missing access error component
  230. return (
  231. <ErrorWrapper>
  232. <MissingProjectMembership organization={organization} project={project} />
  233. </ErrorWrapper>
  234. );
  235. default:
  236. return <LoadingError onRetry={this.remountComponent} />;
  237. }
  238. }
  239. render() {
  240. return (
  241. <SentryDocumentTitle noSuffix title={this.getTitle()}>
  242. {this.renderBody()}
  243. </SentryDocumentTitle>
  244. );
  245. }
  246. }
  247. export {ProjectContext, ProjectContextProvider};
  248. export default withApi(withOrganization(withProjects(ProjectContextProvider)));
  249. const ErrorWrapper = styled('div')`
  250. width: 100%;
  251. margin: ${space(2)} ${space(4)};
  252. `;