From 3c392510f1e3da4a62f199f581b84928d87bc5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 14 Nov 2025 11:29:06 +0000 Subject: [PATCH] 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 --- .../Settings/EditProject/UpdateProject.tsx | 9 +- .../Project/hooks/useProjectForm.test.ts | 113 +++++++++++++++++- .../project/Project/hooks/useProjectForm.ts | 14 +++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx index 1f2a91be31..ec5946e8c7 100644 --- a/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/Settings/EditProject/UpdateProject.tsx @@ -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 '${ diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.test.ts b/frontend/src/component/project/Project/hooks/useProjectForm.test.ts index ed41e8291c..f0ff76498d 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.test.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.test.ts @@ -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(); + }); }); diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 55da1e82b0..f11c039aeb 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -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; };