projectContext.tsx 7.8 KB

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