Skip to content

Commit 33df7ec

Browse files
committed
Add tests for MetadataFields component
1 parent 5acff90 commit 33df7ec

File tree

10 files changed

+426
-325
lines changed

10 files changed

+426
-325
lines changed

webui/package-lock.json

Lines changed: 282 additions & 200 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@
6666
"@stoplight/prism-cli": "^5.14.2",
6767
"@stoplight/prism-core": "^5.8.0",
6868
"@stoplight/prism-http-server": "^5.12.2",
69+
"@testing-library/jest-dom": "^6.9.1",
6970
"@testing-library/react": "^16.3.0",
71+
"@testing-library/user-event": "^14.6.1",
7072
"@types/bootstrap": "^5.2.10",
7173
"@types/js-yaml": "^4.0.9",
7274
"@types/jsonwebtoken": "^9.0.10",

webui/src/lib/components/repository/compareBranchesActionBar.jsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const CompareBranchesActionsBar = (
4343

4444
const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
4545
const textRef = useRef(null);
46-
const [metadataFields, setMetadataFields] = useState([]);
46+
const [metadataFields, setMetadataFields] = useState([])
4747
const initialMerge = {
4848
merging: false,
4949
show: false,
@@ -77,7 +77,7 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
7777
}
7878
const message = textRef.current.value;
7979
const metadata = {};
80-
metadataFields.forEach(pair => metadata[pair.key] = pair.value);
80+
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
8181

8282
let strategy = mergeState.strategy;
8383
if (strategy === "none") {
@@ -150,11 +150,7 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
150150
<Button variant="secondary" disabled={mergeState.merging} onClick={hide}>
151151
Cancel
152152
</Button>
153-
<Button
154-
variant="success"
155-
disabled={mergeState.merging}
156-
onClick={onSubmit}
157-
>
153+
<Button variant="success" disabled={mergeState.merging} onClick={onSubmit}>
158154
{(mergeState.merging) ? 'Merging...' : 'Merge'}
159155
</Button>
160156
</Modal.Footer>

webui/src/lib/components/repository/metadata.jsx

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@ import Form from "react-bootstrap/Form";
66
import Row from "react-bootstrap/Row";
77
import Col from "react-bootstrap/Col";
88

9-
/**
10-
* Helper to check if a metadata key is empty or whitespace-only
11-
*/
12-
const isEmptyKey = (key) => !key || key.trim() === "";
13-
149
/**
1510
* MetadataFields is a component that allows the user to add/remove key-value pairs of metadata.
1611
* @param {Array<{key: string, value: string, touched: boolean}>} metadataFields - current metadata fields to display
@@ -33,14 +28,9 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
3328
};
3429

3530
const onBlurKey = (i) => () => {
36-
setMetadataFields(prev =>
37-
prev.map((field, idx) =>
38-
idx === i ? {...field, touched: true} : field
39-
)
40-
);
31+
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], touched: true}, ...prev.slice(i+1)]);
4132
};
4233

43-
4434
const onRemoveKeyValue = (i) => {
4535
return () => setMetadataFields(prev => [...prev.slice(0, i), ...prev.slice(i + 1)]);
4636
};
@@ -76,7 +66,7 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
7666
</Col>
7767
<Col md={{span: 1}}>
7868
<Form.Text>
79-
<Button size="sm" variant="secondary" onClick={onRemoveKeyValue(i)}>
69+
<Button size="sm" variant="secondary" onClick={onRemoveKeyValue(i)} aria-label={`Remove metadata field ${i + 1}`} >
8070
<XIcon/>
8171
</Button>
8272
</Form.Text>
@@ -93,6 +83,8 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
9383
)
9484
}
9585

86+
const isEmptyKey = (key) => !key || key.trim() === "";
87+
9688
/**
9789
* Validates metadata fields and marks empty keys as touched to show validation errors.
9890
* Use this before submitting to ensure all keys are filled in.

webui/src/lib/components/repository/metadata.test.js

Lines changed: 0 additions & 93 deletions
This file was deleted.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React from "react";
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { render, screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import { MetadataFields, validateMetadataKeys } from './metadata';
6+
7+
/**
8+
* TestContainer is a component wrapper used for testing the MetadataFields component.
9+
* It uses the actual React useState hook to manage the state passed to MetadataFields,
10+
* ensuring that state updates automatically trigger re-renders in the test environment.
11+
*
12+
* @param {object} props
13+
* @param {Array<Object>} [props.initialFields=[]] - The initial array of metadata field objects to populate the component's state.
14+
* @returns {React.JSX.Element} The MetadataFields component rendered with real state management.
15+
*/
16+
const TestContainer = ({ initialFields }) => {
17+
const [fields, setFields] = React.useState(initialFields);
18+
19+
return (<MetadataFields metadataFields={fields} setMetadataFields={setFields}/>);
20+
};
21+
22+
describe('MetadataFields validation flow', () => {
23+
it('does not show error when key is valid', () => {
24+
render(<TestContainer initialFields={[{ key: 'environment', value: 'prod', touched: false }]} />);
25+
26+
expect(screen.queryByText('Key is required')).not.toBeInTheDocument();
27+
});
28+
29+
it('shows error after user blurs empty key field', async () => {
30+
const user = userEvent.setup();
31+
32+
render(<TestContainer initialFields={[{ key: '', value: '', touched: false }]} />);
33+
expect(screen.queryByText('Key is required')).not.toBeInTheDocument();
34+
35+
const keyInput = screen.getByPlaceholderText('Key');
36+
await user.click(keyInput);
37+
await user.tab();
38+
39+
expect(await screen.findByText('Key is required')).toBeInTheDocument();
40+
});
41+
42+
it('clears error when user enters a valid key after blur', async () => {
43+
const user = userEvent.setup();
44+
45+
render(<TestContainer initialFields={[{ key: '', value: '', touched: false }]} />);
46+
47+
const keyInput = screen.getByPlaceholderText('Key');
48+
await user.click(keyInput);
49+
await user.tab();
50+
expect(await screen.findByText('Key is required')).toBeInTheDocument();
51+
52+
await user.type(keyInput, 'env');
53+
54+
expect(screen.queryByText('Key is required')).not.toBeInTheDocument();
55+
expect(keyInput).not.toHaveClass('is-invalid');
56+
expect(keyInput).toHaveValue('env');
57+
});
58+
59+
it('adds a new metadata field row when clicking Add button', async () => {
60+
const user = userEvent.setup();
61+
62+
render(<TestContainer initialFields={[]} />);
63+
64+
await user.click(screen.getByText(/Add Metadata field/i));
65+
66+
expect(screen.getAllByPlaceholderText('Key')).toHaveLength(1);
67+
expect(screen.getByPlaceholderText('Value')).toHaveValue('');
68+
});
69+
70+
it('removes the correct metadata row', async () => {
71+
const user = userEvent.setup();
72+
73+
render(<TestContainer initialFields={[
74+
{ key: 'a', value: '1', touched: false },
75+
{ key: 'b', value: '2', touched: false }
76+
]} />);
77+
78+
const firstDeleteButton = screen.getByRole('button', { name: 'Remove metadata field 1' });
79+
80+
await user.click(firstDeleteButton);
81+
82+
const keyInputs = screen.getAllByPlaceholderText('Key');
83+
expect(keyInputs).toHaveLength(1);
84+
expect(keyInputs[0]).toHaveValue('b');
85+
expect(screen.queryByDisplayValue('a')).not.toBeInTheDocument();
86+
});
87+
});
88+
89+
describe('validateMetadataKeys', () => {
90+
it('returns true for empty array', () => {
91+
const set = vi.fn();
92+
expect(validateMetadataKeys([], set)).toBe(true);
93+
expect(set).not.toHaveBeenCalled();
94+
});
95+
96+
it('returns true when all keys are valid', () => {
97+
const set = vi.fn();
98+
const fields = [
99+
{ key: "key1", value: "value1", touched: false },
100+
{ key: "key2", value: "", touched: false },
101+
];
102+
103+
expect(validateMetadataKeys(fields, set)).toBe(true);
104+
expect(set).not.toHaveBeenCalled();
105+
});
106+
107+
it('returns false when any key is empty or whitespace', () => {
108+
const set = vi.fn();
109+
const fields = [
110+
{ key: "", value: "value", touched: false },
111+
{ key: " ", value: "value", touched: false },
112+
];
113+
114+
expect(validateMetadataKeys(fields, set)).toBe(false);
115+
expect(set).toHaveBeenCalledTimes(1);
116+
});
117+
});

webui/src/pages/repositories/repository/objects.jsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ const isAbortedError = (error, controller) => {
8282
const CommitButton = ({repo, onCommit, enabled = false}) => {
8383
const textRef = useRef(null);
8484

85-
const [committing, setCommitting] = useState(false);
86-
const [show, setShow] = useState(false);
87-
const [metadataFields, setMetadataFields] = useState([]);
85+
const [committing, setCommitting] = useState(false)
86+
const [show, setShow] = useState(false)
87+
const [metadataFields, setMetadataFields] = useState([])
8888

8989
const hide = () => {
9090
if (committing) return;
@@ -98,8 +98,8 @@ const CommitButton = ({repo, onCommit, enabled = false}) => {
9898
}
9999
const message = textRef.current.value;
100100
const metadata = {};
101-
metadataFields.forEach(pair => metadata[pair.key] = pair.value);
102-
setCommitting(true);
101+
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
102+
setCommitting(true)
103103
onCommit({message, metadata}, () => {
104104
setCommitting(false)
105105
setShow(false);
@@ -130,11 +130,7 @@ const CommitButton = ({repo, onCommit, enabled = false}) => {
130130
<Button variant="secondary" disabled={committing} onClick={hide}>
131131
Cancel
132132
</Button>
133-
<Button
134-
variant="success"
135-
disabled={committing}
136-
onClick={onSubmit}
137-
>
133+
<Button variant="success" disabled={committing} onClick={onSubmit}>
138134
Commit Changes
139135
</Button>
140136
</Modal.Footer>
@@ -231,7 +227,7 @@ const ImportModal = ({config, repoId, referenceId, referenceType, path = '', onD
231227
setImportPhase(ImportPhase.InProgress);
232228
try {
233229
const metadata = {};
234-
metadataFields.forEach(pair => metadata[pair.key] = pair.value);
230+
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
235231
setImportPhase(ImportPhase.InProgress)
236232
await startImport(
237233
setImportID,
@@ -299,8 +295,7 @@ const ImportModal = ({config, repoId, referenceId, referenceType, path = '', onD
299295
importPhase={importPhase}
300296
importFunc={doImport}
301297
doneFunc={hide}
302-
isEnabled={isImportEnabled}
303-
/>
298+
isEnabled={isImportEnabled}/>
304299
</Modal.Footer>
305300
</Modal>
306301
</>

webui/test/e2e/common/commitMetadata.spec.ts

Whitespace-only changes.

webui/test/setup.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, afterEach } from 'vitest';
2+
import { cleanup } from '@testing-library/react';
3+
import * as matchers from '@testing-library/jest-dom/matchers';
4+
5+
expect.extend(matchers);
6+
7+
afterEach(() => {
8+
cleanup();
9+
});

webui/vite.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default ({ command }) => {
99
test: {
1010
environment: 'happy-dom',
1111
exclude: ["./test/e2e/**/*", "./node_modules/**/*"],
12+
setupFiles: './test/setup.js',
1213
},
1314
plugins: [
1415
replace({

0 commit comments

Comments
 (0)