Browse Source

ref(tsc): Convert AccountIdentities to FC (#58331)

Convert to FC and useApiQuery.

- Ref https://github.com/getsentry/frontend-tsc/issues/2
ArthurKnaus 1 year ago
parent
commit
67dab7aad5

+ 40 - 34
static/app/views/settings/account/accountIdentities.spec.tsx

@@ -10,7 +10,6 @@ import AccountIdentities from 'sentry/views/settings/account/accountIdentities';
 const ENDPOINT = '/users/me/user-identities/';
 
 describe('AccountIdentities', function () {
-  const router = TestStubs.router();
   beforeEach(function () {
     MockApiClient.clearMockResponses();
   });
@@ -22,19 +21,10 @@ describe('AccountIdentities', function () {
       body: [],
     });
 
-    render(
-      <AccountIdentities
-        route={router.routes[0]}
-        routeParams={router.params}
-        location={router.location}
-        params={router.params}
-        router={router}
-        routes={router.routes}
-      />
-    );
+    render(<AccountIdentities />);
   });
 
-  it('renders list', function () {
+  it('renders list', async function () {
     MockApiClient.addMockResponse({
       url: ENDPOINT,
       method: 'GET',
@@ -49,19 +39,39 @@ describe('AccountIdentities', function () {
           status: 'can_disconnect',
           organization: null,
         },
+        {
+          category: 'org-identity',
+          id: '2',
+          provider: {
+            key: 'google',
+            name: 'Google',
+          },
+          status: 'needed_for_global_auth',
+          organization: null,
+        },
       ],
     });
 
-    render(
-      <AccountIdentities
-        route={router.routes[0]}
-        routeParams={router.params}
-        location={router.location}
-        params={router.params}
-        router={router}
-        routes={router.routes}
-      />
-    );
+    render(<AccountIdentities />);
+
+    expect(await screen.findByTestId('loading-indicator')).toBeInTheDocument();
+
+    expect(await screen.findByText('GitHub')).toBeInTheDocument();
+    expect(await screen.findByText('Google')).toBeInTheDocument();
+  });
+
+  it('renders loading error', async function () {
+    MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      statusCode: 400,
+      body: {},
+    });
+    render(<AccountIdentities />);
+
+    expect(
+      await screen.findByText('There was an error loading data.')
+    ).toBeInTheDocument();
   });
 
   it('disconnects identity', async function () {
@@ -82,16 +92,7 @@ describe('AccountIdentities', function () {
       ],
     });
 
-    render(
-      <AccountIdentities
-        route={router.routes[0]}
-        routeParams={router.params}
-        location={router.location}
-        params={router.params}
-        router={router}
-        routes={router.routes}
-      />
-    );
+    render(<AccountIdentities />);
 
     const disconnectRequest = {
       url: `${ENDPOINT}social-identity/1/`,
@@ -101,12 +102,17 @@ describe('AccountIdentities', function () {
     const mock = MockApiClient.addMockResponse(disconnectRequest);
 
     expect(mock).not.toHaveBeenCalled();
-
-    await userEvent.click(screen.getByRole('button', {name: 'Disconnect'}));
+    await userEvent.click(await screen.findByRole('button', {name: 'Disconnect'}));
 
     renderGlobalModal();
     await userEvent.click(screen.getByTestId('confirm-button'));
 
+    expect(
+      await screen.findByText(
+        'There are no organization identities associated with your Sentry account'
+      )
+    ).toBeInTheDocument();
+
     expect(mock).toHaveBeenCalledTimes(1);
     expect(mock).toHaveBeenCalledWith(
       `${ENDPOINT}social-identity/1/`,

+ 189 - 160
static/app/views/settings/account/accountIdentities.tsx

@@ -1,5 +1,4 @@
-import {Fragment} from 'react';
-import {RouteComponentProps} from 'react-router';
+import {Fragment, useCallback, useMemo} from 'react';
 import styled from '@emotion/styled';
 import moment from 'moment';
 
@@ -9,187 +8,217 @@ import {Button} from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
 import DateTime from 'sentry/components/dateTime';
 import EmptyMessage from 'sentry/components/emptyMessage';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import PanelHeader from 'sentry/components/panels/panelHeader';
 import PanelItem from 'sentry/components/panels/panelItem';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import Tag from 'sentry/components/tag';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {UserIdentityCategory, UserIdentityConfig, UserIdentityStatus} from 'sentry/types';
-import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
 import IdentityIcon from 'sentry/views/settings/components/identityIcon';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 
-const ENDPOINT = '/users/me/user-identities/';
+const EMPTY_ARRAY = [];
+const IDENTITIES_ENDPOINT = '/users/me/user-identities/';
 
-type Props = RouteComponentProps<{}, {}>;
-
-type State = {
-  identities: UserIdentityConfig[] | null;
-} & DeprecatedAsyncView['state'];
-
-class AccountIdentities extends DeprecatedAsyncView<Props, State> {
-  getDefaultState() {
-    return {
-      ...super.getDefaultState(),
-      identities: [],
-    };
+function itemOrder(a: UserIdentityConfig, b: UserIdentityConfig) {
+  function categoryRank(c: UserIdentityConfig) {
+    return [
+      UserIdentityCategory.GLOBAL_IDENTITY,
+      UserIdentityCategory.SOCIAL_IDENTITY,
+      UserIdentityCategory.ORG_IDENTITY,
+    ].indexOf(c.category);
   }
 
-  getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
-    return [['identities', ENDPOINT]];
+  if (a.provider.name !== b.provider.name) {
+    return a.provider.name < b.provider.name ? -1 : 1;
   }
-
-  getTitle() {
-    return t('Identities');
+  if (a.category !== b.category) {
+    return categoryRank(a) - categoryRank(b);
   }
+  const nameA = a.organization?.name ?? '';
+  const nameB = b.organization?.name ?? '';
+  return nameA.localeCompare(nameB);
+}
 
-  renderItem = (identity: UserIdentityConfig) => {
-    return (
-      <IdentityPanelItem key={`${identity.category}:${identity.id}`}>
-        <InternalContainer>
-          <IdentityIcon providerId={identity.provider.key} />
-          <IdentityText isSingleLine={!identity.dateAdded}>
-            <IdentityName>{identity.provider.name}</IdentityName>
-            {identity.dateAdded && <IdentityDateTime date={moment(identity.dateAdded)} />}
-          </IdentityText>
-        </InternalContainer>
-        <InternalContainer>
-          <TagWrapper>
-            {identity.category === UserIdentityCategory.SOCIAL_IDENTITY && (
-              <Tag type="default">{t('Legacy')}</Tag>
-            )}
-            {identity.category !== UserIdentityCategory.ORG_IDENTITY && (
-              <Tag type="default">
-                {identity.isLogin ? t('Sign In') : t('Integration')}
-              </Tag>
-            )}
-            {identity.organization && (
-              <Tag type="highlight">{identity.organization.slug}</Tag>
-            )}
-          </TagWrapper>
-
-          {this.renderButton(identity)}
-        </InternalContainer>
-      </IdentityPanelItem>
-    );
-  };
-
-  renderButton(identity: UserIdentityConfig) {
-    return identity.status === UserIdentityStatus.CAN_DISCONNECT ? (
-      <Confirm
-        onConfirm={() => this.handleDisconnect(identity)}
-        priority="danger"
-        confirmText={t('Disconnect')}
-        message={
-          <Fragment>
-            <Alert type="error" showIcon>
-              {tct('Disconnect Your [provider] Identity?', {
-                provider: identity.provider.name,
-              })}
-            </Alert>
-            <TextBlock>
-              {identity.isLogin
+interface IdentityItemProps {
+  identity: UserIdentityConfig;
+  onDisconnect: (identity: UserIdentityConfig) => void;
+}
+
+function IdentityItem({identity, onDisconnect}: IdentityItemProps) {
+  return (
+    <IdentityPanelItem key={`${identity.category}:${identity.id}`}>
+      <InternalContainer>
+        <IdentityIcon providerId={identity.provider.key} />
+        <IdentityText isSingleLine={!identity.dateAdded}>
+          <IdentityName>{identity.provider.name}</IdentityName>
+          {identity.dateAdded && <IdentityDateTime date={moment(identity.dateAdded)} />}
+        </IdentityText>
+      </InternalContainer>
+      <InternalContainer>
+        <TagWrapper>
+          {identity.category === UserIdentityCategory.SOCIAL_IDENTITY && (
+            <Tag type="default">{t('Legacy')}</Tag>
+          )}
+          {identity.category !== UserIdentityCategory.ORG_IDENTITY && (
+            <Tag type="default">{identity.isLogin ? t('Sign In') : t('Integration')}</Tag>
+          )}
+          {identity.organization && (
+            <Tag type="highlight">{identity.organization.slug}</Tag>
+          )}
+        </TagWrapper>
+
+        {identity.status === UserIdentityStatus.CAN_DISCONNECT ? (
+          <Confirm
+            onConfirm={() => onDisconnect(identity)}
+            priority="danger"
+            confirmText={t('Disconnect')}
+            message={
+              <Fragment>
+                <Alert type="error" showIcon>
+                  {tct('Disconnect Your [provider] Identity?', {
+                    provider: identity.provider.name,
+                  })}
+                </Alert>
+                <TextBlock>
+                  {identity.isLogin
+                    ? t(
+                        'After disconnecting, you will need to use a password or another identity to sign in.'
+                      )
+                    : t("This action can't be undone.")}
+                </TextBlock>
+              </Fragment>
+            }
+          >
+            <Button size="sm">{t('Disconnect')}</Button>
+          </Confirm>
+        ) : (
+          <Button
+            size="sm"
+            disabled
+            title={
+              identity.status === UserIdentityStatus.NEEDED_FOR_GLOBAL_AUTH
                 ? t(
-                    'After disconnecting, you will need to use a password or another identity to sign in.'
+                    'You need this identity to sign into your account. If you want to disconnect it, set a password first.'
                   )
-                : t("This action can't be undone.")}
-            </TextBlock>
-          </Fragment>
-        }
-      >
-        <Button size="sm">{t('Disconnect')}</Button>
-      </Confirm>
-    ) : (
-      <Button
-        size="sm"
-        disabled
-        title={
-          identity.status === UserIdentityStatus.NEEDED_FOR_GLOBAL_AUTH
-            ? t(
-                'You need this identity to sign into your account. If you want to disconnect it, set a password first.'
-              )
-            : identity.status === UserIdentityStatus.NEEDED_FOR_ORG_AUTH
-            ? t('You need this identity to access your organization.')
-            : null
-        }
-      >
-        {t('Disconnect')}
-      </Button>
-    );
+                : identity.status === UserIdentityStatus.NEEDED_FOR_ORG_AUTH
+                ? t('You need this identity to access your organization.')
+                : null
+            }
+          >
+            {t('Disconnect')}
+          </Button>
+        )}
+      </InternalContainer>
+    </IdentityPanelItem>
+  );
+}
+
+function AccountIdentities() {
+  const queryClient = useQueryClient();
+  const {
+    data: identities = EMPTY_ARRAY,
+    isLoading,
+    isError,
+    refetch,
+  } = useApiQuery<UserIdentityConfig[]>([IDENTITIES_ENDPOINT], {
+    staleTime: 0,
+  });
+
+  const appIdentities = useMemo(
+    () =>
+      identities
+        .filter(identity => identity.category !== UserIdentityCategory.ORG_IDENTITY)
+        .sort(itemOrder),
+    [identities]
+  );
+
+  const orgIdentities = useMemo(
+    () =>
+      identities
+        .filter(identity => identity.category === UserIdentityCategory.ORG_IDENTITY)
+        .sort(itemOrder),
+    [identities]
+  );
+
+  const handleDisconnect = useCallback(
+    (identity: UserIdentityConfig) => {
+      disconnectIdentity(identity, () => {
+        setApiQueryData(queryClient, [IDENTITIES_ENDPOINT], oldData => {
+          if (!Array.isArray(oldData)) {
+            return oldData;
+          }
+
+          return oldData.filter(i => i.id !== identity.id);
+        });
+      });
+    },
+    [queryClient]
+  );
+
+  if (isLoading) {
+    return <LoadingIndicator />;
   }
 
-  handleDisconnect = (identity: UserIdentityConfig) => {
-    disconnectIdentity(identity, () => this.reloadData());
-  };
-
-  itemOrder = (a: UserIdentityConfig, b: UserIdentityConfig) => {
-    function categoryRank(c: UserIdentityConfig) {
-      return [
-        UserIdentityCategory.GLOBAL_IDENTITY,
-        UserIdentityCategory.SOCIAL_IDENTITY,
-        UserIdentityCategory.ORG_IDENTITY,
-      ].indexOf(c.category);
-    }
-
-    if (a.provider.name !== b.provider.name) {
-      return a.provider.name < b.provider.name ? -1 : 1;
-    }
-    if (a.category !== b.category) {
-      return categoryRank(a) - categoryRank(b);
-    }
-    if ((a.organization?.name ?? '') !== (b.organization?.name ?? '')) {
-      return (a.organization?.name ?? '') < (b.organization?.name ?? '') ? -1 : 1;
-    }
-    return 0;
-  };
-
-  renderBody() {
-    const appIdentities = this.state.identities
-      ?.filter(identity => identity.category !== UserIdentityCategory.ORG_IDENTITY)
-      .sort(this.itemOrder);
-    const orgIdentities = this.state.identities
-      ?.filter(identity => identity.category === UserIdentityCategory.ORG_IDENTITY)
-      .sort(this.itemOrder);
-
-    return (
-      <Fragment>
-        <SettingsPageHeader title="Identities" />
-
-        <Panel>
-          <PanelHeader>{t('Application Identities')}</PanelHeader>
-          <PanelBody>
-            {!appIdentities?.length ? (
-              <EmptyMessage>
-                {t(
-                  'There are no application identities associated with your Sentry account'
-                )}
-              </EmptyMessage>
-            ) : (
-              appIdentities.map(this.renderItem)
-            )}
-          </PanelBody>
-        </Panel>
-
-        <Panel>
-          <PanelHeader>{t('Organization Identities')}</PanelHeader>
-          <PanelBody>
-            {!orgIdentities?.length ? (
-              <EmptyMessage>
-                {t(
-                  'There are no organization identities associated with your Sentry account'
-                )}
-              </EmptyMessage>
-            ) : (
-              orgIdentities.map(this.renderItem)
-            )}
-          </PanelBody>
-        </Panel>
-      </Fragment>
-    );
+  if (isError) {
+    return <LoadingError onRetry={refetch} />;
   }
+
+  return (
+    <Fragment>
+      <SentryDocumentTitle title={t('Identities')} />
+      <SettingsPageHeader title="Identities" />
+
+      <Panel>
+        <PanelHeader>{t('Application Identities')}</PanelHeader>
+        <PanelBody>
+          {!appIdentities.length ? (
+            <EmptyMessage>
+              {t(
+                'There are no application identities associated with your Sentry account'
+              )}
+            </EmptyMessage>
+          ) : (
+            appIdentities.map(identity => (
+              <IdentityItem
+                key={identity.id}
+                identity={identity}
+                onDisconnect={handleDisconnect}
+              />
+            ))
+          )}
+        </PanelBody>
+      </Panel>
+
+      <Panel>
+        <PanelHeader>{t('Organization Identities')}</PanelHeader>
+        <PanelBody>
+          {!orgIdentities.length ? (
+            <EmptyMessage>
+              {t(
+                'There are no organization identities associated with your Sentry account'
+              )}
+            </EmptyMessage>
+          ) : (
+            orgIdentities.map(identity => (
+              <IdentityItem
+                key={identity.id}
+                identity={identity}
+                onDisconnect={handleDisconnect}
+              />
+            ))
+          )}
+        </PanelBody>
+      </Panel>
+    </Fragment>
+  );
 }
 
 const IdentityPanelItem = styled(PanelItem)`