Skip to content

Commit 4da2d33

Browse files
committed
feat(bulk-import): add fields for annotations, labels and spec input
Signed-off-by: Yi Cai <[email protected]>
1 parent 00fbc7d commit 4da2d33

File tree

6 files changed

+272
-10
lines changed

6 files changed

+272
-10
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useState } from 'react';
2+
3+
import { FormHelperText, TextField } from '@material-ui/core';
4+
5+
import { PullRequestPreview, PullRequestPreviewData } from '../../types';
6+
7+
interface KeyValueTextFieldProps {
8+
repoName: string;
9+
label: string;
10+
name: string;
11+
value: string;
12+
onChange: (
13+
event: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement>,
14+
) => void;
15+
formErrors: PullRequestPreviewData;
16+
setFormErrors: (value: React.SetStateAction<PullRequestPreviewData>) => void;
17+
}
18+
19+
const validateKeyValuePairs = (value: string): string | null => {
20+
const keyValuePairs = value.split(';').map(pair => pair.trim());
21+
for (const pair of keyValuePairs) {
22+
if (pair) {
23+
const [key, val] = pair.split(':').map(part => part.trim());
24+
if (!key || !val) {
25+
return 'Each entry must have a key and a value separated by a colon.';
26+
}
27+
}
28+
}
29+
return null;
30+
};
31+
32+
const KeyValueTextField: React.FC<KeyValueTextFieldProps> = ({
33+
repoName,
34+
label,
35+
name,
36+
value,
37+
onChange,
38+
setFormErrors,
39+
formErrors,
40+
}) => {
41+
const [error, setError] = useState<string | null>(null);
42+
const fieldName = name.split('.').pop() ?? '';
43+
44+
const removeError = () => {
45+
const err = { ...formErrors };
46+
if (err[repoName]) {
47+
delete err[repoName][fieldName as keyof PullRequestPreview];
48+
}
49+
setFormErrors(err);
50+
};
51+
52+
const getUpdatedFormError = (
53+
validationError: string,
54+
): PullRequestPreviewData => {
55+
return {
56+
...formErrors,
57+
[repoName]: {
58+
...formErrors[repoName],
59+
[fieldName]: validationError,
60+
},
61+
};
62+
};
63+
64+
const handleChange = (
65+
event: React.FocusEvent<HTMLTextAreaElement | HTMLInputElement, Element>,
66+
) => {
67+
const validationError = validateKeyValuePairs(event.target.value);
68+
if (validationError) {
69+
setError(validationError);
70+
setFormErrors(getUpdatedFormError(validationError));
71+
} else {
72+
setError(null);
73+
removeError();
74+
}
75+
onChange(event);
76+
};
77+
78+
return (
79+
<div>
80+
<TextField
81+
multiline
82+
label={label}
83+
placeholder="key1: value2; key2: value2"
84+
variant="outlined"
85+
margin="normal"
86+
fullWidth
87+
name={name}
88+
value={value}
89+
onChange={handleChange}
90+
error={!!error}
91+
helperText={error}
92+
/>
93+
<FormHelperText style={{ marginLeft: '0.8rem' }}>
94+
Use semicolon to separate {label.toLowerCase()}
95+
</FormHelperText>
96+
</div>
97+
);
98+
};
99+
100+
export default KeyValueTextField;

plugins/bulk-import/src/components/PreviewFile/PreviewFile.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,14 @@ export const PreviewFileSidebar = ({
145145
{repositoryType === RepositorySelection.Repository ? (
146146
<>
147147
<Typography variant="h5">
148-
{`${data.orgName || data.organizationUrl}/${data.repoName}`}
148+
{`${data.orgName ?? data.organizationUrl}/${data.repoName}`}
149149
</Typography>
150-
<Link to={data.repoUrl || ''}>{data.repoUrl}</Link>
150+
<Link to={data.repoUrl ?? ''}>{data.repoUrl}</Link>
151151
</>
152152
) : (
153153
<>
154154
<Typography variant="h5">{`${data.orgName}`}</Typography>
155-
<Link to={data.repoUrl || ''}>{data.organizationUrl}</Link>
155+
<Link to={data.repoUrl ?? ''}>{data.organizationUrl}</Link>
156156
</>
157157
)}
158158
</div>
@@ -171,7 +171,7 @@ export const PreviewFileSidebar = ({
171171
{repositoryType === RepositorySelection.Repository &&
172172
data.catalogInfoYaml?.prTemplate && (
173173
<PreviewPullRequest
174-
repoName={data.repoName || ''}
174+
repoName={data.repoName ?? ''}
175175
pullRequest={pullRequest}
176176
setPullRequest={setPullRequest}
177177
formErrors={formErrors as PullRequestPreviewData}

plugins/bulk-import/src/components/PreviewFile/PreviewPullRequest.tsx

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react';
22
import { useAsync } from 'react-use';
33

4+
import { Entity, EntityMeta } from '@backstage/catalog-model';
45
import { useApi } from '@backstage/core-plugin-api';
56
import {
67
PreviewCatalogInfoComponent,
@@ -23,6 +24,8 @@ import Typography from '@mui/material/Typography';
2324
import { useFormikContext } from 'formik';
2425

2526
import { AddRepositoriesFormValues, PullRequestPreviewData } from '../../types';
27+
import { getYamlKeyValuePairs } from '../../utils/repository-utils';
28+
import KeyValueTextField from './KeyValueTextField';
2629

2730
const useDrawerContentStyles = makeStyles(theme => ({
2831
previewCard: {
@@ -53,7 +56,7 @@ export const PreviewPullRequest = ({
5356
values.approvalTool === 'git' ? 'Pull request' : 'ServiceNow ticket';
5457

5558
const [entityOwner, setEntityOwner] = React.useState<string | null>(
56-
pullRequest[repoName]?.entityOwner || '',
59+
pullRequest[repoName]?.entityOwner ?? '',
5760
);
5861
const { loading: entitiesLoading, value: entities } = useAsync(async () => {
5962
const allEntities = await catalogApi.getEntities({
@@ -64,7 +67,7 @@ export const PreviewPullRequest = ({
6467

6568
return allEntities.items
6669
.map(e => humanizeEntityRef(e, { defaultNamespace: 'true' }))
67-
.sort();
70+
.sort((a, b) => a.localeCompare(b));
6871
});
6972
React.useEffect(() => {
7073
const newFormErrors = {
@@ -158,8 +161,80 @@ export const PreviewPullRequest = ({
158161
setFormErrors(err);
159162
}
160163
}
164+
if (event.target.name.split('.').find(s => s === 'prAnnotations')) {
165+
const annotationsInput = event.target.value;
166+
const yamlUpdate = { ...pullRequest[repoName]?.yaml };
167+
168+
if (annotationsInput.length === 0) {
169+
delete yamlUpdate.metadata.annotations;
170+
} else {
171+
yamlUpdate.metadata.annotations =
172+
getYamlKeyValuePairs(annotationsInput);
173+
}
174+
175+
setPullRequest({
176+
...pullRequest,
177+
[repoName]: {
178+
...pullRequest[repoName],
179+
prAnnotations: annotationsInput,
180+
yaml: yamlUpdate,
181+
},
182+
});
183+
}
184+
if (event.target.name.split('.').find(s => s === 'prLabels')) {
185+
const labelsInput = event.target.value;
186+
const yamlUpdate = { ...pullRequest[repoName]?.yaml };
187+
188+
if (labelsInput.length === 0) {
189+
delete yamlUpdate.metadata.labels;
190+
} else {
191+
yamlUpdate.metadata.labels = getYamlKeyValuePairs(labelsInput);
192+
}
193+
194+
setPullRequest({
195+
...pullRequest,
196+
[repoName]: {
197+
...pullRequest[repoName],
198+
prLabels: labelsInput,
199+
yaml: yamlUpdate,
200+
},
201+
});
202+
}
203+
if (event.target.name.split('.').find(s => s === 'prSpec')) {
204+
const specInput = event.target.value;
205+
const yamlUpdate = { ...pullRequest[repoName]?.yaml };
206+
207+
if (specInput.length === 0) {
208+
delete yamlUpdate.spec;
209+
} else {
210+
yamlUpdate.spec = getYamlKeyValuePairs(specInput);
211+
}
212+
213+
setPullRequest({
214+
...pullRequest,
215+
[repoName]: {
216+
...pullRequest[repoName],
217+
prSpec: specInput,
218+
yaml: yamlUpdate,
219+
},
220+
});
221+
}
161222
};
162223

224+
const keyValueTextFields = [
225+
{
226+
label: 'Annotations',
227+
name: 'prAnnotations',
228+
value: pullRequest?.[repoName]?.prAnnotations,
229+
},
230+
{
231+
label: 'Labels',
232+
name: 'prLabels',
233+
value: pullRequest?.[repoName]?.prLabels,
234+
},
235+
{ label: 'Spec', name: 'prSpec', value: pullRequest?.[repoName]?.prSpec },
236+
];
237+
163238
return (
164239
<>
165240
<Box marginTop={2}>
@@ -214,7 +289,7 @@ export const PreviewPullRequest = ({
214289

215290
<Autocomplete
216291
options={entities || []}
217-
value={entityOwner || ''}
292+
value={entityOwner ?? ''}
218293
loading={entitiesLoading}
219294
loadingText="Loading groups and users"
220295
disableClearable
@@ -224,7 +299,7 @@ export const PreviewPullRequest = ({
224299
...pullRequest,
225300
[repoName]: {
226301
...pullRequest[repoName],
227-
entityOwner: value || '',
302+
entityOwner: value ?? '',
228303
},
229304
});
230305
}}
@@ -304,15 +379,27 @@ export const PreviewPullRequest = ({
304379
WARNING: This may fail if no CODEOWNERS file is found at the target
305380
location.
306381
</FormHelperText>
382+
{keyValueTextFields.map(field => (
383+
<KeyValueTextField
384+
key={field.name}
385+
label={field.label}
386+
name={`repositories.${pullRequest[repoName].componentName}.${field.name}`}
387+
value={field.value ?? ''}
388+
onChange={handleChange}
389+
setFormErrors={setFormErrors}
390+
formErrors={formErrors}
391+
repoName={repoName}
392+
/>
393+
))}
307394
<Box marginTop={2}>
308395
<Typography variant="h6">
309396
Preview {`${approvalTool.toLowerCase()}`}
310397
</Typography>
311398
</Box>
312399

313400
<PreviewPullRequestComponent
314-
title={pullRequest?.[repoName]?.prTitle || ''}
315-
description={pullRequest?.[repoName]?.prDescription || ''}
401+
title={pullRequest?.[repoName]?.prTitle ?? ''}
402+
description={pullRequest?.[repoName]?.prDescription ?? ''}
316403
classes={{
317404
card: contentClasses.previewCard,
318405
cardContent: contentClasses.previewCardContent,

plugins/bulk-import/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export type RepositoriesData = {
1212
export type PullRequestPreview = {
1313
prTitle?: string;
1414
prDescription?: string;
15+
prAnnotations?: string;
16+
prLabels?: string;
17+
prSpec?: string;
1518
componentName?: string;
1619
entityOwner?: string;
1720
useCodeOwnersFile: boolean;

plugins/bulk-import/src/utils/repository-utils.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getNewOrgsData,
55
getSelectedRepositories,
66
getSelectedRepositoriesCount,
7+
getYamlKeyValuePairs,
78
updateWithNewSelectedRepositories,
89
urlHelper,
910
} from './repository-utils';
@@ -115,4 +116,58 @@ describe('Repository utils', () => {
115116
getDataForRepositories('user:default/guest').slice(1, 3),
116117
);
117118
});
119+
120+
it('should parse key-value pairs correctly with semicolons', () => {
121+
const prKeyValuePairInput = `argocd/app-name: 'guestbook'; github.com/project-slug: janus-idp/backstage-showcase; backstage.io/createdAt: '5/12/2021, 07:03:18 AM'; quay.io/repository-slug: janus-idp/backstage-showcase; backstage.io/kubernetes-id: test-backstage`;
122+
123+
const expectedOutput = {
124+
'argocd/app-name': 'guestbook',
125+
'github.com/project-slug': 'janus-idp/backstage-showcase',
126+
'backstage.io/createdAt': '5/12/2021, 07:03:18 AM',
127+
'quay.io/repository-slug': 'janus-idp/backstage-showcase',
128+
'backstage.io/kubernetes-id': 'test-backstage',
129+
};
130+
131+
expect(getYamlKeyValuePairs(prKeyValuePairInput)).toEqual(expectedOutput);
132+
});
133+
134+
it('should handle empty input', () => {
135+
const prKeyValuePairInput = '';
136+
const expectedOutput = {};
137+
expect(getYamlKeyValuePairs(prKeyValuePairInput)).toEqual(expectedOutput);
138+
});
139+
140+
it('should handle input without quotes', () => {
141+
const prKeyValuePairInput = `backstage.io/kubernetes-id: my-kubernetes-component`;
142+
143+
const expectedOutput = {
144+
'backstage.io/kubernetes-id': 'my-kubernetes-component',
145+
};
146+
147+
expect(getYamlKeyValuePairs(prKeyValuePairInput)).toEqual(expectedOutput);
148+
});
149+
150+
it('should handle extra whitespace around key-value pairs', () => {
151+
const prKeyValuePairInput = ` argocd/app-name : 'guestbook' ; github.com/project-slug : janus-idp/backstage-showcase ; backstage.io/createdAt : '5/12/2021, 07:03:18 AM' `;
152+
153+
const expectedOutput = {
154+
'argocd/app-name': 'guestbook',
155+
'github.com/project-slug': 'janus-idp/backstage-showcase',
156+
'backstage.io/createdAt': '5/12/2021, 07:03:18 AM',
157+
};
158+
159+
expect(getYamlKeyValuePairs(prKeyValuePairInput)).toEqual(expectedOutput);
160+
});
161+
162+
it('should parse key-value pairs correctly with semicolon in value', () => {
163+
const prKeyValuePairInput = `type: other; lifecycle: production; owner: user:guest`;
164+
165+
const expectedOutput = {
166+
type: 'other',
167+
lifecycle: 'production',
168+
owner: 'user:guest',
169+
};
170+
171+
expect(getYamlKeyValuePairs(prKeyValuePairInput)).toEqual(expectedOutput);
172+
});
118173
});

plugins/bulk-import/src/utils/repository-utils.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ export const getPRTemplate = (componentName: string, entityOwner: string) => {
9090
};
9191
};
9292

93+
export const getYamlKeyValuePairs = (
94+
prYamlInput: string,
95+
): Record<string, string> => {
96+
const keyValuePairs: Record<string, string> = {};
97+
const keyValueEntries = prYamlInput.split(';').map(entry => entry.trim());
98+
99+
keyValueEntries.forEach(entry => {
100+
const [key, ...valueParts] = entry.split(':');
101+
const value = valueParts.join(':').trim();
102+
if (key && value) {
103+
keyValuePairs[key.trim()] = value.replace(/(^['"])|(['"]$)/g, '');
104+
}
105+
});
106+
107+
return keyValuePairs;
108+
};
109+
93110
export const createData = (
94111
id: number,
95112
name: string,

0 commit comments

Comments
 (0)