Browse Source

feat(hybrid-cloud): Adds region display to org settings, updates region selector (#62454)

Gabe Villalobos 1 year ago
parent
commit
05089b9372

+ 72 - 0
static/app/utils/regions/index.tsx

@@ -0,0 +1,72 @@
+import {t} from "sentry/locale";
+import ConfigStore from 'sentry/stores/configStore';
+import {Organization, Region} from 'sentry/types';
+
+const RegionDisplayName: Record<string, string> = {
+  US: t('United States of America (US)'),
+  DE: t('European Union (EU)'),
+};
+
+enum RegionFlagIndicator {
+  US = '๐Ÿ‡บ๐Ÿ‡ธ',
+  DE = '๐Ÿ‡ช๐Ÿ‡บ',
+}
+
+export interface RegionData {
+  displayName: string;
+  name: string;
+  url: string;
+  flag?: RegionFlagIndicator;
+}
+
+export function getRegionDisplayName(region: Region): string {
+  return RegionDisplayName[region.name.toUpperCase()] ?? region.name;
+}
+
+export function getRegionFlagIndicator(region: Region): RegionFlagIndicator | undefined {
+  const regionName = region.name.toUpperCase();
+  return RegionFlagIndicator[regionName];
+}
+
+export function getRegionDataFromOrganization(
+  organization: Organization
+): RegionData | undefined {
+  const {regionUrl} = organization.links;
+
+  const regions = ConfigStore.get('regions') ?? [];
+
+  const region = regions.find(value => {
+    return value.url === regionUrl;
+  });
+
+  if (!region) {
+    return undefined;
+  }
+
+  return {
+    flag: getRegionFlagIndicator(region),
+    displayName: getRegionDisplayName(region),
+    name: region.name,
+    url: region.url,
+  };
+}
+
+export function getRegionChoices(): [string, string][] {
+  const regions = ConfigStore.get('regions') ?? [];
+
+  return regions.map(region => {
+    const {url} = region;
+    return [
+      url,
+      `${getRegionFlagIndicator(region) || ''} ${getRegionDisplayName(region)}`,
+    ];
+  });
+}
+
+export function shouldDisplayRegions(): boolean {
+  const regionCount = (ConfigStore.get('regions') ?? []).length;
+  return (
+    ConfigStore.get('features').has('organizations:multi-region-selector') &&
+    regionCount > 1
+  );
+}

+ 15 - 6
static/app/views/organizationCreate/index.spec.tsx

@@ -132,7 +132,7 @@ describe('OrganizationCreate', function () {
     ConfigStore.set('regions', [{name: '--monolith--', url: 'https://example.com'}]);
 
     render(<OrganizationCreate />);
-    expect(screen.queryByLabelText('Data Storage')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Data Storage Location')).not.toBeInTheDocument();
     await userEvent.type(screen.getByPlaceholderText('e.g. My Company'), 'Good Burger');
     await userEvent.click(screen.getByText('Create Organization'));
 
@@ -152,13 +152,22 @@ describe('OrganizationCreate', function () {
     );
   });
 
-  it('renders with region data and selects US region as default when the feature flag is enabled', async function () {
+  it('renders without a pre-selected region, and does not submit until one is selected', async function () {
     const orgCreateMock = multiRegionSetup();
     render(<OrganizationCreate />);
-    expect(screen.getByLabelText('Data Storage')).toBeInTheDocument();
+    expect(screen.getByLabelText('Data Storage Location')).toBeInTheDocument();
     await userEvent.type(screen.getByPlaceholderText('e.g. My Company'), 'Good Burger');
     await userEvent.click(screen.getByText('Create Organization'));
 
+    expect(orgCreateMock).not.toHaveBeenCalled();
+    expect(window.location.assign).not.toHaveBeenCalled();
+
+    await selectEvent.select(
+      screen.getByRole('textbox', {name: 'Data Storage Location'}),
+      '๐Ÿ‡บ๐Ÿ‡ธ United States of America (US)'
+    );
+    await userEvent.click(screen.getByText('Create Organization'));
+
     const expectedHost = 'https://us.example.com';
     await waitFor(() => {
       expect(orgCreateMock).toHaveBeenCalledWith('/organizations/', {
@@ -180,7 +189,7 @@ describe('OrganizationCreate', function () {
     const orgCreateMock = multiRegionSetup();
     ConfigStore.set('features', new Set());
     render(<OrganizationCreate />);
-    expect(screen.queryByLabelText('Data Storage')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Data Storage Location')).not.toBeInTheDocument();
     await userEvent.type(screen.getByPlaceholderText('e.g. My Company'), 'Good Burger');
     await userEvent.click(screen.getByText('Create Organization'));
 
@@ -203,10 +212,10 @@ describe('OrganizationCreate', function () {
   it('uses the host of the selected region when submitting', async function () {
     const orgCreateMock = multiRegionSetup();
     render(<OrganizationCreate />);
-    expect(screen.getByLabelText('Data Storage')).toBeInTheDocument();
+    expect(screen.getByLabelText('Data Storage Location')).toBeInTheDocument();
     await userEvent.type(screen.getByPlaceholderText('e.g. My Company'), 'Good Burger');
     await selectEvent.select(
-      screen.getByRole('textbox', {name: 'Data Storage'}),
+      screen.getByRole('textbox', {name: 'Data Storage Location'}),
       '๐Ÿ‡ช๐Ÿ‡บ European Union (EU)'
     );
     await userEvent.click(screen.getByText('Create Organization'));

+ 39 - 63
static/app/views/organizationCreate/index.tsx

@@ -1,75 +1,54 @@
-import {useState} from 'react';
+import {useCallback} from 'react';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import ApiForm from 'sentry/components/forms/apiForm';
 import CheckboxField from 'sentry/components/forms/fields/checkboxField';
 import SelectField from 'sentry/components/forms/fields/selectField';
 import TextField from 'sentry/components/forms/fields/textField';
+import Form from 'sentry/components/forms/form';
+import {OnSubmitCallback} from "sentry/components/forms/types";
 import NarrowLayout from 'sentry/components/narrowLayout';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t, tct} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import {OrganizationSummary} from 'sentry/types';
+import {
+  getRegionChoices,
+  shouldDisplayRegions,
+} from 'sentry/utils/regions';
+import useApi from 'sentry/utils/useApi';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 
-enum RegionDisplayName {
-  US = '๐Ÿ‡บ๐Ÿ‡ธ United States of America (US)',
-  DE = '๐Ÿ‡ช๐Ÿ‡บ European Union (EU)',
-}
-
-function getRegionChoices(): [string, string][] {
-  const regions = ConfigStore.get('regions') ?? [];
-
-  return regions.map(({name, url}) => {
-    const regionName = name.toUpperCase();
-    if (RegionDisplayName[regionName]) {
-      return [url, RegionDisplayName[regionName]];
-    }
-
-    return [url, name];
-  });
-}
-
-function getDefaultRegionChoice(
-  regionChoices: [string, string][]
-): [string, string] | undefined {
-  if (!shouldDisplayRegions()) {
-    return undefined;
-  }
-
-  const usRegion = regionChoices.find(
-    ([_, regionName]) => regionName === RegionDisplayName.US
-  );
-
-  if (usRegion) {
-    return usRegion;
-  }
-
-  return regionChoices[0];
-}
-
-function shouldDisplayRegions(): boolean {
-  const regionCount = (ConfigStore.get('regions') ?? []).length;
-  return (
-    ConfigStore.get('features').has('organizations:multi-region-selector') &&
-    regionCount > 1
-  );
-}
-
-function removeRegionFromRequestForm(formData: Record<string, any>) {
-  const shallowFormDataCopy = {...formData};
-
-  delete shallowFormDataCopy.region;
-  return shallowFormDataCopy;
+function removeDataStorageLocationFromFormData(
+  formData: Record<string, any>
+): Record<string, any> {
+  const shallowFormDataClone = {...formData};
+  delete shallowFormDataClone.dataStorageLocation;
+  return shallowFormDataClone;
 }
 
 function OrganizationCreate() {
   const termsUrl = ConfigStore.get('termsUrl');
   const privacyUrl = ConfigStore.get('privacyUrl');
   const regionChoices = getRegionChoices();
-  const [regionUrl, setRegion] = useState<string | undefined>(
-    getDefaultRegionChoice(regionChoices)?.[0]
-  );
+  const client = useApi();
+
+  // This is a trimmed down version of the logic in ApiForm. It validates the
+  // form data prior to submitting the request, and overrides the request host
+  // with the selected region's URL if one is provided.
+  const submitOrganizationCreate: OnSubmitCallback = useCallback((data, onSubmitSuccess, onSubmitError, _event, formModel) => {
+    if (!formModel.validateForm()) {
+      return;
+    }
+    const regionUrl = data.dataStorageLocation;
+
+    client.request("/organizations/", {
+      method:"POST",
+      data: removeDataStorageLocationFromFormData(data),
+      host: regionUrl,
+      success: onSubmitSuccess,
+      error: onSubmitError
+    });
+  }, [client]);
 
   return (
     <SentryDocumentTitle title={t('Create Organization')}>
@@ -81,13 +60,12 @@ function OrganizationCreate() {
           )}
         </p>
 
-        <ApiForm
+        <Form
           initialData={{defaultTeam: true}}
           submitLabel={t('Create Organization')}
           apiEndpoint="/organizations/"
           apiMethod="POST"
-          hostOverride={regionUrl}
-          onSubmit={removeRegionFromRequestForm}
+          onSubmit={submitOrganizationCreate}
           onSubmitSuccess={(createdOrg: OrganizationSummary) => {
             const hasCustomerDomain = createdOrg?.features.includes('customer-domains');
             let nextUrl = normalizeUrl(
@@ -118,14 +96,12 @@ function OrganizationCreate() {
             stacked
             required
           />
-          {shouldDisplayRegions() && (
+          {shouldDisplayRegions() &&  (
             <SelectField
-              name="region"
-              label="Data Storage"
+              name="dataStorageLocation"
+              label="Data Storage Location"
               help="Where will this organization reside?"
-              defaultValue={getDefaultRegionChoice(regionChoices)?.[0]}
               choices={regionChoices}
-              onChange={setRegion}
               inline={false}
               stacked
               required
@@ -146,7 +122,7 @@ function OrganizationCreate() {
               required
             />
           )}
-        </ApiForm>
+        </Form>
       </NarrowLayout>
     </SentryDocumentTitle>
   );

+ 9 - 1
static/app/views/settings/organizationGeneralSettings/index.tsx

@@ -24,6 +24,7 @@ import withProjects from 'sentry/utils/withProjects';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
+import {OrganizationRegionAction} from 'sentry/views/settings/organizationGeneralSettings/organizationRegionAction';
 
 import OrganizationSettingsForm from './organizationSettingsForm';
 
@@ -103,11 +104,18 @@ function OrganizationGeneralSettings(props: Props) {
     });
   };
 
+  const organizationRegionInfo = OrganizationRegionAction({
+    organization,
+  });
+
   return (
     <Fragment>
       <SentryDocumentTitle title={t('General Settings')} orgSlug={organization.slug} />
       <div>
-        <SettingsPageHeader title={t('Organization Settings')} />
+        <SettingsPageHeader
+          title={t('Organization Settings')}
+          action={organizationRegionInfo}
+        />
         <PermissionAlert />
 
         <OrganizationSettingsForm initialData={organization} onSave={handleSaveForm} />

+ 44 - 0
static/app/views/settings/organizationGeneralSettings/organizationRegionAction.tsx

@@ -0,0 +1,44 @@
+import styled from '@emotion/styled';
+
+import FieldHelp from 'sentry/components/forms/fieldGroup/fieldHelp';
+import {t} from 'sentry/locale';
+import {space} from "sentry/styles/space";
+import {Organization} from 'sentry/types';
+import {getRegionDataFromOrganization, shouldDisplayRegions} from 'sentry/utils/regions';
+
+type Props = {
+  organization?: Organization;
+};
+
+const OrganizationRegionInformationWrapper = styled('div')`
+  margin-top: ${space(2)};
+  text-align: end;
+`;
+
+const OrganizationFlag = styled('span')`
+  font-size: ${p=> p.theme.fontSizeLarge};
+`;
+
+export function OrganizationRegionAction({organization, ...props}: Props) {
+  if (!organization || !shouldDisplayRegions()) {
+    return null;
+  }
+
+  const regionData = getRegionDataFromOrganization(organization);
+
+  if (!regionData) {
+    return null;
+  }
+  return (
+    <OrganizationRegionInformationWrapper {...props}>
+      <div>
+        {`${regionData.displayName} `}
+        <OrganizationFlag>{regionData.flag}</OrganizationFlag>
+      </div>
+      <FieldHelp>
+        {t("Your organization's data storage location. ")}
+        <a href="https://docs.sentry.io/product/accounts/choose-your-data-center">{t('Learn More')}</a>
+      </FieldHelp>
+    </OrganizationRegionInformationWrapper>
+  );
+}