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
9 changes: 8 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import globals from 'globals';

export default tseslint.config(
{
ignores: ['dist/', 'node_modules/'],
ignores: ['dist/', 'node_modules/', '.claude/'],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
reactHooks.configs.flat.recommended,
{
files: ['src/**/*.{ts,tsx}'],
Expand Down
12 changes: 12 additions & 0 deletions locales/en/plugin__console-functions-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,31 @@
"Back to Functions": "Back to Functions",
"Branch": "Branch",
"Cancel": "Cancel",
"Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".": "Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".",
"Close": "Close",
"Coming soon": "Coming soon",
"ConfigMap": "ConfigMap",
"ConfigMaps": "ConfigMaps",
"Connect": "Connect",
"Connect GitHub": "Connect GitHub",
"Connect to GitHub": "Connect to GitHub",
"Create": "Create",
"Create a function": "Create a function",
"Create a GitHub Personal Access Token with the 'repo' and 'workflow' scopes, then click 'Connect to GitHub' in the header and paste the token.": "Create a GitHub Personal Access Token with the 'repo' and 'workflow' scopes, then click 'Connect to GitHub' in the header and paste the token.",
"Create a namespace and secret": "Create a namespace and secret",
"Create a serverless function to get started.": "Create a serverless function to get started.",
"Create function": "Create function",
"Create new function": "Create new function",
"Create or select a project for your function. If it needs credentials such as an API key, create a secret in that namespace so you can reference it as an environment variable.": "Create or select a project for your function. If it needs credentials such as an API key, create a secret in that namespace so you can reference it as an environment variable.",
"Delete": "Delete",
"Edit": "Edit",
"Edit and redeploy": "Edit and redeploy",
"Edit function": "Edit function",
"Enter your GitHub Personal Access Token to connect your repositories.": "Enter your GitHub Personal Access Token to connect your repositories.",
"Environment Variables": "Environment Variables",
"Error creating function": "Error creating function",
"Error listing functions": "Error listing functions",
"Follow these steps to create and deploy your serverless function.": "Follow these steps to create and deploy your serverless function.",
"Function Settings": "Function Settings",
"Functions": "Functions",
"GitHub Settings": "GitHub Settings",
Expand All @@ -33,6 +42,7 @@
"Name": "Name",
"Namespace": "Namespace",
"No functions found": "No functions found",
"Open the function from the list to edit its files, then click \"Save & Deploy\". This pushes your changes to GitHub, which runs the same workflow again to redeploy the function.": "Open the function from the list to edit its files, then click \"Save & Deploy\". This pushes your changes to GitHub, which runs the same workflow again to redeploy the function.",
"or": "or",
"Owner": "Owner",
"Personal Access Token": "Personal Access Token",
Expand All @@ -51,6 +61,7 @@
"Select a namespace first": "Select a namespace first",
"Select...": "Select...",
"Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.": "Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.",
"Set up guide": "Set up guide",
"Sign in with GitHub": "Sign in with GitHub",
"Start editing": "Start editing",
"Status": "Status",
Expand All @@ -59,5 +70,6 @@
"Unsaved changes": "Unsaved changes",
"URL": "URL",
"Value": "Value",
"View setup guide.": "View setup guide.",
"You have unsaved changes. Leave anyway?": "You have unsaved changes. Leave anyway?"
}
20 changes: 20 additions & 0 deletions src/pages/function-list/FunctionsListPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ describe('FunctionsListPage', () => {
expect(screen.getByText('twoGiants')).toBeInTheDocument();
});

it('renders the setup guide button in the list description', async () => {
renderAuthenticated();
setupBackendListAPIResponse([listItem('my-func')]);
mockUseCluster.mockReturnValue(
clusterData({
functions: [
clusterFunction('my-func', 'Running', 1, 'https://my-func-demo.apps.example.com'),
],
}),
);

render(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

expect(await screen.findByRole('button', { name: 'View setup guide.' })).toBeInTheDocument();
});

it('empty state receives hint and isCreateDisabled when not authenticated', async () => {
mockUseCluster.mockReturnValue(clusterData());

Expand Down
4 changes: 3 additions & 1 deletion src/pages/function-list/FunctionsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { FunctionsEmptyState } from './components/EmptyState';
import { FunctionTable, FunctionTableItem } from './components/FunctionTable';
import { SetupGuide } from './components/SetupGuide';
import { UserAvatar } from '../../common/components/UserAvatar';
import { AuthContext, AuthProvider } from '../../common/context/AuthProvider';
import { ClusterFunction, FunctionListItem } from '../../common/types';
Expand Down Expand Up @@ -59,7 +60,8 @@ function FunctionsListPageContent() {
<Content component={ContentVariants.p}>
{t(
'Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.',
)}
)}{' '}
<SetupGuide />
</Content>
<Toolbar>
<ToolbarContent>
Expand Down
10 changes: 10 additions & 0 deletions src/pages/function-list/components/EmptyState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,14 @@ describe('FunctionsEmptyState', () => {
const link = screen.getByRole('link', { name: 'Create function' });
expect(link).toHaveAttribute('href', '/faas/create');
});

it('renders the setup guide trigger', () => {
render(
<MemoryRouter>
<FunctionsEmptyState />
</MemoryRouter>,
);

expect(screen.getByRole('button', { name: 'View setup guide.' })).toBeInTheDocument();
});
});
4 changes: 4 additions & 0 deletions src/pages/function-list/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { CubesIcon } from '@patternfly/react-icons';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { SetupGuide } from './SetupGuide';

interface FunctionsEmptyStateProps {
isCreateDisabled?: boolean;
Expand All @@ -24,6 +25,9 @@ export function FunctionsEmptyState({ isCreateDisabled }: FunctionsEmptyStatePro
"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.')}
<div className="pf-v6-u-mt-sm">
<SetupGuide />
</div>
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
Expand Down
46 changes: 46 additions & 0 deletions src/pages/function-list/components/SetupGuide.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SetupGuide } from './SetupGuide';

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

describe('SetupGuide', () => {
it('renders the guide trigger button', () => {
render(<SetupGuide />);

expect(screen.getByRole('button', { name: 'View setup guide.' })).toBeInTheDocument();
});

it('does not show the guide content until the button is clicked', () => {
render(<SetupGuide />);

expect(screen.queryByText('Connect GitHub')).not.toBeInTheDocument();
});

it('opens a modal with the setup steps when the button is clicked', async () => {
const user = userEvent.setup();
render(<SetupGuide />);

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

expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Connect GitHub')).toBeInTheDocument();
expect(screen.getByText('Create a namespace and secret')).toBeInTheDocument();
expect(screen.getByText('Create a function')).toBeInTheDocument();
expect(screen.getByText('Edit and redeploy')).toBeInTheDocument();
});

it('closes the modal when Close is clicked', async () => {
const user = userEvent.setup();
render(<SetupGuide />);

await user.click(screen.getByRole('button', { name: 'View setup guide.' }));
expect(screen.getByText('Connect GitHub')).toBeInTheDocument();

await user.click(screen.getByText('Close'));

expect(screen.queryByText('Connect GitHub')).not.toBeInTheDocument();
});
});
100 changes: 100 additions & 0 deletions src/pages/function-list/components/SetupGuide.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
Button,
Content,
List,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from '@patternfly/react-core';
import { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';

export function SetupGuide() {
const { t } = useTranslation('plugin__console-functions-plugin');
const [isOpen, setIsOpen] = useState(false);

return (
<>
<Button
variant="link"
isInline
onClick={() => setIsOpen(true)}
data-test="setup-guide-button"
>
{t('View setup guide.')}
</Button>
<SetupGuideModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

interface SetupGuideModalProps {
isOpen: boolean;
onClose: () => void;
}

function SetupGuideModal({ isOpen, onClose }: SetupGuideModalProps) {
const { t } = useTranslation('plugin__console-functions-plugin');

return (
<Modal isOpen={isOpen} onClose={onClose} variant="medium" data-test="setup-guide-modal">
<ModalHeader title={t('Set up guide')} />
<ModalBody>
<Content component="p">
{t('Follow these steps to create and deploy your serverless function.')}
</Content>
<List component="ol" className="pf-v6-u-mt-md">
{steps(t).map((step) => (
<ListItem key={step.title}>
<Content component="p">
<strong>{step.title}</strong>
</Content>
<Content component="p">{step.body}</Content>
</ListItem>
))}
</List>
</ModalBody>
<ModalFooter>
<Button variant="primary" onClick={onClose} data-test="setup-guide-close-button">
{t('Close')}
</Button>
</ModalFooter>
</Modal>
);
}

interface Step {
title: string;
body: ReactNode;
}

function steps(t: (key: string) => string): Step[] {
return [
{
title: t('Connect GitHub'),
body: t(
"Create a GitHub Personal Access Token with the 'repo' and 'workflow' scopes, then click 'Connect to GitHub' in the header and paste the token.",
),
},
{
title: t('Create a namespace and secret'),
body: t(
'Create or select a project for your function. If it needs credentials such as an API key, create a secret in that namespace so you can reference it as an environment variable.',
),
},
{
title: t('Create a function'),
body: t(
'Click "Create new function", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as "NotDeployed" until the workflow finishes, then the status changes to "Running".',
),
},
{
title: t('Edit and redeploy'),
body: t(
'Open the function from the list to edit its files, then click "Save & Deploy". This pushes your changes to GitHub, which runs the same workflow again to redeploy the function.',
),
},
];
}