Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
},
"json.schemas": [
{
"fileMatch": ["**/console-extensions.json"],
"fileMatch": [
"**/console-extensions.json"
],
"url": "./node_modules/@openshift-console/dynamic-plugin-sdk-webpack/schema/console-extensions.json"
}
]
],
"typescript.preferences.importModuleSpecifier": "relative"
}
20 changes: 20 additions & 0 deletions docs/claude-progress.txt
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets update this after approval.

Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# Claude Progress Log
# Newest entries first. Agents: append your entry at the top after the header.

---
## 2026-04-28 | Session: Runtime PAT entry and user avatar
Worked on: Replace compile-time PAT injection with runtime modal entry, add user avatar to page headers
Completed:
- Runtime PAT entry via PatModal (PF6 Modal, password input, validation, error alert, small variant)
- UserAvatar component in ListPageHeader on all pages (list: clickable, create/edit: disabled)
- PatModal and useUserAvatar consolidated into UserAvatar as private internals, usePatModal hook extracted
- ForgeConnectionProvider context for cross-component PAT state sharing
- GithubService refactored: lazy auth via cached Octokit getter, fetchUserInfo on SourceControlService interface
- ForgeUser type (forge-agnostic naming), PAT and user stored in sessionStorage
- EmptyState simplified to single isCreateDisabled prop (PAT hint text internal, no Tooltip)
- FunctionsListPage wired: autoOpen modal, disabled Create when unauthenticated, skip API call when no PAT
- FunctionCreatePage protected against unauthenticated URL access (warning alert, form hidden)
- Removed DefinePlugin for __GITHUB_PAT__ from webpack.config.ts, deleted src/globals.d.ts
- Extracted errorMessage utility (src/utils/errorMessage.ts)
- Cancel and modal close disabled while validating
- 9 suites, 55 tests, all passing, zero lint errors
Left off: Squash and merge PR #15.
Blockers: None

---
## 2026-04-20 | Session: CI/CD pipeline and ESLint fixes
Worked on: GitHub Actions CI/CD pipeline, ESLint config fixes, README restructure
Expand Down
3 changes: 1 addition & 2 deletions docs/features.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,13 @@
"PatModal opens automatically on first visit to Function List Page when no PAT is stored in session",
"PatModal is dismissable — user can close it without entering a PAT",
"PatModal validates the PAT against GitHub API and on success stores it in session and closes",
"SourceControlService interface extended with init(pat: string) and isInitialized(): boolean methods; GithubService implementation updated accordingly",
"UserAvatar component renders inside ListPageHeader at the top-right corner on every page (list, create, edit)",
"UserAvatar has two states: not initialized shows 'Connect to GitHub' with a key/lock icon (clickable, opens PatModal); initialized shows GitHub username with avatar icon",
"UserAvatar is clickable only on the Function List Page, clicking reopens PatModal to change the PAT and associated GitHub user",
"On Create and Edit pages, UserAvatar displays the GitHub user but is not clickable (PAT cannot be changed from those pages)",
"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",
"PAT change logic is encapsulated in a useUserAvatar custom hook following layered architecture (Hook imports Service, Component imports Hook)",
"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.",
"If the GH PAT is not set in the session then the Create button is inactive/disabled.",
"The GH PAT must not be compiled/hardcode into code at compile time."
],
"passes": false
Expand Down
5 changes: 5 additions & 0 deletions locales/en/plugin__console-functions-plugin.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
{
"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.",
"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.",
"Actions": "Actions",
"Branch": "Branch",
"Cancel": "Cancel",
"Connect": "Connect",
"Connect to GitHub": "Connect to GitHub",
"Create": "Create",
"Create a serverless function to get started.": "Create a serverless function to get started.",
"Create function": "Create function",
"Create new function": "Create new function",
"Delete": "Delete",
"Edit": "Edit",
"Enter your GitHub Personal Access Token to connect your repositories.": "Enter your GitHub Personal Access Token to connect your repositories.",
"Error creating function": "Error creating function",
"Function Settings": "Function Settings",
"FaaS": "FaaS",
Expand Down
28 changes: 28 additions & 0 deletions src/components/EmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,32 @@ describe('FunctionsEmptyState', () => {
const link = screen.getByRole('link', { name: 'Create function' });
expect(link).toHaveAttribute('href', '/faas/create');
});

it('shows PAT hint and disabled button when isCreateDisabled is true', () => {
render(
<MemoryRouter>
<FunctionsEmptyState isCreateDisabled />
</MemoryRouter>,
);

expect(
screen.getByText(/A GitHub Personal Access Token is required to create functions/),
).toBeInTheDocument();
expect(
screen.queryByText('Create a serverless function to get started.'),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create function' })).toBeDisabled();
});

it('shows standard body text when isCreateDisabled is false', () => {
render(
<MemoryRouter>
<FunctionsEmptyState isCreateDisabled={false} />
</MemoryRouter>,
);

expect(screen.getByText('Create a serverless function to get started.')).toBeInTheDocument();
const link = screen.getByRole('link', { name: 'Create function' });
expect(link).toHaveAttribute('href', '/faas/create');
});
});
26 changes: 21 additions & 5 deletions src/components/EmptyState.tsx
Comment thread
twoGiants marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,33 @@ import { CubesIcon } from '@patternfly/react-icons';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom-v5-compat';

export function FunctionsEmptyState() {
interface FunctionsEmptyStateProps {
isCreateDisabled?: boolean;
}

export function FunctionsEmptyState({ isCreateDisabled }: FunctionsEmptyStateProps) {
const { t } = useTranslation('plugin__console-functions-plugin');

return (
<EmptyState headingLevel="h2" icon={CubesIcon} titleText={t('No functions found')}>
<EmptyStateBody>{t('Create a serverless function to get started.')}</EmptyStateBody>
<EmptyStateBody>
{isCreateDisabled
? t(
"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.",
)
: t('Create a serverless function to get started.')}
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button variant="primary" component={(props) => <Link {...props} to="/faas/create" />}>
{t('Create function')}
</Button>
{isCreateDisabled ? (
<Button variant="primary" isDisabled>
{t('Create function')}
</Button>
) : (
<Button variant="primary" component={(props) => <Link {...props} to="/faas/create" />}>
{t('Create function')}
</Button>
)}
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
Expand Down
178 changes: 178 additions & 0 deletions src/components/UserAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserAvatar } from './UserAvatar';
import { PAT_KEY, USER_KEY } from '../services/types';
import { ForgeConnectionContext } from '../context/ForgeConnectionProvider';
import { ReactNode } from 'react';

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

const mockFetchUserInfo = jest.fn();
jest.mock('../services/source-control/useSourceControlService', () => ({
useSourceControlService: () => ({
fetchUserInfo: mockFetchUserInfo,
}),
}));

const testUser = { name: 'twoGiants' };

function renderWithContext(
ui: ReactNode,
contextValue = { isActive: false, connectToForge: jest.fn() },
) {
return render(
<ForgeConnectionContext.Provider value={contextValue}>{ui}</ForgeConnectionContext.Provider>,
);
}

describe('UserAvatar', () => {
beforeEach(() => {
sessionStorage.clear();
});

afterEach(() => {
jest.restoreAllMocks();
});

afterAll(() => {
sessionStorage.clear();
});

describe('rendering', () => {
it('renders "Connect to GitHub" when no user is stored', () => {
renderWithContext(<UserAvatar enableReconnect={false} />);

expect(screen.getByText('Connect to GitHub')).toBeInTheDocument();
});

it('renders username when user is stored in sessionStorage', () => {
sessionStorage.setItem(PAT_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));

renderWithContext(<UserAvatar enableReconnect />);

expect(screen.getByText('twoGiants')).toBeInTheDocument();
});

it('button is clickable when enableReconnect is true', async () => {
const user = userEvent.setup();
sessionStorage.setItem(PAT_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));

renderWithContext(<UserAvatar enableReconnect />);

const button = screen.getByRole('button', { name: 'twoGiants' });
await user.click(button);

expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
});

it('button is disabled when enableReconnect is false', async () => {
const user = userEvent.setup();

renderWithContext(<UserAvatar enableReconnect={false} />);

const button = screen.getByRole('button', { name: 'Connect to GitHub' });
expect(button).toBeDisabled();

await user.click(button);
expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
});
});

describe('modal auto-open', () => {
it('opens modal automatically when enableReconnect is true and no PAT stored', () => {
renderWithContext(<UserAvatar enableReconnect />);

expect(screen.getByText('Personal Access Token')).toBeInTheDocument();
});

it('does not auto-open modal when PAT is already stored', () => {
sessionStorage.setItem(PAT_KEY, 'ghp_test');
sessionStorage.setItem(USER_KEY, JSON.stringify(testUser));

renderWithContext(<UserAvatar enableReconnect />);

expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
});

it('does not auto-open modal when enableReconnect is false', () => {
renderWithContext(<UserAvatar enableReconnect={false} />);

expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
});
});

describe('PAT modal', () => {
it('Connect button disabled when input is empty', () => {
renderWithContext(<UserAvatar enableReconnect />);

expect(screen.getByRole('button', { name: 'Connect' })).toBeDisabled();
});

it('calls fetchUserInfo with PAT and updates UI on successful connect', async () => {
const user = userEvent.setup();
const connectToForge = jest.fn();
mockFetchUserInfo.mockResolvedValue(testUser);

renderWithContext(<UserAvatar enableReconnect />, { isActive: false, connectToForge });

await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_valid');
await user.click(screen.getByRole('button', { name: 'Connect' }));

await waitFor(() => {
expect(mockFetchUserInfo).toHaveBeenCalledWith('ghp_valid');
});

expect(screen.getByText('twoGiants')).toBeInTheDocument();
expect(sessionStorage.getItem(PAT_KEY)).toBe('ghp_valid');
expect(JSON.parse(sessionStorage.getItem(USER_KEY)!)).toEqual(testUser);
expect(connectToForge).toHaveBeenCalled();
});

it('shows error alert when fetchUserInfo rejects', async () => {
const user = userEvent.setup();
mockFetchUserInfo.mockRejectedValue(new Error('Bad credentials'));

renderWithContext(<UserAvatar enableReconnect />);

await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_bad');
await user.click(screen.getByRole('button', { name: 'Connect' }));

expect(await screen.findByText('Bad credentials')).toBeInTheDocument();
});

it('closes modal when Cancel is clicked', async () => {
const user = userEvent.setup();

renderWithContext(<UserAvatar enableReconnect />);

expect(screen.getByText('Personal Access Token')).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(screen.queryByText('Personal Access Token')).not.toBeInTheDocument();
});

it('disables Cancel button while validating', async () => {
const user = userEvent.setup();
let resolveConnect: () => void;
mockFetchUserInfo.mockReturnValue(
new Promise<void>((resolve) => {
resolveConnect = resolve;
}),
);

renderWithContext(<UserAvatar enableReconnect />);

await user.type(screen.getByLabelText('Personal Access Token'), 'ghp_slow');
await user.click(screen.getByRole('button', { name: 'Connect' }));

expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();

resolveConnect!();
});
Comment on lines +159 to +176
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to resolve as we don't assert after resolution. You can remove resolveConnect and change the mockReturnValue to new Promise(() => {}). Makes the test a bit simpler.

});
});
Loading