1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

chore: unique project names validation on creation (#10970)

https://linear.app/unleash/issue/2-4024/we-should-validate-that-new-project-names-are-unique-ui-only

Validates that new project names must be unique.

Covers both:
 - Creating a new project
 - Editing an existing project

---------

Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
Nuno Góis 2025-11-14 11:29:06 +00:00 committed by GitHub
parent ba25d7ada9
commit 3c392510f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 132 additions and 4 deletions

View File

@ -18,6 +18,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { styled } from '@mui/material';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import type { ProjectOverviewSchema } from 'openapi';
import useProjects from 'hooks/api/getters/useProjects/useProjects.ts';
const StyledContainer = styled('div')(({ theme }) => ({
minHeight: 0,
@ -72,7 +73,13 @@ export const UpdateProject = ({ project }: IUpdateProject) => {
);
const { editProject, loading } = useProjectApi();
const { refetch } = useProjectOverview(id);
const { refetch: refetchProjectOverview } = useProjectOverview(id);
const { refetch: refetchProjects } = useProjects();
const refetch = () => {
refetchProjectOverview();
refetchProjects();
};
const formatProjectApiCode = () => {
return `curl --location --request PUT '${

View File

@ -1,8 +1,28 @@
import { renderHook } from '@testing-library/react';
import useProjectForm from './useProjectForm.js';
import { test } from 'vitest';
import { beforeEach, test, vi } from 'vitest';
import { act } from 'react';
const mockUseProjects = vi.hoisted(() => vi.fn());
vi.mock('hooks/api/getters/useProjects/useProjects.js', () => ({
default: mockUseProjects,
}));
const createProjectsResponse = (
projects: Array<{ id: string; name: string }> = [],
) => ({
projects,
error: undefined,
loading: false,
refetch: vi.fn(),
});
beforeEach(() => {
mockUseProjects.mockReset();
mockUseProjects.mockReturnValue(createProjectsResponse());
});
describe('configuring change requests', () => {
test('setting project environments removes any change request envs that are not in the new project env list', () => {
const { result } = renderHook(() => useProjectForm());
@ -93,13 +113,100 @@ describe('payload generation', () => {
});
describe('name validation', () => {
const existingProjects = [
{ id: 'project1', name: 'Project One' },
{ id: 'project2', name: 'Project Two' },
];
test.each([
['An empty string', ''],
['Just whitespace', ' '],
])(`%s is not valid`, (_, value) => {
const { result } = renderHook(() => useProjectForm());
const { result } = renderHook(() => useProjectForm(undefined, 'valid'));
result.current.setProjectName(value);
act(() => result.current.setProjectName(value));
expect(result.current.validateName()).toBeFalsy();
});
test('accepts a unique project name on creation', () => {
mockUseProjects.mockReturnValue(
createProjectsResponse(existingProjects),
);
const { result } = renderHook(() => useProjectForm());
act(() => result.current.setProjectName('Brand New Project'));
expect(result.current.validateName()).toBeTruthy();
});
test('rejects duplicate project name on creation', () => {
mockUseProjects.mockReturnValue(
createProjectsResponse(existingProjects),
);
const { result } = renderHook(() => useProjectForm());
act(() => result.current.setProjectName('Project One'));
let isValid = true;
act(() => {
isValid = result.current.validateName();
});
expect(isValid).toBeFalsy();
expect(result.current.errors).toHaveProperty(
'name',
'This name is already taken by a different project.',
);
});
test('allows keeping the original name when editing, even if that name is also used by a different project', () => {
mockUseProjects.mockReturnValue(
createProjectsResponse(existingProjects),
);
const { result } = renderHook(() =>
useProjectForm('project3', 'Project One'),
);
expect(result.current.validateName()).toBeTruthy();
});
test('rejects renaming to another existing project name', () => {
mockUseProjects.mockReturnValue(
createProjectsResponse(existingProjects),
);
const { result } = renderHook(() =>
useProjectForm('project1', 'Project One'),
);
act(() => result.current.setProjectName('Project Two'));
let isValid = true;
act(() => {
isValid = result.current.validateName();
});
expect(isValid).toBeFalsy();
expect(result.current.errors).toHaveProperty(
'name',
'This name is already taken by a different project.',
);
});
test('accepts renaming to a new unique project name', () => {
mockUseProjects.mockReturnValue(
createProjectsResponse(existingProjects),
);
const { result } = renderHook(() =>
useProjectForm('project1', 'Project One'),
);
act(() => result.current.setProjectName('Project Three'));
expect(result.current.validateName()).toBeTruthy();
});
});

View File

@ -3,6 +3,7 @@ import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import type { ProjectMode } from './useProjectEnterpriseSettingsForm.js';
import useProjects from 'hooks/api/getters/useProjects/useProjects.js';
export const DEFAULT_PROJECT_STICKINESS = 'default';
const useProjectForm = (
@ -18,6 +19,8 @@ const useProjectForm = (
{ requiredApprovals: number }
> = {},
) => {
const { projects } = useProjects();
const { isEnterprise } = useUiConfig();
const [projectId, setProjectId] = useState(initialProjectId);
const [projectMode, setProjectMode] =
@ -172,6 +175,17 @@ const useProjectForm = (
return false;
}
if (
projectName !== initialProjectName &&
projects.some(({ name }) => name === projectName)
) {
setErrors((prev) => ({
...prev,
name: 'This name is already taken by a different project.',
}));
return false;
}
return true;
};