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/entities/project/api/http.ts b/src/entities/project/api/http.ts index 81ba007..4ec0ab3 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: { @@ -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 6b42d9c..d45a319 100644 --- a/src/entities/project/api/queries.ts +++ b/src/entities/project/api/queries.ts @@ -11,11 +11,20 @@ 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, }); } + + 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/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/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 53574a5..f0b7865 100644 --- a/src/entities/project/model/const.ts +++ b/src/entities/project/model/const.ts @@ -1,6 +1,15 @@ 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 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, id: string) => ['teams', teamId, 'projects', id], + 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 67d1cba..e6ee8e5 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -1,28 +1,122 @@ 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(), + 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({ - status: z.enum(['active', 'archived']).optional(), - isPublic: z.boolean().optional(), + settings: UpdateProjectSettingsSchema.optional(), }) .partial() .refine((data) => Object.keys(data).length > 0, { @@ -31,7 +125,7 @@ export const UpdateProjectBody = CreateProjectBody.extend({ }); export const CreateProjectResponse = GlobalSuccess.extend({ - projectId: z.string(), + slug: z.string(), }); export const CreateShareTokenBody = z.object({ @@ -41,47 +135,59 @@ 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(), - name: z.string(), - status: z.enum(['active', 'archived', 'template']), - color: z.string(), - icon: z.string().nullable(), - createdAt: DateTimeString, - canEdit: z.boolean(), -}); +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).extend({ - team: z.object({ +export const ProjectListResponse = PaginatedResponseSchema(ProjectListItemResponse); + +export const ProjectDetailResponse = z + .object({ id: z.string(), + slug: z.string(), name: z.string(), - role: 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 ProjectDetailResponse = z.object({ - id: z.string(), - key: z.string(), - name: z.string(), - status: z.enum(['active', 'archived', 'template']), - description: z.string().nullable(), - visuals: z.object({ color: z.string(), icon: z.string().nullable() }), - meta: z.object({ - taskSequence: z.number(), - createdAt: DateTimeString, - updatedAt: DateTimeString, - }), - access: z.object({ - visibility: z.enum(['public', 'private']), - canEdit: z.boolean(), - canDelete: z.boolean(), - shareUrl: z.string().nullable(), - }), - settings: z.record(z.string(), z.unknown()), +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 835b084..d03fd8e 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; @@ -11,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/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/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..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(), }); @@ -32,9 +35,11 @@ export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { if (!teamId) return; const body: TProject.CreateProjectBody = { + teamId, name: data.name.trim(), - key: data.key.trim().toUpperCase(), + 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 82bb54d..5a2623c 100644 --- a/src/features/projects/create/ui/ProjectIdentityFields.tsx +++ b/src/features/projects/create/ui/ProjectIdentityFields.tsx @@ -1,19 +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; @@ -30,17 +24,27 @@ 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 ( ( + render={({ field, fieldState, formState }) => ( Название { + field.onChange(e); + if (!formState.dirtyFields.slug) { + form.setValue('slug', slugify(e.target.value), { + shouldValidate: true, + shouldDirty: true, + }); + } + }} id={`${idPrefix}-name`} aria-label="Название проекта" placeholder={showPlaceholders ? 'Мой проект' : undefined} @@ -53,37 +57,7 @@ export function ProjectIdentityFields({ )} /> - ( - - Ключ проекта - - { - field.onChange( - e.target.value - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]/g, '') - ); - }} - id={`${idPrefix}-key`} - aria-label="Ключ проекта" - placeholder={showPlaceholders ? 'PROJ' : undefined} - aria-required={showPlaceholders ? true : undefined} - aria-invalid={fieldState.invalid} - autoComplete="off" - disabled={disabled} - /> - - {fieldState.invalid && } - - )} - /> + ({ ...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/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; 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 65a50ad..21ec575 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]); @@ -78,12 +97,12 @@ export function ProjectSettingsPage() {
- +
- + {isTemplate ? (

@@ -99,7 +118,7 @@ export function ProjectSettingsPage() {