Skip to content
Open
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
482 changes: 282 additions & 200 deletions webui/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@
"@stoplight/prism-cli": "^5.14.2",
"@stoplight/prism-core": "^5.8.0",
"@stoplight/prism-http-server": "^5.12.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bootstrap": "^5.2.10",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {useCallback, useRef, useState} from "react";
import {refs as refsAPI} from "../../../lib/api";
import {RefTypeBranch} from "../../../constants";
import {ActionGroup, ActionsBar, AlertError, RefreshButton} from "../controls";
import {MetadataFields} from "./metadata";
import {MetadataFields, validateMetadataKeys} from "./metadata";
import {GitMergeIcon} from "@primer/octicons-react";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
Expand Down Expand Up @@ -72,6 +72,9 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
}

const onSubmit = async () => {
if (!validateMetadataKeys(metadataFields, setMetadataFields)) {
return;
}
const message = textRef.current.value;
const metadata = {};
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
Expand Down
45 changes: 41 additions & 4 deletions webui/src/lib/components/repository/metadata.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Col from "react-bootstrap/Col";

/**
* MetadataFields is a component that allows the user to add/remove key-value pairs of metadata.
* @param {Array<{key: string, value: string}>} metadataFields - current metadata fields to display
* @param {Array<{key: string, value: string, touched: boolean}>} metadataFields - current metadata fields to display
* @param {Function} setMetadataFields - callback to update the metadata fields
* @param rest - any other props to pass to the component
*/
Expand All @@ -27,29 +27,46 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
};
};

const onBlurKey = (i) => () => {
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], touched: true}, ...prev.slice(i+1)]);
};

const onRemoveKeyValue = (i) => {
return () => setMetadataFields(prev => [...prev.slice(0, i), ...prev.slice(i + 1)]);
};

const onAddKeyValue = () => {
setMetadataFields(prev => [...prev, {key: "", value: ""}])
setMetadataFields(prev => [...prev, {key: "", value: "", touched: false}]);
};

return (
<div className="mt-3 mb-3" {...rest}>
{metadataFields.map((f, i) => {
const showError = isEmptyKey(f.key) && f.touched;
return (
<Form.Group key={`commit-metadata-field-${i}`} className="mb-3">
<Row>
<Col md={{span: 5}}>
<Form.Control type="text" placeholder="Key" value={f.key} onChange={onChangeKey(i)}/>
<Form.Control
type="text"
placeholder="Key"
value={f.key}
onChange={onChangeKey(i)}
onBlur={onBlurKey(i)}
isInvalid={showError}
/>
{showError && (
<Form.Control.Feedback type="invalid">
Key is required
</Form.Control.Feedback>
)}
</Col>
<Col md={{span: 5}}>
<Form.Control type="text" placeholder="Value" value={f.value} onChange={onChangeValue(i)}/>
</Col>
<Col md={{span: 1}}>
<Form.Text>
<Button size="sm" variant="secondary" onClick={onRemoveKeyValue(i)}>
<Button size="sm" variant="secondary" onClick={onRemoveKeyValue(i)} aria-label={`Remove metadata field ${i + 1}`} >
<XIcon/>
</Button>
</Form.Text>
Expand All @@ -65,3 +82,23 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
</div>
)
}

const isEmptyKey = (key) => !key || key.trim() === "";

/**
* Validates metadata fields and marks empty keys as touched to show validation errors.
* Use this before submitting to ensure all keys are filled in.
*
* @param {Array<{key: string, value: string, touched: boolean}>} metadataFields - Array of metadata field objects
* @param {Function} setMetadataFields - Setter function to update metadata fields
* @returns {boolean} True if validation passed (no empty keys), false if validation failed
*/
export const validateMetadataKeys = (metadataFields, setMetadataFields) => {
const hasEmptyKeys = metadataFields.some(f => isEmptyKey(f.key));
if (!hasEmptyKeys) return true;

setMetadataFields(prev =>
prev.map(f => isEmptyKey(f.key) ? {...f, touched: true} : f)
);
return false;
};
129 changes: 129 additions & 0 deletions webui/src/lib/components/repository/metadata.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from "react";
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MetadataFields, validateMetadataKeys } from './metadata';

/**
* MetadataFieldsWrapper is a component wrapper used for testing the MetadataFields component.
* It uses the actual React useState hook to manage the state passed to MetadataFields,
* ensuring that state updates automatically trigger re-renders in the test environment.
*
* @param {object} props
* @param {Array<Object>} [props.initialFields=[]] - The initial array of metadata field objects to populate the component's state.
* @returns {React.JSX.Element} The MetadataFields component rendered with real state management.
*/
const MetadataFieldsWrapper = ({ initialFields }) => {
const [fields, setFields] = React.useState(initialFields);

return (<MetadataFields metadataFields={fields} setMetadataFields={setFields}/>);
};

describe('MetadataFields validation flow', () => {
it('does not show error when key is valid', () => {
render(<MetadataFieldsWrapper initialFields={[{ key: 'environment', value: 'prod', touched: false }]} />);

expect(screen.queryByText('Key is required')).not.toBeInTheDocument();
});

it('shows error when key is empty', () => {
render(<MetadataFieldsWrapper initialFields={[{ key: '', value: '', touched: true }]} />);

expect(screen.getByText('Key is required')).toBeInTheDocument();
});

it('shows error when key is whitespace only', () => {
render(<MetadataFieldsWrapper initialFields={[{ key: ' ', value: '', touched: true }]} />);

expect(screen.getByText('Key is required')).toBeInTheDocument();
});

it('shows error after user blurs empty key field', async () => {
const user = userEvent.setup();

render(<MetadataFieldsWrapper initialFields={[{ key: '', value: '', touched: false }]} />);
expect(screen.queryByText('Key is required')).not.toBeInTheDocument();

const keyInput = screen.getByPlaceholderText('Key');
await user.click(keyInput);
await user.tab();

expect(await screen.findByText('Key is required')).toBeInTheDocument();
});

it('clears error when user enters a valid key after blur', async () => {
const user = userEvent.setup();

render(<MetadataFieldsWrapper initialFields={[{ key: '', value: '', touched: false }]} />);

const keyInput = screen.getByPlaceholderText('Key');
await user.click(keyInput);
await user.tab();
expect(await screen.findByText('Key is required')).toBeInTheDocument();

await user.type(keyInput, 'env');

expect(screen.queryByText('Key is required')).not.toBeInTheDocument();
expect(keyInput).not.toHaveClass('is-invalid');
expect(keyInput).toHaveValue('env');
});

it('adds a new metadata field row when clicking Add button', async () => {
const user = userEvent.setup();

render(<MetadataFieldsWrapper initialFields={[]} />);

await user.click(screen.getByText(/Add Metadata field/i));

expect(screen.getAllByPlaceholderText('Key')).toHaveLength(1);
expect(screen.getByPlaceholderText('Value')).toHaveValue('');
});

it('removes the correct metadata row', async () => {
const user = userEvent.setup();

render(<MetadataFieldsWrapper initialFields={[
{ key: 'a', value: '1', touched: false },
{ key: 'b', value: '2', touched: false }
]} />);

const firstDeleteButton = screen.getByRole('button', { name: 'Remove metadata field 1' });

await user.click(firstDeleteButton);

const keyInputs = screen.getAllByPlaceholderText('Key');
expect(keyInputs).toHaveLength(1);
expect(keyInputs[0]).toHaveValue('b');
expect(screen.queryByDisplayValue('a')).not.toBeInTheDocument();
});
});

describe('validateMetadataKeys', () => {
it('returns true for empty array', () => {
const setMetadataFields = vi.fn();
expect(validateMetadataKeys([], setMetadataFields)).toBe(true);
expect(setMetadataFields).not.toHaveBeenCalled();
});

it('returns true when all keys are valid', () => {
const setMetadataFields = vi.fn();
const fields = [
{ key: "key1", value: "value1", touched: false },
{ key: "key2", value: "", touched: false },
];

expect(validateMetadataKeys(fields, setMetadataFields)).toBe(true);
expect(setMetadataFields).not.toHaveBeenCalled();
});

it('returns false when any key is empty or whitespace', () => {
const setMetadataFields = vi.fn();
const fields = [
{ key: "", value: "value", touched: false },
{ key: " ", value: "value", touched: false },
];

expect(validateMetadataKeys(fields, setMetadataFields)).toBe(false);
expect(setMetadataFields).toHaveBeenCalledTimes(1);
});
});
11 changes: 9 additions & 2 deletions webui/src/pages/repositories/repository/objects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {useDropzone} from "react-dropzone";
import pMap from "p-map";
import {formatAlertText} from "../../../lib/components/repository/errors";
import {ChangesTreeContainer} from "../../../lib/components/repository/changes";
import {MetadataFields} from "../../../lib/components/repository/metadata";
import {MetadataFields, validateMetadataKeys} from "../../../lib/components/repository/metadata";
import {ConfirmationModal} from "../../../lib/components/modals";
import { Link } from "../../../lib/components/nav";
import Card from "react-bootstrap/Card";
Expand Down Expand Up @@ -88,10 +88,14 @@ const CommitButton = ({repo, onCommit, enabled = false}) => {

const hide = () => {
if (committing) return;
setShow(false)
setShow(false);
setMetadataFields([]);
}

const onSubmit = () => {
if (!validateMetadataKeys(metadataFields, setMetadataFields)) {
return;
}
const message = textRef.current.value;
const metadata = {};
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
Expand Down Expand Up @@ -217,6 +221,9 @@ const ImportModal = ({config, repoId, referenceId, referenceType, path = '', onD
};

const doImport = async () => {
if (!validateMetadataKeys(metadataFields, setMetadataFields)) {
return;
}
setImportPhase(ImportPhase.InProgress);
try {
const metadata = {};
Expand Down
9 changes: 9 additions & 0 deletions webui/test/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';

expect.extend(matchers);

afterEach(() => {
cleanup();
});
1 change: 1 addition & 0 deletions webui/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default ({ command }) => {
test: {
environment: 'happy-dom',
exclude: ["./test/e2e/**/*", "./node_modules/**/*"],
setupFiles: './test/setup.js',
},
plugins: [
replace({
Expand Down
Loading