Skip to content

Commit 329c090

Browse files
authored
Merge pull request #4450 from pallava-joshi/fix-prevent-adding-projects-with-same-name
frontend: projects: Check for existing project names
2 parents 2c033f8 + 13a456f commit 329c090

20 files changed

+989
-8
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { configureStore } from '@reduxjs/toolkit';
18+
import { Meta, StoryFn } from '@storybook/react';
19+
import { http, HttpResponse } from 'msw';
20+
import { useState } from 'react';
21+
import reducers from '../../redux/reducers/reducers';
22+
import { TestContext } from '../../test';
23+
import { NewProjectPopup } from './NewProjectPopup';
24+
import { PROJECT_ID_LABEL } from './projectUtils';
25+
26+
export default {
27+
title: 'project/NewProjectPopup',
28+
component: NewProjectPopup,
29+
argTypes: {},
30+
decorators: [Story => <Story />],
31+
} as Meta;
32+
33+
const makeStore = () => {
34+
return configureStore({
35+
reducer: reducers,
36+
preloadedState: {
37+
config: {
38+
clusters: null,
39+
statelessClusters: null,
40+
allClusters: {
41+
'cluster-a': { name: 'cluster-a' },
42+
'cluster-b': { name: 'cluster-b' },
43+
} as any,
44+
settings: {
45+
tableRowsPerPageOptions: [15, 25, 50],
46+
timezone: 'UTC',
47+
useEvict: true,
48+
},
49+
},
50+
projects: {
51+
headerActions: {},
52+
customCreateProject: {},
53+
detailsTabs: {},
54+
overviewSections: {},
55+
},
56+
},
57+
});
58+
};
59+
60+
const Template: StoryFn<{ store: ReturnType<typeof configureStore> }> = args => {
61+
const { store } = args;
62+
const [open, setOpen] = useState(true);
63+
return (
64+
<TestContext store={store}>
65+
<button onClick={() => setOpen(true)}>Open Popup</button>
66+
<NewProjectPopup open={open} onClose={() => setOpen(false)} />
67+
</TestContext>
68+
);
69+
};
70+
71+
export const Default = Template.bind({});
72+
Default.args = {
73+
store: makeStore(),
74+
};
75+
Default.parameters = {
76+
msw: {
77+
handlers: {
78+
story: [
79+
http.get('http://localhost:4466/api/v1/namespaces', () =>
80+
HttpResponse.json({
81+
kind: 'NamespaceList',
82+
items: [],
83+
metadata: {},
84+
})
85+
),
86+
],
87+
},
88+
},
89+
};
90+
91+
export const WithExistingProjects = Template.bind({});
92+
WithExistingProjects.args = {
93+
store: makeStore(),
94+
};
95+
WithExistingProjects.parameters = {
96+
msw: {
97+
handlers: {
98+
story: [
99+
http.get('http://localhost:4466/api/v1/namespaces', () =>
100+
HttpResponse.json({
101+
kind: 'NamespaceList',
102+
items: [
103+
{
104+
apiVersion: 'v1',
105+
kind: 'Namespace',
106+
metadata: {
107+
name: 'existing-project',
108+
uid: 'ns-1',
109+
labels: {
110+
[PROJECT_ID_LABEL]: 'existing-project',
111+
},
112+
},
113+
},
114+
{
115+
apiVersion: 'v1',
116+
kind: 'Namespace',
117+
metadata: {
118+
name: 'another-project',
119+
uid: 'ns-2',
120+
labels: {
121+
[PROJECT_ID_LABEL]: 'another-project',
122+
},
123+
},
124+
},
125+
{
126+
apiVersion: 'v1',
127+
kind: 'Namespace',
128+
metadata: {
129+
name: 'default',
130+
uid: 'ns-3',
131+
},
132+
},
133+
],
134+
metadata: {},
135+
})
136+
),
137+
],
138+
},
139+
},
140+
};
141+
WithExistingProjects.storyName = 'With Existing Projects (for duplicate name testing)';

frontend/src/components/project/NewProjectPopup.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
useTheme,
3030
} from '@mui/material';
3131
import { uniq } from 'lodash';
32-
import { ReactNode, useCallback, useState } from 'react';
32+
import { ReactNode, useCallback, useMemo, useState } from 'react';
3333
import { Trans, useTranslation } from 'react-i18next';
3434
import { useHistory } from 'react-router';
3535
import { useClustersConf } from '../../lib/k8s';
@@ -119,11 +119,32 @@ function ProjectFromExistingNamespace({ onBack }: { onBack: () => void }) {
119119
clusters: selectedClusters,
120120
});
121121

122+
const existingProjectNames = useMemo(() => {
123+
if (!namespaces) return new Set<string>();
124+
const result = new Set<string>();
125+
for (const ns of namespaces) {
126+
const labelValue = ns.metadata.labels?.[PROJECT_ID_LABEL];
127+
if (!labelValue) {
128+
continue;
129+
}
130+
result.add(labelValue);
131+
result.add(toKubernetesName(labelValue));
132+
}
133+
return result;
134+
}, [namespaces]);
135+
136+
// Check if project name already exists (using normalized form to match existing entries)
137+
const projectNameExists =
138+
projectName.length > 0 && existingProjectNames.has(toKubernetesName(projectName));
139+
122140
const isReadyToCreate =
123-
selectedClusters.length && (selectedNamespace || typedNamespace) && projectName;
141+
selectedClusters.length &&
142+
(selectedNamespace || typedNamespace) &&
143+
projectName &&
144+
!projectNameExists;
124145

125146
/**
126-
* Creates or updates namespaces for the proejct
147+
* Creates or updates namespaces for the project
127148
*/
128149
const handleCreate = async () => {
129150
if (!isReadyToCreate || isCreating) return;
@@ -223,7 +244,12 @@ function ProjectFromExistingNamespace({ onBack }: { onBack: () => void }) {
223244
}, 0);
224245
}
225246
}}
226-
helperText={t('translation|Enter a name for your new project.')}
247+
error={projectNameExists}
248+
helperText={
249+
projectNameExists
250+
? t('A project with this name already exists')
251+
: t('translation|Enter a name for your new project.')
252+
}
227253
autoComplete="off"
228254
fullWidth
229255
/>

frontend/src/components/project/ProjectCreateFromYaml.stories.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { http, HttpResponse } from 'msw';
2020
import reducers from '../../redux/reducers/reducers';
2121
import { TestContext } from '../../test';
2222
import { CreateNew } from './ProjectCreateFromYaml';
23+
import { PROJECT_ID_LABEL } from './projectUtils';
2324

2425
export default {
2526
title: 'project/CreateFromYaml',
@@ -76,6 +77,76 @@ Default.parameters = {
7677
msw: {
7778
handlers: {
7879
story: [
80+
http.get('http://localhost:4466/api/v1/namespaces', () =>
81+
HttpResponse.json({
82+
kind: 'NamespaceList',
83+
items: [],
84+
metadata: {},
85+
})
86+
),
87+
http.get('http://localhost:4466/clusters/cluster-a/api', () =>
88+
HttpResponse.json({ versions: ['v1'] })
89+
),
90+
http.get('http://localhost:4466/clusters/cluster-a/apis', () =>
91+
HttpResponse.json({ groups: [] })
92+
),
93+
http.get('http://localhost:4466/clusters/cluster-a/api/v1', () =>
94+
HttpResponse.json({
95+
resources: [
96+
{ name: 'pods', singularName: 'pod', namespaced: true, kind: 'Pod', verbs: ['list'] },
97+
{
98+
name: 'configmaps',
99+
singularName: 'configmap',
100+
namespaced: true,
101+
kind: 'ConfigMap',
102+
verbs: ['list'],
103+
},
104+
],
105+
})
106+
),
107+
],
108+
},
109+
},
110+
};
111+
112+
export const WithExistingProjects = Template.bind({});
113+
WithExistingProjects.args = {
114+
store: makeStore(),
115+
};
116+
WithExistingProjects.parameters = {
117+
msw: {
118+
handlers: {
119+
story: [
120+
http.get('http://localhost:4466/api/v1/namespaces', () =>
121+
HttpResponse.json({
122+
kind: 'NamespaceList',
123+
items: [
124+
{
125+
apiVersion: 'v1',
126+
kind: 'Namespace',
127+
metadata: {
128+
name: 'existing-project',
129+
uid: 'ns-1',
130+
labels: {
131+
[PROJECT_ID_LABEL]: 'existing-project',
132+
},
133+
},
134+
},
135+
{
136+
apiVersion: 'v1',
137+
kind: 'Namespace',
138+
metadata: {
139+
name: 'my-app',
140+
uid: 'ns-2',
141+
labels: {
142+
[PROJECT_ID_LABEL]: 'my-app',
143+
},
144+
},
145+
},
146+
],
147+
metadata: {},
148+
})
149+
),
79150
http.get('http://localhost:4466/clusters/cluster-a/api', () =>
80151
HttpResponse.json({ versions: ['v1'] })
81152
),
@@ -100,3 +171,4 @@ Default.parameters = {
100171
},
101172
},
102173
};
174+
WithExistingProjects.storyName = 'With Existing Projects (for duplicate name testing)';

frontend/src/components/project/ProjectCreateFromYaml.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ import {
3131
Typography,
3232
} from '@mui/material';
3333
import { loadAll } from 'js-yaml';
34-
import { Dispatch, FormEvent, SetStateAction, useState } from 'react';
34+
import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react';
3535
import { useDropzone } from 'react-dropzone';
3636
import { Trans, useTranslation } from 'react-i18next';
3737
import { Redirect, useHistory } from 'react-router';
3838
import { useClustersConf } from '../../lib/k8s';
3939
import { apply } from '../../lib/k8s/api/v1/apply';
4040
import { ApiError } from '../../lib/k8s/api/v2/ApiError';
4141
import { KubeObjectInterface } from '../../lib/k8s/KubeObject';
42+
import Namespace from '../../lib/k8s/namespace';
4243
import { createRouteURL } from '../../lib/router/createRouteURL';
4344
import { ViewYaml } from '../advancedSearch/ResourceSearch';
4445
import { DropZoneBox } from '../common/DropZoneBox';
@@ -123,6 +124,34 @@ export function CreateNew() {
123124

124125
const [errors, setErrors] = useState<Record<string, string>>({});
125126

127+
const { items: allProjectNamespaces } = Namespace.useList({
128+
clusters: allClusters ? Object.keys(allClusters) : [],
129+
labelSelector: PROJECT_ID_LABEL,
130+
});
131+
132+
const existingProjectNames = useMemo(() => {
133+
const result = new Set<string>();
134+
if (!allProjectNamespaces) {
135+
return result;
136+
}
137+
138+
for (const ns of allProjectNamespaces) {
139+
const labelValue = ns.metadata.labels?.[PROJECT_ID_LABEL];
140+
if (!labelValue) {
141+
continue;
142+
}
143+
144+
// Store both the raw label and its Kubernetes-normalized form so that
145+
// duplicate detection works regardless of how PROJECT_ID_LABEL was set.
146+
result.add(labelValue);
147+
result.add(toKubernetesName(labelValue));
148+
}
149+
150+
return result;
151+
}, [allProjectNamespaces]);
152+
153+
const projectNameExists = k8sName.length > 0 && existingProjectNames.has(k8sName);
154+
126155
// New state for URL and tab management
127156
const [currentTab, setCurrentTab] = useState(0);
128157
const [yamlUrl, setYamlUrl] = useState('');
@@ -220,6 +249,9 @@ export function CreateNew() {
220249
if (!name.trim()) {
221250
errors.name = t('Name is required');
222251
}
252+
if (projectNameExists) {
253+
errors.name = t('A project with this name already exists');
254+
}
223255
if (!selectedClusters) {
224256
errors.clusters = t('Cluster is required');
225257
}
@@ -278,8 +310,11 @@ export function CreateNew() {
278310
sx={{ minWidth: 400 }}
279311
value={name}
280312
onChange={e => setName(e.target.value)}
281-
error={!!errors.name}
282-
helperText={errors.name}
313+
error={!!errors.name || projectNameExists}
314+
helperText={
315+
errors.name ||
316+
(projectNameExists ? t('A project with this name already exists') : undefined)
317+
}
283318
/>
284319
</Grid>
285320

0 commit comments

Comments
 (0)