From 463f1e9adfbf1fb1739ef52237d1dbb923f7f44d Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Tue, 18 Aug 2026 14:31:40 +0200 Subject: [PATCH 1/4] feat: add setup guide to functions list Add a "View setup guide" link in the functions list description that opens a modal walking users through connecting GitHub, creating a namespace and secret, creating a function, and editing and redeploying it. Also fix the ESLint parser configuration by setting tsconfigRootDir and ignoring the .claude/ directory so lint runs cleanly regardless of local worktree layout. Issue SRVOCF-1040 --- eslint.config.mjs | 9 +- .../en/plugin__console-functions-plugin.json | 12 +++ .../function-list/FunctionsListPage.test.tsx | 20 ++++ src/pages/function-list/FunctionsListPage.tsx | 4 +- .../components/SetupGuide.test.tsx | 46 ++++++++ .../function-list/components/SetupGuide.tsx | 100 ++++++++++++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/pages/function-list/components/SetupGuide.test.tsx create mode 100644 src/pages/function-list/components/SetupGuide.tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index 62ba85fc..25c350ba 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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}'], diff --git a/locales/en/plugin__console-functions-plugin.json b/locales/en/plugin__console-functions-plugin.json index a6e8ff57..8ea807a5 100644 --- a/locales/en/plugin__console-functions-plugin.json +++ b/locales/en/plugin__console-functions-plugin.json @@ -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", @@ -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", @@ -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", @@ -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?" } diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index 797f922f..f73402fe 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -264,6 +264,26 @@ describe('FunctionsListPage', () => { expect(screen.getByTestId('user-avatar')).toBeInTheDocument(); }); + it('renders the setup guide button in the list description', async () => { + renderAuthenticated(); + setupListHandler([listItem('my-func')]); + mockUseCluster.mockReturnValue( + clusterData({ + functions: [ + clusterFunction('my-func', 'Running', 1, 'https://my-func-demo.apps.example.com'), + ], + }), + ); + + render( + + + , + ); + + expect(await screen.findByRole('button', { name: 'View setup guide' })).toBeInTheDocument(); + }); + it('empty state receives hint and isCreateDisabled when not authenticated', async () => { mockUseCluster.mockReturnValue(clusterData()); diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 27bf40d2..4844897f 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -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'; @@ -59,7 +60,8 @@ function FunctionsListPageContent() { {t( 'Serverless functions in your repository and deployed to your cluster. Manage lifecycle, monitor status, and scale on demand.', - )} + )}{' '} + diff --git a/src/pages/function-list/components/SetupGuide.test.tsx b/src/pages/function-list/components/SetupGuide.test.tsx new file mode 100644 index 00000000..038e3608 --- /dev/null +++ b/src/pages/function-list/components/SetupGuide.test.tsx @@ -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(); + + expect(screen.getByRole('button', { name: 'View setup guide' })).toBeInTheDocument(); + }); + + it('does not show the guide content until the button is clicked', () => { + render(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/pages/function-list/components/SetupGuide.tsx b/src/pages/function-list/components/SetupGuide.tsx new file mode 100644 index 00000000..50ca363d --- /dev/null +++ b/src/pages/function-list/components/SetupGuide.tsx @@ -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 ( + <> + + setIsOpen(false)} /> + + ); +} + +interface SetupGuideModalProps { + isOpen: boolean; + onClose: () => void; +} + +function SetupGuideModal({ isOpen, onClose }: SetupGuideModalProps) { + const { t } = useTranslation('plugin__console-functions-plugin'); + + return ( + + + + + {t('Follow these steps to create and deploy your serverless function.')} + + + {steps(t).map((step) => ( + + + {step.title} + + {step.body} + + ))} + + + + + + + ); +} + +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.', + ), + }, + ]; +} From 985a6b91d4495c8c91c62634c954990aa984edea Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Tue, 18 Aug 2026 15:01:16 +0200 Subject: [PATCH 2/4] test: match setup guide button label with trailing period Update the button name queries to expect "View setup guide." so the tests match the label rendered by the component. Issue SRVOCF-1040 --- src/pages/function-list/FunctionsListPage.test.tsx | 2 +- src/pages/function-list/components/SetupGuide.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index f73402fe..fb0a1f25 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -281,7 +281,7 @@ describe('FunctionsListPage', () => { , ); - expect(await screen.findByRole('button', { name: 'View setup guide' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'View setup guide.' })).toBeInTheDocument(); }); it('empty state receives hint and isCreateDisabled when not authenticated', async () => { diff --git a/src/pages/function-list/components/SetupGuide.test.tsx b/src/pages/function-list/components/SetupGuide.test.tsx index 038e3608..536e10da 100644 --- a/src/pages/function-list/components/SetupGuide.test.tsx +++ b/src/pages/function-list/components/SetupGuide.test.tsx @@ -10,7 +10,7 @@ describe('SetupGuide', () => { it('renders the guide trigger button', () => { render(); - expect(screen.getByRole('button', { name: 'View setup guide' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'View setup guide.' })).toBeInTheDocument(); }); it('does not show the guide content until the button is clicked', () => { @@ -23,7 +23,7 @@ describe('SetupGuide', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole('button', { name: 'View setup guide' })); + await user.click(screen.getByRole('button', { name: 'View setup guide.' })); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Connect GitHub')).toBeInTheDocument(); @@ -36,7 +36,7 @@ describe('SetupGuide', () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole('button', { name: 'View setup guide' })); + await user.click(screen.getByRole('button', { name: 'View setup guide.' })); expect(screen.getByText('Connect GitHub')).toBeInTheDocument(); await user.click(screen.getByText('Close')); From 7a5f1a5b5644f9a7d2f0418bf64bd184756a6683 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Mon, 24 Aug 2026 21:47:34 +0200 Subject: [PATCH 3/4] feat: show setup guide in functions empty state The setup guide link previously lived only in the functions list description, which is not rendered when the list is empty. New users, who benefit most from the guide, saw no way to open it until they had already created a function. Render the SetupGuide trigger inside the empty state body so the guide is reachable in both the connected and not-yet-connected states. Co-Authored-By: Claude Opus 4.8 --- src/pages/function-list/components/EmptyState.test.tsx | 10 ++++++++++ src/pages/function-list/components/EmptyState.tsx | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/src/pages/function-list/components/EmptyState.test.tsx b/src/pages/function-list/components/EmptyState.test.tsx index 8fa915ab..a6aaa0c8 100644 --- a/src/pages/function-list/components/EmptyState.test.tsx +++ b/src/pages/function-list/components/EmptyState.test.tsx @@ -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( + + + , + ); + + expect(screen.getByRole('button', { name: 'View setup guide.' })).toBeInTheDocument(); + }); }); diff --git a/src/pages/function-list/components/EmptyState.tsx b/src/pages/function-list/components/EmptyState.tsx index 6eb2d010..1ba8b8cf 100644 --- a/src/pages/function-list/components/EmptyState.tsx +++ b/src/pages/function-list/components/EmptyState.tsx @@ -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; @@ -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.')} +
+ +
From c53b6072e90ba2c85a105c2c9045a9aa258e6ca8 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Tue, 25 Aug 2026 10:06:44 +0200 Subject: [PATCH 4/4] test: use renamed list setup helper after master merge Master's #144 (SRVOCF-822) renamed the list-page test setup helper setupListHandler to setupBackendListAPIResponse. The setup-guide test added on this branch still called the old name, so merging master left one orphaned call that broke the type-check in the yarn build (images job) and the unit job. Rename the orphaned call to match the current helper. Co-Authored-By: Claude Opus 4.8 --- src/pages/function-list/FunctionsListPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index 41582354..788256d9 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -197,7 +197,7 @@ describe('FunctionsListPage', () => { it('renders the setup guide button in the list description', async () => { renderAuthenticated(); - setupListHandler([listItem('my-func')]); + setupBackendListAPIResponse([listItem('my-func')]); mockUseCluster.mockReturnValue( clusterData({ functions: [