Skip to content

Commit 8047280

Browse files
committed
Mark empty metadata keys as invalid
When creating a commit/merge/import, prevent submission when any metadata key is empty. When a metadata field is added, if the key field is touched but left empty, the field will be marked as invalid. If empty metadata fields are added but not touched, when the user clicks submit, the fields will be validated and marked as invalid.
1 parent 3bec196 commit 8047280

File tree

4 files changed

+147
-86
lines changed

4 files changed

+147
-86
lines changed

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useCallback, useRef, useState} from "react";
22
import {refs as refsAPI} from "../../../lib/api";
33
import {RefTypeBranch} from "../../../constants";
44
import {ActionGroup, ActionsBar, AlertError, RefreshButton} from "../controls";
5-
import {MetadataFields, filterEmptyMetadataFields} from "./metadata";
5+
import {MetadataFields, validateMetadataKeys} from "./metadata";
66
import {GitMergeIcon} from "@primer/octicons-react";
77
import Button from "react-bootstrap/Button";
88
import Modal from "react-bootstrap/Modal";
@@ -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,
@@ -72,9 +72,12 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
7272
}
7373

7474
const onSubmit = async () => {
75+
if (!validateMetadataKeys(metadataFields, setMetadataFields)) {
76+
return;
77+
}
7578
const message = textRef.current.value;
7679
const metadata = {};
77-
filterEmptyMetadataFields(metadataFields).forEach(pair => metadata[pair.key] = pair.value)
80+
metadataFields.forEach(pair => metadata[pair.key] = pair.value);
7881

7982
let strategy = mergeState.strategy;
8083
if (strategy === "none") {
@@ -147,7 +150,11 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
147150
<Button variant="secondary" disabled={mergeState.merging} onClick={hide}>
148151
Cancel
149152
</Button>
150-
<Button variant="success" disabled={mergeState.merging} onClick={onSubmit}>
153+
<Button
154+
variant="success"
155+
disabled={mergeState.merging}
156+
onClick={onSubmit}
157+
>
151158
{(mergeState.merging) ? 'Merging...' : 'Merge'}
152159
</Button>
153160
</Modal.Footer>
Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,89 @@
1-
import React, {useCallback} from "react";
1+
import React from "react";
22
import {PlusIcon, XIcon} from "@primer/octicons-react";
33
import Button from "react-bootstrap/Button";
44
import Form from "react-bootstrap/Form";
55
import Row from "react-bootstrap/Row";
66
import Col from "react-bootstrap/Col";
77

88
/**
9-
* MetadataFields component for adding metadata key-value pairs.
10-
* Used in commit, import, and merge modals.
9+
* Helper to check if a metadata key is empty or whitespace-only
10+
*/
11+
const isEmptyKey = (key) => !key || key.trim() === "";
12+
13+
/**
14+
* MetadataFields is a component that allows the user to add/remove key-value pairs of metadata.
15+
* @param {Array<{key: string, value: string, touched: boolean}>} metadataFields - current metadata fields to display
16+
* @param {Function} setMetadataFields - callback to update the metadata fields
17+
* @param rest - any other props to pass to the component
1118
*/
1219
export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) => {
13-
const onChangeKey = useCallback((i) => {
20+
const onChangeKey = (i) => {
1421
return e => {
15-
const key = e.currentTarget.value;
16-
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], key}, ...prev.slice(i+1)]);
17-
e.preventDefault()
22+
const newKey = e.currentTarget.value;
23+
setMetadataFields(prev =>
24+
prev.map((field, idx) =>
25+
idx === i ? {...field, key: newKey} : field
26+
)
27+
);
1828
};
19-
}, [setMetadataFields]);
29+
};
2030

21-
const onChangeValue = useCallback((i) => {
31+
const onChangeValue = (i) => {
2232
return e => {
23-
const value = e.currentTarget.value;
24-
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], value}, ...prev.slice(i+1)]);
33+
const newValue = e.currentTarget.value;
34+
setMetadataFields(prev =>
35+
prev.map((field, idx) =>
36+
idx === i ? {...field, value: newValue} : field
37+
)
38+
);
2539
};
26-
}, [setMetadataFields]);
40+
};
41+
42+
const onBlurKey = (i) => () => {
43+
setMetadataFields(prev =>
44+
prev.map((field, idx) =>
45+
idx === i ? {...field, touched: true} : field
46+
)
47+
);
48+
};
2749

28-
const onRemovePair = useCallback((i) => {
29-
return () => setMetadataFields(prev => [...prev.slice(0, i), ...prev.slice(i + 1)])
30-
}, [setMetadataFields])
3150

32-
const onAddPair = useCallback(() => {
33-
setMetadataFields(prev => [...prev, {key: "", value: ""}])
34-
}, [setMetadataFields])
51+
const onRemoveKeyValue = (i) => () => {
52+
setMetadataFields(prev => prev.filter((_, idx) => idx !== i));
53+
};
54+
55+
const onAddKeyValue = () => {
56+
setMetadataFields(prev => [...prev, {key: "", value: "", touched: false}]);
57+
};
3558

3659
return (
3760
<div className="mt-3 mb-3" {...rest}>
3861
{metadataFields.map((f, i) => {
62+
const showError = isEmptyKey(f.key) && f.touched;
3963
return (
4064
<Form.Group key={`commit-metadata-field-${i}`} className="mb-3">
4165
<Row>
4266
<Col md={{span: 5}}>
43-
<Form.Control type="text" placeholder="Key" defaultValue={f.key} onChange={onChangeKey(i)}/>
67+
<Form.Control
68+
type="text"
69+
placeholder="Key"
70+
value={f.key}
71+
onChange={onChangeKey(i)}
72+
onBlur={onBlurKey(i)}
73+
isInvalid={showError}
74+
/>
75+
{showError && (
76+
<Form.Control.Feedback type="invalid">
77+
Key is required
78+
</Form.Control.Feedback>
79+
)}
4480
</Col>
4581
<Col md={{span: 5}}>
46-
<Form.Control type="text" placeholder="Value" defaultValue={f.value} onChange={onChangeValue(i)}/>
82+
<Form.Control type="text" placeholder="Value" value={f.value} onChange={onChangeValue(i)}/>
4783
</Col>
4884
<Col md={{span: 1}}>
4985
<Form.Text>
50-
<Button size="sm" variant="secondary" onClick={onRemovePair(i)}>
86+
<Button size="sm" variant="secondary" onClick={onRemoveKeyValue(i)}>
5187
<XIcon/>
5288
</Button>
5389
</Form.Text>
@@ -56,7 +92,7 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
5692
</Form.Group>
5793
)
5894
})}
59-
<Button onClick={onAddPair} size="sm" variant="secondary">
95+
<Button onClick={onAddKeyValue} size="sm" variant="secondary">
6096
<PlusIcon/>{' '}
6197
Add Metadata field
6298
</Button>
@@ -65,15 +101,19 @@ export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) =>
65101
}
66102

67103
/**
68-
* Filters out metadata fields where both key and value are empty or whitespace-only.
69-
* Allows fields with empty values (but non-empty keys) or empty keys (but non-empty values) matching the current CLI behavior.
104+
* Validates metadata fields and marks empty keys as touched to show validation errors.
105+
* Use this before submitting to ensure all keys are filled in.
70106
*
71-
* @param {Array<{key: string, value: string}>} metadataFields - Array of metadata field objects
72-
* @returns {Array<{key: string, value: string}>} Filtered array excluding fields where both key and value are empty
107+
* @param {Array<{key: string, value: string, touched: boolean}>} metadataFields - Array of metadata field objects
108+
* @param {Function} setMetadataFields - Setter function to update metadata fields
109+
* @returns {boolean} True if validation passed (no empty keys), false if validation failed
73110
*/
74-
export const filterEmptyMetadataFields = (metadataFields) => {
75-
return metadataFields.filter(field =>
76-
(field.key && field.key.trim() !== "") ||
77-
(field.value && field.value.trim() !== "")
111+
export const validateMetadataKeys = (metadataFields, setMetadataFields) => {
112+
const hasEmptyKeys = metadataFields.some(f => isEmptyKey(f.key));
113+
if (!hasEmptyKeys) return true;
114+
115+
setMetadataFields(prev =>
116+
prev.map(f => isEmptyKey(f.key) ? {...f, touched: true} : f)
78117
);
79-
};
118+
return false;
119+
};
Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,93 @@
11
import { describe, it, expect } from 'vitest';
2-
import { filterEmptyMetadataFields } from './metadata';
2+
import { hasEmptyMetadataKeys } from './metadata';
33

4-
describe('filterEmptyMetadataFields', () => {
5-
it('should include fields with both key and value', () => {
4+
describe('hasEmptyMetadataKeys', () => {
5+
it('should return false when all fields have non-empty keys', () => {
66
const input = [
77
{ key: 'key1', value: 'value1' },
88
{ key: 'key2', value: 'value2' },
99
];
10-
const result = filterEmptyMetadataFields(input);
11-
expect(result).toEqual(input);
10+
const result = hasEmptyMetadataKeys(input);
11+
expect(result).toBe(false);
1212
});
1313

14-
it('should include fields with non-empty key and empty value', () => {
14+
it('should return false when fields have non-empty keys and empty values', () => {
1515
const input = [
1616
{ key: 'key_with_empty_value', value: '' },
1717
{ key: 'tag', value: 'v1.0' }
1818
];
19-
const result = filterEmptyMetadataFields(input);
20-
expect(result).toEqual(input);
19+
const result = hasEmptyMetadataKeys(input);
20+
expect(result).toBe(false);
2121
});
2222

23-
it('should include fields with empty key and non-empty value', () => {
23+
it('should return true when any field has an empty key', () => {
2424
const input = [
2525
{ key: '', value: 'value for empty key' },
2626
{ key: 'tag', value: 'v1.0' }
2727
];
28-
const result = filterEmptyMetadataFields(input);
29-
expect(result).toEqual(input);
28+
const result = hasEmptyMetadataKeys(input);
29+
expect(result).toBe(true);
3030
});
3131

32-
it('should filter out fields where both key and value are empty', () => {
32+
it('should return true when field has empty key and empty value', () => {
3333
const input = [
3434
{ key: 'key1', value: 'value1' },
3535
{ key: '', value: '' },
3636
{ key: 'key2', value: 'value2' },
3737
];
38-
const expected = [
39-
{ key: 'key1', value: 'value1' },
40-
{ key: 'key2', value: 'value2' },
41-
];
42-
const result = filterEmptyMetadataFields(input);
43-
expect(result).toEqual(expected);
38+
const result = hasEmptyMetadataKeys(input);
39+
expect(result).toBe(true);
4440
});
4541

46-
it('should filter out fields where both key and value are whitespace only', () => {
42+
it('should return true when field has whitespace-only key', () => {
4743
const input = [
4844
{ key: 'key1', value: 'value1' },
49-
{ key: ' ', value: ' ' },
50-
{ key: '\t', value: '\n' },
45+
{ key: ' ', value: 'value' },
5146
{ key: 'key2', value: 'value2' },
5247
];
53-
const expected = [
54-
{ key: 'key1', value: 'value1' },
55-
{ key: 'key2', value: 'value2' },
56-
];
57-
const result = filterEmptyMetadataFields(input);
58-
expect(result).toEqual(expected);
48+
const result = hasEmptyMetadataKeys(input);
49+
expect(result).toBe(true);
5950
});
6051

61-
it('should handle empty array', () => {
52+
it('should return false for empty array', () => {
6253
const input = [];
63-
const result = filterEmptyMetadataFields(input);
64-
expect(result).toEqual([]);
54+
const result = hasEmptyMetadataKeys(input);
55+
expect(result).toBe(false);
6556
});
6657

67-
it('should handle array with only empty fields', () => {
58+
it('should return true when all fields have empty keys', () => {
6859
const input = [
69-
{ key: '', value: '' },
70-
{ key: ' ', value: ' ' }
60+
{ key: '', value: 'value1' },
61+
{ key: ' ', value: 'value2' }
7162
];
72-
const result = filterEmptyMetadataFields(input);
73-
expect(result).toEqual([]);
63+
const result = hasEmptyMetadataKeys(input);
64+
expect(result).toBe(true);
7465
});
7566

76-
it('should include fields with whitespace in key but non-empty value', () => {
67+
it('should return true when key is whitespace with non-empty value', () => {
7768
const input = [
7869
{ key: ' ', value: 'has value' }
7970
];
80-
const result = filterEmptyMetadataFields(input);
81-
expect(result).toEqual(input);
71+
const result = hasEmptyMetadataKeys(input);
72+
expect(result).toBe(true);
8273
});
8374

84-
it('should include fields with non-empty key but whitespace in value', () => {
75+
it('should return false when key is non-empty even with whitespace value', () => {
8576
const input = [
8677
{ key: 'haskey', value: ' ' }
8778
];
88-
const result = filterEmptyMetadataFields(input);
89-
expect(result).toEqual(input);
79+
const result = hasEmptyMetadataKeys(input);
80+
expect(result).toBe(false);
81+
});
82+
83+
it('should return true when at least one key is empty among multiple fields', () => {
84+
const input = [
85+
{ key: 'key1', value: 'value1' },
86+
{ key: 'key2', value: 'value2' },
87+
{ key: '', value: 'value3' },
88+
{ key: 'key4', value: 'value4' },
89+
];
90+
const result = hasEmptyMetadataKeys(input);
91+
expect(result).toBe(true);
9092
});
9193
});

0 commit comments

Comments
 (0)