Skip to content

Commit 3bec196

Browse files
committed
Filter empty key-value metadata pairs in UI
Previously the UI for adding metadata fields allowed empty key-value pairs to be added. This is different from the CLI behaviour which filters them. This change moves the `MetadataFields` component into its own file and adds a `filterEmptyMetadataFields` utility which is now used in the `onSubmit` behaviour for the modals that make use of this component. Closes #5251
1 parent f10b322 commit 3bec196

File tree

6 files changed

+179
-69
lines changed

6 files changed

+179
-69
lines changed

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

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,18 @@
1-
import React, {useCallback, useEffect, useState} from "react";
1+
import React, {useEffect, useState} from "react";
22

33
import {
4-
ClockIcon, FileDirectoryFillIcon, FoldDownIcon, FoldUpIcon,
5-
PlusIcon,
6-
XIcon
4+
ClockIcon, FileDirectoryFillIcon, FoldDownIcon, FoldUpIcon
75
} from "@primer/octicons-react";
86

97
import {useAPIWithPagination} from "../../hooks/api";
108
import {useExpandCollapseDirs} from "../../hooks/useExpandCollapseDirs";
119
import {AlertError, TooltipButton} from "../controls";
1210
import {ObjectsDiff} from "./ObjectsDiff";
1311
import {ObjectTreeEntryRow, PrefixTreeEntryRow} from "./treeRows";
14-
import Button from "react-bootstrap/Button";
1512
import Card from "react-bootstrap/Card";
1613
import Table from "react-bootstrap/Table";
1714
import Alert from "react-bootstrap/Alert";
1815
import {refs} from "../../api";
19-
import Form from "react-bootstrap/Form";
20-
import Row from "react-bootstrap/Row";
21-
import Col from "react-bootstrap/Col";
2216

2317
/**
2418
* Tree item is a node in the tree view. It can be expanded to multiple TreeEntryRow:
@@ -253,58 +247,3 @@ export const ChangesTreeContainer = ({results, delimiter, uriNavigator,
253247
export const defaultGetMoreChanges = (repo, leftRefId, rightRefId, delimiter) => (afterUpdated, path, useDelimiter= true, amount = -1) => {
254248
return refs.diff(repo.id, leftRefId, rightRefId, afterUpdated, path, useDelimiter ? delimiter : "", amount > 0 ? amount : undefined);
255249
};
256-
257-
export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) => {
258-
const onChangeKey = useCallback((i) => {
259-
return e => {
260-
const key = e.currentTarget.value;
261-
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], key}, ...prev.slice(i+1)]);
262-
e.preventDefault()
263-
};
264-
}, [setMetadataFields]);
265-
266-
const onChangeValue = useCallback((i) => {
267-
return e => {
268-
const value = e.currentTarget.value;
269-
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], value}, ...prev.slice(i+1)]);
270-
};
271-
}, [setMetadataFields]);
272-
273-
const onRemovePair = useCallback((i) => {
274-
return () => setMetadataFields(prev => [...prev.slice(0, i), ...prev.slice(i + 1)])
275-
}, [setMetadataFields])
276-
277-
const onAddPair = useCallback(() => {
278-
setMetadataFields(prev => [...prev, {key: "", value: ""}])
279-
}, [setMetadataFields])
280-
281-
return (
282-
<div className="mt-3 mb-3" {...rest}>
283-
{metadataFields.map((f, i) => {
284-
return (
285-
<Form.Group key={`commit-metadata-field-${i}`} className="mb-3">
286-
<Row>
287-
<Col md={{span: 5}}>
288-
<Form.Control type="text" placeholder="Key" defaultValue={f.key} onChange={onChangeKey(i)}/>
289-
</Col>
290-
<Col md={{span: 5}}>
291-
<Form.Control type="text" placeholder="Value" defaultValue={f.value} onChange={onChangeValue(i)}/>
292-
</Col>
293-
<Col md={{span: 1}}>
294-
<Form.Text>
295-
<Button size="sm" variant="secondary" onClick={onRemovePair(i)}>
296-
<XIcon/>
297-
</Button>
298-
</Form.Text>
299-
</Col>
300-
</Row>
301-
</Form.Group>
302-
)
303-
})}
304-
<Button onClick={onAddPair} size="sm" variant="secondary">
305-
<PlusIcon/>{' '}
306-
Add Metadata field
307-
</Button>
308-
</div>
309-
)
310-
}

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

Lines changed: 2 additions & 2 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} from "./changes";
5+
import {MetadataFields, filterEmptyMetadataFields} from "./metadata";
66
import {GitMergeIcon} from "@primer/octicons-react";
77
import Button from "react-bootstrap/Button";
88
import Modal from "react-bootstrap/Modal";
@@ -74,7 +74,7 @@ const MergeButton = ({repo, onDone, source, dest, disabled = false}) => {
7474
const onSubmit = async () => {
7575
const message = textRef.current.value;
7676
const metadata = {};
77-
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
77+
filterEmptyMetadataFields(metadataFields).forEach(pair => metadata[pair.key] = pair.value)
7878

7979
let strategy = mergeState.strategy;
8080
if (strategy === "none") {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, {useCallback} from "react";
2+
import {PlusIcon, XIcon} from "@primer/octicons-react";
3+
import Button from "react-bootstrap/Button";
4+
import Form from "react-bootstrap/Form";
5+
import Row from "react-bootstrap/Row";
6+
import Col from "react-bootstrap/Col";
7+
8+
/**
9+
* MetadataFields component for adding metadata key-value pairs.
10+
* Used in commit, import, and merge modals.
11+
*/
12+
export const MetadataFields = ({ metadataFields, setMetadataFields, ...rest}) => {
13+
const onChangeKey = useCallback((i) => {
14+
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()
18+
};
19+
}, [setMetadataFields]);
20+
21+
const onChangeValue = useCallback((i) => {
22+
return e => {
23+
const value = e.currentTarget.value;
24+
setMetadataFields(prev => [...prev.slice(0,i), {...prev[i], value}, ...prev.slice(i+1)]);
25+
};
26+
}, [setMetadataFields]);
27+
28+
const onRemovePair = useCallback((i) => {
29+
return () => setMetadataFields(prev => [...prev.slice(0, i), ...prev.slice(i + 1)])
30+
}, [setMetadataFields])
31+
32+
const onAddPair = useCallback(() => {
33+
setMetadataFields(prev => [...prev, {key: "", value: ""}])
34+
}, [setMetadataFields])
35+
36+
return (
37+
<div className="mt-3 mb-3" {...rest}>
38+
{metadataFields.map((f, i) => {
39+
return (
40+
<Form.Group key={`commit-metadata-field-${i}`} className="mb-3">
41+
<Row>
42+
<Col md={{span: 5}}>
43+
<Form.Control type="text" placeholder="Key" defaultValue={f.key} onChange={onChangeKey(i)}/>
44+
</Col>
45+
<Col md={{span: 5}}>
46+
<Form.Control type="text" placeholder="Value" defaultValue={f.value} onChange={onChangeValue(i)}/>
47+
</Col>
48+
<Col md={{span: 1}}>
49+
<Form.Text>
50+
<Button size="sm" variant="secondary" onClick={onRemovePair(i)}>
51+
<XIcon/>
52+
</Button>
53+
</Form.Text>
54+
</Col>
55+
</Row>
56+
</Form.Group>
57+
)
58+
})}
59+
<Button onClick={onAddPair} size="sm" variant="secondary">
60+
<PlusIcon/>{' '}
61+
Add Metadata field
62+
</Button>
63+
</div>
64+
)
65+
}
66+
67+
/**
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.
70+
*
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
73+
*/
74+
export const filterEmptyMetadataFields = (metadataFields) => {
75+
return metadataFields.filter(field =>
76+
(field.key && field.key.trim() !== "") ||
77+
(field.value && field.value.trim() !== "")
78+
);
79+
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { filterEmptyMetadataFields } from './metadata';
3+
4+
describe('filterEmptyMetadataFields', () => {
5+
it('should include fields with both key and value', () => {
6+
const input = [
7+
{ key: 'key1', value: 'value1' },
8+
{ key: 'key2', value: 'value2' },
9+
];
10+
const result = filterEmptyMetadataFields(input);
11+
expect(result).toEqual(input);
12+
});
13+
14+
it('should include fields with non-empty key and empty value', () => {
15+
const input = [
16+
{ key: 'key_with_empty_value', value: '' },
17+
{ key: 'tag', value: 'v1.0' }
18+
];
19+
const result = filterEmptyMetadataFields(input);
20+
expect(result).toEqual(input);
21+
});
22+
23+
it('should include fields with empty key and non-empty value', () => {
24+
const input = [
25+
{ key: '', value: 'value for empty key' },
26+
{ key: 'tag', value: 'v1.0' }
27+
];
28+
const result = filterEmptyMetadataFields(input);
29+
expect(result).toEqual(input);
30+
});
31+
32+
it('should filter out fields where both key and value are empty', () => {
33+
const input = [
34+
{ key: 'key1', value: 'value1' },
35+
{ key: '', value: '' },
36+
{ key: 'key2', value: 'value2' },
37+
];
38+
const expected = [
39+
{ key: 'key1', value: 'value1' },
40+
{ key: 'key2', value: 'value2' },
41+
];
42+
const result = filterEmptyMetadataFields(input);
43+
expect(result).toEqual(expected);
44+
});
45+
46+
it('should filter out fields where both key and value are whitespace only', () => {
47+
const input = [
48+
{ key: 'key1', value: 'value1' },
49+
{ key: ' ', value: ' ' },
50+
{ key: '\t', value: '\n' },
51+
{ key: 'key2', value: 'value2' },
52+
];
53+
const expected = [
54+
{ key: 'key1', value: 'value1' },
55+
{ key: 'key2', value: 'value2' },
56+
];
57+
const result = filterEmptyMetadataFields(input);
58+
expect(result).toEqual(expected);
59+
});
60+
61+
it('should handle empty array', () => {
62+
const input = [];
63+
const result = filterEmptyMetadataFields(input);
64+
expect(result).toEqual([]);
65+
});
66+
67+
it('should handle array with only empty fields', () => {
68+
const input = [
69+
{ key: '', value: '' },
70+
{ key: ' ', value: ' ' }
71+
];
72+
const result = filterEmptyMetadataFields(input);
73+
expect(result).toEqual([]);
74+
});
75+
76+
it('should include fields with whitespace in key but non-empty value', () => {
77+
const input = [
78+
{ key: ' ', value: 'has value' }
79+
];
80+
const result = filterEmptyMetadataFields(input);
81+
expect(result).toEqual(input);
82+
});
83+
84+
it('should include fields with non-empty key but whitespace in value', () => {
85+
const input = [
86+
{ key: 'haskey', value: ' ' }
87+
];
88+
const result = filterEmptyMetadataFields(input);
89+
expect(result).toEqual(input);
90+
});
91+
});

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ import { getRepoStorageConfig } from "./utils";
4444
import {useDropzone} from "react-dropzone";
4545
import pMap from "p-map";
4646
import {formatAlertText} from "../../../lib/components/repository/errors";
47-
import {ChangesTreeContainer, MetadataFields} from "../../../lib/components/repository/changes";
47+
import {ChangesTreeContainer} from "../../../lib/components/repository/changes";
48+
import {MetadataFields, filterEmptyMetadataFields} from "../../../lib/components/repository/metadata";
4849
import {ConfirmationModal} from "../../../lib/components/modals";
4950
import { Link } from "../../../lib/components/nav";
5051
import Card from "react-bootstrap/Card";
@@ -93,7 +94,7 @@ const CommitButton = ({repo, onCommit, enabled = false}) => {
9394
const onSubmit = () => {
9495
const message = textRef.current.value;
9596
const metadata = {};
96-
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
97+
filterEmptyMetadataFields(metadataFields).forEach(pair => metadata[pair.key] = pair.value)
9798
setCommitting(true)
9899
onCommit({message, metadata}, () => {
99100
setCommitting(false)
@@ -219,7 +220,7 @@ const ImportModal = ({config, repoId, referenceId, referenceType, path = '', onD
219220
setImportPhase(ImportPhase.InProgress);
220221
try {
221222
const metadata = {};
222-
metadataFields.forEach(pair => metadata[pair.key] = pair.value)
223+
filterEmptyMetadataFields(metadataFields).forEach(pair => metadata[pair.key] = pair.value)
223224
setImportPhase(ImportPhase.InProgress)
224225
await startImport(
225226
setImportID,

webui/src/pages/repositories/services/import_data.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import React, {useState} from "react";
77
import Button from "react-bootstrap/Button";
88
import Alert from "react-bootstrap/Alert";
99
import Form from "react-bootstrap/Form";
10-
import {MetadataFields} from "../../../lib/components/repository/changes";
10+
import {MetadataFields} from "../../../lib/components/repository/metadata";
1111

1212
const ImportPhase = {
1313
NotStarted: 0,

0 commit comments

Comments
 (0)