projectContext.tsx 8.2 KB

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