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

Commit 1d40185

Browse files
committed
refactor: fold LeaveModal into EditToolbar, document sub-component rule
Signed-off-by: Stanislav Jakuschevskij <sjakusch@redhat.com>
1 parent 699d995 commit 1d40185

11 files changed

Lines changed: 195 additions & 77 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Arrows mean "imports / depends on."
2626
|-------|---------|------------|
2727
| **Types** | `common/services/types.ts` | nothing |
2828
| **Services** | `common/services/*/Service.ts` + implementations | Types, Utils |
29-
| **Hooks** | `common/services/*/use*.ts` | Services, Types, Utils |
29+
| **Hooks** | `common/services/*/use*.ts`, `common/hooks/`, `pages/<name>/hooks/` | Services, Types, Utils |
3030
| **Components** | `common/components/` (shared), `pages/<name>/components/` (page-specific) | Hooks, Types, Utils |
3131
| **Pages** | `pages/<name>/` | Components, Hooks, Utils |
3232
| **Utils** | `common/utils/` | nothing (cross-cutting) |
@@ -48,17 +48,23 @@ Arrows mean "imports / depends on."
4848

4949
## React
5050

51-
### Page / Component / Hook Rules
51+
### Pages
5252

53-
**Components are simple by default**they receive data via props, render it, and call callbacks. No logic at the top of a component.
53+
- **Smart for page-specific data**pages use central hooks (e.g. `useClusterService`, `useSourceControl`) to fetch, prepare, and transform all data needed for downstream components.
5454

55-
**A component may own its own data and state when it encapsulates a self-contained capability that is not specific to any one page** (e.g., forge connection, auth flows, notification subscriptions). The component becomes the single owner of that concern. Pages consume it without orchestrating its internals.
55+
### Components
5656

57-
**Pages are smart for page-specific data** — they use central hooks (e.g. `useClusterService`, `useSourceControl`) to fetch, prepare, and transform all data needed for downstream components.
57+
- **Simple by default** — they receive data via props, render it, and call callbacks. No logic at the top of a component.
58+
- **May own data when self-contained** — a component may own its own data and state when it encapsulates a self-contained capability that is not specific to any one page (e.g., forge connection, auth flows, notification subscriptions). The component becomes the single owner of that concern. Pages consume it without orchestrating its internals.
59+
- **Sub-components** — if a sub-component is only used by one parent, keep it in the parent's file, unexported. Extract to its own file only when the sub-component is used by multiple siblings.
5860

59-
**Extract logic into hooks** — if a page or component has any logic (state management, data transformation, side effects), extract it into a custom hook. If the hook is reused by multiple components, put it in a separate file: `useFunctionTable.ts`. If the hook is only used by one component, keep it in the same file, do not export it. If there is no logic, no hook is needed.
61+
### Hooks
6062

61-
**File ordering** — within a file, put the exported component at the top, then its hook below, then helper functions at the bottom. Readers see the main thing first and can drill down.
63+
- **Extract logic into hooks** — if a page or component has any logic (state management, data transformation, side effects), extract it into a custom hook. If the hook is only used by one component, keep it in the same file, do not export it. If the hook is reused by multiple components within one page, put it in `src/pages/<name>/hooks/`. If reused across pages, put it in `src/common/hooks/`. If there is no logic, no hook is needed.
64+
65+
### File Ordering
66+
67+
Within a file, put the exported component at the top, then its hook below, then sub-components, then helper functions at the bottom. Readers see the main thing first and can drill down.
6268

6369
### Performance
6470

@@ -67,6 +73,7 @@ Arrows mean "imports / depends on."
6773
## Architectural Guidance
6874

6975
- PatternFly components preferred over custom HTML
76+
- PatternFly styling and styling rules over custom CSS
7077
- Error handling through ErrorProvider/addError pattern
7178
- Shared utilities in `common/utils/`, not hand-rolled per component
7279
- Services consumed through hooks, never imported directly

docs/TESTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Do NOT write all test cases first and then implement everything at once.
2424
MSW is the primary mocking strategy for anything that hits the network (GitHub API, K8s API, Go backend). K8s API mocking uses MSW WebSocket capability.
2525

2626
`vi.mock` is only for framework and library internals that have no external service:
27+
2728
- `react-i18next` (translation hook)
2829
- `@openshift-console/dynamic-plugin-sdk` (console shell runtime components like DocumentTitle, ListPageHeader, consoleFetchJSON)
2930
- `@patternfly/react-icons` (UI library)
@@ -57,11 +58,13 @@ If it makes an HTTP or WebSocket call, mock it with MSW, not `vi.mock`.
5758
Every component gets its own exhaustive test file. Every page gets its own test file that tests the page's orchestration and integration with its components.
5859

5960
**Component tests** cover:
61+
6062
- Rendering based on props (all states and variants)
6163
- User interactions that trigger callbacks (clicks, input, form validation)
6264
- Internal state (expand/collapse, selection)
6365

6466
**Page tests** cover:
67+
6568
- Component is present on the page and wired correctly
6669
- Data flows from hooks/services to components (correct props)
6770
- User actions that trigger cross-component effects or service calls (e.g., form submit calls service, then navigates)
@@ -85,6 +88,8 @@ Overlap between component tests and page tests is expected and acceptable. They
8588
- **Act:** Perform user actions
8689
- **Assert:** Verify expected state
8790

91+
6. **Scoping** — Place beforeEach, afterEach, and afterAll inside describe blocks.
92+
8893
## Mocking Patterns
8994

9095
MSW is the primary approach. `vi.mock` is rare (see Mock Strategy above).

src/common/services/cluster/OcpClusterService.test.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,13 @@ vi.mock('@openshift-console/dynamic-plugin-sdk', () => {
99
return { consoleFetchJSON: fn };
1010
});
1111

12-
beforeEach(() => {
13-
(window as unknown as Record<string, unknown>).SERVER_FLAGS = {
14-
kubeAPIServerURL: 'https://api.cluster.example.com:6443',
15-
};
16-
});
17-
18-
afterEach(() => {
19-
vi.clearAllMocks();
20-
delete (window as unknown as Record<string, unknown>).SERVER_FLAGS;
21-
});
22-
2312
describe('OcpClusterService', () => {
2413
const namespace = 'my-ns';
2514

2615
beforeEach(() => {
16+
(window as unknown as Record<string, unknown>).SERVER_FLAGS = {
17+
kubeAPIServerURL: 'https://api.cluster.example.com:6443',
18+
};
2719
// POST calls: SA, Role, RoleBinding, ImageBuilderBinding, TokenRequest
2820
mockPost
2921
.mockResolvedValueOnce({})
@@ -33,6 +25,11 @@ describe('OcpClusterService', () => {
3325
.mockResolvedValueOnce({ status: { token: 'sa-token-value' } });
3426
});
3527

28+
afterEach(() => {
29+
vi.clearAllMocks();
30+
delete (window as unknown as Record<string, unknown>).SERVER_FLAGS;
31+
});
32+
3633
it('creates SA, Role, RoleBinding, gets token, and returns kubeconfig', async () => {
3734
const svc = new OcpClusterService();
3835
const kubeconfig = await svc.generateKubeconfig(namespace);

src/common/services/function/FunctionService.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({
88
},
99
}));
1010

11-
afterEach(() => {
12-
vi.restoreAllMocks();
13-
});
14-
1511
const config: FunctionConfig = {
1612
name: 'my-func',
1713
runtime: 'node',
@@ -26,6 +22,10 @@ const files: FileEntry[] = [
2622
];
2723

2824
describe('FunctionBackendService', () => {
25+
afterEach(() => {
26+
vi.restoreAllMocks();
27+
});
28+
2929
it('calls consoleFetchJSON.post with the proxy URL and returns generated files', async () => {
3030
vi.mocked(consoleFetchJSON.post).mockResolvedValue(files);
3131

src/pages/function-create/FunctionCreatePage.test.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,6 @@ vi.mock('../../common/components/UserAvatar', () => ({
5555
),
5656
}));
5757

58-
beforeEach(() => {
59-
sessionStorage.clear();
60-
});
61-
62-
afterEach(() => {
63-
vi.clearAllMocks();
64-
});
65-
66-
afterAll(() => {
67-
sessionStorage.clear();
68-
});
69-
7058
const renderPage = () =>
7159
render(
7260
<MemoryRouter>
@@ -82,6 +70,18 @@ const fillForm = async (user: ReturnType<typeof userEvent.setup>) => {
8270
};
8371

8472
describe('FunctionCreatePage', () => {
73+
beforeEach(() => {
74+
sessionStorage.clear();
75+
});
76+
77+
afterEach(() => {
78+
vi.clearAllMocks();
79+
});
80+
81+
afterAll(() => {
82+
sessionStorage.clear();
83+
});
84+
8585
it('renders CreateFunctionForm', () => {
8686
sessionStorage.setItem(PAT_KEY, 'ghp_test');
8787

src/pages/function-create/components/CreateFunctionForm.test.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,12 @@ vi.mock('react-i18next', () => ({
2222
useTranslation: () => ({ t: (key: string) => key }),
2323
}));
2424

25-
afterEach(() => {
26-
vi.restoreAllMocks();
27-
});
28-
2925
describe('CreateFunctionForm', () => {
3026
const onSubmit = vi.fn();
3127
const onCancel = vi.fn();
3228

33-
beforeEach(() => {
34-
onSubmit.mockClear();
35-
onCancel.mockClear();
29+
afterEach(() => {
30+
vi.restoreAllMocks();
3631
});
3732

3833
it('renders all form fields', () => {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { render, screen, act, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { EditToolbar } from './EditToolbar';
4+
5+
const mockNavigate = vi.fn();
6+
7+
vi.mock('react-i18next', () => ({
8+
useTranslation: () => ({ t: (key: string) => key }),
9+
}));
10+
11+
vi.mock('react-router-dom-v5-compat', () => ({
12+
useNavigate: () => mockNavigate,
13+
}));
14+
15+
describe('EditToolbar', () => {
16+
beforeEach(() => {
17+
mockNavigate.mockClear();
18+
});
19+
20+
it('renders Back and Save & Deploy buttons', () => {
21+
render(<EditToolbar hasChanges={false} onSave={vi.fn()} />);
22+
23+
expect(screen.getByRole('button', { name: 'Back to Functions' })).toBeInTheDocument();
24+
expect(screen.getByRole('button', { name: 'Save & Deploy' })).toBeInTheDocument();
25+
});
26+
27+
it('disables Save & Deploy when hasChanges is false', () => {
28+
render(<EditToolbar hasChanges={false} onSave={vi.fn()} />);
29+
30+
expect(screen.getByRole('button', { name: 'Save & Deploy' })).toBeDisabled();
31+
});
32+
33+
it('enables Save & Deploy when hasChanges is true', () => {
34+
render(<EditToolbar hasChanges={true} onSave={vi.fn()} />);
35+
36+
expect(screen.getByRole('button', { name: 'Save & Deploy' })).toBeEnabled();
37+
});
38+
39+
it('shows success alert after save and auto-dismisses after 2s', async () => {
40+
vi.useFakeTimers({ shouldAdvanceTime: true });
41+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
42+
const onSave = vi.fn().mockResolvedValue(undefined);
43+
render(<EditToolbar hasChanges={true} onSave={onSave} />);
44+
45+
await user.click(screen.getByRole('button', { name: 'Save & Deploy' }));
46+
47+
expect(screen.getByText('Pushed to GitHub. Deployment running...')).toBeInTheDocument();
48+
49+
act(() => {
50+
vi.advanceTimersByTime(2000);
51+
});
52+
53+
await waitFor(() => {
54+
expect(screen.queryByText('Pushed to GitHub. Deployment running...')).not.toBeInTheDocument();
55+
});
56+
57+
vi.useRealTimers();
58+
});
59+
60+
it('shows danger alert when save fails', async () => {
61+
const user = userEvent.setup();
62+
const onSave = vi.fn().mockRejectedValue(new Error('push failed'));
63+
render(<EditToolbar hasChanges={true} onSave={onSave} />);
64+
65+
await user.click(screen.getByRole('button', { name: 'Save & Deploy' }));
66+
67+
await waitFor(() => {
68+
expect(screen.getByText('push failed')).toBeInTheDocument();
69+
});
70+
});
71+
72+
it('navigates to /faas when Back is clicked and no changes', async () => {
73+
const user = userEvent.setup();
74+
render(<EditToolbar hasChanges={false} onSave={vi.fn()} />);
75+
76+
await user.click(screen.getByRole('button', { name: 'Back to Functions' }));
77+
78+
expect(mockNavigate).toHaveBeenCalledWith('/faas');
79+
});
80+
81+
it('shows leave modal when Back is clicked with unsaved changes', async () => {
82+
const user = userEvent.setup();
83+
render(<EditToolbar hasChanges={true} onSave={vi.fn()} />);
84+
85+
await user.click(screen.getByRole('button', { name: 'Back to Functions' }));
86+
87+
expect(mockNavigate).not.toHaveBeenCalled();
88+
expect(screen.getByText('You have unsaved changes. Leave anyway?')).toBeInTheDocument();
89+
});
90+
91+
it('closes modal and stays on page when Stay is clicked', async () => {
92+
const user = userEvent.setup();
93+
render(<EditToolbar hasChanges={true} onSave={vi.fn()} />);
94+
95+
await user.click(screen.getByRole('button', { name: 'Back to Functions' }));
96+
await user.click(screen.getByRole('button', { name: 'Stay' }));
97+
98+
expect(mockNavigate).not.toHaveBeenCalled();
99+
expect(screen.queryByText('You have unsaved changes. Leave anyway?')).not.toBeInTheDocument();
100+
});
101+
102+
it('closes modal and navigates when Leave is clicked', async () => {
103+
const user = userEvent.setup();
104+
render(<EditToolbar hasChanges={true} onSave={vi.fn()} />);
105+
106+
await user.click(screen.getByRole('button', { name: 'Back to Functions' }));
107+
await user.click(screen.getByRole('button', { name: 'Leave' }));
108+
109+
expect(mockNavigate).toHaveBeenCalledWith('/faas');
110+
});
111+
});

src/pages/function-edit/components/EditToolbar.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {
22
Alert,
33
Button,
4+
Modal,
5+
ModalBody,
6+
ModalFooter,
7+
ModalHeader,
48
Toolbar,
59
ToolbarContent,
610
ToolbarGroup,
@@ -10,7 +14,6 @@ import { ArrowLeftIcon } from '@patternfly/react-icons';
1014
import { useEffect, useRef, useState } from 'react';
1115
import { useTranslation } from 'react-i18next';
1216
import { useNavigate } from 'react-router-dom-v5-compat';
13-
import { LeaveModal } from './LeaveModal';
1417

1518
interface EditToolbarProps {
1619
hasChanges: boolean;
@@ -130,3 +133,30 @@ function useEditToolbar(
130133
onLeaveCancel,
131134
};
132135
}
136+
137+
function LeaveModal({
138+
isOpen,
139+
onStay,
140+
onLeave,
141+
}: {
142+
isOpen: boolean;
143+
onStay: () => void;
144+
onLeave: () => void;
145+
}) {
146+
const { t } = useTranslation('plugin__console-functions-plugin');
147+
148+
return (
149+
<Modal isOpen={isOpen} onClose={onStay} variant="small">
150+
<ModalHeader title={t('Unsaved changes')} />
151+
<ModalBody>{t('You have unsaved changes. Leave anyway?')}</ModalBody>
152+
<ModalFooter>
153+
<Button variant="primary" onClick={onStay}>
154+
{t('Stay')}
155+
</Button>
156+
<Button variant="link" onClick={onLeave}>
157+
{t('Leave')}
158+
</Button>
159+
</ModalFooter>
160+
</Modal>
161+
);
162+
}

src/pages/function-edit/components/LeaveModal.tsx

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

0 commit comments

Comments
 (0)