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

feat: Add views for react rendered unsubscribe views (#59223)

I'm moving the unsubscribe views to react rendered to address silo
boundary issues in hybrid cloud, remove more django views and help
remove our reliance on the AuthMiddleware.
Mark Story 1 год назад
Родитель
Сommit
beb5590f4b

+ 5 - 3
src/sentry/api/endpoints/organization_unsubscribe.py

@@ -47,12 +47,14 @@ class OrganizationUnsubscribeBase(Endpoint, Generic[T]):
         if not request.user_from_signed_request:
             raise NotFound()
         instance = self.fetch_instance(request, organization_slug, id)
-        display_name = ""
-        if hasattr(request.user, "get_display_name"):
-            display_name = request.user.get_display_name()
         view_url = ""
         if hasattr(instance, "get_absolute_url"):
             view_url = str(instance.get_absolute_url())
+        display_name = ""
+        user = request.user
+        if hasattr(user, "get_display_name"):
+            display_name = str(user.get_display_name())
+
         data = {
             "viewUrl": view_url,
             "type": self.object_type,

+ 25 - 5
src/sentry/web/urls.py

@@ -370,11 +370,7 @@ urlpatterns += [
                     SetupWizardView.as_view(),
                     name="sentry-project-wizard-fetch",
                 ),
-                # compatibility
-                re_path(
-                    r"^settings/notifications/unsubscribe/(?P<project_id>\d+)/$",
-                    accounts.email_unsubscribe_project,
-                ),
+                # Compatibility
                 re_path(
                     r"^settings/notifications/",
                     RedirectView.as_view(
@@ -383,6 +379,10 @@ urlpatterns += [
                 ),
                 # TODO(hybridcloud) These routes can be removed in Jan 2024 as all valid links
                 # will have been generated with hybrid-cloud compatible URLs.
+                re_path(
+                    r"^settings/notifications/unsubscribe/(?P<project_id>\d+)/$",
+                    accounts.email_unsubscribe_project,
+                ),
                 re_path(
                     r"^notifications/unsubscribe/(?P<project_id>\d+)/$",
                     accounts.email_unsubscribe_project,
@@ -644,6 +644,26 @@ urlpatterns += [
                     react_page_view,
                     name="sentry-customer-domain-legal-settings",
                 ),
+                re_path(
+                    r"^unsubscribe/(?P<organization_slug>\w+)/project/(?P<project_id>\d+)/$",
+                    react_page_view,
+                    name="sentry-organization-unsubscribe-project",
+                ),
+                re_path(
+                    r"^unsubscribe/project/(?P<project_id>\d+)/$",
+                    react_page_view,
+                    name="sentry-customer-domain-unsubscribe-project",
+                ),
+                re_path(
+                    r"^unsubscribe/(?P<organization_slug>\w+)/issue/(?P<issue_id>\d+)/$",
+                    react_page_view,
+                    name="sentry-organization-unsubscribe-issue",
+                ),
+                re_path(
+                    r"^unsubscribe/issue/(?P<issue_id>\d+)/$",
+                    react_page_view,
+                    name="sentry-customer-domain-unsubscribe-issue",
+                ),
                 re_path(
                     r"^(?P<organization_slug>[\w_-]+)/$",
                     react_page_view,

+ 20 - 0
static/app/routes.tsx

@@ -199,6 +199,26 @@ function buildRoutes() {
         path="/organizations/:orgId/share/issue/:shareId/"
         component={make(() => import('sentry/views/sharedGroupDetails'))}
       />
+      {usingCustomerDomain && (
+        <Route
+          path="/unsubscribe/project/:id/"
+          component={make(() => import('sentry/views/unsubscribe/project'))}
+        />
+      )}
+      <Route
+        path="/unsubscribe/:orgId/project/:id/"
+        component={make(() => import('sentry/views/unsubscribe/project'))}
+      />
+      {usingCustomerDomain && (
+        <Route
+          path="/unsubscribe/issue/:id/"
+          component={make(() => import('sentry/views/unsubscribe/issue'))}
+        />
+      )}
+      <Route
+        path="/unsubscribe/:orgId/issue/:id/"
+        component={make(() => import('sentry/views/unsubscribe/issue'))}
+      />
       <Route
         path="/organizations/new/"
         component={make(() => import('sentry/views/organizationCreate'))}

+ 63 - 0
static/app/views/unsubscribe/issue.spec.tsx

@@ -0,0 +1,63 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import UnsubscribeIssue from 'sentry/views/unsubscribe/issue';
+
+describe('UnsubscribeIssue', function () {
+  const params = {orgId: 'acme', id: '9876'};
+  let mockUpdate, mockGet;
+  beforeEach(() => {
+    mockUpdate = MockApiClient.addMockResponse({
+      url: '/organizations/acme/unsubscribe/issue/9876/?_=signature-value',
+      method: 'POST',
+      status: 201,
+    });
+    mockGet = MockApiClient.addMockResponse({
+      url: '/organizations/acme/unsubscribe/issue/9876/',
+      method: 'GET',
+      status: 200,
+      body: {
+        viewUrl: 'https://acme.sentry.io/issue/9876/',
+        type: 'issue',
+        displayName: 'Bruce Wayne',
+      },
+    });
+  });
+
+  it('loads data from the the API based on URL parameters', async function () {
+    const {router, routerProps, routerContext} = initializeOrg({
+      router: {
+        location: {query: {_: 'signature-value'}},
+        params,
+      },
+    });
+    render(
+      <UnsubscribeIssue {...routerProps} location={router.location} params={params} />,
+      {context: routerContext}
+    );
+
+    expect(await screen.findByText('selected issue')).toBeInTheDocument();
+    expect(screen.getByText('workflow notifications')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Unsubscribe'})).toBeInTheDocument();
+    expect(mockGet).toHaveBeenCalled();
+  });
+
+  it('makes an API request when the form is submitted', async function () {
+    const {router, routerProps, routerContext} = initializeOrg({
+      router: {
+        location: {query: {_: 'signature-value'}},
+        params,
+      },
+    });
+    render(
+      <UnsubscribeIssue {...routerProps} location={router.location} params={params} />,
+      {context: routerContext}
+    );
+
+    expect(await screen.findByText('selected issue')).toBeInTheDocument();
+    const button = screen.getByRole('button', {name: 'Unsubscribe'});
+    await userEvent.click(button);
+
+    expect(mockUpdate).toHaveBeenCalled();
+  });
+});

+ 104 - 0
static/app/views/unsubscribe/issue.tsx

@@ -0,0 +1,104 @@
+import {Fragment} from 'react';
+import {browserHistory, RouteComponentProps} from 'react-router';
+
+import Alert from 'sentry/components/alert';
+import ApiForm from 'sentry/components/forms/apiForm';
+import HiddenField from 'sentry/components/forms/fields/hiddenField';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import NarrowLayout from 'sentry/components/narrowLayout';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useParams} from 'sentry/utils/useParams';
+
+type RouteParams = {
+  id: string;
+  orgId: string;
+};
+
+type Props = RouteComponentProps<RouteParams, {}>;
+
+function UnsubscribeIssue({location}: Props) {
+  const signature = decodeScalar(location.query._);
+  const params = useParams();
+  return (
+    <SentryDocumentTitle title={t('Issue Notification Unsubscribe')}>
+      <NarrowLayout>
+        <h3>{t('Issue Notification Unsubscribe')}</h3>
+        <UnsubscribeBody
+          signature={signature}
+          orgSlug={params.orgId}
+          issueId={params.id}
+        />
+      </NarrowLayout>
+    </SentryDocumentTitle>
+  );
+}
+
+interface UnsubscribeResponse {
+  displayName: string;
+  type: string;
+  viewUrl: string;
+}
+
+type BodyProps = {
+  issueId: string;
+  orgSlug: string;
+  signature?: string;
+};
+
+function UnsubscribeBody({orgSlug, issueId, signature}: BodyProps) {
+  const endpoint = `/organizations/${orgSlug}/unsubscribe/issue/${issueId}/`;
+  const {isLoading, isError, data} = useApiQuery<UnsubscribeResponse>(
+    [endpoint, {query: {_: signature}}],
+    {staleTime: 0}
+  );
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+  if (isError) {
+    return (
+      <Alert type="error">
+        {t('There was an error loading unsubscribe data. Your link may have expired.')}
+      </Alert>
+    );
+  }
+
+  return (
+    <Fragment>
+      <p>
+        <strong>{t('Account')}</strong>: {data.displayName}
+      </p>
+      <p>
+        {tct('You are about to unsubscribe from [docsLink] for the [viewLink].', {
+          docsLink: (
+            <ExternalLink href="https://docs.sentry.io/workflow/notifications/workflow/">
+              {t('workflow notifications')}
+            </ExternalLink>
+          ),
+          viewLink: <Link to={data.viewUrl}>{t('selected %s', data.type)}</Link>,
+        })}
+      </p>
+      <ApiForm
+        apiEndpoint={`${endpoint}?_=${signature}`}
+        apiMethod="POST"
+        submitLabel={t('Unsubscribe')}
+        cancelLabel={t('Cancel')}
+        onCancel={() => {
+          browserHistory.push('/auth/login/');
+        }}
+        onSubmitSuccess={() => {
+          browserHistory.push('/auth/login/');
+        }}
+      >
+        <HiddenField name="cancel" value="1" />
+      </ApiForm>
+    </Fragment>
+  );
+}
+
+export default UnsubscribeIssue;

+ 57 - 0
static/app/views/unsubscribe/project.spec.tsx

@@ -0,0 +1,57 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import UnsubscribeProject from 'sentry/views/unsubscribe/project';
+
+describe('UnsubscribeProject', function () {
+  const params = {orgId: 'acme', id: '9876'};
+  let mockUpdate, mockGet;
+  beforeEach(() => {
+    mockUpdate = MockApiClient.addMockResponse({
+      url: '/organizations/acme/unsubscribe/project/9876/?_=signature-value',
+      method: 'POST',
+      status: 201,
+    });
+    mockGet = MockApiClient.addMockResponse({
+      url: '/organizations/acme/unsubscribe/project/9876/',
+      method: 'GET',
+      status: 200,
+      body: {
+        viewUrl: 'https://acme.sentry.io/projects/react/',
+        type: 'project',
+        slug: 'react',
+        displayName: 'Bruce Wayne',
+      },
+    });
+  });
+
+  it('loads data from the the API based on URL parameters', async function () {
+    const {router, routerProps, routerContext} = initializeOrg({
+      router: {location: {query: {_: 'signature-value'}}, params},
+    });
+    render(
+      <UnsubscribeProject {...routerProps} location={router.location} params={params} />,
+      {context: routerContext}
+    );
+
+    expect(await screen.findByText('acme / react')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Unsubscribe'})).toBeInTheDocument();
+    expect(mockGet).toHaveBeenCalled();
+  });
+
+  it('makes an API request when the form is submitted', async function () {
+    const {router, routerProps, routerContext} = initializeOrg({
+      router: {location: {query: {_: 'signature-value'}}, params},
+    });
+    render(
+      <UnsubscribeProject {...routerProps} location={router.location} params={params} />,
+      {context: routerContext}
+    );
+
+    expect(await screen.findByText('acme / react')).toBeInTheDocument();
+    const button = screen.getByRole('button', {name: 'Unsubscribe'});
+    await userEvent.click(button);
+
+    expect(mockUpdate).toHaveBeenCalled();
+  });
+});

+ 104 - 0
static/app/views/unsubscribe/project.tsx

@@ -0,0 +1,104 @@
+import {Fragment} from 'react';
+import {browserHistory, RouteComponentProps} from 'react-router';
+
+import Alert from 'sentry/components/alert';
+import ApiForm from 'sentry/components/forms/apiForm';
+import HiddenField from 'sentry/components/forms/fields/hiddenField';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import NarrowLayout from 'sentry/components/narrowLayout';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useParams} from 'sentry/utils/useParams';
+
+type RouteParams = {
+  id: string;
+  orgId: string;
+};
+
+type Props = RouteComponentProps<RouteParams, {}>;
+
+function UnsubscribeProject({location}: Props) {
+  const signature = decodeScalar(location.query._);
+  const params = useParams();
+  return (
+    <SentryDocumentTitle title={t('Unsubscribe')}>
+      <NarrowLayout>
+        <h3>{t('Unsubscribe')}</h3>
+        <UnsubscribeBody
+          signature={signature}
+          orgSlug={params.orgId}
+          issueId={params.id}
+        />
+      </NarrowLayout>
+    </SentryDocumentTitle>
+  );
+}
+
+interface UnsubscribeResponse {
+  displayName: string;
+  slug: string;
+  type: string;
+  viewUrl: string;
+}
+
+type BodyProps = {
+  issueId: string;
+  orgSlug: string;
+  signature?: string;
+};
+
+function UnsubscribeBody({orgSlug, issueId, signature}: BodyProps) {
+  const endpoint = `/organizations/${orgSlug}/unsubscribe/project/${issueId}/`;
+  const {isLoading, isError, data} = useApiQuery<UnsubscribeResponse>(
+    [endpoint, {query: {_: signature}}],
+    {staleTime: 0}
+  );
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+  if (isError) {
+    return (
+      <Alert type="error">
+        {t('There was an error loading unsubscribe data. Your link may have expired.')}
+      </Alert>
+    );
+  }
+
+  return (
+    <Fragment>
+      <p>
+        <strong>{t('Account')}</strong>: {data.displayName}
+      </p>
+      <p>
+        {t(
+          'You are about to unsubscribe from project notifications for the following project:'
+        )}
+      </p>
+      <p>
+        <strong>
+          {orgSlug} / {data.slug}
+        </strong>
+      </p>
+      <p>{t('You can subscribe to it again by going to your account settings.')}</p>
+      <ApiForm
+        apiEndpoint={`${endpoint}?_=${signature}`}
+        apiMethod="POST"
+        submitLabel={t('Unsubscribe')}
+        cancelLabel={t('Cancel')}
+        onCancel={() => {
+          browserHistory.push('/auth/login/');
+        }}
+        onSubmitSuccess={() => {
+          browserHistory.push('/auth/login/');
+        }}
+      >
+        <HiddenField name="cancel" value="1" />
+      </ApiForm>
+    </Fragment>
+  );
+}
+
+export default UnsubscribeProject;