From 15eb6fbed7e784df4889348de15f4db172c3bf8c Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 00:18:34 +0300 Subject: [PATCH 1/6] refactor(project): update project API and schemas --- src/entities/project/api/http.ts | 20 ++-- src/entities/project/api/queries.ts | 6 +- src/entities/project/model/const.ts | 7 +- src/entities/project/model/schemas.ts | 149 ++++++++++++++++++++------ src/entities/project/model/types.ts | 4 + 5 files changed, 139 insertions(+), 47 deletions(-) diff --git a/src/entities/project/api/http.ts b/src/entities/project/api/http.ts index 81ba007..2e2cd15 100644 --- a/src/entities/project/api/http.ts +++ b/src/entities/project/api/http.ts @@ -14,9 +14,9 @@ export class ProjectHttp { }); } - static getProject(teamId: string, id: string, token?: string, signal?: AbortSignal) { + static getProject(teamId: string, slug: string, token?: string, signal?: AbortSignal) { return api({ - url: `/teams/${teamId}/projects/${id}`, + url: `/teams/${teamId}/projects/${slug}`, method: 'GET', params: token ? { token } : undefined, contracts: { @@ -38,9 +38,9 @@ export class ProjectHttp { }); } - static updateProject(teamId: string, id: string, data: TProject.UpdateProjectBody) { + static updateProject(teamId: string, slug: string, data: TProject.UpdateProjectBody) { return api({ - url: `/teams/${teamId}/projects/${id}`, + url: `/teams/${teamId}/projects/${slug}`, method: 'PATCH', data, contracts: { @@ -50,9 +50,9 @@ export class ProjectHttp { }); } - static removeProject(teamId: string, id: string) { + static removeProject(teamId: string, slug: string) { return api({ - url: `/teams/${teamId}/projects/${id}`, + url: `/teams/${teamId}/projects/${slug}`, method: 'DELETE', contracts: { response: SProject.ActionResponse, @@ -60,9 +60,9 @@ export class ProjectHttp { }); } - static archiveProject(teamId: string, id: string) { + static archiveProject(teamId: string, slug: string) { return api({ - url: `/teams/${teamId}/projects/${id}/archive`, + url: `/teams/${teamId}/projects/${slug}/archive`, method: 'POST', contracts: { response: SProject.ActionResponse, @@ -70,9 +70,9 @@ export class ProjectHttp { }); } - static createShareToken(teamId: string, id: string, data: TProject.CreateShareTokenBody = {}) { + static createShareToken(teamId: string, slug: string, data: TProject.CreateShareTokenBody = {}) { return api({ - url: `/teams/${teamId}/projects/${id}/share`, + url: `/teams/${teamId}/projects/${slug}/share`, method: 'POST', data, contracts: { diff --git a/src/entities/project/api/queries.ts b/src/entities/project/api/queries.ts index 6b42d9c..d6d85d9 100644 --- a/src/entities/project/api/queries.ts +++ b/src/entities/project/api/queries.ts @@ -11,10 +11,10 @@ export class ProjectQueries { }); } - static getProject(teamId: string, id: string, token?: string) { + static getProject(teamId: string, slug: string, token?: string) { return queryOptions({ - queryKey: [...projectFabricKeys.detail(teamId, id), token ?? null], - queryFn: async ({ signal }) => ProjectHttp.getProject(teamId, id, token, signal), + queryKey: [...projectFabricKeys.detail(teamId, slug), token ?? null], + queryFn: async ({ signal }) => ProjectHttp.getProject(teamId, slug, token, signal), staleTime: 60_000, }); } diff --git a/src/entities/project/model/const.ts b/src/entities/project/model/const.ts index 53574a5..b5d711e 100644 --- a/src/entities/project/model/const.ts +++ b/src/entities/project/model/const.ts @@ -1,6 +1,11 @@ import { createEntityKeys } from 'shared/lib/utils'; +export const PROJECT_STATUSES = ['active', 'archived', 'template', 'deleted'] as const; +export const PROJECT_VISIBILITIES = ['public', 'private'] as const; +export const LAYOUTS = ['kanban', 'list', 'calendar', 'gantt'] as const; +export const MEMBER_ROLE = ['owner', 'admin', 'member', 'viewer'] as const; + export const projectFabricKeys = createEntityKeys('project', { list: (teamId: string) => ['teams', teamId, 'projects'], - detail: (teamId: string, id: string) => ['teams', teamId, 'projects', id], + detail: (teamId: string, slug: string) => ['teams', teamId, 'projects', slug], }); diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts index 67d1cba..a891662 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -1,28 +1,113 @@ import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/api'; import { z } from 'zod/v4'; import { PROJECT_ICONS } from '../config/icons'; +import { MEMBER_ROLE, PROJECT_STATUSES, PROJECT_VISIBILITIES } from './const'; export const ActionResponse = GlobalSuccess; +export const ProjectStatusSchema = z.enum(PROJECT_STATUSES); +export const ProjectVisibilitySchema = z.enum(PROJECT_VISIBILITIES); +export const ProjectMemberRoleSchema = z.enum(MEMBER_ROLE); -export const CreateProjectBody = z.object({ - name: z.string().min(1).max(100), - key: z +const PositiveIntegerSchema = z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional(); + +export const ProjectSettingsSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + projectId: z.string().min(1, 'ID проекта обязателен'), + defaultView: z.enum(['kanban', 'list', 'calendar', 'gantt']), + taskPrefix: z.string().max(10, 'Префикс не должен превышать 10 символов').nullable().optional(), + autoCloseDays: PositiveIntegerSchema, + maxTasksPerArea: PositiveIntegerSchema, + maxMembers: PositiveIntegerSchema, + maxAreas: PositiveIntegerSchema, + allowGuests: z.boolean().default(false), + timeTracking: z.boolean().default(false), + timeTrackingMode: z.enum(['optional', 'required', 'disabled']), + defaultAssigneeId: z.string().nullable().optional(), + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); + +export const CreateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}).partial({ + defaultView: true, + timeTrackingMode: true, +}); + +export const UpdateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}).partial(); + +export const ProjectSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым'), + teamId: z.string().nullish(), + slug: z + .string() + .min(1, 'Уникальный идентификатор в URL обязателен') + .max(100, 'Уникальный идентификатор не должен превышать 100 символов') + .regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + 'Идентификатор должен быть в формате kebab-case (например: "my-project")' + ), + name: z .string() - .min(2) - .max(10) - .regex(/^[A-Z0-9]+$/), - description: z.string().max(2000).optional().nullable(), - icon: z.enum(PROJECT_ICONS).optional().nullable(), + .min(1, 'Название проекта обязательно') + .max(100, 'Название не должно превышать 100 символов'), + description: z.string().nullish(), + descriptionHtml: z.string().nullish(), + icon: z.enum(PROJECT_ICONS).nullish(), color: z .string() - .regex(/^#[A-Fa-f0-9]{6}$/) - .optional(), - visibility: z.enum(['public', 'private']), + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)' + ) + .nullish(), + status: ProjectStatusSchema, + visibility: ProjectVisibilitySchema, + sequence: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным'), + ownerId: z.string().nullish(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + deletedAt: DateTimeString.nullish(), }); +export const CreateProjectBody = ProjectSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + ownerId: true, +}) + .partial({ + description: true, + descriptionHtml: true, + icon: true, + color: true, + sequence: true, + visibility: true, + slug: true, + }) + .extend({ + settings: CreateProjectSettingsSchema.optional(), + }); + export const UpdateProjectBody = CreateProjectBody.extend({ - status: z.enum(['active', 'archived']).optional(), - isPublic: z.boolean().optional(), + settings: UpdateProjectSettingsSchema.optional(), }) .partial() .refine((data) => Object.keys(data).length > 0, { @@ -31,7 +116,7 @@ export const UpdateProjectBody = CreateProjectBody.extend({ }); export const CreateProjectResponse = GlobalSuccess.extend({ - projectId: z.string(), + slug: z.string(), }); export const CreateShareTokenBody = z.object({ @@ -41,47 +126,45 @@ export const CreateShareTokenBody = z.object({ export const CreateShareTokenResponse = GlobalSuccess.extend({ payload: z.object({ token: z.string(), - isYourself: z.boolean(), - expiresAt: DateTimeString.nullable(), + expiresAt: DateTimeString, }), }); export const ProjectListItemResponse = z.object({ id: z.string(), - key: z.string(), + slug: z.string(), name: z.string(), - status: z.enum(['active', 'archived', 'template']), + status: ProjectStatusSchema, color: z.string(), icon: z.string().nullable(), createdAt: DateTimeString, - canEdit: z.boolean(), + role: ProjectMemberRoleSchema, }); -export const ProjectListResponse = PaginatedResponseSchema(ProjectListItemResponse).extend({ - team: z.object({ - id: z.string(), - name: z.string(), - role: z.string(), - }), -}); +export const ProjectListResponse = PaginatedResponseSchema(ProjectListItemResponse); export const ProjectDetailResponse = z.object({ id: z.string(), - key: z.string(), + slug: z.string(), name: z.string(), - status: z.enum(['active', 'archived', 'template']), + status: z.enum(['active', 'archived', 'template', 'deleted']), description: z.string().nullable(), - visuals: z.object({ color: z.string(), icon: z.string().nullable() }), + descriptionHtml: z.string().nullish(), + visuals: z.object({ color: z.string().nullish(), icon: z.string().nullish().optional() }), meta: z.object({ - taskSequence: z.number(), + sequence: z.number().int().nonnegative(), createdAt: DateTimeString, updatedAt: DateTimeString, }), access: z.object({ visibility: z.enum(['public', 'private']), - canEdit: z.boolean(), - canDelete: z.boolean(), + currentUserRole: z.enum(['owner', 'admin', 'member', 'viewer']), shareUrl: z.string().nullable(), }), - settings: z.record(z.string(), z.unknown()), + settings: ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, + }), }); diff --git a/src/entities/project/model/types.ts b/src/entities/project/model/types.ts index 835b084..932d7d1 100644 --- a/src/entities/project/model/types.ts +++ b/src/entities/project/model/types.ts @@ -1,6 +1,10 @@ import { z } from 'zod/v4'; import * as SProject from './schemas'; +export type ProjectStatus = z.infer; +export type ProjectVisibility = z.infer; +export type ProjectMemberRole = z.infer; + export type CreateProjectBody = z.infer; export type UpdateProjectBody = z.infer; export type CreateProjectResponse = z.infer; From df001669f94b7bbe9a458a9e419caddfc7d93c93 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 00:29:22 +0300 Subject: [PATCH 2/6] refactoring (project): updating project settings, processing identifiers, replacing projectId with slug --- .../projects/{[projectId] => [slug]}/page.tsx | 0 .../{[projectId] => [slug]}/settings/page.tsx | 0 .../ui/settings/ProjectSettingsPage.tsx | 40 +++++++++++++------ .../ui/settings/ProjectSettingsSaveBar.tsx | 23 ++++++++--- src/pages/team/ui/projects/ProjectCard.tsx | 17 ++++---- .../ui/projects/ProjectActions.tsx | 2 +- .../ui/projects/ProjectsContent.tsx | 2 +- 7 files changed, 57 insertions(+), 27 deletions(-) rename app/(protected)/team/projects/{[projectId] => [slug]}/page.tsx (100%) rename app/(protected)/team/projects/{[projectId] => [slug]}/settings/page.tsx (100%) diff --git a/app/(protected)/team/projects/[projectId]/page.tsx b/app/(protected)/team/projects/[slug]/page.tsx similarity index 100% rename from app/(protected)/team/projects/[projectId]/page.tsx rename to app/(protected)/team/projects/[slug]/page.tsx diff --git a/app/(protected)/team/projects/[projectId]/settings/page.tsx b/app/(protected)/team/projects/[slug]/settings/page.tsx similarity index 100% rename from app/(protected)/team/projects/[projectId]/settings/page.tsx rename to app/(protected)/team/projects/[slug]/settings/page.tsx diff --git a/src/pages/project/ui/settings/ProjectSettingsPage.tsx b/src/pages/project/ui/settings/ProjectSettingsPage.tsx index 65a50ad..2ef410b 100644 --- a/src/pages/project/ui/settings/ProjectSettingsPage.tsx +++ b/src/pages/project/ui/settings/ProjectSettingsPage.tsx @@ -32,10 +32,26 @@ export function ProjectSettingsPage() { mode: 'onChange', defaultValues: { name: '', - key: '', + slug: '', description: '', + descriptionHtml: '', + icon: undefined, + color: null, visibility: 'private', status: 'active', + sequence: 0, + settings: { + allowGuests: false, + timeTracking: false, + autoCloseDays: null, + maxTasksPerArea: null, + maxMembers: null, + maxAreas: null, + defaultView: 'kanban', + taskPrefix: null, + timeTrackingMode: 'optional', + defaultAssigneeId: null, + }, }, }); @@ -45,12 +61,15 @@ export function ProjectSettingsPage() { if (project) { reset({ name: project.name, - key: project.key, + slug: project.slug, description: project.description ?? '', + descriptionHtml: project.descriptionHtml ?? '', icon: (project.visuals.icon ?? undefined) as ProjectSettingsFormValues['icon'], color: project.visuals.color, visibility: project.access.visibility, status: project.status === 'archived' ? 'archived' : 'active', + sequence: project.meta.sequence, + settings: project.settings, }); } }, [reset, project]); @@ -73,17 +92,18 @@ export function ProjectSettingsPage() { } const isTemplate = project.status === 'template'; - + const canEdit = + project.access.currentUserRole === 'admin' || project.access.currentUserRole === 'owner'; return (
- +
- + {isTemplate ? (

@@ -96,11 +116,7 @@ export function ProjectSettingsPage() { render={({ field, fieldState }) => ( Статус - @@ -117,13 +133,13 @@ export function ProjectSettingsPage() {

- {project.access.canDelete && teamId && ( + {canEdit && teamId && ( )}
- {project.access.canEdit ? : null} + {canEdit ? : null}
); } diff --git a/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx b/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx index 04986ff..db3db8f 100644 --- a/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx +++ b/src/pages/project/ui/settings/ProjectSettingsSaveBar.tsx @@ -5,6 +5,8 @@ import { useFormContext, useFormState } from 'react-hook-form'; import { FloatingSaveBar } from 'shared/ui'; import { useUpdateProject } from '../../api/useUpdateProject'; import type { ProjectSettingsFormValues } from '../../model/settings'; +import { useRouter } from 'next/navigation'; +import { routes } from 'shared/config'; interface ProjectSettingsSaveBarProps { project: TProject.ProjectDetailResponse; @@ -13,24 +15,32 @@ interface ProjectSettingsSaveBarProps { export function ProjectSettingsSaveBar({ project }: ProjectSettingsSaveBarProps) { const form = useFormContext(); const { isDirty, dirtyFields, isValidating, isValid } = useFormState({ control: form.control }); - + const router = useRouter(); const updateProject = useUpdateProject({ - onSuccess: () => { + onSuccess: (_d, vars) => { form.reset(form.getValues()); + if (vars.slug) { + router.replace(routes.team.project.settings(vars.slug)); + } }, }); const onSubmit = (data: ProjectSettingsFormValues) => { const body: TProject.UpdateProjectBody = { - ...(dirtyFields.name && { name: data.name.trim() }), - ...(dirtyFields.key && { key: data.key.trim().toUpperCase() }), + ...(dirtyFields.name && { name: data.name?.trim() }), + ...(dirtyFields.slug && { slug: data.slug?.trim() }), ...(dirtyFields.description && { description: data.description?.trim() ? data.description.trim() : null, }), + ...(dirtyFields.descriptionHtml && { + descriptionHtml: data.descriptionHtml?.trim() ? data.descriptionHtml.trim() : null, + }), ...(dirtyFields.icon && { icon: data.icon ?? null }), ...(dirtyFields.color && { color: data.color }), ...(dirtyFields.visibility && { visibility: data.visibility }), ...(dirtyFields.status && { status: data.status }), + ...(dirtyFields.sequence && { sequence: data.sequence }), + ...(dirtyFields.settings && { settings: data.settings }), }; if (Object.keys(body).length === 0) { @@ -47,12 +57,15 @@ export function ProjectSettingsSaveBar({ project }: ProjectSettingsSaveBarProps) onDiscard={() => form.reset({ name: project.name, - key: project.key, + slug: project.slug, description: project.description ?? '', + descriptionHtml: project.descriptionHtml ?? '', icon: (project.visuals.icon ?? undefined) as ProjectSettingsFormValues['icon'], color: project.visuals.color, visibility: project.access.visibility, status: project.status === 'archived' ? 'archived' : 'active', + sequence: project.meta.sequence, + settings: project.settings, }) } pending={updateProject.isPending || isValidating} diff --git a/src/pages/team/ui/projects/ProjectCard.tsx b/src/pages/team/ui/projects/ProjectCard.tsx index be85f17..9b1579a 100644 --- a/src/pages/team/ui/projects/ProjectCard.tsx +++ b/src/pages/team/ui/projects/ProjectCard.tsx @@ -40,6 +40,7 @@ const statusLabels: Record = active: 'Активен', archived: 'В архиве', template: 'Шаблон', + deleted: 'Удалён', }; export function ProjectCard({ @@ -53,7 +54,7 @@ export function ProjectCard({ const teamId = useTeamStore.use.teamId(); const name = nameProp ?? project?.name ?? 'Atlas Platform'; const description = - descriptionProp ?? (project ? `Ключ проекта: ${project.key}` : 'Core team workspace.'); + descriptionProp ?? (project ? `Ключ проекта: ${project.slug}` : 'Core team workspace.'); const statusLabel = statusLabelProp ?? (project ? statusLabels[project.status] : 'On Track'); const iconEmoji = project ? projectIconCodeToEmoji(project.icon) : null; const iconColor = project?.color; @@ -64,8 +65,8 @@ export function ProjectCard({ const mockMembersCount = project ? (project.id.charCodeAt(1) % 3) + 2 : 3; const mockMembers = Array.from({ length: mockMembersCount }).map((_, i) => i + 1); - const projectHref = project && teamId ? routes.team.project.root(project.id) : null; - + const projectHref = project && teamId ? routes.team.project.root(project.slug) : null; + const canEdit = project?.role === 'admin' || project?.role === 'owner'; const card = ( - {project?.key && ( + {project?.slug && ( - {project.key} + {project.slug} )} @@ -164,7 +165,7 @@ export function ProjectCard({ teamId={teamId!} projectId={project.id} asChild - disabled={!(project.canEdit && teamId)} + disabled={!(canEdit && teamId)} > e.preventDefault()}> Восстановить @@ -177,7 +178,7 @@ export function ProjectCard({ teamId={teamId!} projectId={project?.id ?? ''} asChild - disabled={!(project?.canEdit && teamId)} + disabled={!(canEdit && teamId)} > e.preventDefault()}> Архивировать @@ -233,7 +234,7 @@ export function ProjectCard({
- {project?.canEdit ? ( + {canEdit ? ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index 63c09b7..48c7aa4 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -24,7 +24,7 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp const [shareOpen, setShareOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [restoreOpen, setRestoreOpen] = useState(false); - const canManage = Boolean(teamId && project.canEdit); + const canManage = Boolean(teamId && (project.role === 'owner' || project.role === 'admin')); const openDialog = (setDialogOpen: (open: boolean) => void) => (event: Event) => { event.preventDefault(); diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx index 9808d45..2128041 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx @@ -67,7 +67,7 @@ export function ProjectsContent() { isActive={pathname?.startsWith(routes.team.project.root(project.id))} asChild > - + {projectIconCodeToEmoji(project.icon)} {project.name} From 8837a481bab879f3e452f6bb4380d7616ed19f81 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 00:40:54 +0300 Subject: [PATCH 3/6] refactor(project): add slug generation, update project schem, replace projectId with slug --- src/features/projects/create/lib/slugify.ts | 10 +++++++ .../projects/create/model/default-values.ts | 4 ++- src/features/projects/create/model/types.ts | 2 +- .../create/model/useCreateProjectForm.ts | 4 ++- .../create/ui/ProjectIdentityFields.tsx | 29 +++++++++++++------ src/pages/project/api/useQueryProject.ts | 6 ++-- src/pages/project/api/useUpdateProject.ts | 12 ++++---- src/pages/project/model/settings.ts | 4 +-- 8 files changed, 47 insertions(+), 24 deletions(-) create mode 100644 src/features/projects/create/lib/slugify.ts diff --git a/src/features/projects/create/lib/slugify.ts b/src/features/projects/create/lib/slugify.ts new file mode 100644 index 0000000..1d83ad4 --- /dev/null +++ b/src/features/projects/create/lib/slugify.ts @@ -0,0 +1,10 @@ +export function slugify(value: string) { + return value + .trim() + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/src/features/projects/create/model/default-values.ts b/src/features/projects/create/model/default-values.ts index 0446298..bd7e585 100644 --- a/src/features/projects/create/model/default-values.ts +++ b/src/features/projects/create/model/default-values.ts @@ -8,10 +8,12 @@ function pickRandom(items: readonly T[]): T { export function getDefaultCreateProjectValues(): CreateProjectFormValues { return { name: '', - key: '', + slug: '', + teamId: null, description: '', icon: pickRandom(PROJECT_ICONS), color: pickRandom(PROJECT_COLORS), visibility: 'private', + status: 'active', }; } diff --git a/src/features/projects/create/model/types.ts b/src/features/projects/create/model/types.ts index 1d59a67..afe8fea 100644 --- a/src/features/projects/create/model/types.ts +++ b/src/features/projects/create/model/types.ts @@ -6,5 +6,5 @@ export type CreateProjectFormValues = z.input; //todo исправить(должны быть все поля) export type ProjectIdentityFormValues = Pick< CreateProjectFormValues, - 'name' | 'key' | 'description' | 'icon' | 'color' + 'name' | 'slug' | 'description' | 'icon' | 'color' >; diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts index dfb6079..af3a68c 100644 --- a/src/features/projects/create/model/useCreateProjectForm.ts +++ b/src/features/projects/create/model/useCreateProjectForm.ts @@ -32,8 +32,10 @@ export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { if (!teamId) return; const body: TProject.CreateProjectBody = { + teamId, name: data.name.trim(), - key: data.key.trim().toUpperCase(), + slug: data.slug?.trim(), + status: data.status, visibility: data.visibility ?? 'private', ...(data.description?.trim() ? { description: data.description.trim() } : {}), ...(data.icon ? { icon: data.icon } : {}), diff --git a/src/features/projects/create/ui/ProjectIdentityFields.tsx b/src/features/projects/create/ui/ProjectIdentityFields.tsx index 82bb54d..0e4e25c 100644 --- a/src/features/projects/create/ui/ProjectIdentityFields.tsx +++ b/src/features/projects/create/ui/ProjectIdentityFields.tsx @@ -14,6 +14,7 @@ import { import type { ProjectIdentityFormValues } from '../model/types'; import { ProjectColorPicker } from './ProjectColorPicker'; import { ProjectIconPicker } from './ProjectIconPicker'; +import { slugify } from '../lib/slugify'; interface ProjectIdentityFieldsProps { disabled?: boolean; @@ -21,6 +22,15 @@ interface ProjectIdentityFieldsProps { showPlaceholders?: boolean; } +function slugifyOnChange(value: string) { + return value + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+/, ''); +} + export function ProjectIdentityFields({ disabled = false, idPrefix = 'project', @@ -36,11 +46,17 @@ export function ProjectIdentityFields({ ( + render={({ field, fieldState, formState }) => ( Название { + field.onChange(e); + if (!formState.dirtyFields.slug) { + form.setValue('slug', slugify(e.target.value)); + } + }} id={`${idPrefix}-name`} aria-label="Название проекта" placeholder={showPlaceholders ? 'Мой проект' : undefined} @@ -54,7 +70,7 @@ export function ProjectIdentityFields({ )} /> ( @@ -64,16 +80,11 @@ export function ProjectIdentityFields({ {...field} value={field.value ?? ''} onChange={(e) => { - field.onChange( - e.target.value - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]/g, '') - ); + field.onChange(slugifyOnChange(e.target.value)); }} id={`${idPrefix}-key`} aria-label="Ключ проекта" - placeholder={showPlaceholders ? 'PROJ' : undefined} + placeholder={showPlaceholders ? 'my-project' : undefined} aria-required={showPlaceholders ? true : undefined} aria-invalid={fieldState.invalid} autoComplete="off" diff --git a/src/pages/project/api/useQueryProject.ts b/src/pages/project/api/useQueryProject.ts index b40624b..d716e7f 100644 --- a/src/pages/project/api/useQueryProject.ts +++ b/src/pages/project/api/useQueryProject.ts @@ -6,10 +6,10 @@ import { useParams } from 'next/navigation'; export function useQueryProject() { const teamId = useTeamStore.use.teamId(); const params = useParams(); - const projectId = typeof params?.projectId === 'string' ? params.projectId : undefined; + const slug = typeof params?.slug === 'string' ? params.slug : undefined; return useQuery({ - ...ProjectQueries.getProject(teamId!, projectId!), - enabled: Boolean(teamId && projectId), + ...ProjectQueries.getProject(teamId!, slug!), + enabled: Boolean(teamId && slug), }); } diff --git a/src/pages/project/api/useUpdateProject.ts b/src/pages/project/api/useUpdateProject.ts index 6c07d27..6e63b13 100644 --- a/src/pages/project/api/useUpdateProject.ts +++ b/src/pages/project/api/useUpdateProject.ts @@ -11,25 +11,25 @@ type UseUpdateProjectProps = Omit< export function useUpdateProject({ onSuccess, ...rest }: UseUpdateProjectProps = {}) { const teamId = useTeamStore.use.teamId(); - const params = useParams(); - const projectId = typeof params?.projectId === 'string' ? params.projectId : undefined; + const params: Record<'slug', string | string[]> | null = useParams(); + const slug = typeof params?.slug === 'string' ? params.slug : undefined; return useMutation({ ...rest, mutationFn: (data) => { - if (!teamId || !projectId) { + if (!teamId || !slug) { throw new Error('Не выбран проект'); } - return ProjectHttp.updateProject(teamId, projectId, data); + return ProjectHttp.updateProject(teamId, slug, data); }, onSuccess: async (res, _v, _r, context) => { onSuccess?.(res, _v, _r, context); toast.success(res.message ?? 'Проект обновлён'); - if (teamId && projectId) { + if (teamId && slug) { await Promise.all([ context.client.invalidateQueries({ - queryKey: projectFabricKeys.detail(teamId, projectId), + queryKey: projectFabricKeys.detail(teamId, slug), }), context.client.invalidateQueries({ queryKey: projectFabricKeys.list(teamId), diff --git a/src/pages/project/model/settings.ts b/src/pages/project/model/settings.ts index 63c6a2a..32c385a 100644 --- a/src/pages/project/model/settings.ts +++ b/src/pages/project/model/settings.ts @@ -1,8 +1,6 @@ import { SProject } from 'entities/project'; import { z } from 'zod/v4'; -export const ProjectSettingsFormSchema = SProject.CreateProjectBody.extend({ - status: z.enum(['active', 'archived']), -}); +export const ProjectSettingsFormSchema = SProject.UpdateProjectBody; export type ProjectSettingsFormValues = z.infer; From 51d9446c13a85ab6551abebfe439bebc70336a49 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 00:41:26 +0300 Subject: [PATCH 4/6] refactor(project): replace projectId with slug in project-related components and functions --- src/entities/project/lib/share-url.ts | 4 ++-- .../projects/archive/model/useArchiveProject.ts | 6 +++--- .../projects/archive/model/useRestoreProject.ts | 6 +++--- .../projects/archive/ui/ArchiveProjectDialog.tsx | 6 +++--- .../projects/archive/ui/RestoreProjectDialog.tsx | 6 +++--- .../projects/remove/model/useRemoveProject.ts | 4 ++-- .../projects/remove/ui/RemoveProjectDialog.tsx | 6 +++--- src/features/projects/share/model/useShareProject.ts | 6 +++--- src/features/projects/share/ui/ShareProjectDialog.tsx | 8 ++++---- src/pages/project/ui/settings/ProjectDangerZone.tsx | 11 +++-------- src/pages/project/ui/settings/ProjectSettingsPage.tsx | 2 +- src/pages/team/ui/projects/ProjectCard.tsx | 8 ++++---- src/shared/config/routes.ts | 4 ++-- .../app-sidebar/ui/projects/ProjectActions.tsx | 6 +++--- 14 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/entities/project/lib/share-url.ts b/src/entities/project/lib/share-url.ts index 0f432f5..228263e 100644 --- a/src/entities/project/lib/share-url.ts +++ b/src/entities/project/lib/share-url.ts @@ -1,6 +1,6 @@ -export function buildProjectShareUrl(projectId: string, token: string) { +export function buildProjectShareUrl(slug: string, token: string) { const origin = typeof window !== 'undefined' ? window.location.origin : ''; - const url = new URL(`/projects/${projectId}`, origin || 'http://localhost'); + const url = new URL(`/projects/${slug}`, origin || 'http://localhost'); url.searchParams.set('token', token); diff --git a/src/features/projects/archive/model/useArchiveProject.ts b/src/features/projects/archive/model/useArchiveProject.ts index d537c4e..aefa6fc 100644 --- a/src/features/projects/archive/model/useArchiveProject.ts +++ b/src/features/projects/archive/model/useArchiveProject.ts @@ -4,7 +4,7 @@ import { toast } from 'sonner'; type ArchiveProjectVariables = { teamId: string; - id: string; + slug: string; }; export type UseArchiveProjectOptions = Omit< @@ -15,7 +15,7 @@ export type UseArchiveProjectOptions = Omit< export function useArchiveProject({ onSuccess, ...rest }: UseArchiveProjectOptions = {}) { return useMutation({ ...rest, - mutationFn: ({ teamId, id }) => ProjectHttp.archiveProject(teamId, id), + mutationFn: ({ teamId, slug }) => ProjectHttp.archiveProject(teamId, slug), onSuccess: async (res, variables, _r, context) => { onSuccess?.(res, variables, _r, context); toast.success(res.message ?? 'Проект архивирован'); @@ -25,7 +25,7 @@ export function useArchiveProject({ onSuccess, ...rest }: UseArchiveProjectOptio queryKey: projectFabricKeys.list(variables.teamId), }), context.client.invalidateQueries({ - queryKey: projectFabricKeys.detail(variables.teamId, variables.id), + queryKey: projectFabricKeys.detail(variables.teamId, variables.slug), }), ]); }, diff --git a/src/features/projects/archive/model/useRestoreProject.ts b/src/features/projects/archive/model/useRestoreProject.ts index 1b6a558..3c921e2 100644 --- a/src/features/projects/archive/model/useRestoreProject.ts +++ b/src/features/projects/archive/model/useRestoreProject.ts @@ -4,7 +4,7 @@ import { toast } from 'sonner'; type RestoreProjectVariables = { teamId: string; - id: string; + slug: string; }; export type UseRestoreProjectOptions = Omit< @@ -15,7 +15,7 @@ export type UseRestoreProjectOptions = Omit< export function useRestoreProject({ onSuccess, ...rest }: UseRestoreProjectOptions = {}) { return useMutation({ ...rest, - mutationFn: ({ teamId, id }) => ProjectHttp.updateProject(teamId, id, { status: 'active' }), + mutationFn: ({ teamId, slug }) => ProjectHttp.updateProject(teamId, slug, { status: 'active' }), onSuccess: async (res, variables, _r, context) => { onSuccess?.(res, variables, _r, context); toast.success(res.message ?? 'Проект восстановлен'); @@ -25,7 +25,7 @@ export function useRestoreProject({ onSuccess, ...rest }: UseRestoreProjectOptio queryKey: projectFabricKeys.list(variables.teamId), }), context.client.invalidateQueries({ - queryKey: projectFabricKeys.detail(variables.teamId, variables.id), + queryKey: projectFabricKeys.detail(variables.teamId, variables.slug), }), ]); }, diff --git a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx index ab39fa2..d0e2b44 100644 --- a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx +++ b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx @@ -18,7 +18,7 @@ import { useArchiveProject } from '../model/useArchiveProject'; interface ArchiveProjectDialogProps extends ComponentProps { projectName: string; teamId: string; - projectId: string; + slug: string; onArchived?: () => void; dialog?: ComponentProps; } @@ -26,7 +26,7 @@ interface ArchiveProjectDialogProps extends ComponentProps { - archiveProject.mutate({ teamId, id: projectId }); + archiveProject.mutate({ teamId, slug }); }; return ( diff --git a/src/features/projects/archive/ui/RestoreProjectDialog.tsx b/src/features/projects/archive/ui/RestoreProjectDialog.tsx index 05f7b4f..fbca113 100644 --- a/src/features/projects/archive/ui/RestoreProjectDialog.tsx +++ b/src/features/projects/archive/ui/RestoreProjectDialog.tsx @@ -18,14 +18,14 @@ import { useRestoreProject } from '../model/useRestoreProject'; interface RestoreProjectDialogProps extends ComponentProps { projectName: string; teamId: string; - projectId: string; + slug: string; dialog?: ComponentProps; } export function RestoreProjectDialog({ projectName, teamId, - projectId, + slug, dialog = {}, ...props }: RestoreProjectDialogProps) { @@ -40,7 +40,7 @@ export function RestoreProjectDialog({ }); const onRestore = () => { - restoreProject.mutate({ teamId, id: projectId }); + restoreProject.mutate({ teamId, slug }); }; return ( diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/model/useRemoveProject.ts index bdfafb8..0181d27 100644 --- a/src/features/projects/remove/model/useRemoveProject.ts +++ b/src/features/projects/remove/model/useRemoveProject.ts @@ -6,7 +6,7 @@ import { toast } from 'sonner'; type RemoveProjectVariables = { teamId: string; - id: string; + slug: string; }; export type UseRemoveProjectOptions = Omit< @@ -20,7 +20,7 @@ export function useRemoveProject({ onSuccess, ...rest }: UseRemoveProjectOptions return useMutation({ ...rest, - mutationFn: ({ teamId, id }) => ProjectHttp.removeProject(teamId, id), + mutationFn: ({ teamId, slug }) => ProjectHttp.removeProject(teamId, slug), onSuccess: async (res, variables, _r, context) => { onSuccess?.(res, variables, _r, context); diff --git a/src/features/projects/remove/ui/RemoveProjectDialog.tsx b/src/features/projects/remove/ui/RemoveProjectDialog.tsx index a494eb5..d91e5fa 100644 --- a/src/features/projects/remove/ui/RemoveProjectDialog.tsx +++ b/src/features/projects/remove/ui/RemoveProjectDialog.tsx @@ -16,17 +16,17 @@ import { useRemoveProject } from '../model/useRemoveProject'; interface Props extends ComponentProps { projectName: string; teamId: string; - projectId: string; + slug: string; } -export function RemoveProjectDialog({ projectName, teamId, projectId, ...props }: Props) { +export function RemoveProjectDialog({ projectName, teamId, slug, ...props }: Props) { const [inputValue, setInputValue] = useState(''); const removeProject = useRemoveProject(); const isMatch = inputValue.trim() === projectName.trim(); const onRemove = () => { - removeProject.mutate({ teamId, id: projectId }); + removeProject.mutate({ teamId, slug }); }; return ( diff --git a/src/features/projects/share/model/useShareProject.ts b/src/features/projects/share/model/useShareProject.ts index 9b3dfed..123b61c 100644 --- a/src/features/projects/share/model/useShareProject.ts +++ b/src/features/projects/share/model/useShareProject.ts @@ -4,7 +4,7 @@ import { toast } from 'sonner'; type ShareProjectVariables = { teamId: string; - id: string; + slug: string; body?: TProject.CreateShareTokenBody; }; @@ -16,13 +16,13 @@ export type UseShareProjectOptions = Omit< export function useShareProject({ onSuccess, ...rest }: UseShareProjectOptions = {}) { return useMutation({ ...rest, - mutationFn: ({ teamId, id, body = {} }) => ProjectHttp.createShareToken(teamId, id, body), + mutationFn: ({ teamId, slug, body = {} }) => ProjectHttp.createShareToken(teamId, slug, body), onSuccess: async (res, variables, _r, context) => { onSuccess?.(res, variables, _r, context); toast.success(res.message ?? 'Ссылка для доступа создана'); await context.client.invalidateQueries({ - queryKey: projectFabricKeys.detail(variables.teamId, variables.id), + queryKey: projectFabricKeys.detail(variables.teamId, variables.slug), }); }, }); diff --git a/src/features/projects/share/ui/ShareProjectDialog.tsx b/src/features/projects/share/ui/ShareProjectDialog.tsx index 5a4813d..0ed0cc2 100644 --- a/src/features/projects/share/ui/ShareProjectDialog.tsx +++ b/src/features/projects/share/ui/ShareProjectDialog.tsx @@ -38,14 +38,14 @@ import { useShareProject } from '../model/useShareProject'; interface ShareProjectDialogProps extends ComponentProps { projectName: string; teamId: string; - projectId: string; + slug: string; dialog?: ComponentProps; } export function ShareProjectDialog({ projectName, teamId, - projectId, + slug, dialog = {}, ...props }: ShareProjectDialogProps) { @@ -60,7 +60,7 @@ export function ShareProjectDialog({ const [expiresAt, setExpiresAt] = useState(null); const shareProject = useShareProject({ onSuccess: (res) => { - setShareUrl(buildProjectShareUrl(projectId, res.payload.token)); + setShareUrl(buildProjectShareUrl(slug, res.payload.token)); setExpiresAt(res.payload.expiresAt); }, }); @@ -82,7 +82,7 @@ export function ShareProjectDialog({ const onCreateLink = () => { shareProject.mutate({ teamId, - id: projectId, + slug, body: ttlOptionToBody(ttlOption), }); }; diff --git a/src/pages/project/ui/settings/ProjectDangerZone.tsx b/src/pages/project/ui/settings/ProjectDangerZone.tsx index 06b5410..cbb3c1f 100644 --- a/src/pages/project/ui/settings/ProjectDangerZone.tsx +++ b/src/pages/project/ui/settings/ProjectDangerZone.tsx @@ -15,10 +15,10 @@ import { interface ProjectDangerZoneProps { projectName: string; teamId: string; - projectId: string; + slug: string; } -export function ProjectDangerZone({ projectName, teamId, projectId }: ProjectDangerZoneProps) { +export function ProjectDangerZone({ projectName, teamId, slug }: ProjectDangerZoneProps) { return ( @@ -31,12 +31,7 @@ export function ProjectDangerZone({ projectName, teamId, projectId }: ProjectDan - + diff --git a/src/pages/project/ui/settings/ProjectSettingsPage.tsx b/src/pages/project/ui/settings/ProjectSettingsPage.tsx index 2ef410b..2d3245b 100644 --- a/src/pages/project/ui/settings/ProjectSettingsPage.tsx +++ b/src/pages/project/ui/settings/ProjectSettingsPage.tsx @@ -135,7 +135,7 @@ export function ProjectSettingsPage() { {canEdit && teamId && ( - + )} diff --git a/src/pages/team/ui/projects/ProjectCard.tsx b/src/pages/team/ui/projects/ProjectCard.tsx index 9b1579a..372eebd 100644 --- a/src/pages/team/ui/projects/ProjectCard.tsx +++ b/src/pages/team/ui/projects/ProjectCard.tsx @@ -153,7 +153,7 @@ export function ProjectCard({ @@ -163,7 +163,7 @@ export function ProjectCard({ @@ -176,7 +176,7 @@ export function ProjectCard({ @@ -189,7 +189,7 @@ export function ProjectCard({ diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 36081e4..3a114a0 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -17,8 +17,8 @@ export const routes = { settings: (): Route => '/team/settings', projects: (): Route => '/team/projects', project: { - root: (projectId: string): Route => `/team/projects/${projectId}` as Route, - settings: (projectId: string): Route => `/team/projects/${projectId}/settings` as Route, + root: (slug: string): Route => `/team/projects/${slug}` as Route, + settings: (slug: string): Route => `/team/projects/${slug}/settings` as Route, }, }, auth: { diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index 48c7aa4..f46f22d 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -64,14 +64,14 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp {project.status === 'archived' ? ( ) : ( @@ -79,7 +79,7 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp ) From c0e1bdb622e09e26d520d0d600831e7b604520f0 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 18 Jun 2026 03:40:03 +0300 Subject: [PATCH 5/6] refactor(project): add slug validation via API --- src/entities/project/api/http.ts | 11 +++ src/entities/project/api/queries.ts | 9 ++ src/entities/project/index.ts | 3 + src/entities/project/lib/useCheckSlug.ts | 90 +++++++++++++++++++ .../project/lib/useSlugFieldStatus.ts | 42 +++++++++ .../project/lib/validate-project-slug.ts | 21 +++++ src/entities/project/model/const.ts | 4 + src/entities/project/model/schemas.ts | 14 +++ src/entities/project/model/types.ts | 2 + src/entities/project/ui/SlugField.tsx | 79 ++++++++++++++++ src/entities/project/ui/SlugFieldStatus.tsx | 31 +++++++ .../create/model/useCreateProjectForm.ts | 11 ++- .../create/ui/ProjectIdentityFields.tsx | 55 ++---------- 13 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 src/entities/project/lib/useCheckSlug.ts create mode 100644 src/entities/project/lib/useSlugFieldStatus.ts create mode 100644 src/entities/project/lib/validate-project-slug.ts create mode 100644 src/entities/project/ui/SlugField.tsx create mode 100644 src/entities/project/ui/SlugFieldStatus.tsx diff --git a/src/entities/project/api/http.ts b/src/entities/project/api/http.ts index 2e2cd15..4ec0ab3 100644 --- a/src/entities/project/api/http.ts +++ b/src/entities/project/api/http.ts @@ -81,4 +81,15 @@ export class ProjectHttp { }, }); } + + static checkSlug(teamId: string, slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${teamId}/projects/check-slug?q=${slug}`, + method: 'GET', + contracts: { + response: SProject.CheckSlugResponse, + }, + signal, + }); + } } diff --git a/src/entities/project/api/queries.ts b/src/entities/project/api/queries.ts index d6d85d9..d45a319 100644 --- a/src/entities/project/api/queries.ts +++ b/src/entities/project/api/queries.ts @@ -18,4 +18,13 @@ export class ProjectQueries { staleTime: 60_000, }); } + + static checkSlug(teamId: string, slug: string) { + return queryOptions({ + queryKey: projectFabricKeys.checkSlug(teamId, slug), + queryFn: async ({ signal }) => ProjectHttp.checkSlug(teamId, slug, signal), + gcTime: 5000, + staleTime: 5000, + }); + } } diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 190e163..1f360b8 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -7,3 +7,6 @@ export { PROJECT_ICONS } from './config/icons'; export { PROJECT_COLORS } from './config/colors'; export { projectIconCodeToEmoji } from './lib/emoji'; export { buildProjectShareUrl } from './lib/share-url'; +export { validateProjectSlugAsync } from './lib/validate-project-slug'; +export { SlugField } from './ui/SlugField'; +export { useCheckSlug } from './lib/useCheckSlug'; diff --git a/src/entities/project/lib/useCheckSlug.ts b/src/entities/project/lib/useCheckSlug.ts new file mode 100644 index 0000000..c241dcd --- /dev/null +++ b/src/entities/project/lib/useCheckSlug.ts @@ -0,0 +1,90 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { FieldErrors } from 'react-hook-form'; +import { debounce } from 'shared/lib/utils'; +import { ProjectQueries } from '../api/queries'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, projectFabricKeys } from '../model/const'; + +const DEBOUNCE_MS = 400; +const SLUG_UNAVAILABLE_MESSAGE = 'Этот адрес уже занят'; + +export type CheckSlugErrors = FieldErrors<{ slug: string }>; + +function prepareResponse(available: boolean, message?: string | null): CheckSlugErrors { + if (available) { + return {}; + } + + return { + slug: { + type: 'validate', + message: message ?? SLUG_UNAVAILABLE_MESSAGE, + }, + }; +} + +export function useCheckSlug(defaultValue: string, teamId: string) { + const currentSlug = useRef(defaultValue); + const pendingResolve = useRef<((errors: CheckSlugErrors) => void) | null>(null); + const queryClient = useQueryClient(); + + const resolvePending = useCallback((errors: CheckSlugErrors) => { + pendingResolve.current?.(errors); + pendingResolve.current = null; + }, []); + + const debouncedCheckSlug = useMemo(() => { + // eslint-disable-next-line react-hooks/refs -- refs read only in async-callback debounce + return debounce(async (value: string) => { + try { + const data = await queryClient.fetchQuery(ProjectQueries.checkSlug(teamId!, value)); + + // when the server response arrives but is already outdated + if (value !== currentSlug.current) { + return; + } + + resolvePending(prepareResponse(data.available, data.reason)); + } catch { + if (value === currentSlug.current) { + resolvePending({}); + } + } + }, DEBOUNCE_MS); + }, [queryClient, resolvePending, teamId]); + + const cancel = useCallback(() => { + debouncedCheckSlug.cancelDebouncedCallback(); + queryClient.cancelQueries({ queryKey: projectFabricKeys.checkSlug() }); + resolvePending({}); + }, [debouncedCheckSlug, queryClient, resolvePending]); + + useEffect(() => { + return () => { + cancel(); + queryClient.removeQueries({ queryKey: projectFabricKeys.checkSlug() }); + }; + }, [cancel, queryClient]); + + return useCallback( + (value: string): Promise => + new Promise((resolve) => { + const isValid = + defaultValue !== value && + value.length >= MIN_SLUG_LENGTH && + value.length <= MAX_SLUG_LENGTH; + + cancel(); + currentSlug.current = value; + + if (!isValid) { + resolve({}); + return; + } + + pendingResolve.current = resolve; + debouncedCheckSlug.debouncedCallback(value); + }), + [cancel, debouncedCheckSlug, defaultValue] + ); +} diff --git a/src/entities/project/lib/useSlugFieldStatus.ts b/src/entities/project/lib/useSlugFieldStatus.ts new file mode 100644 index 0000000..d3156b9 --- /dev/null +++ b/src/entities/project/lib/useSlugFieldStatus.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { ProjectQueries } from '../api/queries'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from '../model/const'; + +type SlugFieldStatusValue = 'pending' | 'success' | 'error'; + +interface SlugFieldStatusState { + isDirty: boolean; + slug: string; +} + +export function useSlugFieldStatus({ + isDirty, + slug, + teamId, +}: SlugFieldStatusState & { teamId: string }): SlugFieldStatusValue | undefined { + const { data, isPending } = useQuery({ + ...ProjectQueries.checkSlug(teamId!, slug), + enabled: false, + }); + + return useMemo(() => { + if (!isDirty || slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + return undefined; + } + + if (isPending) { + return 'pending'; + } + + if (data?.available === false) { + return 'error'; + } + + if (data?.available === true) { + return 'success'; + } + + return undefined; + }, [data?.available, isDirty, isPending, slug]); +} diff --git a/src/entities/project/lib/validate-project-slug.ts b/src/entities/project/lib/validate-project-slug.ts new file mode 100644 index 0000000..28763fe --- /dev/null +++ b/src/entities/project/lib/validate-project-slug.ts @@ -0,0 +1,21 @@ +import type { FieldValues, ResolverOptions } from 'react-hook-form'; +import type { CheckSlugErrors } from './useCheckSlug'; + +type SlugFormValues = { slug?: string }; + +function shouldValidateSlugAsync(names: readonly string[] | undefined): boolean { + return !names?.length || names.includes('slug'); +} + +export function validateProjectSlugAsync( + checkSlug: (value: string) => Promise, + values: T, + _context: unknown, + options: ResolverOptions +): Promise { + if (!shouldValidateSlugAsync(options.names as readonly string[] | undefined)) { + return Promise.resolve({}); + } + + return checkSlug(values.slug ?? ''); +} diff --git a/src/entities/project/model/const.ts b/src/entities/project/model/const.ts index b5d711e..f0b7865 100644 --- a/src/entities/project/model/const.ts +++ b/src/entities/project/model/const.ts @@ -4,8 +4,12 @@ export const PROJECT_STATUSES = ['active', 'archived', 'template', 'deleted'] as export const PROJECT_VISIBILITIES = ['public', 'private'] as const; export const LAYOUTS = ['kanban', 'list', 'calendar', 'gantt'] as const; export const MEMBER_ROLE = ['owner', 'admin', 'member', 'viewer'] as const; +export const MIN_SLUG_LENGTH = 1 as const; +export const MAX_SLUG_LENGTH = 100 as const; export const projectFabricKeys = createEntityKeys('project', { list: (teamId: string) => ['teams', teamId, 'projects'], detail: (teamId: string, slug: string) => ['teams', teamId, 'projects', slug], + checkSlug: (teamId?: string, slug?: string) => + ['teams', teamId, 'projects', 'check-slug', slug].filter(Boolean), }); diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts index a891662..d4b01b5 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -104,6 +104,15 @@ export const CreateProjectBody = ProjectSchema.omit({ }) .extend({ settings: CreateProjectSettingsSchema.optional(), + slug: z + .string() + .max(100, 'Уникальный идентификатор не должен превышать 100 символов') + .refine( + (value) => value === '' || /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value), + 'Идентификатор должен быть в формате kebab-case (например: "my-project")' + ) + + .optional(), }); export const UpdateProjectBody = CreateProjectBody.extend({ @@ -168,3 +177,8 @@ export const ProjectDetailResponse = z.object({ updatedAt: true, }), }); + +export const CheckSlugResponse = z.object({ + available: z.boolean(), + reason: z.string().nullable(), +}); diff --git a/src/entities/project/model/types.ts b/src/entities/project/model/types.ts index 932d7d1..d03fd8e 100644 --- a/src/entities/project/model/types.ts +++ b/src/entities/project/model/types.ts @@ -15,3 +15,5 @@ export type ActionResponse = z.infer; export type ProjectListItemResponse = z.infer; export type ProjectListResponse = z.infer; export type ProjectDetailResponse = z.infer; + +export type CheckSlugResponse = z.infer; diff --git a/src/entities/project/ui/SlugField.tsx b/src/entities/project/ui/SlugField.tsx new file mode 100644 index 0000000..99641c6 --- /dev/null +++ b/src/entities/project/ui/SlugField.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useId } from 'react'; +import { Controller, FieldPath, FieldValues, useFormContext } from 'react-hook-form'; +import { cn } from 'shared/lib/utils'; +import { + Field, + FieldError, + FieldLabel, + InputGroup, + InputGroupAddon, + InputGroupInput, +} from 'shared/ui'; +import { SlugFieldStatus } from './SlugFieldStatus'; + +interface SlugFieldProps { + name: FieldPath; + disabled?: boolean; + label?: string; + prefix?: string; + placeholder?: string; + className?: string; + teamId: string; +} + +function slugifyOnChange(value: string) { + return value + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+/, ''); +} + +export function SlugField({ + disabled = false, + label = 'Короткий адрес в ссылке (необязательно)', + prefix, + placeholder = 'my-team', + className, + name, + teamId, +}: SlugFieldProps) { + const id = useId(); + const { trigger, control } = useFormContext(); + + return ( + ( + + {label} + + {prefix ? {prefix} : null} + { + field.onChange(slugifyOnChange(e.target.value)); + void trigger(name); + }} + id={id} + aria-label={label} + placeholder={placeholder} + aria-invalid={fieldState.invalid} + autoComplete="off" + disabled={disabled} + /> + + + + + {fieldState.invalid && } + + )} + /> + ); +} diff --git a/src/entities/project/ui/SlugFieldStatus.tsx b/src/entities/project/ui/SlugFieldStatus.tsx new file mode 100644 index 0000000..466f52f --- /dev/null +++ b/src/entities/project/ui/SlugFieldStatus.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { CheckCircle, XCircle } from 'lucide-react'; +import { Spinner } from 'shared/ui'; +import { useSlugFieldStatus } from '../lib/useSlugFieldStatus'; + +interface SlugFieldStatusProps { + slug: string; + isDirty: boolean; + teamId: string; +} + +export function SlugFieldStatus({ slug, isDirty, teamId }: SlugFieldStatusProps) { + const status = useSlugFieldStatus({ isDirty, slug, teamId }); + + if (!status) { + return null; + } + + return ( + <> + {status === 'pending' ? ( + + ) : status === 'error' ? ( + + ) : ( + + )} + + ); +} diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts index af3a68c..b1048b1 100644 --- a/src/features/projects/create/model/useCreateProjectForm.ts +++ b/src/features/projects/create/model/useCreateProjectForm.ts @@ -1,5 +1,4 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { type TProject } from 'entities/project'; +import { useCheckSlug, validateProjectSlugAsync, type TProject } from 'entities/project'; import { useTeamStore } from 'entities/team'; import { useForm } from 'react-hook-form'; import { extractValidationIssues } from 'shared/api'; @@ -8,12 +7,16 @@ import { getDefaultCreateProjectValues } from './default-values'; import { CreateProjectFormSchema } from './schemas'; import type { CreateProjectFormValues } from './types'; import { useCreateProject, type UseCreateProjectOptions } from './useCreateProject'; +import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { const teamId = useTeamStore.use.teamId(); + const checkSlug = useCheckSlug('', teamId!); const form = useForm({ - resolver: zodResolver(CreateProjectFormSchema), + resolver: useZodValidationWithAsyncCheck(CreateProjectFormSchema, (...args) => + validateProjectSlugAsync(checkSlug, ...args) + ), defaultValues: getDefaultCreateProjectValues(), }); @@ -34,9 +37,9 @@ export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { const body: TProject.CreateProjectBody = { teamId, name: data.name.trim(), - slug: data.slug?.trim(), status: data.status, visibility: data.visibility ?? 'private', + ...(data.slug?.trim() ? { slug: data.slug?.trim() } : {}), ...(data.description?.trim() ? { description: data.description.trim() } : {}), ...(data.icon ? { icon: data.icon } : {}), ...(data.color ? { color: data.color } : {}), diff --git a/src/features/projects/create/ui/ProjectIdentityFields.tsx b/src/features/projects/create/ui/ProjectIdentityFields.tsx index 0e4e25c..5a2623c 100644 --- a/src/features/projects/create/ui/ProjectIdentityFields.tsx +++ b/src/features/projects/create/ui/ProjectIdentityFields.tsx @@ -1,20 +1,13 @@ 'use client'; import { Controller, useFormContext } from 'react-hook-form'; -import { - Field, - FieldError, - FieldGroup, - FieldLabel, - Input, - InputGroup, - InputGroupInput, - Textarea, -} from 'shared/ui'; +import { Field, FieldError, FieldGroup, FieldLabel, Input, Textarea } from 'shared/ui'; import type { ProjectIdentityFormValues } from '../model/types'; import { ProjectColorPicker } from './ProjectColorPicker'; import { ProjectIconPicker } from './ProjectIconPicker'; import { slugify } from '../lib/slugify'; +import { SlugField } from 'entities/project'; +import { useTeamStore } from 'entities/team'; interface ProjectIdentityFieldsProps { disabled?: boolean; @@ -22,15 +15,6 @@ interface ProjectIdentityFieldsProps { showPlaceholders?: boolean; } -function slugifyOnChange(value: string) { - return value - .toLowerCase() - .replace(/[\s_]+/g, '-') - .replace(/[^a-z0-9-]/g, '') - .replace(/-+/g, '-') - .replace(/^-+/, ''); -} - export function ProjectIdentityFields({ disabled = false, idPrefix = 'project', @@ -40,6 +24,7 @@ export function ProjectIdentityFields({ const iconError = form.formState.errors.icon; const colorError = form.formState.errors.color; const hasVisualError = Boolean(iconError || colorError); + const teamId = useTeamStore((s) => s.teamId); return ( @@ -54,7 +39,10 @@ export function ProjectIdentityFields({ onChange={(e) => { field.onChange(e); if (!formState.dirtyFields.slug) { - form.setValue('slug', slugify(e.target.value)); + form.setValue('slug', slugify(e.target.value), { + shouldValidate: true, + shouldDirty: true, + }); } }} id={`${idPrefix}-name`} @@ -69,32 +57,7 @@ export function ProjectIdentityFields({ )} /> - ( - - Ключ проекта - - { - field.onChange(slugifyOnChange(e.target.value)); - }} - id={`${idPrefix}-key`} - aria-label="Ключ проекта" - placeholder={showPlaceholders ? 'my-project' : undefined} - aria-required={showPlaceholders ? true : undefined} - aria-invalid={fieldState.invalid} - autoComplete="off" - disabled={disabled} - /> - - {fieldState.invalid && } - - )} - /> + Date: Thu, 18 Jun 2026 18:15:27 +0300 Subject: [PATCH 6/6] refactor(project): add canEdit property to project responses and update related components --- src/entities/project/model/schemas.ts | 79 +++++++++++-------- .../ui/settings/ProjectSettingsPage.tsx | 17 ++-- src/pages/team/ui/projects/ProjectCard.tsx | 7 +- .../ui/projects/ProjectActions.tsx | 2 +- 4 files changed, 58 insertions(+), 47 deletions(-) diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts index d4b01b5..e6ee8e5 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -139,44 +139,53 @@ export const CreateShareTokenResponse = GlobalSuccess.extend({ }), }); -export const ProjectListItemResponse = z.object({ - id: z.string(), - slug: z.string(), - name: z.string(), - status: ProjectStatusSchema, - color: z.string(), - icon: z.string().nullable(), - createdAt: DateTimeString, - role: ProjectMemberRoleSchema, -}); +export const ProjectListItemResponse = z + .object({ + id: z.string(), + slug: z.string(), + name: z.string(), + status: ProjectStatusSchema, + color: z.string(), + icon: z.string().nullable(), + createdAt: DateTimeString, + role: ProjectMemberRoleSchema, + }) + .transform((data) => ({ ...data, canEdit: data.role === 'admin' || data.role === 'owner' })); +// TODO: временно добавил canEdit через transform export const ProjectListResponse = PaginatedResponseSchema(ProjectListItemResponse); -export const ProjectDetailResponse = z.object({ - id: z.string(), - slug: z.string(), - name: z.string(), - status: z.enum(['active', 'archived', 'template', 'deleted']), - description: z.string().nullable(), - descriptionHtml: z.string().nullish(), - visuals: z.object({ color: z.string().nullish(), icon: z.string().nullish().optional() }), - meta: z.object({ - sequence: z.number().int().nonnegative(), - createdAt: DateTimeString, - updatedAt: DateTimeString, - }), - access: z.object({ - visibility: z.enum(['public', 'private']), - currentUserRole: z.enum(['owner', 'admin', 'member', 'viewer']), - shareUrl: z.string().nullable(), - }), - settings: ProjectSettingsSchema.omit({ - id: true, - projectId: true, - createdAt: true, - updatedAt: true, - }), -}); +export const ProjectDetailResponse = z + .object({ + id: z.string(), + slug: z.string(), + name: z.string(), + status: z.enum(['active', 'archived', 'template', 'deleted']), + description: z.string().nullable(), + descriptionHtml: z.string().nullish(), + visuals: z.object({ color: z.string().nullish(), icon: z.string().nullish().optional() }), + meta: z.object({ + sequence: z.number().int().nonnegative(), + createdAt: DateTimeString, + updatedAt: DateTimeString, + }), + access: z.object({ + visibility: z.enum(['public', 'private']), + currentUserRole: z.enum(['owner', 'admin', 'member', 'viewer']), + shareUrl: z.string().nullable(), + }), + settings: ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, + }), + }) + .transform((data) => ({ + ...data, + canEdit: data.access.currentUserRole === 'admin' || data.access.currentUserRole === 'owner', + })); +// TODO: временно добавил canEdit через transform export const CheckSlugResponse = z.object({ available: z.boolean(), diff --git a/src/pages/project/ui/settings/ProjectSettingsPage.tsx b/src/pages/project/ui/settings/ProjectSettingsPage.tsx index 2d3245b..21ec575 100644 --- a/src/pages/project/ui/settings/ProjectSettingsPage.tsx +++ b/src/pages/project/ui/settings/ProjectSettingsPage.tsx @@ -92,18 +92,17 @@ export function ProjectSettingsPage() { } const isTemplate = project.status === 'template'; - const canEdit = - project.access.currentUserRole === 'admin' || project.access.currentUserRole === 'owner'; + return (
- +
- + {isTemplate ? (

@@ -116,7 +115,11 @@ export function ProjectSettingsPage() { render={({ field, fieldState }) => ( Статус - @@ -133,13 +136,13 @@ export function ProjectSettingsPage() {

- {canEdit && teamId && ( + {project.canEdit && teamId && ( )}
- {canEdit ? : null} + {project.canEdit ? : null}
); } diff --git a/src/pages/team/ui/projects/ProjectCard.tsx b/src/pages/team/ui/projects/ProjectCard.tsx index 372eebd..757073f 100644 --- a/src/pages/team/ui/projects/ProjectCard.tsx +++ b/src/pages/team/ui/projects/ProjectCard.tsx @@ -66,7 +66,6 @@ export function ProjectCard({ const mockMembers = Array.from({ length: mockMembersCount }).map((_, i) => i + 1); const projectHref = project && teamId ? routes.team.project.root(project.slug) : null; - const canEdit = project?.role === 'admin' || project?.role === 'owner'; const card = ( e.preventDefault()}> Восстановить @@ -178,7 +177,7 @@ export function ProjectCard({ teamId={teamId!} slug={project?.slug ?? ''} asChild - disabled={!(canEdit && teamId)} + disabled={!(project?.canEdit && teamId)} > e.preventDefault()}> Архивировать @@ -234,7 +233,7 @@ export function ProjectCard({
- {canEdit ? ( + {project?.canEdit ? ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index f46f22d..1686598 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -24,7 +24,7 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp const [shareOpen, setShareOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [restoreOpen, setRestoreOpen] = useState(false); - const canManage = Boolean(teamId && (project.role === 'owner' || project.role === 'admin')); + const canManage = Boolean(teamId && project.canEdit); const openDialog = (setDialogOpen: (open: boolean) => void) => (event: Event) => { event.preventDefault();