organizationContextContainer.tsx 10 KB

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