mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
fix(settings): audit log scrolls in place instead of growing the settings page (#735)
* fix(settings): audit log scrolls in place instead of growing the settings page The rows and Show more sit in a keyboard-focusable region capped at min(24rem, 60vh); the header, export and filters stay above it. * style: oxfmt * fix(settings): add the audit scroll label to en.json; move the scroll region to its own file (max-lines)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type React from 'react'
|
||||
|
||||
/**
|
||||
* A long audit history scrolls in place so the settings page keeps its length: the header, export
|
||||
* and filters stay above it, and Show more pages in at the bottom. About ten rows (384px), or 60%
|
||||
* of the window when that is smaller. The top edge is ruled so more reads as more, and it runs to
|
||||
* the card's edges so the scrollbar sits against the card border. Focusable so the keyboard can
|
||||
* scroll it.
|
||||
*/
|
||||
export function AuditLogScrollRegion({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-audit-log-scroll=""
|
||||
className="-mx-5 max-h-[min(24rem,60vh)] scrollbar-sleek overflow-y-auto overscroll-contain border-t border-border px-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// category and by person, with CSV export. Owners and admins only. A team whose plan does not
|
||||
// include it sees the upsell instead; the control plane refuses the data either way.
|
||||
import { Download, History, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/class-names'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InlineError } from '@/components/ui/inline-error'
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
CloudApiSkeletonRows
|
||||
} from '../cloud-api/cloud-api-parts'
|
||||
import { SettingsGroup } from '../section-layout/SettingsGroup'
|
||||
import { AuditLogScrollRegion } from './AuditLogScrollRegion'
|
||||
import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell'
|
||||
import { auditCategoryLabel, auditEventSentence, unnamedPersonIn } from './enterprise-audit-copy'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -364,55 +366,60 @@ export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.El
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={t('settings.enterprise.audit.title', 'Audit log')}
|
||||
aria-busy={log.refreshing}
|
||||
className={log.refreshing ? 'opacity-60' : undefined}
|
||||
<AuditLogScrollRegion
|
||||
label={t('settings.enterprise.audit.scrollLabel', 'Audit log entries')}
|
||||
>
|
||||
{log.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
data-settings-row=""
|
||||
className="flex items-center gap-3 py-2 text-xs"
|
||||
>
|
||||
<AuditSentence sentence={auditEventSentence(event, t, locale, nameOf)} />
|
||||
<span className="w-28 shrink-0 truncate text-muted-foreground">
|
||||
{auditCategoryLabel(event.category, t)}
|
||||
</span>
|
||||
<span className="w-48 shrink-0 truncate text-foreground">
|
||||
<ActorCell event={event} viewerUserId={props.viewerUserId} />
|
||||
</span>
|
||||
<span
|
||||
className="w-24 shrink-0 text-right text-muted-foreground"
|
||||
title={formatAbsoluteTime(event.at, locale)}
|
||||
>
|
||||
{formatRelativeFromNow(event.at, props.now, locale)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{log.moreError ? (
|
||||
<div data-settings-row="" className="py-3">
|
||||
<CloudApiErrorNotice message={log.moreError} onRetry={props.onLoadMore} />
|
||||
</div>
|
||||
) : log.hasMore ? (
|
||||
<div data-settings-row="" className="flex justify-center py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled={log.loadingMore}
|
||||
aria-busy={log.loadingMore}
|
||||
onClick={() => (log.loadingMore ? undefined : props.onLoadMore())}
|
||||
<ul
|
||||
aria-label={t('settings.enterprise.audit.title', 'Audit log')}
|
||||
aria-busy={log.refreshing}
|
||||
// The region draws the top rule; the first row's own rule would double it.
|
||||
className={cn('-mt-px', log.refreshing && 'opacity-60')}
|
||||
>
|
||||
{log.loadingMore ? (
|
||||
<Loader2 className="animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t('settings.enterprise.audit.showMore', 'Show more')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{log.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
data-settings-row=""
|
||||
className="flex items-center gap-3 py-2 text-xs"
|
||||
>
|
||||
<AuditSentence sentence={auditEventSentence(event, t, locale, nameOf)} />
|
||||
<span className="w-28 shrink-0 truncate text-muted-foreground">
|
||||
{auditCategoryLabel(event.category, t)}
|
||||
</span>
|
||||
<span className="w-48 shrink-0 truncate text-foreground">
|
||||
<ActorCell event={event} viewerUserId={props.viewerUserId} />
|
||||
</span>
|
||||
<span
|
||||
className="w-24 shrink-0 text-right text-muted-foreground"
|
||||
title={formatAbsoluteTime(event.at, locale)}
|
||||
>
|
||||
{formatRelativeFromNow(event.at, props.now, locale)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{log.moreError ? (
|
||||
<div data-settings-row="" className="py-3">
|
||||
<CloudApiErrorNotice message={log.moreError} onRetry={props.onLoadMore} />
|
||||
</div>
|
||||
) : log.hasMore ? (
|
||||
<div data-settings-row="" className="flex justify-center py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled={log.loadingMore}
|
||||
aria-busy={log.loadingMore}
|
||||
onClick={() => (log.loadingMore ? undefined : props.onLoadMore())}
|
||||
>
|
||||
{log.loadingMore ? (
|
||||
<Loader2 className="animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t('settings.enterprise.audit.showMore', 'Show more')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</AuditLogScrollRegion>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -170,6 +170,26 @@ describe('audit log', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull()
|
||||
})
|
||||
|
||||
it('scrolls a long log in place: rows and Show more inside, header, export and filters outside', () => {
|
||||
render(
|
||||
<EnterpriseSettingsView
|
||||
audit={fixtureAuditProps({ log: { ...EMPTY, events: FIXTURE_EVENTS, hasMore: true } })}
|
||||
/>
|
||||
)
|
||||
const region = screen.getByRole('region', { name: 'Audit log entries' })
|
||||
// Capped so the settings page keeps a normal length, and reachable by keyboard to scroll.
|
||||
expect(region.className).toContain('max-h-[min(24rem,60vh)]')
|
||||
expect(region.className).toContain('overflow-y-auto')
|
||||
expect(region.tabIndex).toBe(0)
|
||||
expect(region.querySelector('ul[aria-label="Audit log"]')).toBeTruthy()
|
||||
expect(region.contains(screen.getByRole('button', { name: 'Show more' }))).toBe(true)
|
||||
expect(region.contains(screen.getByRole('button', { name: 'Export CSV' }))).toBe(false)
|
||||
expect(region.contains(screen.getByRole('combobox', { name: 'Filter by activity' }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(region.contains(screen.getByText('Audit log', { selector: 'h3' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('offers both filters and the export, and exports once per click', () => {
|
||||
const onExport = vi.fn()
|
||||
const { rerender } = render(<EnterpriseSettingsView audit={fixtureAuditProps({ onExport })} />)
|
||||
|
||||
@@ -1507,6 +1507,7 @@
|
||||
"emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.",
|
||||
"clearFilters": "Clear filters",
|
||||
"showMore": "Show more",
|
||||
"scrollLabel": "Audit log entries",
|
||||
"category": {
|
||||
"members": "Members",
|
||||
"security": "Security",
|
||||
|
||||
Reference in New Issue
Block a user