Просмотр исходного кода

feat(loader): Add new loader settings page (#52512)


---------

Co-authored-by: Daniel Griesser <daniel.griesser.86@gmail.com>
Co-authored-by: Priscila Oliveira <priscila.oliveira@sentry.io>
Francesco Novy 1 год назад
Родитель
Сommit
e719c10373

+ 5 - 0
static/app/routes.tsx

@@ -588,6 +588,11 @@ function buildRoutes() {
           )}
         />
       </Route>
+      <Route
+        path="loader-script/"
+        name={t('Loader Script')}
+        component={make(() => import('sentry/views/settings/project/loaderScript'))}
+      />
       <Route
         path="user-feedback/"
         name={t('User Feedback')}

+ 341 - 0
static/app/views/settings/project/loaderScript.spec.tsx

@@ -0,0 +1,341 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  screen,
+  userEvent,
+  waitForElementToBeRemoved,
+} from 'sentry-test/reactTestingLibrary';
+
+import {t} from 'sentry/locale';
+import {Organization, Project, ProjectKey} from 'sentry/types';
+import LoaderScript from 'sentry/views/settings/project/loaderScript';
+
+function mockApi({
+  organization,
+  project,
+  projectKeys,
+}: {
+  organization: Organization;
+  project: Project;
+  projectKeys: ProjectKey[];
+}) {
+  MockApiClient.clearMockResponses();
+  MockApiClient.addMockResponse({
+    url: `/projects/${organization.slug}/${project.slug}/keys/`,
+    method: 'GET',
+    body: projectKeys,
+  });
+}
+
+describe('LoaderScript', function () {
+  it('renders error', async function () {
+    const {organization, project} = initializeOrg();
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/keys/`,
+      method: 'GET',
+      statusCode: 400,
+    });
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(screen.getByTestId('loading-error')).toHaveTextContent(
+      'Failed to load project keys.'
+    );
+  });
+
+  it('renders empty', async function () {
+    const {organization, project} = initializeOrg();
+
+    mockApi({organization, project, projectKeys: []});
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(
+      screen.getByText('There are no keys active for this project.')
+    ).toBeInTheDocument();
+  });
+
+  it('renders for single project', async function () {
+    const {organization, project} = initializeOrg();
+    const projectKey = TestStubs.ProjectKeys()[0];
+    const projectKeys = [projectKey];
+
+    mockApi({organization, project, projectKeys});
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    // Loader Script is rendered
+    expect(screen.getByText(`Client Key: ${projectKey.name}`)).toBeInTheDocument();
+    const loaderScript = screen.getByRole('textbox', {
+      name: 'Loader Script',
+    }) as HTMLInputElement;
+    const loaderScriptValue = loaderScript.value;
+    expect(loaderScriptValue).toEqual(expect.stringContaining(projectKeys[0].dsn.cdn));
+  });
+
+  it('renders multiple keys', async function () {
+    const {organization, project} = initializeOrg();
+    const projectKeys = TestStubs.ProjectKeys([
+      {
+        dsn: {
+          secret:
+            'http://188ee45a58094d939428d8585aa6f662:a33bf9aba64c4bbdaf873bb9023b6d2c@dev.getsentry.net:8000/1',
+          minidump:
+            'http://dev.getsentry.net:8000/api/1/minidump?sentry_key=188ee45a58094d939428d8585aa6f662',
+          public: 'http://188ee45a58094d939428d8585aa6f662@dev.getsentry.net:8000/1',
+          csp: 'http://dev.getsentry.net:8000/api/1/csp-report/?sentry_key=188ee45a58094d939428d8585aa6f662',
+          security:
+            'http://dev.getsentry.net:8000/api/1/security-report/?sentry_key=188ee45a58094d939428d8585aa6f662',
+        },
+        public: '188ee45a58094d939428d8585aa6f662',
+        secret: 'a33bf9aba64c4bbdaf873bb9023b6d2c',
+        name: 'Key 2',
+        rateLimit: null,
+        projectId: 1,
+        dateCreated: '2018-02-28T07:13:51.087Z',
+        id: '188ee45a58094d939428d8585aa6f662',
+        isActive: true,
+        label: 'Key 2',
+        browserSdkVersion: 'latest',
+        browserSdk: {
+          choices: [
+            ['latest', 'latest'],
+            ['7.x', '7.x'],
+            ['6.x', '6.x'],
+            ['5.x', '5.x'],
+            ['4.x', '4.x'],
+          ],
+        },
+        dynamicSdkLoaderOptions: {
+          hasPerformance: false,
+          hasReplay: false,
+          hasDebug: false,
+        },
+      },
+    ]);
+
+    mockApi({organization, project, projectKeys});
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(screen.getByText(`Client Key: ${projectKeys[0].name}`)).toBeInTheDocument();
+    expect(screen.getByText(`Client Key: ${projectKeys[1].name}`)).toBeInTheDocument();
+
+    const allLoaderScripts = screen.getAllByRole('textbox', {
+      name: 'Loader Script',
+    }) as HTMLInputElement[];
+
+    expect(allLoaderScripts).toHaveLength(2);
+  });
+
+  it('allows to update key settings', async function () {
+    const {organization, project} = initializeOrg();
+    const baseKey = TestStubs.ProjectKeys()[0];
+    const projectKey = {
+      ...baseKey,
+      dynamicSdkLoaderOptions: {
+        ...baseKey.dynamicSdkLoaderOptions,
+        hasReplay: true,
+      },
+    };
+
+    mockApi({organization, project, projectKeys: [projectKey]});
+
+    const mockPut = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/keys/${projectKey.id}/`,
+      method: 'PUT',
+      body: {
+        ...projectKey,
+        dynamicSdkLoaderOptions: {
+          ...projectKey.dynamicSdkLoaderOptions,
+          hasPerformance: true,
+        },
+      },
+    });
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(screen.getByText(t('Enable Performance Monitoring'))).toBeInTheDocument();
+    expect(screen.getByText(t('Enable Session Replay'))).toBeInTheDocument();
+    expect(screen.getByText(t('Enable Debug Bundles & Logging'))).toBeInTheDocument();
+
+    let performanceCheckbox = screen.getByRole('checkbox', {
+      name: t('Enable Performance Monitoring'),
+    });
+    expect(performanceCheckbox).toBeEnabled();
+    expect(performanceCheckbox).not.toBeChecked();
+
+    const replayCheckbox = screen.getByRole('checkbox', {
+      name: t('Enable Session Replay'),
+    });
+    expect(replayCheckbox).toBeEnabled();
+    expect(replayCheckbox).toBeChecked();
+
+    const debugCheckbox = screen.getByRole('checkbox', {
+      name: t('Enable Debug Bundles & Logging'),
+    });
+    expect(debugCheckbox).toBeEnabled();
+    expect(debugCheckbox).not.toBeChecked();
+
+    // Toggle performance option
+    await userEvent.click(
+      screen.getByRole('checkbox', {
+        name: t('Enable Performance Monitoring'),
+      })
+    );
+
+    performanceCheckbox = await screen.findByRole('checkbox', {
+      name: t('Enable Performance Monitoring'),
+      checked: true,
+    });
+    expect(performanceCheckbox).toBeEnabled();
+    expect(performanceCheckbox).toBeChecked();
+
+    expect(mockPut).toHaveBeenCalledWith(
+      `/projects/${organization.slug}/${project.slug}/keys/${projectKey.id}/`,
+      expect.objectContaining({
+        data: expect.objectContaining({
+          dynamicSdkLoaderOptions: {
+            ...projectKey.dynamicSdkLoaderOptions,
+            hasPerformance: true,
+          },
+        }),
+      })
+    );
+  });
+
+  it('allows to update one of multiple keys', async function () {
+    const {organization, project} = initializeOrg();
+    const projectKeys = TestStubs.ProjectKeys([
+      {
+        dsn: {
+          secret:
+            'http://188ee45a58094d939428d8585aa6f662:a33bf9aba64c4bbdaf873bb9023b6d2c@dev.getsentry.net:8000/1',
+          minidump:
+            'http://dev.getsentry.net:8000/api/1/minidump?sentry_key=188ee45a58094d939428d8585aa6f662',
+          public: 'http://188ee45a58094d939428d8585aa6f662@dev.getsentry.net:8000/1',
+          csp: 'http://dev.getsentry.net:8000/api/1/csp-report/?sentry_key=188ee45a58094d939428d8585aa6f662',
+          security:
+            'http://dev.getsentry.net:8000/api/1/security-report/?sentry_key=188ee45a58094d939428d8585aa6f662',
+        },
+        public: '188ee45a58094d939428d8585aa6f662',
+        secret: 'a33bf9aba64c4bbdaf873bb9023b6d2c',
+        name: 'Key 2',
+        rateLimit: null,
+        projectId: 1,
+        dateCreated: '2018-02-28T07:13:51.087Z',
+        id: '188ee45a58094d939428d8585aa6f662',
+        isActive: true,
+        label: 'Key 2',
+        browserSdkVersion: 'latest',
+        browserSdk: {
+          choices: [
+            ['latest', 'latest'],
+            ['7.x', '7.x'],
+            ['6.x', '6.x'],
+            ['5.x', '5.x'],
+            ['4.x', '4.x'],
+          ],
+        },
+        dynamicSdkLoaderOptions: {
+          hasPerformance: false,
+          hasReplay: false,
+          hasDebug: false,
+        },
+      },
+    ]);
+    const projectKey = projectKeys[1];
+
+    mockApi({organization, project, projectKeys});
+    const mockPut = MockApiClient.addMockResponse({
+      url: `/projects/${organization.slug}/${project.slug}/keys/${projectKey.id}/`,
+      method: 'PUT',
+      body: {
+        ...projectKey,
+        dynamicSdkLoaderOptions: {
+          ...projectKey.dynamicSdkLoaderOptions,
+          hasPerformance: true,
+        },
+      },
+    });
+
+    render(<LoaderScript project={project} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Performance Monitoring'),
+        checked: false,
+      })
+    ).toHaveLength(2);
+    expect(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Session Replay'),
+        checked: false,
+      })
+    ).toHaveLength(2);
+    expect(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Debug Bundles & Logging'),
+        checked: false,
+      })
+    ).toHaveLength(2);
+
+    // Toggle performance option
+    await userEvent.click(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Performance Monitoring'),
+      })[1]
+    );
+
+    expect(
+      await screen.findByRole('checkbox', {
+        name: t('Enable Performance Monitoring'),
+        checked: true,
+      })
+    ).toBeInTheDocument();
+
+    expect(
+      screen.getByRole('checkbox', {
+        name: t('Enable Performance Monitoring'),
+        checked: false,
+      })
+    ).toBeInTheDocument();
+    expect(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Session Replay'),
+        checked: false,
+      })
+    ).toHaveLength(2);
+    expect(
+      screen.getAllByRole('checkbox', {
+        name: t('Enable Debug Bundles & Logging'),
+        checked: false,
+      })
+    ).toHaveLength(2);
+
+    expect(mockPut).toHaveBeenCalledWith(
+      `/projects/${organization.slug}/${project.slug}/keys/${projectKey.id}/`,
+      expect.objectContaining({
+        data: expect.objectContaining({
+          dynamicSdkLoaderOptions: {
+            ...projectKey.dynamicSdkLoaderOptions,
+            hasPerformance: true,
+          },
+        }),
+      })
+    );
+  });
+});

+ 140 - 0
static/app/views/settings/project/loaderScript.tsx

@@ -0,0 +1,140 @@
+import {Fragment, useCallback, useState} from 'react';
+
+import {LinkButton} from 'sentry/components/button';
+import EmptyMessage from 'sentry/components/emptyMessage';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Link from 'sentry/components/links/link';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import Panel from 'sentry/components/panels/panel';
+import PanelAlert from 'sentry/components/panels/panelAlert';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import {t, tct} from 'sentry/locale';
+import {Organization, Project} from 'sentry/types';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {LoaderSettings} from 'sentry/views/settings/project/projectKeys/details/loaderSettings';
+import {ProjectKey} from 'sentry/views/settings/project/projectKeys/types';
+
+export function ProjectLoaderScript({project}: {project: Project}) {
+  const organization = useOrganization();
+  const apiEndpoint = `/projects/${organization.slug}/${project.slug}/keys/`;
+  const [updatedProjectKeys, setUpdatedProjectKeys] = useState<ProjectKey[]>([]);
+
+  const {
+    data: projectKeys,
+    isLoading,
+    error,
+    refetch: refetchProjectKeys,
+  } = useApiQuery<ProjectKey[]>([apiEndpoint], {
+    staleTime: 0,
+  });
+
+  const handleUpdateProjectKey = useCallback(
+    (projectKey: ProjectKey) => {
+      const existingProjectIndex = updatedProjectKeys.findIndex(
+        key => key.id === projectKey.id
+      );
+      const newUpdatedProjectKeys =
+        existingProjectIndex > -1
+          ? [...updatedProjectKeys].map((updatedProjectKey, index) => {
+              return index === existingProjectIndex ? projectKey : updatedProjectKey;
+            })
+          : [...updatedProjectKeys, projectKey];
+
+      setUpdatedProjectKeys(newUpdatedProjectKeys);
+    },
+    [updatedProjectKeys]
+  );
+
+  return (
+    <Fragment>
+      <SettingsPageHeader title={t('Loader Script')} />
+
+      <TextBlock>
+        {tct(
+          'The Loader Script is the easiest way to initialize the Sentry SDK. The Loader Script automatically keeps your Sentry SDK up to date and offers configuration for different Sentry features. [docsLink:Learn more about the Loader Script]. Note: The Loader Script is bound to a Client Key (DSN), to create a new Script, go to the [clientKeysLink:Client Keys page].',
+          {
+            docsLink: (
+              <ExternalLink href="https://docs.sentry.io/platforms/javascript/install/loader/" />
+            ),
+            clientKeysLink: (
+              <Link
+                to={`/settings/${organization.slug}/projects/${project.slug}/keys/`}
+              />
+            ),
+          }
+        )}
+      </TextBlock>
+
+      {isLoading && <LoadingIndicator />}
+      {!!error && (
+        <LoadingError
+          message={t('Failed to load project keys.')}
+          onRetry={refetchProjectKeys}
+        />
+      )}
+      {!isLoading && !error && !projectKeys?.length && (
+        <EmptyMessage title={t('There are no keys active for this project.')} />
+      )}
+
+      {projectKeys?.map(key => {
+        const actualKey =
+          updatedProjectKeys.find(updatedKey => updatedKey.id === key.id) ?? key;
+        return (
+          <LoaderItem
+            key={actualKey.id}
+            organization={organization}
+            project={project}
+            projectKey={actualKey}
+            onUpdateProjectKey={handleUpdateProjectKey}
+          />
+        );
+      })}
+    </Fragment>
+  );
+}
+
+function LoaderItem({
+  organization,
+  project,
+  projectKey,
+  onUpdateProjectKey,
+}: {
+  onUpdateProjectKey: (projectKey: ProjectKey) => void;
+  organization: Organization;
+  project: Project;
+  projectKey: ProjectKey;
+}) {
+  return (
+    <Panel>
+      <PanelHeader hasButtons>
+        {tct('Client Key: [name]', {name: projectKey.name})}
+
+        <LinkButton
+          to={`/settings/${organization.slug}/projects/${project.slug}/keys/${projectKey.id}/`}
+        >
+          {t('View Key Details')}
+        </LinkButton>
+      </PanelHeader>
+      <PanelBody>
+        <PanelAlert type="info" showIcon>
+          {t('Note that it can take a few minutes until changed options are live.')}
+        </PanelAlert>
+
+        <LoaderSettings
+          orgSlug={organization.slug}
+          keyId={projectKey.id}
+          project={project}
+          data={projectKey}
+          updateData={onUpdateProjectKey}
+        />
+      </PanelBody>
+    </Panel>
+  );
+}
+
+export default ProjectLoaderScript;

+ 5 - 0
static/app/views/settings/project/navigationConfiguration.tsx

@@ -122,6 +122,11 @@ export default function getConfiguration({
           title: t('Client Keys (DSN)'),
           description: t("View and manage the project's client keys (DSN)"),
         },
+        {
+          path: `${pathPrefix}/loader-script/`,
+          title: t('Loader Script'),
+          description: t("View and manage the project's Loader Script"),
+        },
         {
           path: `${pathPrefix}/release-tracking/`,
           title: t('Releases'),

+ 5 - 5
static/app/views/settings/project/projectKeys/details/loaderSettings.tsx

@@ -141,13 +141,13 @@ export function LoaderSettings({keyId, orgSlug, project, data, updateData}: Prop
             inline={false}
             flexibleControlStateSize
           >
-            <TextCopyInput>
+            <TextCopyInput aria-label={t('Loader Script')}>
               {`<script src='${loaderLink}' crossorigin="anonymous"></script>`}
             </TextCopyInput>
           </FieldGroup>
 
           <SelectField
-            name="browserSdkVersion"
+            name={`${keyId}-browserSdkVersion`}
             label={t('SDK Version')}
             options={
               data.browserSdk
@@ -168,7 +168,7 @@ export function LoaderSettings({keyId, orgSlug, project, data, updateData}: Prop
 
           <BooleanField
             label={t('Enable Performance Monitoring')}
-            name="has-performance"
+            name={`${keyId}-has-performance`}
             value={
               sdkVersionSupportsPerformanceAndReplay(data.browserSdkVersion)
                 ? values.hasPerformance
@@ -196,7 +196,7 @@ export function LoaderSettings({keyId, orgSlug, project, data, updateData}: Prop
 
           <BooleanField
             label={t('Enable Session Replay')}
-            name="has-replay"
+            name={`${keyId}-has-replay`}
             value={
               sdkVersionSupportsPerformanceAndReplay(data.browserSdkVersion)
                 ? values.hasReplay
@@ -228,7 +228,7 @@ export function LoaderSettings({keyId, orgSlug, project, data, updateData}: Prop
 
           <BooleanField
             label={t('Enable Debug Bundles & Logging')}
-            name="has-logging"
+            name={`${keyId}-has-logging`}
             value={values.hasDebug}
             onChange={value => {
               updateLoaderOption({hasDebug: value});