From 0270fd024de6298bd4162a684a8c7a43e960031c Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Thu, 8 Oct 2026 15:53:53 -0700 Subject: [PATCH] fix: stabilize account scopes icon components --- src/renderer/routes/Accounts.test.tsx | 128 ++++++++++++++++++++++++++ src/renderer/routes/Accounts.tsx | 21 ++++- 2 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 0cb2970e4..5ff682381 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -11,6 +11,14 @@ import { import { useAccountsStore } from '../stores'; +import { IconColor } from '../types'; + +import { + getAlternateScopeNames, + getRecommendedScopeNames, + getRequiredScopeNames, +} from '../utils/auth/scopes'; +import { getAccountUUID } from '../utils/auth/utils'; import { Errors } from '../utils/core/errors'; import * as logger from '../utils/core/logger'; import * as comms from '../utils/system/comms'; @@ -61,6 +69,124 @@ describe('renderer/routes/Accounts.tsx', () => { }); }); + describe('Scopes icon', () => { + const scopeCases = [ + { + name: 'recommended scopes', + scopes: getRecommendedScopeNames(), + color: IconColor.GREEN, + variant: 'default', + }, + { + name: 'alternate scopes only', + scopes: getAlternateScopeNames(), + color: 'text-gitify-warning', + variant: 'default', + }, + { + name: 'neither scope set', + scopes: getRequiredScopeNames(), + color: null, + variant: 'danger', + }, + { + name: 'both scope sets', + scopes: [...new Set([...getRecommendedScopeNames(), ...getAlternateScopeNames()])], + color: IconColor.GREEN, + variant: 'default', + }, + ]; + + it.each(scopeCases)( + 'preserves the icon color and button variant for $name', + async ({ scopes, color, variant }) => { + await act(async () => { + renderWithProviders(, { + accounts: [{ ...mockPersonalAccessTokenAccount, scopes }], + }); + }); + + const button = screen.getByTestId('account-view-scopes'); + const icon = button.querySelector('svg'); + + expect(icon).toHaveClass('octicon-shield-check'); + expect(button).toHaveAttribute('data-variant', variant); + expect(button).toHaveAttribute('data-size', 'small'); + expect(button).toHaveAccessibleName( + `View scopes for ${mockPersonalAccessTokenAccount.user?.login}`, + ); + if (color) { + expect(icon).toHaveClass(color); + } + for (const scopeColor of [IconColor.GREEN, 'text-gitify-warning']) { + if (scopeColor !== color) { + expect(icon).not.toHaveClass(scopeColor); + } + } + }, + ); + + it.each(scopeCases)( + 'retains the icon across an unrelated account update for $name', + async ({ scopes }) => { + const account = { ...mockPersonalAccessTokenAccount, scopes }; + + await act(async () => { + renderWithProviders(, { accounts: [account] }); + }); + + const icon = screen.getByTestId('account-view-scopes').querySelector('svg'); + expect(icon).toBeInTheDocument(); + + const updatedAccount = { + ...account, + user: { ...account.user!, name: 'Updated account name' }, + }; + expect(getAccountUUID(updatedAccount)).toBe(getAccountUUID(account)); + + await act(async () => { + useAccountsStore.setState({ accounts: [updatedAccount] }); + }); + + expect(screen.getByText('Updated account name')).toBeInTheDocument(); + expect(screen.getByTestId('account-view-scopes').querySelector('svg')).toBe(icon); + }, + ); + + it('updates the icon, variant, and navigation account when scopes change', async () => { + const account = { + ...mockPersonalAccessTokenAccount, + scopes: getRequiredScopeNames(), + }; + + await act(async () => { + renderWithProviders(, { accounts: [account] }); + }); + + const button = screen.getByTestId('account-view-scopes'); + expect(button).toHaveAttribute('data-variant', 'danger'); + expect(button.querySelector('svg')).not.toHaveClass(IconColor.GREEN, 'text-gitify-warning'); + + const updatedAccount = { ...account, scopes: getRecommendedScopeNames() }; + expect(getAccountUUID(updatedAccount)).toBe(getAccountUUID(account)); + + await act(async () => { + useAccountsStore.setState({ accounts: [updatedAccount] }); + }); + + const updatedButton = screen.getByTestId('account-view-scopes'); + expect(updatedButton.querySelector('svg')).toHaveClass(IconColor.GREEN); + expect(updatedButton.querySelector('svg')).not.toHaveClass('text-gitify-warning'); + expect(updatedButton).toHaveAttribute('data-variant', 'default'); + + await userEvent.click(updatedButton); + + expect(navigateMock).toHaveBeenCalledWith('/account-scopes', { + state: { account: updatedAccount }, + }); + }); + }); + describe('Account interactions', () => { const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn()); @@ -332,6 +458,8 @@ describe('renderer/routes/Accounts.tsx', () => { }); }); + expect(screen.queryByTestId('account-view-scopes')).not.toBeInTheDocument(); + await userEvent.click(screen.getByTestId('account-reauthenticate')); expect(navigateMock).toHaveBeenCalledTimes(1); diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index b633edea2..066af7164 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -6,6 +6,7 @@ import { ArrowLeftIcon, ChevronRightIcon, KeyIcon, + type OcticonProps, PersonAddIcon, PersonIcon, ShieldCheckIcon, @@ -16,6 +17,8 @@ import { } from '@primer/octicons-react'; import { ActionList, ActionMenu, Button, IconButton, Stack, Text } from '@primer/react'; +import { cn } from 'cn'; + import { useLogins } from '../hooks/useLogins'; import { useNotifications } from '../hooks/useNotifications'; import { useAccountsStore } from '../stores'; @@ -38,14 +41,22 @@ import { getAdapter, listAdapters } from '../utils/forges/registry'; import { openAccountProfile, openAccountSettings, openHost } from '../utils/system/links'; import { getPlatformIcon } from '../utils/ui/icons'; -function getScopeIconClassName(account: Account): string { +const RecommendedScopesIcon: FC = ({ className, ...props }) => ( + +); + +const AlternateScopesIcon: FC = ({ className, ...props }) => ( + +); + +function getScopeIcon(account: Account): FC { if (hasRecommendedScopes(account)) { - return IconColor.GREEN; + return RecommendedScopesIcon; } if (hasAlternateScopes(account)) { - return 'text-gitify-warning'; + return AlternateScopesIcon; } - return ''; + return ShieldCheckIcon; } export const AccountsRoute: FC = () => { @@ -242,7 +253,7 @@ export const AccountsRoute: FC = () => { } + icon={getScopeIcon(account)} onClick={() => navigate('/account-scopes', { state: { account },