organizationContextContainer.tsx 11 KB

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