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

Commit 7091ccf

Browse files
matejvasekclaude
andcommitted
refactor: address PR #15 review feedback
Simplify EmptyState to a single isCreateDisabled prop. When disabled, show only the PAT hint text instead of both messages, and remove the redundant Tooltip from the disabled button. Remove Tooltip from the table-view Create button in FunctionsListPage as well. Protect FunctionCreatePage against unauthenticated access via URL by showing a warning alert and hiding the form when no PAT is stored. Clean up UserAvatar: remove all useCallback wrappers, remove unused logout, use guard pattern in readStoredUser, extract usePatModal hook, shrink modal to small variant, disable Cancel and modal close while validating. Extract shared errorMessage utility. Remove stale question from features.json and update i18n keys. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com>
1 parent ba7ea1a commit 7091ccf

10 files changed

Lines changed: 138 additions & 110 deletions

docs/features.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@
109109
"On Create and Edit pages, UserAvatar displays the GitHub user but is not clickable (PAT cannot be changed from those pages)",
110110
"When PatModal is dismissed without a PAT, the Function List Page (both empty state and table view) shows a hint text directing the user to click 'Connect to GitHub' in the top-right corner to set a PAT",
111111
"PAT change logic is encapsulated in a useUserAvatar custom hook following layered architecture (Hook imports Service, Component imports Hook)",
112-
"Question is how would change to pat be propagated, maybe the github service can use \"lazy\" authentication -- callback that reads PAT instead of passing PAT to the service",
113-
"If the GH PAT is not set in the session then the Create button is inactive/disabled. The tooltiop of the button should say that PAT is required.",
112+
"If the GH PAT is not set in the session then the Create button is inactive/disabled.",
114113
"The GH PAT must not be compiled/hardcode into code at compile time."
115114
],
116115
"passes": false

locales/en/plugin__console-functions-plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
2-
"A GitHub Personal Access Token is required to create functions.": "A GitHub Personal Access Token is required to create functions.",
2+
"A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.": "A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.",
3+
"A GitHub Personal Access Token is required to create functions. Go to the Functions page and click 'Connect to GitHub' to connect.": "A GitHub Personal Access Token is required to create functions. Go to the Functions page and click 'Connect to GitHub' to connect.",
34
"Actions": "Actions",
45
"Branch": "Branch",
56
"Cancel": "Cancel",
@@ -23,7 +24,6 @@
2324
"Namespace": "Namespace",
2425
"No functions found": "No functions found",
2526
"Owner": "Owner",
26-
"PAT is required to create functions. Click 'Connect to GitHub' in the top-right corner.": "PAT is required to create functions. Click 'Connect to GitHub' in the top-right corner.",
2727
"Personal Access Token": "Personal Access Token",
2828
"Registry": "Registry",
2929
"Replicas": "Replicas",

src/components/EmptyState.test.tsx

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,45 +32,30 @@ describe('FunctionsEmptyState', () => {
3232
expect(link).toHaveAttribute('href', '/faas/create');
3333
});
3434

35-
it('renders hint text when provided', () => {
35+
it('shows PAT hint and disabled button when isCreateDisabled is true', () => {
3636
render(
3737
<MemoryRouter>
38-
<FunctionsEmptyState hint="Connect your PAT first." />
38+
<FunctionsEmptyState isCreateDisabled />
3939
</MemoryRouter>,
4040
);
4141

42-
expect(screen.getByText('Connect your PAT first.')).toBeInTheDocument();
42+
expect(
43+
screen.getByText(/A GitHub Personal Access Token is required to create functions/),
44+
).toBeInTheDocument();
45+
expect(
46+
screen.queryByText('Create a serverless function to get started.'),
47+
).not.toBeInTheDocument();
48+
expect(screen.getByRole('button', { name: 'Create function' })).toBeDisabled();
4349
});
4450

45-
it('does not render hint when not provided', () => {
46-
render(
47-
<MemoryRouter>
48-
<FunctionsEmptyState />
49-
</MemoryRouter>,
50-
);
51-
52-
// Only the standard body text should be present
53-
expect(screen.getByText('Create a serverless function to get started.')).toBeInTheDocument();
54-
});
55-
56-
it('Create button disabled when isCreateDisabled is true', () => {
57-
render(
58-
<MemoryRouter>
59-
<FunctionsEmptyState isCreateDisabled={true} />
60-
</MemoryRouter>,
61-
);
62-
63-
const button = screen.getByRole('button', { name: 'Create function' });
64-
expect(button).toBeDisabled();
65-
});
66-
67-
it('Create button remains a link when isCreateDisabled is false', () => {
51+
it('shows standard body text when isCreateDisabled is false', () => {
6852
render(
6953
<MemoryRouter>
7054
<FunctionsEmptyState isCreateDisabled={false} />
7155
</MemoryRouter>,
7256
);
7357

58+
expect(screen.getByText('Create a serverless function to get started.')).toBeInTheDocument();
7459
const link = screen.getByRole('link', { name: 'Create function' });
7560
expect(link).toHaveAttribute('href', '/faas/create');
7661
});

src/components/EmptyState.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,33 @@ import {
44
EmptyStateActions,
55
EmptyStateBody,
66
EmptyStateFooter,
7-
Tooltip,
87
} from '@patternfly/react-core';
98
import { CubesIcon } from '@patternfly/react-icons';
109
import { useTranslation } from 'react-i18next';
1110
import { Link } from 'react-router-dom-v5-compat';
1211

1312
interface FunctionsEmptyStateProps {
14-
hint?: string;
1513
isCreateDisabled?: boolean;
1614
}
1715

18-
export function FunctionsEmptyState({ hint, isCreateDisabled }: FunctionsEmptyStateProps) {
16+
export function FunctionsEmptyState({ isCreateDisabled }: FunctionsEmptyStateProps) {
1917
const { t } = useTranslation('plugin__console-functions-plugin');
2018

2119
return (
2220
<EmptyState headingLevel="h2" icon={CubesIcon} titleText={t('No functions found')}>
23-
<EmptyStateBody>{t('Create a serverless function to get started.')}</EmptyStateBody>
24-
{hint && <EmptyStateBody>{hint}</EmptyStateBody>}
21+
<EmptyStateBody>
22+
{isCreateDisabled
23+
? t(
24+
"A GitHub Personal Access Token is required to create functions. Click 'Connect to GitHub' in the top-right corner to connect. Once connected, the create button will be enabled.",
25+
)
26+
: t('Create a serverless function to get started.')}
27+
</EmptyStateBody>
2528
<EmptyStateFooter>
2629
<EmptyStateActions>
2730
{isCreateDisabled ? (
28-
<Tooltip content={t('A GitHub Personal Access Token is required to create functions.')}>
29-
<Button variant="primary" isDisabled>
30-
{t('Create function')}
31-
</Button>
32-
</Tooltip>
31+
<Button variant="primary" isDisabled>
32+
{t('Create function')}
33+
</Button>
3334
) : (
3435
<Button variant="primary" component={(props) => <Link {...props} to="/faas/create" />}>
3536
{t('Create function')}

src/components/UserAvatar.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,5 +155,24 @@ describe('UserAvatar', () => {
155155

156156
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
157157
});
158+
159+
it('disables Cancel button while validating', async () => {
160+
const user = userEvent.setup();
161+
let resolveConnect: () => void;
162+
mockFetchUserInfo.mockReturnValue(
163+
new Promise<void>((resolve) => {
164+
resolveConnect = resolve;
165+
}),
166+
);
167+
168+
renderWithContext(<UserAvatar enableReconnect />);
169+
170+
await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_slow');
171+
await user.click(screen.getByRole('button', { name: 'Connect' }));
172+
173+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
174+
175+
resolveConnect!();
176+
});
158177
});
159178
});

src/components/UserAvatar.tsx

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import {
1212
import { KeyIcon, UserIcon } from '@patternfly/react-icons';
1313
import { useTranslation } from 'react-i18next';
1414
import { ForgeUser, PAT_KEY, USER_KEY } from '../services/types';
15-
import { useCallback, useContext, useState } from 'react';
15+
import { useContext, useState } from 'react';
1616
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
1717
import { useSourceControlService } from '../services/source-control/useSourceControlService';
18+
import { errorMessage } from '../utils/errorMessage';
1819

1920
interface UserAvatarProps {
2021
enableReconnect: boolean;
@@ -51,39 +52,34 @@ function useUserAvatar(enableReconnect: boolean) {
5152
() => enableReconnect && !sessionStorage.getItem(PAT_KEY),
5253
);
5354

54-
const login = useCallback(async (pat: string) => {
55+
const login = async (pat: string) => {
5556
const forgeUser = await sourceControlService.fetchUserInfo(pat);
5657
sessionStorage.setItem(PAT_KEY, pat);
5758
sessionStorage.setItem(USER_KEY, JSON.stringify(forgeUser));
5859
setUser(forgeUser);
5960
setIsModalOpen(false);
6061
connectToForge();
61-
}, []);
62-
63-
// TODO(twoGiants): unused
64-
const logout = useCallback(() => {
65-
sessionStorage.removeItem(PAT_KEY);
66-
sessionStorage.removeItem(USER_KEY);
67-
setUser(null);
68-
}, []);
62+
};
6963

70-
const openModal = useCallback(() => setIsModalOpen(true), []);
71-
const closeModal = useCallback(() => setIsModalOpen(false), []);
64+
const openModal = () => setIsModalOpen(true);
65+
const closeModal = () => setIsModalOpen(false);
7266

73-
return { user, isModalOpen, openModal, closeModal, login, logout };
67+
return { user, isModalOpen, openModal, closeModal, login };
7468
}
7569

7670
function readStoredUser(): ForgeUser | null {
7771
const pat = sessionStorage.getItem(PAT_KEY);
7872
const userJson = sessionStorage.getItem(USER_KEY);
79-
if (pat && userJson) {
80-
try {
81-
return JSON.parse(userJson) as ForgeUser;
82-
} catch {
83-
return null;
84-
}
73+
74+
if (!pat || !userJson) {
75+
return null;
76+
}
77+
78+
try {
79+
return JSON.parse(userJson) as ForgeUser;
80+
} catch {
81+
return null;
8582
}
86-
return null;
8783
}
8884

8985
interface PatModalProps {
@@ -94,25 +90,10 @@ interface PatModalProps {
9490

9591
function PatModal({ isOpen, onClose, onConnect }: PatModalProps) {
9692
const { t } = useTranslation('plugin__console-functions-plugin');
97-
// TODO(twoGiants): extract into customHook
98-
const [pat, setPat] = useState('');
99-
const [isValidating, setIsValidating] = useState(false);
100-
const [error, setError] = useState<string | null>(null);
101-
102-
const handleConnect = async () => {
103-
setIsValidating(true);
104-
setError(null);
105-
try {
106-
await onConnect(pat);
107-
} catch (err) {
108-
setError(err instanceof Error ? err.message : String(err));
109-
} finally {
110-
setIsValidating(false);
111-
}
112-
};
93+
const { pat, isValidating, error, setPat, handleConnect } = usePatModal(onConnect);
11394

11495
return (
115-
<Modal isOpen={isOpen} onClose={onClose} variant="medium">
96+
<Modal isOpen={isOpen} onClose={isValidating ? undefined : onClose} variant="small">
11697
<ModalHeader title={t('Connect to GitHub')} />
11798
<ModalBody>
11899
{error && (
@@ -124,7 +105,7 @@ function PatModal({ isOpen, onClose, onConnect }: PatModalProps) {
124105
id="pat-input"
125106
type="password"
126107
value={pat}
127-
onChange={(_event, value) => setPat(value)}
108+
onChange={(_, value) => setPat(value)}
128109
/>
129110
</FormGroup>
130111
</Form>
@@ -139,10 +120,30 @@ function PatModal({ isOpen, onClose, onConnect }: PatModalProps) {
139120
>
140121
{t('Connect')}
141122
</Button>
142-
<Button variant="link" onClick={onClose}>
123+
<Button variant="link" onClick={onClose} isDisabled={isValidating}>
143124
{t('Cancel')}
144125
</Button>
145126
</ModalFooter>
146127
</Modal>
147128
);
148129
}
130+
131+
function usePatModal(onConnect: (pat: string) => Promise<void>) {
132+
const [pat, setPat] = useState('');
133+
const [isValidating, setIsValidating] = useState(false);
134+
const [error, setError] = useState<string | null>(null);
135+
136+
const handleConnect = async () => {
137+
setIsValidating(true);
138+
setError(null);
139+
try {
140+
await onConnect(pat);
141+
} catch (err) {
142+
setError(errorMessage(err));
143+
} finally {
144+
setIsValidating(false);
145+
}
146+
};
147+
148+
return { pat, isValidating, error, setPat, handleConnect };
149+
}

src/utils/errorMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function errorMessage(err: unknown): string {
2+
return err instanceof Error ? err.message : String(err);
3+
}

src/views/FunctionCreatePage.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { MemoryRouter } from 'react-router-dom';
44
import FunctionCreatePage from './FunctionCreatePage';
5+
import { PAT_KEY } from '../services/types';
56

67
const mockGenerateFunction = jest.fn();
78
const mockPush = jest.fn();
@@ -43,10 +44,18 @@ jest.mock('../components/UserAvatar', () => ({
4344
),
4445
}));
4546

47+
beforeEach(() => {
48+
sessionStorage.clear();
49+
});
50+
4651
afterEach(() => {
4752
jest.clearAllMocks();
4853
});
4954

55+
afterAll(() => {
56+
sessionStorage.clear();
57+
});
58+
5059
const fillForm = async (user: ReturnType<typeof userEvent.setup>) => {
5160
await user.type(screen.getByRole('textbox', { name: /Owner/ }), 'testuser');
5261
await user.type(screen.getByRole('textbox', { name: /Repository/ }), 'my-repo');
@@ -58,6 +67,8 @@ const fillForm = async (user: ReturnType<typeof userEvent.setup>) => {
5867

5968
describe('FunctionCreatePage', () => {
6069
it('renders CreateFunctionForm', () => {
70+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
71+
6172
render(
6273
<MemoryRouter>
6374
<FunctionCreatePage />
@@ -69,6 +80,7 @@ describe('FunctionCreatePage', () => {
6980
});
7081

7182
it('calls generateFunction then push on submit, and navigates on success', async () => {
83+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
7284
const user = userEvent.setup();
7385
const files = [{ path: 'func.yaml', mode: '100644', content: 'name: f', type: 'blob' }];
7486
mockGenerateFunction.mockResolvedValue(files);
@@ -107,6 +119,7 @@ describe('FunctionCreatePage', () => {
107119
});
108120

109121
it('shows an alert on error', async () => {
122+
sessionStorage.setItem(PAT_KEY, 'ghp_test');
110123
const user = userEvent.setup();
111124
mockGenerateFunction.mockRejectedValue(new Error('Backend error'));
112125

@@ -133,4 +146,17 @@ describe('FunctionCreatePage', () => {
133146

134147
expect(screen.getByTestId('user-avatar')).toBeInTheDocument();
135148
});
149+
150+
it('shows warning and hides form when no PAT is set', () => {
151+
render(
152+
<MemoryRouter>
153+
<FunctionCreatePage />
154+
</MemoryRouter>,
155+
);
156+
157+
expect(
158+
screen.getByText(/A GitHub Personal Access Token is required to create functions/),
159+
).toBeInTheDocument();
160+
expect(screen.queryByRole('textbox', { name: /Owner/ })).not.toBeInTheDocument();
161+
});
136162
});

0 commit comments

Comments
 (0)