Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit 1df2c65

Browse files
twoGiantsclaude
andcommitted
refactor: consolidate UserAvatar, PatModal, hook
UserAvatar, PatModal, and useUserAvatar were separate public modules. Consumers had to wire them together manually, and connection state (whether a PAT was set) lived in the hook with no way to share it across the component tree. Merge PatModal and useUserAvatar into UserAvatar as private internals. Add ForgeConnectionProvider with React context so that FunctionsListPage can react when the user connects. Split FunctionsListPage into a provider wrapper and a content component (required because useContext resolves against ancestors, not descendants). Update all tests to exercise the new public API (enableReconnect prop) instead of testing deleted internals directly. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 88816f2 commit 1df2c65

14 files changed

Lines changed: 358 additions & 492 deletions

.vscode/settings.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
},
1717
"json.schemas": [
1818
{
19-
"fileMatch": ["**/console-extensions.json"],
19+
"fileMatch": [
20+
"**/console-extensions.json"
21+
],
2022
"url": "./node_modules/@openshift-console/dynamic-plugin-sdk-webpack/schema/console-extensions.json"
2123
}
22-
]
24+
],
25+
"typescript.preferences.importModuleSpecifier": "relative"
2326
}

src/components/PatModal.test.tsx

Lines changed: 0 additions & 69 deletions
This file was deleted.

src/components/PatModal.tsx

Lines changed: 0 additions & 73 deletions
This file was deleted.

src/components/UserAvatar.test.tsx

Lines changed: 133 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,158 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { UserAvatar } from './UserAvatar';
4+
import { GithubService } from '../services/source-control/GithubService';
5+
import { PAT_KEY, USER_KEY } from '../services/types';
6+
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
7+
import { ReactNode } from 'react';
48

59
jest.mock('react-i18next', () => ({
610
useTranslation: () => ({ t: (key: string) => key }),
711
}));
812

9-
afterEach(() => {
10-
jest.restoreAllMocks();
11-
});
13+
jest.mock('../services/source-control/GithubService', () => ({
14+
GithubService: { validateToken: jest.fn() },
15+
}));
16+
17+
const mockValidateToken = GithubService.validateToken as jest.Mock;
18+
19+
const testUser = { login: 'twoGiants', avatarUrl: 'https://example.com/avatar.png' };
20+
21+
function renderWithContext(
22+
ui: ReactNode,
23+
contextValue = { isActive: false, connectToForge: jest.fn() },
24+
) {
25+
return render(
26+
<ForgeConnectionContext.Provider value={contextValue}>{ui}</ForgeConnectionContext.Provider>,
27+
);
28+
}
1229

1330
describe('UserAvatar', () => {
14-
it('renders "Connect to GitHub" with KeyIcon when user is null', () => {
15-
render(<UserAvatar user={null} isClickable={false} onClick={jest.fn()} />);
31+
beforeEach(() => {
32+
sessionStorage.clear();
33+
});
34+
35+
afterEach(() => {
36+
jest.restoreAllMocks();
37+
});
1638

17-
expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
39+
afterAll(() => {
40+
sessionStorage.clear();
1841
});
42+
describe('rendering', () => {
43+
it('renders "Connect to GitHub" when no user is stored', () => {
44+
renderWithContext(<UserAvatar enableReconnect={false} />);
45+
46+
expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
47+
});
48+
49+
it('renders username when user is stored in sessionStorage', () => {
50+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
51+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
52+
53+
renderWithContext(<UserAvatar enableReconnect />);
54+
55+
expect(screen.getByText('twoGiants')).toBeInTheDocument();
56+
});
57+
58+
it('button is clickable when enableReconnect is true', async () => {
59+
const user = userEvent.setup();
60+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
61+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
62+
63+
renderWithContext(<UserAvatar enableReconnect />);
64+
65+
const button = screen.getByRole('button', { name: 'twoGiants' });
66+
await user.click(button);
67+
68+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
69+
});
70+
71+
it('button is disabled when enableReconnect is false', async () => {
72+
const user = userEvent.setup();
1973

20-
it('renders username with UserIcon when user is set', () => {
21-
render(
22-
<UserAvatar
23-
user={{ login: 'twoGiants', avatarUrl: 'https://example.com/avatar.png' }}
24-
isClickable={false}
25-
onClick={jest.fn()}
26-
/>,
27-
);
74+
renderWithContext(<UserAvatar enableReconnect={false} />);
2875

29-
expect(screen.getByText('twoGiants')).toBeInTheDocument();
76+
const button = screen.getByRole('button', { name: 'Connect to GitHub' });
77+
expect(button).toBeDisabled();
78+
79+
await user.click(button);
80+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
81+
});
3082
});
3183

32-
it('clickable renders as button, calls onClick on click', async () => {
33-
const user = userEvent.setup();
34-
const onClick = jest.fn();
84+
describe('modal auto-open', () => {
85+
it('opens modal automatically when enableReconnect is true and no PAT stored', () => {
86+
renderWithContext(<UserAvatar enableReconnect />);
87+
88+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
89+
});
90+
91+
it('does not auto-open modal when PAT is already stored', () => {
92+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
93+
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));
94+
95+
renderWithContext(<UserAvatar enableReconnect />);
3596

36-
render(<UserAvatar user={null} isClickable={true} onClick={onClick} />);
97+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
98+
});
3799

38-
const button = screen.getByRole('button', { name: 'Connect to GitHub' });
39-
await user.click(button);
100+
it('does not auto-open modal when enableReconnect is false', () => {
101+
renderWithContext(<UserAvatar enableReconnect={false} />);
40102

41-
expect(onClick).toHaveBeenCalled();
103+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
104+
});
42105
});
43106

44-
it('non-clickable renders as aria-disabled button that does not call onClick', async () => {
45-
const u = userEvent.setup();
46-
const onClick = jest.fn();
107+
describe('PAT modal', () => {
108+
it('Connect button disabled when input is empty', () => {
109+
renderWithContext(<UserAvatar enableReconnect />);
110+
111+
expect(screen.getByRole('button', { name: 'Connect' })).toBeDisabled();
112+
});
113+
114+
it('calls validateToken with PAT and updates UI on successful connect', async () => {
115+
const user = userEvent.setup();
116+
const connectToForge = jest.fn();
117+
mockValidateToken.mockResolvedValue(testUser);
118+
119+
renderWithContext(<UserAvatar enableReconnect />, { isActive: false, connectToForge });
120+
121+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
122+
await user.click(screen.getByRole('button', { name: 'Connect' }));
123+
124+
await waitFor(() => {
125+
expect(mockValidateToken).toHaveBeenCalledWith('ghp_valid');
126+
});
127+
128+
expect(screen.getByText('twoGiants')).toBeInTheDocument();
129+
expect(sessionStorage.getItem(PAT_KEY)).toBe('ghp_valid');
130+
expect(JSON.parse(sessionStorage.getItem(USER_KEY)!)).toEqual(testUser);
131+
expect(connectToForge).toHaveBeenCalled();
132+
});
133+
134+
it('shows error alert when validateToken rejects', async () => {
135+
const user = userEvent.setup();
136+
mockValidateToken.mockRejectedValue(new Error('Bad credentials'));
137+
138+
renderWithContext(<UserAvatar enableReconnect />);
139+
140+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
141+
await user.click(screen.getByRole('button', { name: 'Connect' }));
142+
143+
expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
144+
});
145+
146+
it('closes modal when Cancel is clicked', async () => {
147+
const user = userEvent.setup();
148+
149+
renderWithContext(<UserAvatar enableReconnect />);
47150

48-
render(<UserAvatar user={null} isClickable={false} onClick={onClick} />);
151+
expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
49152

50-
const button = screen.getByRole('button', { name: 'Connect to GitHub' });
51-
expect(button).toHaveAttribute('aria-disabled', 'true');
153+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
52154

53-
await u.click(button);
54-
expect(onClick).not.toHaveBeenCalled();
155+
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
156+
});
55157
});
56158
});

0 commit comments

Comments
 (0)