Browse Source

feat(ui): Use user 24 hour clock setting on audit log (#29768)

Scott Cooper 3 years ago
parent
commit
0ea657b3fd

+ 12 - 0
static/app/types/index.tsx

@@ -2253,3 +2253,15 @@ export type EventIdResponse = {
   eventId: string;
   event: Event;
 };
+
+export type AuditLog = {
+  id: string;
+  actor: User;
+  event: string;
+  ipAddress: string;
+  note: string;
+  targetObject: number;
+  targetUser: Actor | null;
+  data: any;
+  dateCreated: string;
+};

+ 65 - 63
static/app/views/settings/organizationAuditLog/auditLogList.tsx

@@ -1,15 +1,17 @@
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
 import UserAvatar from 'app/components/avatar/userAvatar';
 import DateTime from 'app/components/dateTime';
 import SelectField from 'app/components/forms/selectField';
 import Pagination from 'app/components/pagination';
-import {Panel, PanelBody, PanelHeader, PanelItem} from 'app/components/panels';
+import {PanelTable} from 'app/components/panels';
 import Tooltip from 'app/components/tooltip';
 import {t} from 'app/locale';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
-import EmptyMessage from 'app/views/settings/components/emptyMessage';
+import {AuditLog} from 'app/types';
+import {use24Hours} from 'app/utils/dates';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
 
 const avatarStyle = {
@@ -19,20 +21,23 @@ const avatarStyle = {
 };
 
 type Props = {
-  entries: any[];
-  pageLinks: string;
+  isLoading: boolean;
+  entries: AuditLog[] | null;
+  pageLinks: string | null;
   eventType: string;
   eventTypes: string[];
   onEventSelect: (value: string) => void;
 };
 
 const AuditLogList = ({
+  isLoading,
   pageLinks,
   entries,
   eventType,
   eventTypes,
   onEventSelect,
 }: Props) => {
+  const is24Hours = use24Hours();
   const hasEntries = entries && entries.length > 0;
   const ipv4Length = 15;
   const options = [
@@ -55,54 +60,55 @@ const AuditLogList = ({
   return (
     <div>
       <SettingsPageHeader title={t('Audit Log')} action={action} />
-      <Panel>
-        <StyledPanelHeader disablePadding>
-          <div>{t('Member')}</div>
-          <div>{t('Action')}</div>
-          <div>{t('IP')}</div>
-          <div>{t('Time')}</div>
-        </StyledPanelHeader>
-
-        <PanelBody>
-          {!hasEntries && <EmptyMessage>{t('No audit entries available')}</EmptyMessage>}
-
-          {hasEntries &&
-            entries.map(entry => (
-              <StyledPanelItem center key={entry.id}>
-                <UserInfo>
-                  <div>
-                    {entry.actor.email && (
-                      <UserAvatar style={avatarStyle} user={entry.actor} />
-                    )}
-                  </div>
-                  <NameContainer>
-                    <Name data-test-id="actor-name">
-                      {entry.actor.isSuperuser
-                        ? t('%s (Sentry Staff)', entry.actor.name)
-                        : entry.actor.name}
-                    </Name>
-                    <Note>{entry.note}</Note>
-                  </NameContainer>
-                </UserInfo>
-                <div>
-                  <MonoDetail>{entry.event}</MonoDetail>
-                </div>
-                <TimestampOverflow>
+      <PanelTable
+        headers={[t('Member'), t('Action'), t('IP'), t('Time')]}
+        isEmpty={!hasEntries}
+        emptyMessage={t('No audit entries available')}
+        isLoading={isLoading}
+      >
+        {entries?.map(entry => (
+          <Fragment key={entry.id}>
+            <UserInfo>
+              <div>
+                {entry.actor.email && (
+                  <UserAvatar style={avatarStyle} user={entry.actor} />
+                )}
+              </div>
+              <NameContainer>
+                <Name data-test-id="actor-name">
+                  {entry.actor.isSuperuser
+                    ? t('%s (Sentry Staff)', entry.actor.name)
+                    : entry.actor.name}
+                </Name>
+                <Note>{entry.note}</Note>
+              </NameContainer>
+            </UserInfo>
+            <FlexCenter>
+              <MonoDetail>{entry.event}</MonoDetail>
+            </FlexCenter>
+            <FlexCenter>
+              {entry.ipAddress && (
+                <IpAddressOverflow>
                   <Tooltip
                     title={entry.ipAddress}
-                    disabled={entry.ipAddress && entry.ipAddress.length <= ipv4Length}
+                    disabled={entry.ipAddress.length <= ipv4Length}
                   >
                     <MonoDetail>{entry.ipAddress}</MonoDetail>
                   </Tooltip>
-                </TimestampOverflow>
-                <TimestampInfo>
-                  <DateTime dateOnly date={entry.dateCreated} />
-                  <DateTime timeOnly format="LT zz" date={entry.dateCreated} />
-                </TimestampInfo>
-              </StyledPanelItem>
-            ))}
-        </PanelBody>
-      </Panel>
+                </IpAddressOverflow>
+              )}
+            </FlexCenter>
+            <TimestampInfo>
+              <DateTime dateOnly date={entry.dateCreated} />
+              <DateTime
+                timeOnly
+                format={is24Hours ? 'HH:mm zz' : 'LT zz'}
+                date={entry.dateCreated}
+              />
+            </TimestampInfo>
+          </Fragment>
+        ))}
+      </PanelTable>
       {pageLinks && <Pagination pageLinks={pageLinks} />}
     </div>
   );
@@ -110,9 +116,10 @@ const AuditLogList = ({
 
 const UserInfo = styled('div')`
   display: flex;
+  align-items: center;
   line-height: 1.2;
   font-size: 13px;
-  flex: 1;
+  min-width: 250px;
 `;
 
 const NameContainer = styled('div')`
@@ -125,30 +132,25 @@ const Name = styled('div')`
   font-weight: 600;
   font-size: 15px;
 `;
+
 const Note = styled('div')`
   font-size: 13px;
   word-break: break-word;
 `;
-const TimestampOverflow = styled('div')`
-  ${overflowEllipsis};
-`;
 
-const MonoDetail = styled('code')`
-  font-size: ${p => p.theme.fontSizeMedium};
+const FlexCenter = styled('div')`
+  display: flex;
+  align-items: center;
 `;
 
-const StyledPanelHeader = styled(PanelHeader)`
-  display: grid;
-  grid-template-columns: 1fr max-content 130px 150px;
-  grid-column-gap: ${space(2)};
-  padding: ${space(2)};
+const IpAddressOverflow = styled('div')`
+  ${overflowEllipsis};
+  min-width: 90px;
 `;
 
-const StyledPanelItem = styled(PanelItem)`
-  display: grid;
-  grid-template-columns: 1fr max-content 130px 150px;
-  grid-column-gap: ${space(2)};
-  padding: ${space(2)};
+const MonoDetail = styled('code')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  white-space: no-wrap;
 `;
 
 const TimestampInfo = styled('div')`

+ 13 - 4
static/app/views/settings/organizationAuditLog/index.tsx

@@ -1,7 +1,7 @@
 import {browserHistory, RouteComponentProps} from 'react-router';
 
 import {t} from 'app/locale';
-import {Organization} from 'app/types';
+import {AuditLog, Organization} from 'app/types';
 import routeTitleGen from 'app/utils/routeTitle';
 import withOrganization from 'app/utils/withOrganization';
 import AsyncView from 'app/views/asyncView';
@@ -67,7 +67,10 @@ type Props = RouteComponentProps<{orgId: string}, {}> &
     organization: Organization;
   };
 
-type State = AsyncView['state'];
+type State = AsyncView['state'] & {
+  entryList: AuditLog[] | null;
+  entryListPageLinks: string | null;
+};
 
 class OrganizationAuditLog extends AsyncView<Props, State> {
   getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
@@ -98,15 +101,21 @@ class OrganizationAuditLog extends AsyncView<Props, State> {
     });
   };
 
+  renderLoading() {
+    return this.renderBody();
+  }
+
   renderBody() {
+    const {entryList, entryListPageLinks, loading, reloading} = this.state;
     const currentEventType = this.props.location.query.event;
     return (
       <AuditLogList
-        entries={this.state.entryList}
-        pageLinks={this.state.entryListPageLinks}
+        entries={entryList}
+        pageLinks={entryListPageLinks}
         eventType={currentEventType}
         eventTypes={EVENT_TYPES}
         onEventSelect={this.handleEventSelect}
+        isLoading={loading || reloading}
         {...this.props}
       />
     );

+ 73 - 0
tests/js/spec/views/settings/organizationAuditLog.spec.jsx

@@ -0,0 +1,73 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {mountWithTheme, screen} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'app/stores/configStore';
+import OrganizationAuditLog from 'app/views/settings/organizationAuditLog';
+
+describe('OrganizationAuditLog', () => {
+  const user = {
+    ...TestStubs.User(),
+    options: {
+      clock24Hours: true,
+      timezone: 'America/Los_Angeles',
+    },
+  };
+
+  beforeEach(() => {
+    ConfigStore.loadInitialData({user});
+  });
+
+  it('renders', () => {
+    MockApiClient.addMockResponse({
+      url: `/organizations/org-slug/audit-logs/`,
+      method: 'GET',
+      body: [
+        {
+          id: '4500000',
+          actor: TestStubs.User(),
+          event: 'project.remove',
+          ipAddress: '127.0.0.1',
+          note: 'removed project test',
+          targetObject: 5466660,
+          targetUser: null,
+          data: {},
+          dateCreated: '2021-09-28T00:29:33.940848Z',
+        },
+        {
+          id: '430000',
+          actor: TestStubs.User(),
+          event: 'org.create',
+          ipAddress: '127.0.0.1',
+          note: 'created the organization',
+          targetObject: 54215,
+          targetUser: null,
+          data: {},
+          dateCreated: '2016-11-21T04:02:45.929313Z',
+        },
+      ],
+    });
+
+    const {router, routerContext, organization} = initializeOrg({
+      projects: [],
+      router: {
+        location: {query: {}},
+        params: {orgId: 'org-slug'},
+      },
+    });
+    mountWithTheme(
+      <OrganizationAuditLog
+        organization={organization}
+        params={{orgId: organization.slug}}
+        location={router.location}
+      />,
+      {
+        context: routerContext,
+      }
+    );
+
+    expect(screen.getByText('project.remove')).toBeInTheDocument();
+    expect(screen.getByText('org.create')).toBeInTheDocument();
+    expect(screen.getAllByText('127.0.0.1')).toHaveLength(2);
+    expect(screen.getByText('17:29 PDT')).toBeInTheDocument();
+  });
+});