projectContext.tsx 7.8 KB

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