Repository navigation
fix(SEL-615): Beta-unavailable UI for Settings/Products/Audit Log/Notifications #3739
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
07f593e
b76f627
b012f09
e8ed472
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { render, screen, waitFor } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
| import SettingsPage from '@/app/dashboard/settings/page'; | ||
| import ProductsPage from '@/app/dashboard/products/page'; | ||
| import AuditLogPage from '@/app/dashboard/audit-log/page'; | ||
| import NotificationsPage from '@/app/dashboard/notifications/page'; | ||
|
|
||
| const fetchMock = vi.hoisted(() => vi.fn()); | ||
| vi.mock('@/lib/fetch-with-csrf', () => ({ fetchWithCsrf: (...args: unknown[]) => fetchMock(...args) })); | ||
| vi.mock('@/lib/api-config', () => ({ getServiceURL: () => '/api/ingestion' })); | ||
| vi.mock('@/lib/auth-context', () => ({ useAuth: () => ({ isAuthenticated: true, user: null }) })); | ||
| vi.mock('@/lib/tenant-context', () => ({ useTenant: () => ({ tenantId: 't', isSystemTenant: false }) })); | ||
| vi.mock('@/hooks/use-billing', () => ({ useCurrentSubscription: () => ({ data: null, isLoading: false, isError: false }) })); | ||
| vi.mock('@/hooks/use-dashboard-refresh', () => ({ useDashboardRefresh: vi.fn() })); | ||
|
|
||
| beforeEach(() => fetchMock.mockReset()); | ||
|
|
||
| for (const [name, Page, primaryPath] of [ | ||
| ['Settings', SettingsPage, '/api/v1/settings/t'], | ||
| ['Products', ProductsPage, '/api/v1/products/t'], | ||
| ['Audit Log', AuditLogPage, '/api/v1/audit-log/t'], | ||
| ['Notifications', NotificationsPage, '/api/v1/notifications/t/preferences'], | ||
| ] as const) { | ||
| describe(name, () => { | ||
| function renderPage() { | ||
| const client = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }); | ||
| render(<QueryClientProvider client={client}><Page /></QueryClientProvider>); | ||
| } | ||
|
|
||
| function expectProfileBlocked() { | ||
| if (name === 'Settings') { | ||
| expect(screen.getByRole('button', { name: 'Save Changes' })).toBeDisabled(); | ||
| expect(screen.queryByLabelText('Company Type')).not.toBeInTheDocument(); | ||
| } | ||
| } | ||
|
|
||
| it.each([404, 403])('shows Beta unavailable for HTTP %s', async status => { | ||
| fetchMock.mockResolvedValue(new Response(null, { status })); | ||
| renderPage(); | ||
| expect(await screen.findByText(/not yet available in RegEngine Beta/)).toBeVisible(); | ||
| expect(screen.queryByText(/Couldn't load|We could not load/)).not.toBeInTheDocument(); | ||
| expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); | ||
| expectProfileBlocked(); | ||
| expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(primaryPath), expect.anything()); | ||
| }); | ||
|
|
||
| it.each(['server', 'network'] as const)('keeps ErrorState and working retry for %s failure', async failure => { | ||
| if (failure === 'server') fetchMock.mockResolvedValue(new Response(null, { status: 503 })); | ||
| else fetchMock.mockResolvedValue(Response.error()); | ||
| renderPage(); | ||
| expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this"); | ||
| expect(screen.queryByText(/not yet available in RegEngine Beta/)).not.toBeInTheDocument(); | ||
| expect(screen.getByRole('link', { name: 'Contact support' })).toBeVisible(); | ||
| expectProfileBlocked(); | ||
| const callsBeforeRetry = fetchMock.mock.calls.filter(([url]) => url.includes(primaryPath)).length; | ||
| await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' })); | ||
| await waitFor(() => expect(fetchMock.mock.calls.filter(([url]) => url.includes(primaryPath)).length).toBeGreaterThan(callsBeforeRetry)); | ||
| }); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button'; | |
| import { Badge } from '@/components/ui/badge'; | ||
| import { Callout } from '@/components/ui/callout'; | ||
| import { ErrorState } from '@/components/ui/error-state'; | ||
| import { BetaUnavailableState } from '@/components/ui/beta-unavailable-state'; | ||
| import { isBetaUnavailableError } from '@/lib/dashboard-load-failure'; | ||
| import { Field } from '@/components/ui/field'; | ||
| import { Input, fieldControlClasses } from '@/components/ui/input'; | ||
| import { Skeleton } from '@/components/ui/skeleton'; | ||
|
|
@@ -20,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { useAuth } from '@/lib/auth-context'; | ||
| import { useTenant } from '@/lib/tenant-context'; | ||
| import { useCurrentSubscription } from '@/hooks/use-billing'; | ||
| import { getErrorRequestId } from '@/lib/api-error'; | ||
| import { apiError, getErrorRequestId, withRequestId } from '@/lib/api-error'; | ||
| import { | ||
| Building2, Key, Database, Plug, | ||
| Save, CheckCircle2, CreditCard, | ||
|
|
@@ -121,7 +123,7 @@ export default function SettingsPage() { | |
| fei_number: '', | ||
| }); | ||
|
|
||
| const { data: settingsData } = useQuery({ | ||
| const { data: settingsData, error: settingsError, isSuccess: settingsLoaded, refetch: refetchSettings, isFetching: settingsFetching } = useQuery({ | ||
| queryKey: ['settings', tenantId], | ||
| queryFn: async (): Promise<SettingsResponse | null> => { | ||
| const { getServiceURL } = await import('@/lib/api-config'); | ||
|
|
@@ -130,7 +132,7 @@ export default function SettingsPage() { | |
| credentials: 'include', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }); | ||
| if (!res.ok) return null; | ||
| if (!res.ok) throw withRequestId(apiError(res.status, res.statusText), res); | ||
| return res.json(); | ||
| }, | ||
| enabled: !!effectiveTenantId && isAuthenticated, | ||
|
|
@@ -237,7 +239,7 @@ export default function SettingsPage() { | |
| primaryAction={ | ||
| <Button | ||
| onClick={handleSave} | ||
| disabled={saving} | ||
| disabled={saving || !settingsLoaded || !!settingsError} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| loading={saving} | ||
| size="lg" | ||
| className="w-full sm:w-auto" | ||
|
|
@@ -329,7 +331,10 @@ export default function SettingsPage() { | |
|
|
||
| {/* ── Company Profile Tab ── */} | ||
| <TabsContent value="profile"> | ||
| <Card> | ||
| {settingsError ? ( | ||
| isBetaUnavailableError(settingsError) ? <BetaUnavailableState feature="Company profile" /> : | ||
| <ErrorState message="We could not load this page." onRetry={() => refetchSettings()} retrying={settingsFetching} requestId={getErrorRequestId(settingsError)} /> | ||
| ) : settingsLoaded ? <Card> | ||
| <CardHeader> | ||
| <div className="min-w-0"> | ||
| <CardTitle>Company Profile</CardTitle> | ||
|
|
@@ -384,7 +389,7 @@ export default function SettingsPage() { | |
| /> | ||
| </Field> | ||
| </CardContent> | ||
| </Card> | ||
| </Card> : null} | ||
| </TabsContent> | ||
|
|
||
| {/* ── API Keys Tab ── */} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { Callout } from '@/components/ui/callout'; | ||
| import { BETA_UNAVAILABLE_COPY } from '@/lib/dashboard-load-failure'; | ||
|
|
||
| export interface BetaUnavailableStateProps { | ||
| feature?: string; | ||
| } | ||
|
|
||
| export function BetaUnavailableState({ feature = 'This feature' }: BetaUnavailableStateProps) { | ||
| return ( | ||
| <Callout tone="info" role="status" title={`${feature} is ${BETA_UNAVAILABLE_COPY}`}> | ||
| <a href="/settings/support" className="text-small underline">Contact support</a> | ||
| </Callout> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { getErrorStatus } from '@/lib/api-error'; | ||
|
|
||
| export const BETA_UNAVAILABLE_COPY = 'not yet available in RegEngine Beta'; | ||
|
|
||
| export function isBetaUnavailableStatus(status: unknown): boolean { | ||
| return status === 404 || status === 403; | ||
| } | ||
|
|
||
| export function isBetaUnavailableError(error: unknown): boolean { | ||
| return isBetaUnavailableStatus(getErrorStatus(error)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: regengine/RegEngine
Length of output: 4522
Script executed:
Repository: regengine/RegEngine
Length of output: 18558
Script executed:
Repository: regengine/RegEngine
Length of output: 27846
Make the network fixture reject the fetch promise.
Response.error()is a resolvedResponsewithok === false. Each page therefore enters its!res.okresponse-error branch instead of testing a rejectedfetchWithCsrfpromise. The existingdashboard-load-errors.test.tsxcases cover the same pages, but also use resolvedResponse.error(), so they do not cover this rejection path.Proposed change
Committable suggestion