organizationContext.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import * as React from 'react';
  2. import DocumentTitle from 'react-document-title';
  3. import {RouteComponentProps} from 'react-router';
  4. import {PlainRoute} from 'react-router/lib/Route';
  5. import styled from '@emotion/styled';
  6. import * as Sentry from '@sentry/react';
  7. import {openSudo} from 'app/actionCreators/modal';
  8. import {fetchOrganizationDetails} from 'app/actionCreators/organization';
  9. import ProjectActions from 'app/actions/projectActions';
  10. import {Client} from 'app/api';
  11. import Alert from 'app/components/alert';
  12. import LoadingError from 'app/components/loadingError';
  13. import LoadingIndicator from 'app/components/loadingIndicator';
  14. import Sidebar from 'app/components/sidebar';
  15. import {ORGANIZATION_FETCH_ERROR_TYPES} from 'app/constants';
  16. import {t} from 'app/locale';
  17. import SentryTypes from 'app/sentryTypes';
  18. import ConfigStore from 'app/stores/configStore';
  19. import HookStore from 'app/stores/hookStore';
  20. import OrganizationStore from 'app/stores/organizationStore';
  21. import space from 'app/styles/space';
  22. import {Organization} from 'app/types';
  23. import {metric} from 'app/utils/analytics';
  24. import {callIfFunction} from 'app/utils/callIfFunction';
  25. import getRouteStringFromRoutes from 'app/utils/getRouteStringFromRoutes';
  26. import RequestError from 'app/utils/requestError/requestError';
  27. import withApi from 'app/utils/withApi';
  28. import withOrganizations from 'app/utils/withOrganizations';
  29. const defaultProps = {
  30. detailed: true,
  31. };
  32. type Props = {
  33. api: Client;
  34. routes: PlainRoute[];
  35. includeSidebar: boolean;
  36. useLastOrganization: boolean;
  37. organizationsLoading: boolean;
  38. organizations: Organization[];
  39. detailed: boolean;
  40. } & typeof defaultProps &
  41. RouteComponentProps<{orgId: string}, {}>;
  42. type State = {
  43. organization: Organization | null;
  44. loading: boolean;
  45. dirty?: boolean;
  46. errorType?: string | null;
  47. error?: RequestError | null;
  48. hooks?: React.ReactNode[];
  49. prevProps: {
  50. orgId: string;
  51. organizationsLoading: boolean;
  52. location: RouteComponentProps<{orgId: string}, {}>['location'];
  53. };
  54. };
  55. class OrganizationContext extends React.Component<Props, State> {
  56. static getDerivedStateFromProps(props: Readonly<Props>, prevState: State): State {
  57. const {prevProps} = prevState;
  58. if (OrganizationContext.shouldRemount(prevProps, props)) {
  59. return OrganizationContext.getDefaultState(props);
  60. }
  61. const {organizationsLoading, location, params} = props;
  62. const {orgId} = params;
  63. return {
  64. ...prevState,
  65. prevProps: {
  66. orgId,
  67. organizationsLoading,
  68. location,
  69. },
  70. };
  71. }
  72. static shouldRemount(prevProps: State['prevProps'], props: Props): boolean {
  73. const hasOrgIdAndChanged =
  74. prevProps.orgId && props.params.orgId && prevProps.orgId !== props.params.orgId;
  75. const hasOrgId =
  76. props.params.orgId ||
  77. (props.useLastOrganization && ConfigStore.get('lastOrganization'));
  78. // protect against the case where we finish fetching org details
  79. // and then `OrganizationsStore` finishes loading:
  80. // only fetch in the case where we don't have an orgId
  81. //
  82. // Compare `getOrganizationSlug` because we may have a last used org from server
  83. // if there is no orgId in the URL
  84. const organizationLoadingChanged =
  85. prevProps.organizationsLoading !== props.organizationsLoading &&
  86. props.organizationsLoading === false;
  87. return (
  88. hasOrgIdAndChanged ||
  89. (!hasOrgId && organizationLoadingChanged) ||
  90. (props.location.state === 'refresh' && prevProps.location.state !== 'refresh')
  91. );
  92. }
  93. static getDefaultState(props: Props): State {
  94. const prevProps = {
  95. orgId: props.params.orgId,
  96. organizationsLoading: props.organizationsLoading,
  97. location: props.location,
  98. };
  99. if (OrganizationContext.isOrgStorePopulatedCorrectly(props)) {
  100. // retrieve initial state from store
  101. return {
  102. ...OrganizationStore.get(),
  103. prevProps,
  104. };
  105. }
  106. return {
  107. loading: true,
  108. error: null,
  109. errorType: null,
  110. organization: null,
  111. prevProps,
  112. };
  113. }
  114. static getOrganizationSlug(props: Props) {
  115. return (
  116. props.params.orgId ||
  117. ((props.useLastOrganization &&
  118. (ConfigStore.get('lastOrganization') ||
  119. props.organizations?.[0]?.slug)) as string)
  120. );
  121. }
  122. static isOrgChanging(props: Props): boolean {
  123. const {organization} = OrganizationStore.get();
  124. if (!organization) {
  125. return false;
  126. }
  127. return organization.slug !== OrganizationContext.getOrganizationSlug(props);
  128. }
  129. static isOrgStorePopulatedCorrectly(props: Props) {
  130. const {detailed} = props;
  131. const {organization, dirty} = OrganizationStore.get();
  132. return (
  133. !dirty &&
  134. organization &&
  135. !OrganizationContext.isOrgChanging(props) &&
  136. (!detailed || (detailed && organization.projects && organization.teams))
  137. );
  138. }
  139. static childContextTypes = {
  140. organization: SentryTypes.Organization,
  141. };
  142. static defaultProps = defaultProps;
  143. constructor(props: Props) {
  144. super(props);
  145. this.state = OrganizationContext.getDefaultState(props);
  146. }
  147. getChildContext() {
  148. return {
  149. organization: this.state.organization,
  150. };
  151. }
  152. componentDidMount() {
  153. this.fetchData(true);
  154. }
  155. componentDidUpdate(prevProps: Props) {
  156. const remountPrevProps: State['prevProps'] = {
  157. orgId: prevProps.params.orgId,
  158. organizationsLoading: prevProps.organizationsLoading,
  159. location: prevProps.location,
  160. };
  161. if (OrganizationContext.shouldRemount(remountPrevProps, this.props)) {
  162. this.remountComponent();
  163. }
  164. }
  165. componentWillUnmount() {
  166. this.unlisteners.forEach(callIfFunction);
  167. }
  168. unlisteners = [
  169. ProjectActions.createSuccess.listen(() => this.onProjectCreation(), undefined),
  170. OrganizationStore.listen(data => this.loadOrganization(data), undefined),
  171. ];
  172. remountComponent = () => {
  173. this.setState(OrganizationContext.getDefaultState(this.props), this.fetchData);
  174. };
  175. onProjectCreation() {
  176. // If a new project was created, we need to re-fetch the
  177. // org details endpoint, which will propagate re-rendering
  178. // for the entire component tree
  179. fetchOrganizationDetails(
  180. this.props.api,
  181. OrganizationContext.getOrganizationSlug(this.props),
  182. true,
  183. true
  184. );
  185. }
  186. isLoading() {
  187. // In the absence of an organization slug, the loading state should be
  188. // derived from this.props.organizationsLoading from OrganizationsStore
  189. if (!OrganizationContext.getOrganizationSlug(this.props)) {
  190. return this.props.organizationsLoading;
  191. }
  192. // The following loading logic exists because we could either be waiting for
  193. // the whole organization object to come in or just the teams and projects.
  194. const {loading, error, organization} = this.state;
  195. const {detailed} = this.props;
  196. return (
  197. loading ||
  198. (!error &&
  199. detailed &&
  200. (!organization || !organization.projects || !organization.teams))
  201. );
  202. }
  203. fetchData(isInitialFetch = false) {
  204. if (!OrganizationContext.getOrganizationSlug(this.props)) {
  205. return;
  206. }
  207. // fetch from the store, then fetch from the API if necessary
  208. if (OrganizationContext.isOrgStorePopulatedCorrectly(this.props)) {
  209. return;
  210. }
  211. metric.mark({name: 'organization-details-fetch-start'});
  212. fetchOrganizationDetails(
  213. this.props.api,
  214. OrganizationContext.getOrganizationSlug(this.props),
  215. this.props.detailed,
  216. !OrganizationContext.isOrgChanging(this.props), // if true, will preserve a lightweight org that was fetched,
  217. isInitialFetch
  218. );
  219. }
  220. loadOrganization(orgData: State) {
  221. const {organization, error} = orgData;
  222. const hooks: React.ReactNode[] = [];
  223. if (organization && !error) {
  224. HookStore.get('organization:header').forEach(cb => {
  225. hooks.push(cb(organization));
  226. });
  227. // Configure scope to have organization tag
  228. Sentry.configureScope(scope => {
  229. // XXX(dcramer): this is duplicated in sdk.py on the backend
  230. scope.setTag('organization', organization.id);
  231. scope.setTag('organization.slug', organization.slug);
  232. scope.setContext('organization', {id: organization.id, slug: organization.slug});
  233. });
  234. } else if (error) {
  235. // If user is superuser, open sudo window
  236. const user = ConfigStore.get('user');
  237. if (!user || !user.isSuperuser || error.status !== 403) {
  238. // This `catch` can swallow up errors in development (and tests)
  239. // So let's log them. This may create some noise, especially the test case where
  240. // we specifically test this branch
  241. console.error(error); // eslint-disable-line no-console
  242. } else {
  243. openSudo({
  244. retryRequest: () => Promise.resolve(this.fetchData()),
  245. });
  246. }
  247. }
  248. this.setState({...orgData, hooks}, () => {
  249. // Take a measurement for when organization details are done loading and the new state is applied
  250. if (organization) {
  251. metric.measure({
  252. name: 'app.component.perf',
  253. start: 'organization-details-fetch-start',
  254. data: {
  255. name: 'org-details',
  256. route: getRouteStringFromRoutes(this.props.routes),
  257. organization_id: parseInt(organization.id, 10),
  258. },
  259. });
  260. }
  261. });
  262. }
  263. getOrganizationDetailsEndpoint() {
  264. return `/organizations/${OrganizationContext.getOrganizationSlug(this.props)}/`;
  265. }
  266. getTitle() {
  267. if (this.state.organization) {
  268. return this.state.organization.name;
  269. }
  270. return 'Sentry';
  271. }
  272. renderSidebar(): React.ReactNode {
  273. if (!this.props.includeSidebar) {
  274. return null;
  275. }
  276. const {children: _, ...props} = this.props;
  277. return <Sidebar {...props} organization={this.state.organization as Organization} />;
  278. }
  279. renderError() {
  280. let errorComponent: React.ReactElement;
  281. switch (this.state.errorType) {
  282. case ORGANIZATION_FETCH_ERROR_TYPES.ORG_NO_ACCESS:
  283. // We can still render when an org can't be loaded due to 401. The
  284. // backend will handle redirects when this is a problem.
  285. return this.renderBody();
  286. case ORGANIZATION_FETCH_ERROR_TYPES.ORG_NOT_FOUND:
  287. errorComponent = (
  288. <Alert type="error">
  289. {t('The organization you were looking for was not found.')}
  290. </Alert>
  291. );
  292. break;
  293. default:
  294. errorComponent = <LoadingError onRetry={this.remountComponent} />;
  295. }
  296. return <ErrorWrapper>{errorComponent}</ErrorWrapper>;
  297. }
  298. renderBody() {
  299. return (
  300. <DocumentTitle title={this.getTitle()}>
  301. <div className="app">
  302. {this.state.hooks}
  303. {this.renderSidebar()}
  304. {this.props.children}
  305. </div>
  306. </DocumentTitle>
  307. );
  308. }
  309. render() {
  310. if (this.isLoading()) {
  311. return (
  312. <LoadingIndicator triangle>
  313. {t('Loading data for your organization.')}
  314. </LoadingIndicator>
  315. );
  316. }
  317. if (this.state.error) {
  318. return (
  319. <React.Fragment>
  320. {this.renderSidebar()}
  321. {this.renderError()}
  322. </React.Fragment>
  323. );
  324. }
  325. return this.renderBody();
  326. }
  327. }
  328. export default withApi(withOrganizations(Sentry.withProfiler(OrganizationContext)));
  329. export {OrganizationContext};
  330. const ErrorWrapper = styled('div')`
  331. padding: ${space(3)};
  332. `;