organizationContextContainer.tsx 10.0 KB

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