projectContext.tsx 8.5 KB

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