diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx new file mode 100644 index 0000000..3865873 --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx index d347ef0..c1308ca 100644 --- a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { BoardsPage } from 'pages/boards'; +import { BoardsPage } from 'pages/project/boards'; export default async function Page({ params, diff --git a/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/error.tsx b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/error.tsx new file mode 100644 index 0000000..5fc79dc --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/[boardSlug]/settings/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/projects/[projectSlug]/page.tsx b/app/(protected)/team/projects/[projectSlug]/page.tsx index 89fe1a1..ef668fe 100644 --- a/app/(protected)/team/projects/[projectSlug]/page.tsx +++ b/app/(protected)/team/projects/[projectSlug]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { BoardsPage } from 'pages/boards'; +import { BoardsPage } from 'pages/project/boards'; export default async function Page({ params }: { params: Promise<{ projectSlug: string }> }) { const { projectSlug } = await params; diff --git a/app/(protected)/team/projects/[projectSlug]/settings/error.tsx b/app/(protected)/team/projects/[projectSlug]/settings/error.tsx new file mode 100644 index 0000000..9130f6b --- /dev/null +++ b/app/(protected)/team/projects/[projectSlug]/settings/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/team/projects/[projectSlug]/settings/page.tsx b/app/(protected)/team/projects/[projectSlug]/settings/page.tsx index 2239b10..7c7e0d1 100644 --- a/app/(protected)/team/projects/[projectSlug]/settings/page.tsx +++ b/app/(protected)/team/projects/[projectSlug]/settings/page.tsx @@ -1,4 +1,5 @@ import { notFound } from 'next/navigation'; +import { ProjectSettingsPage } from 'pages/project/settings'; export default async function Page({ params }: { params: Promise<{ projectSlug: string }> }) { const { projectSlug } = await params; @@ -7,5 +8,5 @@ export default async function Page({ params }: { params: Promise<{ projectSlug: return notFound(); } - return
конфиг проекта {projectSlug}
; + return ; } diff --git a/app/(protected)/team/projects/error.tsx b/app/(protected)/team/projects/error.tsx new file mode 100644 index 0000000..dd95e0b --- /dev/null +++ b/app/(protected)/team/projects/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/public/[teamId]/[projectSlug]/[boardSlug]/error.tsx b/app/public/[teamId]/[projectSlug]/[boardSlug]/error.tsx new file mode 100644 index 0000000..3865873 --- /dev/null +++ b/app/public/[teamId]/[projectSlug]/[boardSlug]/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/public/[teamId]/[projectSlug]/[boardSlug]/page.tsx b/app/public/[teamId]/[projectSlug]/[boardSlug]/page.tsx new file mode 100644 index 0000000..c1308ca --- /dev/null +++ b/app/public/[teamId]/[projectSlug]/[boardSlug]/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from 'next/navigation'; +import { BoardsPage } from 'pages/project/boards'; + +export default async function Page({ + params, +}: { + params: Promise<{ boardSlug: string; projectSlug: string }>; +}) { + const { boardSlug, projectSlug } = await params; + + if (!boardSlug || !projectSlug) { + return notFound(); + } + + return ; +} diff --git a/next-env.d.ts b/next-env.d.ts index cdb6b7b..c05d9f7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import './.next/dev/types/routes.d.ts'; +import './.next/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/entities/board/api/http.ts b/src/entities/board/api/http.ts index e320997..cdd6543 100644 --- a/src/entities/board/api/http.ts +++ b/src/entities/board/api/http.ts @@ -5,7 +5,7 @@ import type * as TBoard from '../model/types'; export class BoardHttp { static getBoardList(projectSlug: string, signal?: AbortSignal) { return api({ - url: `/projects/${projectSlug}/area`, + url: `/projects/${projectSlug}/areas`, method: 'GET', contracts: { response: SBoard.BoardListResponse, @@ -16,7 +16,7 @@ export class BoardHttp { static getBoard(projectSlug: string, id: string, signal?: AbortSignal) { return api({ - url: `/projects/${projectSlug}/area/${id}`, + url: `/projects/${projectSlug}/areas/${id}`, method: 'GET', contracts: { response: SBoard.Board, @@ -27,7 +27,7 @@ export class BoardHttp { static createBoard(projectSlug: string, data: TBoard.CreateBoardBody) { return api({ - url: `/projects/${projectSlug}/area`, + url: `/projects/${projectSlug}/areas`, method: 'POST', data, contracts: { @@ -39,7 +39,7 @@ export class BoardHttp { static updateBoard(projectSlug: string, boardSlug: string, data: TBoard.UpdateBoardBody) { return api({ - url: `/projects/${projectSlug}/area/${boardSlug}`, + url: `/projects/${projectSlug}/areas/${boardSlug}`, method: 'PUT', data, contracts: { @@ -51,7 +51,7 @@ export class BoardHttp { static removeBoard(projectSlug: string, boardSlug: string) { return api({ - url: `/projects/${projectSlug}/area/${boardSlug}`, + url: `/projects/${projectSlug}/areas/${boardSlug}`, method: 'DELETE', contracts: { response: SBoard.ActionResponse, @@ -61,7 +61,7 @@ export class BoardHttp { static getBoardColumnList(boardSlug: string, signal?: AbortSignal) { return api({ - url: `/area/${boardSlug}/states`, + url: `/areas/${boardSlug}/states`, method: 'GET', contracts: { response: SBoard.BoardColumnListResponse, @@ -72,7 +72,7 @@ export class BoardHttp { static getBoardColumn(boardSlug: string, columnId: string, signal?: AbortSignal) { return api({ - url: `/area/${boardSlug}/states/${columnId}`, + url: `/areas/${boardSlug}/states/${columnId}`, method: 'GET', contracts: { response: SBoard.BoardColumn, @@ -83,7 +83,7 @@ export class BoardHttp { static createBoardColumn(boardSlug: string, data: TBoard.CreateBoardColumnBody) { return api({ - url: `/area/${boardSlug}/states`, + url: `/areas/${boardSlug}/states`, method: 'POST', data, contracts: { @@ -99,7 +99,7 @@ export class BoardHttp { data: TBoard.UpdateBoardColumnBody ) { return api({ - url: `/area/${boardSlug}/states/${columnId}`, + url: `/areas/${boardSlug}/states/${columnId}`, method: 'PATCH', data, contracts: { @@ -111,7 +111,7 @@ export class BoardHttp { static removeBoardColumn(boardSlug: string, columnId: string) { return api({ - url: `/area/${boardSlug}/states/${columnId}`, + url: `/areas/${boardSlug}/states/${columnId}`, method: 'DELETE', contracts: { response: SBoard.ActionResponse, @@ -120,11 +120,23 @@ export class BoardHttp { } static restoreBoardColumn(boardSlug: string, columnId: string) { return api({ - url: `/area/${boardSlug}/states/${columnId}/restore`, + url: `/areas/${boardSlug}/states/${columnId}/restore`, method: 'POST', contracts: { response: SBoard.ActionResponse, }, }); } + + static moveBoardColumn(boardSlug: string, columnId: string, data: TBoard.MoveBoardColumnBody) { + return api({ + url: `/areas/${boardSlug}/states/${columnId}/move`, + method: 'POST', + data, + contracts: { + body: SBoard.MoveBoardColumnBody, + response: SBoard.ActionResponse, + }, + }); + } } diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index 5082726..5de20e8 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -1,6 +1,7 @@ export { BoardHttp } from './api/http'; export { BoardQueries } from './api/queries'; export { BOARD_COLUMN_COLORS } from './config/colors'; +export { calculateNewPosition } from './lib/calculate-new-position'; export { boardFabricKeys } from './model/consts'; export * as SBoard from './model/schemas'; export type * as TBoard from './model/types'; diff --git a/src/entities/board/lib/calculate-new-position.ts b/src/entities/board/lib/calculate-new-position.ts new file mode 100644 index 0000000..ce5cd7b --- /dev/null +++ b/src/entities/board/lib/calculate-new-position.ts @@ -0,0 +1,24 @@ +const MIN_STEP = 10000000; + +function isNumber(value: number | null): value is number { + return value !== null && !Number.isNaN(value); +} + +function prepareResult(result: number): number { + return Math.round(result); // TODO: remove this after change schema to allow float +} + +export function calculateNewPosition( + prevPosition: number | null, + nextPosition: number | null +): number { + if (!isNumber(prevPosition) && isNumber(nextPosition)) return prepareResult(nextPosition / 2); + if (isNumber(prevPosition) && !isNumber(nextPosition)) + return prepareResult(prevPosition + MIN_STEP); + + if (isNumber(prevPosition) && isNumber(nextPosition)) { + return prepareResult((prevPosition + nextPosition) / 2); + } + + return MIN_STEP; +} diff --git a/src/entities/board/model/schemas.ts b/src/entities/board/model/schemas.ts index 586fe4d..752c70b 100644 --- a/src/entities/board/model/schemas.ts +++ b/src/entities/board/model/schemas.ts @@ -1,6 +1,6 @@ import { createSortingSchema, CursorQuerySchema, DateTimeString, GlobalSuccess } from 'shared/api'; -import { z } from 'zod/v4'; import { HEX_COLOR_REGEX } from 'shared/lib/utils'; +import { z } from 'zod/v4'; export const ActionResponse = GlobalSuccess; @@ -47,10 +47,7 @@ export const Board = z.object({ .int('Количество задач должно быть целым числом') .min(0, 'Количество задач не может быть отрицательным'), defaultView: ViewTypeEnum, - position: z - .number() - .int('Позиция должна быть целым числом') - .min(0, 'Позиция не может быть отрицательной'), + position: z.number().min(0, 'Позиция не может быть отрицательной'), maxTasksLimit: z .number() .int('Лимит задач должен быть целым числом') @@ -79,10 +76,7 @@ export const BoardColumn = z.object({ .nullable() .optional(), icon: z.string().max(20, 'Иконка должна быть не длиннее 20 символов').nullable().optional(), - position: z - .number() - .int('Позиция должна быть целым числом') - .min(0, 'Позиция не может быть отрицательной'), + position: z.number().min(0, 'Позиция не может быть отрицательной'), isVisible: z.boolean(), maxTasksLimit: z .number() @@ -164,6 +158,14 @@ export const UpdateBoardColumnBody = CreateBoardColumnBody.partial().refine( } ); +export const MoveBoardColumnBody = z + .object({ + position: z.number().nonnegative(), + prevStatePosition: z.number().nonnegative().nullable(), + nextStatePosition: z.number().nonnegative().nullable(), + }) + .strict(); + export const BoardColumnQueryParams = z .object({ hidden: z.boolean().optional(), diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts index 62f1917..8e16e80 100644 --- a/src/entities/board/model/types.ts +++ b/src/entities/board/model/types.ts @@ -17,6 +17,7 @@ export type CreateBoardResponse = z.infer; export type CreateBoardColumnBody = z.infer; export type UpdateBoardColumnBody = z.infer; +export type MoveBoardColumnBody = z.infer; export type CreateBoardColumnResponse = z.infer; export type ActionResponse = z.infer; diff --git a/src/entities/project/api/queries.ts b/src/entities/project/api/queries.ts index d45a319..0599e04 100644 --- a/src/entities/project/api/queries.ts +++ b/src/entities/project/api/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query'; -import { projectFabricKeys } from '../model/const'; +import { projectFabricKeys } from '../model/consts'; import { ProjectHttp } from './http'; export class ProjectQueries { diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 1f360b8..66661b3 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -2,7 +2,7 @@ export * as SProject from './model/schemas'; export type * as TProject from './model/types'; export { ProjectHttp } from './api/http'; export { ProjectQueries } from './api/queries'; -export { projectFabricKeys } from './model/const'; +export { projectFabricKeys } from './model/consts'; export { PROJECT_ICONS } from './config/icons'; export { PROJECT_COLORS } from './config/colors'; export { projectIconCodeToEmoji } from './lib/emoji'; diff --git a/src/entities/project/lib/useCheckSlug.ts b/src/entities/project/lib/useCheckSlug.ts index c241dcd..51458a7 100644 --- a/src/entities/project/lib/useCheckSlug.ts +++ b/src/entities/project/lib/useCheckSlug.ts @@ -3,7 +3,7 @@ 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'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, projectFabricKeys } from '../model/consts'; const DEBOUNCE_MS = 400; const SLUG_UNAVAILABLE_MESSAGE = 'Этот адрес уже занят'; diff --git a/src/entities/project/lib/useSlugFieldStatus.ts b/src/entities/project/lib/useSlugFieldStatus.ts index d3156b9..3aaa0f1 100644 --- a/src/entities/project/lib/useSlugFieldStatus.ts +++ b/src/entities/project/lib/useSlugFieldStatus.ts @@ -1,7 +1,7 @@ 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'; +import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from '../model/consts'; type SlugFieldStatusValue = 'pending' | 'success' | 'error'; @@ -15,17 +15,20 @@ export function useSlugFieldStatus({ slug, teamId, }: SlugFieldStatusState & { teamId: string }): SlugFieldStatusValue | undefined { - const { data, isPending } = useQuery({ - ...ProjectQueries.checkSlug(teamId!, slug), - enabled: false, + const canCheck = + Boolean(teamId) && isDirty && slug.length >= MIN_SLUG_LENGTH && slug.length <= MAX_SLUG_LENGTH; + + const { data, isFetching } = useQuery({ + ...ProjectQueries.checkSlug(teamId, slug), + enabled: canCheck, }); return useMemo(() => { - if (!isDirty || slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + if (!canCheck) { return undefined; } - if (isPending) { + if (isFetching) { return 'pending'; } @@ -38,5 +41,5 @@ export function useSlugFieldStatus({ } return undefined; - }, [data?.available, isDirty, isPending, slug]); + }, [canCheck, data?.available, isFetching]); } diff --git a/src/entities/project/model/const.ts b/src/entities/project/model/consts.ts similarity index 100% rename from src/entities/project/model/const.ts rename to src/entities/project/model/consts.ts diff --git a/src/entities/project/model/schemas.ts b/src/entities/project/model/schemas.ts index 979ed3a..ee8a5de 100644 --- a/src/entities/project/model/schemas.ts +++ b/src/entities/project/model/schemas.ts @@ -2,7 +2,7 @@ import { DateTimeString, GlobalSuccess, PaginatedResponseSchema } from 'shared/a import { HEX_COLOR_REGEX } from 'shared/lib/utils'; import { z } from 'zod/v4'; import { PROJECT_ICONS } from '../config/icons'; -import { MEMBER_ROLE, PROJECT_STATUSES, PROJECT_VISIBILITIES } from './const'; +import { MEMBER_ROLE, PROJECT_STATUSES, PROJECT_VISIBILITIES } from './consts'; export const ActionResponse = GlobalSuccess; export const ProjectStatusSchema = z.enum(PROJECT_STATUSES); diff --git a/src/entities/project/ui/SlugField.tsx b/src/entities/project/ui/SlugField.tsx index 99641c6..78e21d3 100644 --- a/src/entities/project/ui/SlugField.tsx +++ b/src/entities/project/ui/SlugField.tsx @@ -68,7 +68,11 @@ export function SlugField({ disabled={disabled} /> - + {fieldState.invalid && } diff --git a/src/entities/task/api/queries.ts b/src/entities/task/api/queries.ts index e1bd5d0..e88ddd9 100644 --- a/src/entities/task/api/queries.ts +++ b/src/entities/task/api/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query'; -import { taskFabricKeys } from '../model/const'; +import { taskFabricKeys } from '../model/consts'; import type { Task, TaskContextQuery, TaskListQuery } from '../model/types'; import { TaskHttp } from './http'; diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index 253e8e8..9506c21 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -2,4 +2,4 @@ export type * as TTask from './model/types'; export * as STask from './model/schemas'; export { TaskHttp } from './api/http'; export { TaskQueries } from './api/queries'; -export { taskFabricKeys } from './model/const'; +export { taskFabricKeys } from './model/consts'; diff --git a/src/entities/task/model/const.ts b/src/entities/task/model/consts.ts similarity index 100% rename from src/entities/task/model/const.ts rename to src/entities/task/model/consts.ts diff --git a/src/entities/task/model/schemas.ts b/src/entities/task/model/schemas.ts index da8983b..9976dde 100644 --- a/src/entities/task/model/schemas.ts +++ b/src/entities/task/model/schemas.ts @@ -77,7 +77,8 @@ export const CreateTaskResponse = GlobalSuccess.extend({ id: z.string(), }); -export const UpdateTaskBody = CreateTaskBody.partial() +export const UpdateTaskBody = CreateTaskBody.omit({ position: true }) + .partial() .refine((data) => Object.keys(data).length > 0, { error: 'Необходимо передать хотя бы одно поле для обновления', abort: true, @@ -89,6 +90,8 @@ export const MoveTaskBody = z targetAreaId: z.string().optional(), targetStateId: z.string().nullable().optional(), position: z.number().int().nonnegative(), + prevIssuePosition: z.number().int().nonnegative().nullable(), + nextIssuePosition: z.number().int().nonnegative().nullable(), }) .strict(); @@ -115,10 +118,10 @@ export const TaskListQuery = TaskContextQuery.extend({ reporterId: z.string().optional(), priority: IssueFilterPriority.optional(), type: IssueFilterType.optional(), - parentId: z.string().optional(), + parentId: z.string().nullable().optional(), labels: z.string().optional(), cursor: z.string().optional(), - limit: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), sortBy: IssueSortBy.optional(), sortOrder: IssueSortOrder.optional(), areaId: z.string().optional(), diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts index 5333f19..acbc46f 100644 --- a/src/entities/team/api/http.ts +++ b/src/entities/team/api/http.ts @@ -84,7 +84,7 @@ export class TeamHttp { static acceptInvitation(code: string) { return api({ - url: `/teams/invitations/${code}/accept`, + url: `/teams//invitations/${code}/accept`, // TODO: Убрать дублирующий слеш в URL, когда починят роутинг на бэке method: 'POST', contracts: { response: STeam.ActionResponse, diff --git a/src/entities/team/api/queries.ts b/src/entities/team/api/queries.ts index eb8320a..b3f9def 100644 --- a/src/entities/team/api/queries.ts +++ b/src/entities/team/api/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query'; -import { teamFabricKeys } from '../model/const'; +import { teamFabricKeys } from '../model/consts'; import { TeamHttp } from './http'; export class TeamQueries { diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index 2777269..58d2ddb 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -2,7 +2,7 @@ export * as STeam from './model/schemas'; export type * as TTeam from './model/types'; export { TeamHttp } from './api/http'; export { TeamQueries } from './api/queries'; -export { teamFabricKeys } from './model/const'; +export { teamFabricKeys } from './model/consts'; export { ROLE_LABELS, INVITATION_ROLES } from './config/roles'; export { STATUS_LABELS, MEMBER_STATUSES } from './config/statuses'; export { useTeamStore } from './model/store'; diff --git a/src/entities/team/model/const.ts b/src/entities/team/model/consts.ts similarity index 100% rename from src/entities/team/model/const.ts rename to src/entities/team/model/consts.ts diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts index c5388b1..38d8d49 100644 --- a/src/entities/user/api/queries.ts +++ b/src/entities/user/api/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from '@tanstack/react-query'; -import { userFabricKeys } from '../model/const'; +import { userFabricKeys } from '../model/consts'; import { UserHttp } from './http'; export class UserQueries { diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 3efd22d..cf8f0b9 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -2,5 +2,5 @@ export * as SUser from './model/schemas'; export type * as TUser from './model/types'; export { UserHttp } from './api/http'; export { UserQueries } from './api/queries'; -export { userFabricKeys } from './model/const'; +export { userFabricKeys } from './model/consts'; export { UserAvatar } from './ui/UserAvatar'; diff --git a/src/entities/user/model/const.ts b/src/entities/user/model/consts.ts similarity index 100% rename from src/entities/user/model/const.ts rename to src/entities/user/model/consts.ts diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 82a91b5..2bfb22c 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -137,7 +137,7 @@ export const UserInvitationResponse = z.object({ expiresAt: DateTimeString, }); -export const UserInvitationListResponse = PaginatedResponseSchema(UserInvitationResponse); +export const UserInvitationListResponse = UserInvitationResponse.array(); export const UserActivityResponse = z.object({ id: z.string(), diff --git a/src/features/auth/sign-out/model/useSignOut.ts b/src/features/auth/sign-out/api/useSignOut.ts similarity index 100% rename from src/features/auth/sign-out/model/useSignOut.ts rename to src/features/auth/sign-out/api/useSignOut.ts diff --git a/src/features/auth/sign-out/ui/SignOut.tsx b/src/features/auth/sign-out/ui/SignOut.tsx index c5d5225..a2bb4f0 100644 --- a/src/features/auth/sign-out/ui/SignOut.tsx +++ b/src/features/auth/sign-out/ui/SignOut.tsx @@ -1,7 +1,7 @@ import { LogOut } from 'lucide-react'; import { type ComponentProps } from 'react'; import { Button, buttonVariants } from 'shared/ui'; -import { useSignOut, UseSignOutOptions } from '../model/useSignOut'; +import { useSignOut, UseSignOutOptions } from '../api/useSignOut'; import { Slot } from 'radix-ui'; import { cn } from 'shared/lib/utils'; diff --git a/src/features/boards/column/create/model/useCreateBoardColumn.ts b/src/features/boards/column/create/api/useCreateBoardColumn.ts similarity index 100% rename from src/features/boards/column/create/model/useCreateBoardColumn.ts rename to src/features/boards/column/create/api/useCreateBoardColumn.ts diff --git a/src/features/boards/column/create/config/default-values.ts b/src/features/boards/column/create/config/default-values.ts deleted file mode 100644 index e8e1ded..0000000 --- a/src/features/boards/column/create/config/default-values.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PROJECT_COLORS } from 'entities/project'; -import { CreateBoardColumnFormValues } from '../model/types'; - -export function getDefaultCreateBoardColumnValues(position = 0): CreateBoardColumnFormValues { - return { - title: '', - color: PROJECT_COLORS[0], - position, - }; -} diff --git a/src/features/boards/column/create/config/useDefaultCreateBoardColumnValues.ts b/src/features/boards/column/create/config/useDefaultCreateBoardColumnValues.ts new file mode 100644 index 0000000..94e17cc --- /dev/null +++ b/src/features/boards/column/create/config/useDefaultCreateBoardColumnValues.ts @@ -0,0 +1,28 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; +import { BoardQueries, calculateNewPosition, type TBoard } from 'entities/board'; +import { PROJECT_COLORS } from 'entities/project'; +import { CreateBoardColumnFormValues } from '../model/types'; + +export function useDefaultCreateBoardColumnValues( + boardSlug: string +): () => CreateBoardColumnFormValues { + const queryClient = useQueryClient(); + + return () => { + const columns = queryClient.getQueryData>( + BoardQueries.getBoardColumnList(boardSlug).queryKey + ); + + const lastPosition = columns + ? Math.max(...Object.values(columns).map((column) => column.position)) + : null; + + return { + title: '', + color: PROJECT_COLORS[0], + position: calculateNewPosition(lastPosition, null), + }; + }; +} diff --git a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts index 317b612..1ec027e 100644 --- a/src/features/boards/column/create/model/useCreateBoardColumnForm.ts +++ b/src/features/boards/column/create/model/useCreateBoardColumnForm.ts @@ -3,33 +3,29 @@ import { type TBoard } from 'entities/board'; import { useForm } from 'react-hook-form'; import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; -import { getDefaultCreateBoardColumnValues } from '../config/default-values'; import { CreateBoardColumnFormSchema } from './schemas'; import { type CreateBoardColumnFormValues } from './types'; -import { useCreateBoardColumn, UseCreateBoardColumnOptions } from './useCreateBoardColumn'; - -type UseCreateBoardColumnFormOptions = UseCreateBoardColumnOptions & { - defaultPosition?: number; -}; +import { useCreateBoardColumn, UseCreateBoardColumnOptions } from '../api/useCreateBoardColumn'; +import { useDefaultCreateBoardColumnValues } from '../config/useDefaultCreateBoardColumnValues'; export function useCreateBoardColumnForm( boardSlug: string, - options: UseCreateBoardColumnFormOptions = {} + options: UseCreateBoardColumnOptions = {} ) { - const { defaultPosition = 100, ...mutationOptions } = options; + const getDefaultValues = useDefaultCreateBoardColumnValues(boardSlug); const form = useForm({ resolver: zodResolver(CreateBoardColumnFormSchema), - defaultValues: getDefaultCreateBoardColumnValues(defaultPosition), + defaultValues: getDefaultValues(), }); const createBoardColumn = useCreateBoardColumn({ - ...mutationOptions, + ...options, meta: { skipGlobalValidationToast: true, }, onError: (err, ...args) => { - mutationOptions.onError?.(err, ...args); + options.onError?.(err, ...args); setFormErrors(extractValidationIssues(err), form); }, }); @@ -37,7 +33,7 @@ export function useCreateBoardColumnForm( const onSubmit = (data: CreateBoardColumnFormValues) => { const body: TBoard.CreateBoardColumnBody = { title: data.title, - position: data.position, //todo: пока будет 100, потом будет динамический position + position: data.position, ...(data.color ? { color: data.color } : {}), }; diff --git a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx index f3bce41..77a35b3 100644 --- a/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx +++ b/src/features/boards/column/create/ui/CreateBoardColumnForm.tsx @@ -3,7 +3,7 @@ import { ComponentProps } from 'react'; import { Controller, FormProvider } from 'react-hook-form'; import { cn } from 'shared/lib/utils'; import { ColorPicker, Field, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; -import { UseCreateBoardColumnOptions } from '../model/useCreateBoardColumn'; +import { UseCreateBoardColumnOptions } from '../api/useCreateBoardColumn'; import { useCreateBoardColumnForm } from '../model/useCreateBoardColumnForm'; interface CreateBoardColumnFormProps extends Omit, 'children' | 'onSubmit'> { diff --git a/src/features/boards/column/remove/model/useRemoveColumn.ts b/src/features/boards/column/remove/api/useRemoveColumn.ts similarity index 100% rename from src/features/boards/column/remove/model/useRemoveColumn.ts rename to src/features/boards/column/remove/api/useRemoveColumn.ts diff --git a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx index 4f46505..c86e681 100644 --- a/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx +++ b/src/features/boards/column/remove/ui/RemoveColumnDialog.tsx @@ -14,7 +14,7 @@ import { RemoveColunmnVariables, UseDeleteColumnOptions, useRemoveColumn, -} from '../model/useRemoveColumn'; +} from '../api/useRemoveColumn'; type Props = ComponentProps & RemoveColunmnVariables & { options?: UseDeleteColumnOptions }; diff --git a/src/features/boards/create/model/useCreateBoard.ts b/src/features/boards/create/api/useCreateBoard.ts similarity index 100% rename from src/features/boards/create/model/useCreateBoard.ts rename to src/features/boards/create/api/useCreateBoard.ts diff --git a/src/features/boards/create/model/useCreateBoardForm.ts b/src/features/boards/create/model/useCreateBoardForm.ts index f9d245f..893ba5d 100644 --- a/src/features/boards/create/model/useCreateBoardForm.ts +++ b/src/features/boards/create/model/useCreateBoardForm.ts @@ -1,17 +1,16 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { type TBoard } from 'entities/board'; -import { useParams } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { extractValidationIssues } from 'shared/api'; +import { useRouteParams } from 'shared/lib/hooks'; import { setFormErrors } from 'shared/lib/utils'; import { getDefaultCreateBoardValues } from '../config/default-values'; import { CreateBoardFormSchema } from './schemas'; import { type CreateBoardFormValues } from './types'; -import { useCreateBoard, UseCreateBoardOptions } from './useCreateBoard'; +import { useCreateBoard, UseCreateBoardOptions } from '../api/useCreateBoard'; export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { - const params = useParams<{ projectSlug: string }>(); - const slug = params?.projectSlug; + const { projectSlug } = useRouteParams(); const form = useForm({ resolver: zodResolver(CreateBoardFormSchema), @@ -34,12 +33,12 @@ export function useCreateBoardForm(options: UseCreateBoardOptions = {}) { title: data.title, }; - createBoard.mutate({ projectSlug: slug!, body }); + createBoard.mutate({ projectSlug, body }); }; return { form, - projectSlug: slug!, + projectSlug, isPending: createBoard.isPending, handleSubmit: form.handleSubmit(onSubmit), }; diff --git a/src/features/boards/create/ui/CreateBoardForm.tsx b/src/features/boards/create/ui/CreateBoardForm.tsx index 6b02e16..485bbfc 100644 --- a/src/features/boards/create/ui/CreateBoardForm.tsx +++ b/src/features/boards/create/ui/CreateBoardForm.tsx @@ -2,7 +2,7 @@ import { Controller, FormProvider } from 'react-hook-form'; import { cn } from 'shared/lib/utils'; import { Field, FieldError, FieldGroup, FieldLabel, Input } from 'shared/ui'; import { useCreateBoardForm } from '../model/useCreateBoardForm'; -import { UseCreateBoardOptions } from '../model/useCreateBoard'; +import { UseCreateBoardOptions } from '../api/useCreateBoard'; import { ComponentProps } from 'react'; interface CreateBoardFormProps extends Omit, 'children' | 'onSubmit'> { diff --git a/src/features/boards/remove/model/useRemoveBoard.ts b/src/features/boards/remove/api/useRemoveBoard.ts similarity index 100% rename from src/features/boards/remove/model/useRemoveBoard.ts rename to src/features/boards/remove/api/useRemoveBoard.ts diff --git a/src/features/boards/remove/ui/RemoveBoardDialog.tsx b/src/features/boards/remove/ui/RemoveBoardDialog.tsx index 3ee0dd5..7d9b959 100644 --- a/src/features/boards/remove/ui/RemoveBoardDialog.tsx +++ b/src/features/boards/remove/ui/RemoveBoardDialog.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { RemoveBoardVariables, useRemoveBoard } from '../model/useRemoveBoard'; +import { RemoveBoardVariables, useRemoveBoard } from '../api/useRemoveBoard'; type Props = ComponentProps & RemoveBoardVariables & { boardName: string }; diff --git a/src/features/otp-form/model/useResend.ts b/src/features/otp-form/api/useResend.ts similarity index 100% rename from src/features/otp-form/model/useResend.ts rename to src/features/otp-form/api/useResend.ts diff --git a/src/features/otp-form/ui/ResendCodeControl.tsx b/src/features/otp-form/ui/ResendCodeControl.tsx index 1e144c1..899cac7 100644 --- a/src/features/otp-form/ui/ResendCodeControl.tsx +++ b/src/features/otp-form/ui/ResendCodeControl.tsx @@ -5,7 +5,7 @@ import { useTimer } from 'shared/lib/hooks'; import { classNames, formatTime } from 'shared/lib/utils'; import { Button } from 'shared/ui'; import { RESEND_CODE_DELAY_MS } from '../model/consts'; -import { useResendCode, UseResendOptions } from '../model/useResend'; +import { useResendCode, UseResendOptions } from '../api/useResend'; import { type TAuth } from 'entities/auth'; import { toast } from 'sonner'; diff --git a/src/features/projects/archive/model/useArchiveProject.ts b/src/features/projects/archive/api/useArchiveProject.ts similarity index 100% rename from src/features/projects/archive/model/useArchiveProject.ts rename to src/features/projects/archive/api/useArchiveProject.ts diff --git a/src/features/projects/archive/model/useRestoreProject.ts b/src/features/projects/archive/api/useRestoreProject.ts similarity index 100% rename from src/features/projects/archive/model/useRestoreProject.ts rename to src/features/projects/archive/api/useRestoreProject.ts diff --git a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx index d0e2b44..29cdbcf 100644 --- a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx +++ b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx @@ -13,7 +13,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { useArchiveProject } from '../model/useArchiveProject'; +import { useArchiveProject } from '../api/useArchiveProject'; interface ArchiveProjectDialogProps extends ComponentProps { projectName: string; diff --git a/src/features/projects/archive/ui/RestoreProjectDialog.tsx b/src/features/projects/archive/ui/RestoreProjectDialog.tsx index fbca113..95abef5 100644 --- a/src/features/projects/archive/ui/RestoreProjectDialog.tsx +++ b/src/features/projects/archive/ui/RestoreProjectDialog.tsx @@ -13,7 +13,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { useRestoreProject } from '../model/useRestoreProject'; +import { useRestoreProject } from '../api/useRestoreProject'; interface RestoreProjectDialogProps extends ComponentProps { projectName: string; diff --git a/src/features/projects/create/model/useCreateProject.ts b/src/features/projects/create/api/useCreateProject.ts similarity index 100% rename from src/features/projects/create/model/useCreateProject.ts rename to src/features/projects/create/api/useCreateProject.ts diff --git a/src/features/projects/create/model/useCreateProjectForm.ts b/src/features/projects/create/model/useCreateProjectForm.ts index 8ae08c6..a2d549a 100644 --- a/src/features/projects/create/model/useCreateProjectForm.ts +++ b/src/features/projects/create/model/useCreateProjectForm.ts @@ -1,12 +1,12 @@ import { useForm } from 'react-hook-form'; -import { useCheckSlug, validateProjectSlugAsync, type TProject } from 'entities/project'; +import { type TProject, useCheckSlug, validateProjectSlugAsync } from 'entities/project'; import { useTeamStore } from 'entities/team'; import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; import { getDefaultCreateProjectValues } from '../config/default-values'; import { CreateProjectFormSchema } from './schemas'; -import { type UseCreateProjectOptions, useCreateProject } from './useCreateProject'; +import { useCreateProject, type UseCreateProjectOptions } from '../api/useCreateProject'; import type { CreateProjectFormValues } from './types'; export function useCreateProjectForm(options: UseCreateProjectOptions = {}) { diff --git a/src/features/projects/create/ui/CreateProjectForm.tsx b/src/features/projects/create/ui/CreateProjectForm.tsx index dc24312..b145f6a 100644 --- a/src/features/projects/create/ui/CreateProjectForm.tsx +++ b/src/features/projects/create/ui/CreateProjectForm.tsx @@ -4,7 +4,7 @@ import { ComponentProps } from 'react'; import { FormProvider } from 'react-hook-form'; import { cn } from 'shared/lib/utils'; import { FieldGroup, Separator } from 'shared/ui'; -import type { UseCreateProjectOptions } from '../model/useCreateProject'; +import type { UseCreateProjectOptions } from '../api/useCreateProject'; import { useCreateProjectForm } from '../model/useCreateProjectForm'; import { ProjectIdentityFields } from './ProjectIdentityFields'; import { VisibilityPicker } from './VisibilityPicker'; diff --git a/src/features/projects/remove/model/useRemoveProject.ts b/src/features/projects/remove/api/useRemoveProject.ts similarity index 100% rename from src/features/projects/remove/model/useRemoveProject.ts rename to src/features/projects/remove/api/useRemoveProject.ts diff --git a/src/features/projects/remove/ui/RemoveProjectDialog.tsx b/src/features/projects/remove/ui/RemoveProjectDialog.tsx index d91e5fa..b1642a7 100644 --- a/src/features/projects/remove/ui/RemoveProjectDialog.tsx +++ b/src/features/projects/remove/ui/RemoveProjectDialog.tsx @@ -11,7 +11,7 @@ import { AlertDialogTrigger, Input, } from 'shared/ui'; -import { useRemoveProject } from '../model/useRemoveProject'; +import { useRemoveProject } from '../api/useRemoveProject'; interface Props extends ComponentProps { projectName: string; diff --git a/src/features/projects/share/model/useShareProject.ts b/src/features/projects/share/api/useShareProject.ts similarity index 100% rename from src/features/projects/share/model/useShareProject.ts rename to src/features/projects/share/api/useShareProject.ts diff --git a/src/features/projects/share/model/copy-share-url.ts b/src/features/projects/share/lib/copy-share-url.ts similarity index 100% rename from src/features/projects/share/model/copy-share-url.ts rename to src/features/projects/share/lib/copy-share-url.ts diff --git a/src/features/projects/share/model/ttl-option-to-body.ts b/src/features/projects/share/lib/ttl-option-to-body.ts similarity index 83% rename from src/features/projects/share/model/ttl-option-to-body.ts rename to src/features/projects/share/lib/ttl-option-to-body.ts index 975da1f..e1e152a 100644 --- a/src/features/projects/share/model/ttl-option-to-body.ts +++ b/src/features/projects/share/lib/ttl-option-to-body.ts @@ -1,5 +1,5 @@ import type { TProject } from 'entities/project'; -import type { ShareTtlOption } from './types'; +import type { ShareTtlOption } from '../model/types'; export function ttlOptionToBody(option: ShareTtlOption): TProject.CreateShareTokenBody { const expiresAt = new Date(); diff --git a/src/features/projects/share/ui/ShareProjectDialog.tsx b/src/features/projects/share/ui/ShareProjectDialog.tsx index 0ed0cc2..d41f733 100644 --- a/src/features/projects/share/ui/ShareProjectDialog.tsx +++ b/src/features/projects/share/ui/ShareProjectDialog.tsx @@ -30,10 +30,10 @@ import { Spinner, } from 'shared/ui'; import { SHARE_TTL_OPTIONS } from '../config/ttl-options'; -import { copyShareUrl } from '../model/copy-share-url'; -import { ttlOptionToBody } from '../model/ttl-option-to-body'; +import { copyShareUrl } from '../lib/copy-share-url'; +import { ttlOptionToBody } from '../lib/ttl-option-to-body'; import type { ShareTtlOption } from '../model/types'; -import { useShareProject } from '../model/useShareProject'; +import { useShareProject } from '../api/useShareProject'; interface ShareProjectDialogProps extends ComponentProps { projectName: string; diff --git a/src/features/task/create/model/useCreateTask.ts b/src/features/task/create/api/useCreateTask.ts similarity index 100% rename from src/features/task/create/model/useCreateTask.ts rename to src/features/task/create/api/useCreateTask.ts diff --git a/src/features/task/create/index.ts b/src/features/task/create/index.ts index 05a74e7..1d76c35 100644 --- a/src/features/task/create/index.ts +++ b/src/features/task/create/index.ts @@ -1 +1 @@ -export { useCreateTask } from './model/useCreateTask'; +export { useCreateTask } from './api/useCreateTask'; diff --git a/src/features/task/create/model/useClickOutside.ts b/src/features/task/create/lib/useClickOutside.ts similarity index 100% rename from src/features/task/create/model/useClickOutside.ts rename to src/features/task/create/lib/useClickOutside.ts diff --git a/src/features/task/remove/model/useRemoveTask.ts b/src/features/task/remove/api/useRemoveTask.ts similarity index 100% rename from src/features/task/remove/model/useRemoveTask.ts rename to src/features/task/remove/api/useRemoveTask.ts diff --git a/src/features/task/remove/ui/RemoveTaskDialog.tsx b/src/features/task/remove/ui/RemoveTaskDialog.tsx index 8dd6a6e..4a01e3b 100644 --- a/src/features/task/remove/ui/RemoveTaskDialog.tsx +++ b/src/features/task/remove/ui/RemoveTaskDialog.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from 'shared/ui'; -import { RemoveTaskVariables, useRemoveTask } from '../model/useRemoveTask'; +import { RemoveTaskVariables, useRemoveTask } from '../api/useRemoveTask'; type Props = ComponentProps & RemoveTaskVariables & { diff --git a/src/features/task/update/model/useUpdateTask.ts b/src/features/task/update/api/useUpdateTask.ts similarity index 100% rename from src/features/task/update/model/useUpdateTask.ts rename to src/features/task/update/api/useUpdateTask.ts diff --git a/src/features/task/update/index.ts b/src/features/task/update/index.ts index 2160830..13d6f21 100644 --- a/src/features/task/update/index.ts +++ b/src/features/task/update/index.ts @@ -1 +1 @@ -export { useUpdateTask } from './model/useUpdateTask'; +export { useUpdateTask } from './api/useUpdateTask'; diff --git a/src/features/teams/active-team/model/useSwitchTeam.ts b/src/features/teams/active-team/model/useSwitchTeam.ts index 6ca72e5..d4aaa8f 100644 --- a/src/features/teams/active-team/model/useSwitchTeam.ts +++ b/src/features/teams/active-team/model/useSwitchTeam.ts @@ -19,10 +19,13 @@ interface UseSwitchTeamProps { export function useSwitchTeam({ teams = [], defaultOptions = {} }: UseSwitchTeamProps = {}) { const router = useRouter(); + const currentTeamId = useTeamStore.use.teamId(); const setTeamId = useTeamStore.use.setTeamId(); const switchTeam = useCallback( (teamId: string, options: SwitchTeamOptions = {}) => { + if (teamId === currentTeamId) return; + const { redirect = false, showToast = true } = { ...defaultOptions, ...options }; const team = teams.find((t) => t.id === teamId); @@ -38,7 +41,7 @@ export function useSwitchTeam({ teams = [], defaultOptions = {} }: UseSwitchTeam router.push(routes.team.root()); } }, - [setTeamId, teams, router, defaultOptions] + [currentTeamId, setTeamId, teams, router, defaultOptions] ); return { switchTeam }; diff --git a/src/features/teams/create/model/useCreateTeam.ts b/src/features/teams/create/api/useCreateTeam.ts similarity index 100% rename from src/features/teams/create/model/useCreateTeam.ts rename to src/features/teams/create/api/useCreateTeam.ts diff --git a/src/features/teams/create/model/useCreateTeamForm.ts b/src/features/teams/create/model/useCreateTeamForm.ts index e5b69e2..e0740d3 100644 --- a/src/features/teams/create/model/useCreateTeamForm.ts +++ b/src/features/teams/create/model/useCreateTeamForm.ts @@ -4,7 +4,7 @@ import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; import { CreateTeamFormSchema } from './schemas'; import type { CreateTeamFormValues } from './types'; -import { useCreateTeam, type UseCreateTeamOptions } from './useCreateTeam'; +import { useCreateTeam, type UseCreateTeamOptions } from '../api/useCreateTeam'; import { zodResolver } from '@hookform/resolvers/zod'; export function useCreateTeamForm(mutateOptions: UseCreateTeamOptions = {}) { diff --git a/src/features/teams/create/ui/CreateTeamForm.tsx b/src/features/teams/create/ui/CreateTeamForm.tsx index 37a2d0c..0e2b769 100644 --- a/src/features/teams/create/ui/CreateTeamForm.tsx +++ b/src/features/teams/create/ui/CreateTeamForm.tsx @@ -4,7 +4,7 @@ import { ComponentProps } from 'react'; import { Controller, FormProvider } from 'react-hook-form'; import { cn } from 'shared/lib/utils'; import { Field, FieldError, FieldGroup, FieldLabel, Input, Textarea } from 'shared/ui'; -import type { UseCreateTeamOptions } from '../model/useCreateTeam'; +import type { UseCreateTeamOptions } from '../api/useCreateTeam'; import { useCreateTeamForm } from '../model/useCreateTeamForm'; interface CreateTeamFormProps extends Omit, 'children' | 'onSubmit'> { diff --git a/src/features/teams/invite/model/useInviteTeamMember.ts b/src/features/teams/invite/api/useInviteTeamMember.ts similarity index 100% rename from src/features/teams/invite/model/useInviteTeamMember.ts rename to src/features/teams/invite/api/useInviteTeamMember.ts diff --git a/src/features/teams/invite/model/useInviteTeamMemberForm.ts b/src/features/teams/invite/model/useInviteTeamMemberForm.ts index a069e59..1163a82 100644 --- a/src/features/teams/invite/model/useInviteTeamMemberForm.ts +++ b/src/features/teams/invite/model/useInviteTeamMemberForm.ts @@ -7,7 +7,7 @@ import { extractValidationIssues } from 'shared/api'; import { setFormErrors } from 'shared/lib/utils'; import { InviteTeamMemberFormSchema } from './schemas'; import type { InviteTeamMemberFormValues } from './types'; -import { useInviteTeamMember, type UseInviteTeamMemberOptions } from './useInviteTeamMember'; +import { useInviteTeamMember, type UseInviteTeamMemberOptions } from '../api/useInviteTeamMember'; export function useInviteTeamMemberForm( mutateOptions: UseInviteTeamMemberOptions = {}, diff --git a/src/features/teams/invite/ui/InviteTeamMemberForm.tsx b/src/features/teams/invite/ui/InviteTeamMemberForm.tsx index 70232b5..47e6f74 100644 --- a/src/features/teams/invite/ui/InviteTeamMemberForm.tsx +++ b/src/features/teams/invite/ui/InviteTeamMemberForm.tsx @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue, } from 'shared/ui'; -import type { UseInviteTeamMemberOptions } from '../model/useInviteTeamMember'; +import type { UseInviteTeamMemberOptions } from '../api/useInviteTeamMember'; import { useInviteTeamMemberForm } from '../model/useInviteTeamMemberForm'; interface InviteTeamMemberFormProps extends Omit, 'children' | 'onSubmit'> { diff --git a/src/features/teams/remove/model/useRemoveTeam.ts b/src/features/teams/remove/api/useRemoveTeam.ts similarity index 100% rename from src/features/teams/remove/model/useRemoveTeam.ts rename to src/features/teams/remove/api/useRemoveTeam.ts diff --git a/src/features/teams/remove/ui/RemoveTeamDialog.tsx b/src/features/teams/remove/ui/RemoveTeamDialog.tsx index b49724d..f011c06 100644 --- a/src/features/teams/remove/ui/RemoveTeamDialog.tsx +++ b/src/features/teams/remove/ui/RemoveTeamDialog.tsx @@ -11,7 +11,7 @@ import { AlertDialogTrigger, Input, } from 'shared/ui'; -import { useRemoveTeam } from '../model/useRemoveTeam'; +import { useRemoveTeam } from '../api/useRemoveTeam'; interface Props extends ComponentProps { teamName: string; diff --git a/src/features/upload-avatar/model/useUploadAvatar.ts b/src/features/upload-avatar/api/useUploadAvatar.ts similarity index 100% rename from src/features/upload-avatar/model/useUploadAvatar.ts rename to src/features/upload-avatar/api/useUploadAvatar.ts diff --git a/src/features/upload-avatar/ui/UploadAvatar.tsx b/src/features/upload-avatar/ui/UploadAvatar.tsx index e030787..2a01463 100644 --- a/src/features/upload-avatar/ui/UploadAvatar.tsx +++ b/src/features/upload-avatar/ui/UploadAvatar.tsx @@ -3,7 +3,7 @@ import { Pencil } from 'lucide-react'; import { type ChangeEvent, type ComponentProps, type ReactElement, useRef } from 'react'; import { classNames } from 'shared/lib/utils'; import { type Avatar, Button } from 'shared/ui'; -import { useUploadAvatar, UseUploadFileOptions } from '../model/useUploadAvatar'; +import { useUploadAvatar, UseUploadFileOptions } from '../api/useUploadAvatar'; interface UploadAvatarProps { className?: string; diff --git a/src/pages/auth/forgot-password/model/useResetPassword.ts b/src/pages/auth/forgot-password/api/useResetPassword.ts similarity index 100% rename from src/pages/auth/forgot-password/model/useResetPassword.ts rename to src/pages/auth/forgot-password/api/useResetPassword.ts diff --git a/src/pages/auth/forgot-password/model/useSendCode.ts b/src/pages/auth/forgot-password/api/useSendCode.ts similarity index 100% rename from src/pages/auth/forgot-password/model/useSendCode.ts rename to src/pages/auth/forgot-password/api/useSendCode.ts diff --git a/src/pages/auth/forgot-password/model/useSendPassword.ts b/src/pages/auth/forgot-password/api/useSendPassword.ts similarity index 100% rename from src/pages/auth/forgot-password/model/useSendPassword.ts rename to src/pages/auth/forgot-password/api/useSendPassword.ts diff --git a/src/pages/auth/forgot-password/ui/EmailForm.tsx b/src/pages/auth/forgot-password/ui/EmailForm.tsx index d0217bc..dfd3812 100644 --- a/src/pages/auth/forgot-password/ui/EmailForm.tsx +++ b/src/pages/auth/forgot-password/ui/EmailForm.tsx @@ -18,7 +18,7 @@ import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import type { EmailFormValues } from '../model/types'; import { EmailForm as EmailFormSchema } from '../model/schemas'; -import { UseResetePasswordOptions, useResetPassword } from '../model/useResetPassword'; +import { UseResetePasswordOptions, useResetPassword } from '../api/useResetPassword'; import { setFormErrors } from 'shared/lib/utils'; import { extractValidationIssues } from 'shared/api'; import { ComponentProps } from 'react'; diff --git a/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx b/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx index 6946f33..6ba755f 100644 --- a/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx +++ b/src/pages/auth/forgot-password/ui/ForgotPasswordPage.tsx @@ -13,7 +13,7 @@ import { RESEND_CODE_DELAY_MS, ResendCodeControl, } from 'features/otp-form'; -import { useSendCode } from '../model/useSendCode'; +import { useSendCode } from '../api/useSendCode'; import { useLocalStorageDraft } from 'shared/lib/hooks'; type ForgotPasswordStep = 'email' | 'password' | 'otp' | null; diff --git a/src/pages/auth/forgot-password/ui/PasswordForm.tsx b/src/pages/auth/forgot-password/ui/PasswordForm.tsx index 80baa72..e72ecf6 100644 --- a/src/pages/auth/forgot-password/ui/PasswordForm.tsx +++ b/src/pages/auth/forgot-password/ui/PasswordForm.tsx @@ -21,7 +21,7 @@ import { setFormErrors } from 'shared/lib/utils'; import { extractValidationIssues } from 'shared/api'; import { PasswordForm as PasswordFormSchema } from '../model/schemas'; import type { PasswordFormValues } from '../model/types'; -import { useSendPassword, UseSendPasswordOptions } from '../model/useSendPassword'; +import { useSendPassword, UseSendPasswordOptions } from '../api/useSendPassword'; interface PasswordFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSendPasswordOptions; diff --git a/src/pages/auth/signin/model/useSignin.ts b/src/pages/auth/signin/api/useSignin.ts similarity index 100% rename from src/pages/auth/signin/model/useSignin.ts rename to src/pages/auth/signin/api/useSignin.ts diff --git a/src/pages/auth/signin/ui/SigninForm.tsx b/src/pages/auth/signin/ui/SigninForm.tsx index ad65507..573fad0 100644 --- a/src/pages/auth/signin/ui/SigninForm.tsx +++ b/src/pages/auth/signin/ui/SigninForm.tsx @@ -26,7 +26,7 @@ import { } from 'shared/ui'; import { SigninForm as SigninFormSchema } from '../model/schemas'; import type { SigninFormValues } from '../model/types'; -import { useSignin, UseSigninOptions } from '../model/useSignin'; +import { useSignin, UseSigninOptions } from '../api/useSignin'; interface SigninFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseSigninOptions; diff --git a/src/pages/auth/signup/model/useSignup.ts b/src/pages/auth/signup/api/useSignup.ts similarity index 100% rename from src/pages/auth/signup/model/useSignup.ts rename to src/pages/auth/signup/api/useSignup.ts diff --git a/src/pages/auth/signup/model/useSignupConfirm.ts b/src/pages/auth/signup/api/useSignupConfirm.ts similarity index 100% rename from src/pages/auth/signup/model/useSignupConfirm.ts rename to src/pages/auth/signup/api/useSignupConfirm.ts diff --git a/src/pages/auth/signup/model/utils/field-name-mapper.ts b/src/pages/auth/signup/lib/field-name-mapper.ts similarity index 86% rename from src/pages/auth/signup/model/utils/field-name-mapper.ts rename to src/pages/auth/signup/lib/field-name-mapper.ts index b6c6992..d162d50 100644 --- a/src/pages/auth/signup/model/utils/field-name-mapper.ts +++ b/src/pages/auth/signup/lib/field-name-mapper.ts @@ -1,5 +1,5 @@ import type { FieldPath } from 'react-hook-form'; -import type { SignupFormValues } from '../types'; +import type { SignupFormValues } from '../model/types'; import { type TAuth } from 'entities/auth'; export const fieldNameMapper = ( diff --git a/src/pages/auth/signup/model/utils/prepare-fullname.ts b/src/pages/auth/signup/lib/prepare-fullname.ts similarity index 100% rename from src/pages/auth/signup/model/utils/prepare-fullname.ts rename to src/pages/auth/signup/lib/prepare-fullname.ts diff --git a/src/pages/auth/signup/ui/SignupForm.tsx b/src/pages/auth/signup/ui/SignupForm.tsx index b69e8ae..f63558e 100644 --- a/src/pages/auth/signup/ui/SignupForm.tsx +++ b/src/pages/auth/signup/ui/SignupForm.tsx @@ -25,11 +25,11 @@ import { SignupForm as SignupFormSchema } from '../model/schemas'; import { cn, setFormErrors } from 'shared/lib/utils'; import { routes } from 'shared/config'; import { ComponentProps, useState } from 'react'; -import { fieldNameMapper } from '../model/utils/field-name-mapper'; -import { prepareFullName } from '../model/utils/prepare-fullname'; +import { fieldNameMapper } from '../lib/field-name-mapper'; +import { prepareFullName } from '../lib/prepare-fullname'; import { extractValidationIssues } from 'shared/api'; import { type TAuth } from 'entities/auth'; -import { useSignup, type UseSignupOptions } from '../model/useSignup'; +import { useSignup, type UseSignupOptions } from '../api/useSignup'; import { OAuthLoginButtons, OAuthSeparator } from 'features/auth/oauth-login'; interface SignupFormProps extends Omit, 'children' | 'onSubmit'> { diff --git a/src/pages/auth/signup/ui/SignupPage.tsx b/src/pages/auth/signup/ui/SignupPage.tsx index 13eda92..94bf094 100644 --- a/src/pages/auth/signup/ui/SignupPage.tsx +++ b/src/pages/auth/signup/ui/SignupPage.tsx @@ -13,7 +13,7 @@ import { useRouter } from 'next/navigation'; import { AccessToken } from 'shared/api'; import { routes } from 'shared/config'; import { toast } from 'sonner'; -import { useSignupConfirm } from '../model/useSignupConfirm'; +import { useSignupConfirm } from '../api/useSignupConfirm'; import { useLocalStorageDraft } from 'shared/lib/hooks'; type SignupStep = 'signup' | 'otp' | null; diff --git a/src/pages/boards/model/tasks-by-column-to-list.ts b/src/pages/boards/model/tasks-by-column-to-list.ts deleted file mode 100644 index 2a3c4fb..0000000 --- a/src/pages/boards/model/tasks-by-column-to-list.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TTask } from 'entities/task'; - -export function tasksByColumnToList(value: Record): TTask.TaskListResponse { - return Object.entries(value).flatMap(([stateId, tasks]) => - tasks.map((task, position) => ({ - ...task, - stateId, - position, - })) - ); -} diff --git a/src/pages/invitations/model/get-invitations-count-text.ts b/src/pages/invitations/lib/get-invitations-count-text.ts similarity index 100% rename from src/pages/invitations/model/get-invitations-count-text.ts rename to src/pages/invitations/lib/get-invitations-count-text.ts diff --git a/src/pages/invitations/ui/InvitationCard.tsx b/src/pages/invitations/ui/InvitationCard.tsx index e621440..d9cdd05 100644 --- a/src/pages/invitations/ui/InvitationCard.tsx +++ b/src/pages/invitations/ui/InvitationCard.tsx @@ -30,10 +30,7 @@ export function InvitationCard({ invitation }: InvitationCardProps) { - + diff --git a/src/pages/invitations/ui/InvitationsPageContent.tsx b/src/pages/invitations/ui/InvitationsPageContent.tsx index 81f25eb..af22ad9 100644 --- a/src/pages/invitations/ui/InvitationsPageContent.tsx +++ b/src/pages/invitations/ui/InvitationsPageContent.tsx @@ -5,13 +5,13 @@ import { UserQueries } from 'entities/user'; import { MailOpen } from 'lucide-react'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'shared/ui'; import { PageWrapper } from 'widgets/page-wrapper'; -import { getInvitationsCountText } from '../model/get-invitations-count-text'; +import { getInvitationsCountText } from '../lib/get-invitations-count-text'; import { InvitationCard } from './InvitationCard'; export function InvitationsPageContent() { const invitationsQuery = useSuspenseQuery(UserQueries.getMyInvitations()); - const invitations = invitationsQuery.data.items; - const invitationsCount = invitationsQuery.data.items.length ?? 0; + const invitations = invitationsQuery.data; + const invitationsCount = invitationsQuery.data.length ?? 0; return ( , + 'mutationFn' +>; + +export function useMoveColumn({ onSettled, onError, ...options }: UseMoveColumnOptions = {}) { + return useMutation({ + ...options, + mutationFn: ({ boardSlug, columnId, body }) => + BoardHttp.moveBoardColumn(boardSlug, columnId, body), + onSettled: (data, error, variables, onMutateResult, context) => { + onSettled?.(data, error, variables, onMutateResult, context); + + context?.client.invalidateQueries({ + queryKey: boardFabricKeys.columns(variables.boardSlug), + }); + }, + onError: (error, variables, onMutateResult, context) => { + onError?.(error, variables, onMutateResult, context); + toast.error(error.message ?? 'Не удалось переместить колонку'); + }, + }); +} diff --git a/src/pages/boards/model/useMoveTask.ts b/src/pages/project/boards/api/useMoveTask.ts similarity index 100% rename from src/pages/boards/model/useMoveTask.ts rename to src/pages/project/boards/api/useMoveTask.ts diff --git a/src/pages/boards/index.ts b/src/pages/project/boards/index.ts similarity index 100% rename from src/pages/boards/index.ts rename to src/pages/project/boards/index.ts diff --git a/src/pages/project/boards/lib/build-move-column-body.ts b/src/pages/project/boards/lib/build-move-column-body.ts new file mode 100644 index 0000000..a755226 --- /dev/null +++ b/src/pages/project/boards/lib/build-move-column-body.ts @@ -0,0 +1,20 @@ +import { type TBoard } from 'entities/board'; +import { calculateNewPosition } from 'entities/board'; + +export function buildMoveColumnBody( + columns: Record, + columnId: string, + columnOrder: string[] +): TBoard.MoveBoardColumnBody { + const orderedColumns = columnOrder.map((id) => columns[id]).filter(Boolean); + const index = orderedColumns.findIndex((column) => column.id === columnId); + + const prevPosition = orderedColumns[index - 1]?.position ?? null; + const nextPosition = orderedColumns[index + 1]?.position ?? null; + + return { + position: calculateNewPosition(prevPosition, nextPosition), + prevStatePosition: prevPosition, + nextStatePosition: nextPosition, + }; +} diff --git a/src/pages/project/boards/lib/build-move-task-body.ts b/src/pages/project/boards/lib/build-move-task-body.ts new file mode 100644 index 0000000..62e2217 --- /dev/null +++ b/src/pages/project/boards/lib/build-move-task-body.ts @@ -0,0 +1,21 @@ +import { type TTask } from 'entities/task'; +import { calculateNewPosition } from 'entities/board'; + +export function buildMoveTaskBody( + columns: Record, + taskId: string, + toContainer: string +): TTask.MoveTaskBody { + const destinationTasks = columns[toContainer] ?? []; + const index = destinationTasks.findIndex((task) => task.id === taskId); + + const prevPosition = destinationTasks[index - 1]?.position ?? null; + const nextPosition = destinationTasks[index + 1]?.position ?? null; + + return { + targetStateId: toContainer, + position: calculateNewPosition(prevPosition, nextPosition), //хз почему это бэк не делает + prevIssuePosition: prevPosition, + nextIssuePosition: nextPosition, + }; +} diff --git a/src/pages/project/boards/lib/tasks-by-column-to-list.ts b/src/pages/project/boards/lib/tasks-by-column-to-list.ts new file mode 100644 index 0000000..7603f38 --- /dev/null +++ b/src/pages/project/boards/lib/tasks-by-column-to-list.ts @@ -0,0 +1,21 @@ +import { type TTask } from 'entities/task'; +import { calculateNewPosition } from 'entities/board'; + +export function tasksByColumnToList( + value: Record, + draggedTaskId?: string +): TTask.TaskListResponse { + return Object.entries(value).flatMap(([stateId, tasks]) => + tasks.map((task, index, array) => ({ + ...task, + stateId, + position: + draggedTaskId === task.id + ? calculateNewPosition( + array[index - 1]?.position ?? null, + array[index + 1]?.position ?? null + ) + : task.position, + })) + ); +} diff --git a/src/pages/boards/model/useInlineFieldKeyDown.ts b/src/pages/project/boards/model/useInlineFieldKeyDown.ts similarity index 100% rename from src/pages/boards/model/useInlineFieldKeyDown.ts rename to src/pages/project/boards/model/useInlineFieldKeyDown.ts diff --git a/src/pages/boards/model/useInlineFieldOutsidePointerDown.ts b/src/pages/project/boards/model/useInlineFieldOutsidePointerDown.ts similarity index 100% rename from src/pages/boards/model/useInlineFieldOutsidePointerDown.ts rename to src/pages/project/boards/model/useInlineFieldOutsidePointerDown.ts diff --git a/src/pages/project/boards/model/useSetBoardColumnOrder.ts b/src/pages/project/boards/model/useSetBoardColumnOrder.ts new file mode 100644 index 0000000..116d945 --- /dev/null +++ b/src/pages/project/boards/model/useSetBoardColumnOrder.ts @@ -0,0 +1,42 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { boardFabricKeys, calculateNewPosition, type TBoard } from 'entities/board'; +import { useCallback } from 'react'; + +export function useSetBoardColumnOrder(boardSlug: string) { + const queryClient = useQueryClient(); + + return useCallback( + (columnOrder: string[], draggedColumnId?: string) => { + const queryKey = boardFabricKeys.columns(boardSlug); + + queryClient.setQueriesData>( + { queryKey }, + (columns) => { + if (!columns) { + return columns; + } + + const orderedColumns = columnOrder.map((id) => columns[id]).filter(Boolean); + + return orderedColumns.reduce>( + (acc, column, index, array) => { + acc[column.id] = { + ...column, + position: + draggedColumnId === column.id + ? calculateNewPosition( + array[index - 1]?.position ?? null, + array[index + 1]?.position ?? null + ) + : column.position, + }; + return acc; + }, + {} + ); + } + ); + }, + [boardSlug, queryClient] + ); +} diff --git a/src/pages/boards/model/useSetBoardTasks.ts b/src/pages/project/boards/model/useSetBoardTasks.ts similarity index 72% rename from src/pages/boards/model/useSetBoardTasks.ts rename to src/pages/project/boards/model/useSetBoardTasks.ts index 763e3ae..7f9f172 100644 --- a/src/pages/boards/model/useSetBoardTasks.ts +++ b/src/pages/project/boards/model/useSetBoardTasks.ts @@ -1,17 +1,17 @@ import { useQueryClient } from '@tanstack/react-query'; import { taskFabricKeys, TTask } from 'entities/task'; import { useCallback } from 'react'; -import { tasksByColumnToList } from './tasks-by-column-to-list'; +import { tasksByColumnToList } from '../lib/tasks-by-column-to-list'; export function useSetBoardTasks(projectSlug: string, boardSlug: string) { const queryClient = useQueryClient(); return useCallback( - (value: Record) => { + (value: Record, draggedTaskId?: string) => { const queryKey = taskFabricKeys.list(projectSlug, boardSlug); queryClient.setQueriesData({ queryKey }, () => - tasksByColumnToList(value) + tasksByColumnToList(value, draggedTaskId) ); }, [boardSlug, projectSlug, queryClient] diff --git a/src/pages/boards/ui/BoardsPage.tsx b/src/pages/project/boards/ui/BoardsPage.tsx similarity index 91% rename from src/pages/boards/ui/BoardsPage.tsx rename to src/pages/project/boards/ui/BoardsPage.tsx index 2ba2b3c..e9a8aca 100644 --- a/src/pages/boards/ui/BoardsPage.tsx +++ b/src/pages/project/boards/ui/BoardsPage.tsx @@ -6,12 +6,13 @@ import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import { useLayoutEffect } from 'react'; import { routes } from 'shared/config'; +import { BoardsPageFallback } from './BoardsPageFallback'; const BoardsPageContent = dynamic( () => import('./BoardsPageContent').then((mod) => mod.BoardsPageContent), { ssr: false, - loading: () =>
Loading...
, //todo позже + loading: () => , } ); diff --git a/src/pages/boards/ui/BoardsPageContent.tsx b/src/pages/project/boards/ui/BoardsPageContent.tsx similarity index 100% rename from src/pages/boards/ui/BoardsPageContent.tsx rename to src/pages/project/boards/ui/BoardsPageContent.tsx diff --git a/src/pages/project/boards/ui/BoardsPageFallback.tsx b/src/pages/project/boards/ui/BoardsPageFallback.tsx new file mode 100644 index 0000000..faf76f6 --- /dev/null +++ b/src/pages/project/boards/ui/BoardsPageFallback.tsx @@ -0,0 +1,56 @@ +import { Skeleton } from 'shared/ui'; + +const COLUMN_COUNT = 4; +const BOARD_TAB_COUNT = 3; +const TASK_COUNTS = [3, 2, 4, 2] as const; + +function BoardColumnSkeleton({ taskCount }: { taskCount: number }) { + return ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: taskCount }).map((_, index) => ( + + ))} +
+
+ ); +} + +export function BoardsPageFallback() { + return ( +
+
+
+ {Array.from({ length: BOARD_TAB_COUNT }).map((_, index) => ( + + ))} +
+
+ + +
+
+
+
+ {Array.from({ length: COLUMN_COUNT }).map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/boards/ui/board/Board.tsx b/src/pages/project/boards/ui/board/Board.tsx similarity index 51% rename from src/pages/boards/ui/board/Board.tsx rename to src/pages/project/boards/ui/board/Board.tsx index a5065ae..9b6a8e4 100644 --- a/src/pages/boards/ui/board/Board.tsx +++ b/src/pages/project/boards/ui/board/Board.tsx @@ -1,13 +1,24 @@ 'use client'; +import { arrayMove } from '@dnd-kit/sortable'; import { useSuspenseQuery } from '@tanstack/react-query'; import { BoardQueries } from 'entities/board'; import { TTask } from 'entities/task'; import { useCallback } from 'react'; import { classNames } from 'shared/lib/utils'; -import { Kanban, KanbanBoard, KanbanItemMoveEvent, KanbanOverlay } from 'shared/ui'; +import { + Kanban, + KanbanBoard, + KanbanColumnMoveEvent, + KanbanItemMoveEvent, + KanbanOverlay, +} from 'shared/ui'; import { useBoardDataQuery } from '../../api/useBoardDataQuery'; -import { useMoveTask } from '../../model/useMoveTask'; +import { buildMoveColumnBody } from '../../lib/build-move-column-body'; +import { buildMoveTaskBody } from '../../lib/build-move-task-body'; +import { useMoveColumn } from '../../api/useMoveColumn'; +import { useMoveTask } from '../../api/useMoveTask'; +import { useSetBoardColumnOrder } from '../../model/useSetBoardColumnOrder'; import { useSetBoardTasks } from '../../model/useSetBoardTasks'; import { Column } from '../column/Column'; @@ -19,30 +30,53 @@ interface BoardProps extends Omit, 'chi export const Board = ({ projectSlug, boardSlug, className, ...props }: BoardProps) => { const value = useBoardDataQuery(projectSlug, boardSlug); const columns = useSuspenseQuery(BoardQueries.getBoardColumnList(boardSlug)); - const setValue = useSetBoardTasks(projectSlug, boardSlug); + const setTasks = useSetBoardTasks(projectSlug, boardSlug); + const setColumnOrder = useSetBoardColumnOrder(boardSlug); const { mutate: moveTask } = useMoveTask(); + const { mutate: moveColumn } = useMoveColumn(); + + const handleValueChange = useCallback( + (nextValue: Record, draggedTaskId?: string, draggedColumnId?: string) => { + setTasks(nextValue, draggedTaskId); + + if (draggedColumnId) { + setColumnOrder(Object.keys(nextValue), draggedColumnId); + } + }, + [setColumnOrder, setTasks] + ); const handleItemMove = useCallback( - ({ taskId, item, toContainer, toIndex }: KanbanItemMoveEvent) => { + ({ taskId, toContainer }: KanbanItemMoveEvent) => { moveTask({ slug: projectSlug, key: boardSlug, taskId, - body: { - targetStateId: toContainer, - targetAreaId: item.areaId, - position: toIndex, - }, + body: buildMoveTaskBody(value, taskId, toContainer), + }); + }, + [boardSlug, moveTask, projectSlug, value] + ); + + const handleColumnMove = useCallback( + ({ columnId, fromIndex, toIndex }: KanbanColumnMoveEvent) => { + const columnOrder = arrayMove(Object.keys(value), fromIndex, toIndex); + + moveColumn({ + boardSlug, + columnId, + body: buildMoveColumnBody(columns.data, columnId, columnOrder), }); }, - [boardSlug, moveTask, projectSlug] + [boardSlug, columns.data, moveColumn, value] ); return ( item.id} > diff --git a/src/pages/boards/ui/column/Column.tsx b/src/pages/project/boards/ui/column/Column.tsx similarity index 98% rename from src/pages/boards/ui/column/Column.tsx rename to src/pages/project/boards/ui/column/Column.tsx index af68694..b22f866 100644 --- a/src/pages/boards/ui/column/Column.tsx +++ b/src/pages/project/boards/ui/column/Column.tsx @@ -41,6 +41,7 @@ export function Column({ {...props} > (color ?? BOARD_COLUMN_COLORS[0]); const columnId = value; @@ -49,7 +51,12 @@ export function TaskColumnHeader(props: TaskColumnHeaderProps) {
- +
diff --git a/src/pages/boards/ui/column/ColumnHeaderActions.tsx b/src/pages/project/boards/ui/column/ColumnHeaderActions.tsx similarity index 100% rename from src/pages/boards/ui/column/ColumnHeaderActions.tsx rename to src/pages/project/boards/ui/column/ColumnHeaderActions.tsx diff --git a/src/pages/boards/ui/column/CreateTaskInline.tsx b/src/pages/project/boards/ui/column/CreateTaskInline.tsx similarity index 82% rename from src/pages/boards/ui/column/CreateTaskInline.tsx rename to src/pages/project/boards/ui/column/CreateTaskInline.tsx index 8256941..6d36346 100644 --- a/src/pages/boards/ui/column/CreateTaskInline.tsx +++ b/src/pages/project/boards/ui/column/CreateTaskInline.tsx @@ -1,7 +1,9 @@ +import { TTask } from 'entities/task'; import { useCreateTask } from 'features/task/create'; import { PlusIcon } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; import { Button, Card, CardContent, Checkbox, Textarea } from 'shared/ui'; +import { calculateNewPosition } from 'entities/board'; import { useInlineFieldKeyDown } from '../../model/useInlineFieldKeyDown'; import { useInlineFieldOutsidePointerDown } from '../../model/useInlineFieldOutsidePointerDown'; @@ -9,9 +11,15 @@ interface CreateTaskInlineProps { projectSlug: string; boardSlug: string; columnId: string; + tasks: TTask.Task[]; } -export function CreateTaskInline({ projectSlug, boardSlug, columnId }: CreateTaskInlineProps) { +export function CreateTaskInline({ + projectSlug, + boardSlug, + columnId, + tasks, +}: CreateTaskInlineProps) { const [isCreating, setIsCreating] = useState(false); const [taskTitle, setTaskTitle] = useState(''); const { mutateAsync: createTask } = useCreateTask(); @@ -31,16 +39,20 @@ export function CreateTaskInline({ projectSlug, boardSlug, columnId }: CreateTas return; } + const nextPosition = [...tasks].sort((a, b) => a.position - b.position)[0]?.position ?? null; + const position = calculateNewPosition(null, nextPosition); + await createTask({ slug: projectSlug, key: boardSlug, body: { title: titleValue, stateId: columnId, + position, }, }); }, - [projectSlug, boardSlug, columnId, taskTitle, createTask] + [projectSlug, boardSlug, columnId, taskTitle, tasks, createTask] ); const onKeyDown = useInlineFieldKeyDown({ diff --git a/src/pages/boards/ui/stages/Stages.tsx b/src/pages/project/boards/ui/stages/Stages.tsx similarity index 90% rename from src/pages/boards/ui/stages/Stages.tsx rename to src/pages/project/boards/ui/stages/Stages.tsx index 1ef58b2..d9eafcd 100644 --- a/src/pages/boards/ui/stages/Stages.tsx +++ b/src/pages/project/boards/ui/stages/Stages.tsx @@ -5,7 +5,13 @@ import { BoardQueries } from 'entities/board'; import { CreateBoardColumnDialog } from 'features/boards/column/create'; import { CreateBoardDialog } from 'features/boards/create'; import { RemoveBoardDialog } from 'features/boards/remove'; -import { BetweenVerticalStartIcon, EllipsisVertical, SquareKanbanIcon, Trash2 } from 'lucide-react'; +import { + BetweenVerticalStartIcon, + CogIcon, + EllipsisVertical, + SquareKanbanIcon, + Trash2, +} from 'lucide-react'; import Link from 'next/link'; import { ComponentProps } from 'react'; import { routes } from 'shared/config'; @@ -72,6 +78,12 @@ export function BoardList({ projectSlug, boardSlug, className, ...rest }: BoardL Добавить доску + + ); diff --git a/src/pages/boards/ui/task/Task.tsx b/src/pages/project/boards/ui/task/Task.tsx similarity index 87% rename from src/pages/boards/ui/task/Task.tsx rename to src/pages/project/boards/ui/task/Task.tsx index 80703fe..4e2a1d3 100644 --- a/src/pages/boards/ui/task/Task.tsx +++ b/src/pages/project/boards/ui/task/Task.tsx @@ -1,9 +1,9 @@ 'use client'; -import { TTask } from 'entities/task/'; +import { TTask } from 'entities/task'; import React, { ComponentProps, useState } from 'react'; import { KanbanItem, KanbanItemHandle } from 'shared/ui'; -import { useBoardParams } from '../../model/useBoardParams'; +import { useRouteParams } from 'shared/lib/hooks'; import { TaskCard } from './TaskCard'; import { TaskWidget } from './TaskWidget'; @@ -14,7 +14,7 @@ interface TaskProps extends Omit, 'value' | 'c export function TaskComponent({ task, asHandle = true, ...props }: TaskProps) { const [open, setOpen] = useState(false); - const { projectSlug, boardSlug } = useBoardParams(); + const { projectSlug, boardSlug } = useRouteParams(); const handleOpen = () => setOpen(true); diff --git a/src/pages/boards/ui/task/TaskCard.tsx b/src/pages/project/boards/ui/task/TaskCard.tsx similarity index 96% rename from src/pages/boards/ui/task/TaskCard.tsx rename to src/pages/project/boards/ui/task/TaskCard.tsx index 7479004..8036629 100644 --- a/src/pages/boards/ui/task/TaskCard.tsx +++ b/src/pages/project/boards/ui/task/TaskCard.tsx @@ -40,6 +40,7 @@ export function TaskCard({ task, onClick }: TaskCardProps) { +
{task.position}
{task.assignee && ( diff --git a/src/pages/boards/ui/task/TaskCardActions.tsx b/src/pages/project/boards/ui/task/TaskCardActions.tsx similarity index 92% rename from src/pages/boards/ui/task/TaskCardActions.tsx rename to src/pages/project/boards/ui/task/TaskCardActions.tsx index a550af4..612c31e 100644 --- a/src/pages/boards/ui/task/TaskCardActions.tsx +++ b/src/pages/project/boards/ui/task/TaskCardActions.tsx @@ -8,7 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from 'shared/ui'; -import { useBoardParams } from '../../model/useBoardParams'; +import { useRouteParams } from 'shared/lib/hooks'; interface TaskCardActionsProps { taskId: string; @@ -16,7 +16,7 @@ interface TaskCardActionsProps { } export function TaskCardActions({ taskId, taskTitle }: TaskCardActionsProps) { - const { projectSlug, boardSlug } = useBoardParams(); + const { projectSlug, boardSlug } = useRouteParams(); return (
event.stopPropagation()}> diff --git a/src/pages/boards/ui/task/TaskCardTitle.tsx b/src/pages/project/boards/ui/task/TaskCardTitle.tsx similarity index 96% rename from src/pages/boards/ui/task/TaskCardTitle.tsx rename to src/pages/project/boards/ui/task/TaskCardTitle.tsx index 13ca780..bee84da 100644 --- a/src/pages/boards/ui/task/TaskCardTitle.tsx +++ b/src/pages/project/boards/ui/task/TaskCardTitle.tsx @@ -3,7 +3,7 @@ import { useUpdateTask } from 'features/task/update'; import { Pencil } from 'lucide-react'; import { FocusEvent, useCallback, useRef, useState } from 'react'; import { Button, Textarea } from 'shared/ui'; -import { useBoardParams } from '../../model/useBoardParams'; +import { useRouteParams } from 'shared/lib/hooks'; import { useInlineFieldKeyDown } from '../../model/useInlineFieldKeyDown'; import { useInlineFieldOutsidePointerDown } from '../../model/useInlineFieldOutsidePointerDown'; @@ -15,7 +15,7 @@ export function TaskCardTitle({ task }: TaskCardTitleProps) { const [isEditing, setIsEditing] = useState(false); const [title, setTitle] = useState(task.title); const containerRef = useRef(null); - const { projectSlug, boardSlug } = useBoardParams(); + const { projectSlug, boardSlug } = useRouteParams(); const { mutateAsync: updateTask } = useUpdateTask(); const resetDraft = useCallback( diff --git a/src/pages/boards/ui/task/TaskWidget.tsx b/src/pages/project/boards/ui/task/TaskWidget.tsx similarity index 88% rename from src/pages/boards/ui/task/TaskWidget.tsx rename to src/pages/project/boards/ui/task/TaskWidget.tsx index 9f69f51..67dd2b9 100644 --- a/src/pages/boards/ui/task/TaskWidget.tsx +++ b/src/pages/project/boards/ui/task/TaskWidget.tsx @@ -10,7 +10,7 @@ import { DrawerHeader, DrawerTitle, } from 'shared/ui'; -import { TaskWidgetContent } from 'widgets/task'; +import { Task } from 'widgets/task'; interface TaskWidgetProps { taskId: string | null; @@ -43,9 +43,7 @@ export function TaskWidget({
- {taskId ? ( - - ) : null} + {taskId ? : null}
diff --git a/src/pages/project/settings/api/useUpdateProject.ts b/src/pages/project/settings/api/useUpdateProject.ts new file mode 100644 index 0000000..62bd5ee --- /dev/null +++ b/src/pages/project/settings/api/useUpdateProject.ts @@ -0,0 +1,38 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { projectFabricKeys, ProjectHttp, type TProject } from 'entities/project'; +import { useTeamStore } from 'entities/team'; +import { toast } from 'sonner'; + +type UseUpdateProjectProps = Omit< + UseMutationOptions, + 'mutationFn' +> & { slug: string }; + +export function useUpdateProject({ onSuccess, slug, ...rest }: UseUpdateProjectProps) { + const teamId = useTeamStore.use.teamId(); + + return useMutation({ + ...rest, + mutationFn: (data) => { + if (!teamId || !slug) { + throw new Error('Не выбран проект'); + } + return ProjectHttp.updateProject(teamId, slug, data); + }, + onSuccess: async (res, v, r, context) => { + onSuccess?.(res, v, r, context); + toast.success(res.message ?? 'Проект обновлён'); + + if (teamId && slug) { + await Promise.all([ + context.client.invalidateQueries({ + queryKey: projectFabricKeys.detail(teamId, slug), + }), + context.client.invalidateQueries({ + queryKey: projectFabricKeys.list(teamId), + }), + ]); + } + }, + }); +} diff --git a/src/pages/project/settings/config/project-statuses.ts b/src/pages/project/settings/config/project-statuses.ts new file mode 100644 index 0000000..c0794a0 --- /dev/null +++ b/src/pages/project/settings/config/project-statuses.ts @@ -0,0 +1,4 @@ +export const PROJECT_STATUS_OPTIONS = [ + { value: 'active', label: 'Активен' }, + { value: 'archived', label: 'В архиве' }, +] as const; diff --git a/src/pages/project/settings/index.ts b/src/pages/project/settings/index.ts new file mode 100644 index 0000000..0d2f045 --- /dev/null +++ b/src/pages/project/settings/index.ts @@ -0,0 +1 @@ +export { ProjectSettingsPage } from './ui/ProjectSettingsPage'; diff --git a/src/pages/project/settings/lib/map-project-to-form-values.ts b/src/pages/project/settings/lib/map-project-to-form-values.ts new file mode 100644 index 0000000..22bc969 --- /dev/null +++ b/src/pages/project/settings/lib/map-project-to-form-values.ts @@ -0,0 +1,19 @@ +import type { TProject } from 'entities/project'; +import type { ProjectSettingsFormValues } from '../model/types'; + +export function mapProjectToFormValues( + project: TProject.ProjectDetailResponse +): ProjectSettingsFormValues { + return { + name: project.name, + slug: project.slug, + description: project.description ?? '', + descriptionHtml: project.descriptionHtml ?? '', + icon: (project.visuals.icon ?? undefined) as ProjectSettingsFormValues['icon'], + color: project.visuals.color ?? null, + visibility: project.access.visibility, + status: project.status === 'archived' ? 'archived' : 'active', + sequence: project.meta.sequence, + settings: project.settings, + }; +} diff --git a/src/pages/project/settings/model/types.ts b/src/pages/project/settings/model/types.ts new file mode 100644 index 0000000..f88e9f6 --- /dev/null +++ b/src/pages/project/settings/model/types.ts @@ -0,0 +1,6 @@ +import { SProject } from 'entities/project'; +import { z } from 'zod/v4'; + +export const ProjectSettingsFormSchema = SProject.UpdateProjectBody; + +export type ProjectSettingsFormValues = z.input; diff --git a/src/pages/project/settings/ui/ProjectDangerZone.tsx b/src/pages/project/settings/ui/ProjectDangerZone.tsx new file mode 100644 index 0000000..85570d7 --- /dev/null +++ b/src/pages/project/settings/ui/ProjectDangerZone.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { RemoveProjectDialog } from 'features/projects/remove'; +import { AlertTriangle } from 'lucide-react'; +import { + Button, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from 'shared/ui'; + +interface ProjectDangerZoneProps { + projectName: string; + teamId: string; + slug: string; +} + +export function ProjectDangerZone({ projectName, teamId, slug }: ProjectDangerZoneProps) { + return ( + + + + + + Удаление проекта + + Безвозвратно удалить проект со всеми задачами и данными. + + + + + + + + + ); +} diff --git a/src/pages/project/settings/ui/ProjectSettingsPage.tsx b/src/pages/project/settings/ui/ProjectSettingsPage.tsx new file mode 100644 index 0000000..8825c02 --- /dev/null +++ b/src/pages/project/settings/ui/ProjectSettingsPage.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useTeamStore } from 'entities/team'; +import dynamic from 'next/dynamic'; +import { ProjectSettingsPageFallback } from './ProjectSettingsPageFallback'; + +const ProjectSettingsPageContent = dynamic( + () => import('./ProjectSettingsPageContent').then((mod) => mod.ProjectSettingsPageContent), + { + ssr: false, + loading: () => , + } +); + +interface ProjectSettingsPageProps { + projectSlug: string; +} + +export function ProjectSettingsPage({ projectSlug }: ProjectSettingsPageProps) { + const teamId = useTeamStore.use.teamId(); + + if (!teamId) { + return ; + } + + return ; +} diff --git a/src/pages/project/settings/ui/ProjectSettingsPageContent.tsx b/src/pages/project/settings/ui/ProjectSettingsPageContent.tsx new file mode 100644 index 0000000..2011030 --- /dev/null +++ b/src/pages/project/settings/ui/ProjectSettingsPageContent.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { ProjectQueries, useCheckSlug, validateProjectSlugAsync } from 'entities/project'; +import { ProjectIdentityFields, VisibilityPicker } from 'features/projects/create'; +import { useEffect } from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useZodValidationWithAsyncCheck } from 'shared/lib/hooks'; +import { + CardSection, + Field, + FieldError, + FieldLabel, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { mapProjectToFormValues } from '../lib/map-project-to-form-values'; +import { ProjectSettingsFormSchema, type ProjectSettingsFormValues } from '../model/types'; +import { PROJECT_STATUS_OPTIONS } from '../config/project-statuses'; +import { ProjectDangerZone } from './ProjectDangerZone'; +import { ProjectSettingsSaveBar } from './ProjectSettingsSaveBar'; + +interface ProjectSettingsPageContentProps { + projectSlug: string; + teamId: string; +} + +export function ProjectSettingsPageContent({ + projectSlug, + teamId, +}: ProjectSettingsPageContentProps) { + const projectQuery = useSuspenseQuery(ProjectQueries.getProject(teamId, projectSlug)); + const project = projectQuery.data; + const checkSlug = useCheckSlug(projectSlug, teamId); + + const form = useForm({ + resolver: useZodValidationWithAsyncCheck(ProjectSettingsFormSchema, (...args) => + validateProjectSlugAsync(checkSlug, ...args) + ), + mode: 'onChange', + defaultValues: mapProjectToFormValues(project), + }); + + const { reset } = form; + + useEffect(() => { + reset(mapProjectToFormValues(project)); + }, [reset, project.id, project.meta.updatedAt, project]); + + const isTemplate = project.status === 'template'; + + return ( + + +
+ + + + + +
+ + + {isTemplate ? ( +

+ Проект-шаблон: смена статуса недоступна. +

+ ) : ( + ( + + Статус + + {fieldState.invalid && } + + )} + /> + )} +
+
+ + {project.canEdit ? ( + + + + ) : null} +
+
+ {project.canEdit ? : null} +
+ ); +} diff --git a/src/pages/project/settings/ui/ProjectSettingsPageFallback.tsx b/src/pages/project/settings/ui/ProjectSettingsPageFallback.tsx new file mode 100644 index 0000000..a961573 --- /dev/null +++ b/src/pages/project/settings/ui/ProjectSettingsPageFallback.tsx @@ -0,0 +1,90 @@ +import { ComponentProps } from 'react'; +import { classNames } from 'shared/lib/utils'; +import { CardSection, Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; + +export function ProjectSettingsPageFallback() { + return ( + } + className="space-y-5" + aria-busy="true" + aria-label="Загрузка настроек проекта" + > +
+ + + + + +
+
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + + + + +
+
+ ); +} + +function FieldSkeleton({ className, ...props }: ComponentProps) { + return ( +
+ + +
+ ); +} + +function ProjectIdentityFieldsSkeleton() { + return ( +
+ + + +
+ +
+
+ +
+ + +
+
+ +
+
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/project/settings/ui/ProjectSettingsSaveBar.tsx b/src/pages/project/settings/ui/ProjectSettingsSaveBar.tsx new file mode 100644 index 0000000..172dc1a --- /dev/null +++ b/src/pages/project/settings/ui/ProjectSettingsSaveBar.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { type TProject } from 'entities/project'; +import { useFormContext, useFormState } from 'react-hook-form'; +import { FloatingSaveBar } from 'shared/ui'; +import { useUpdateProject } from '../api/useUpdateProject'; +import type { ProjectSettingsFormValues } from '../model/types'; +import { mapProjectToFormValues } from '../lib/map-project-to-form-values'; +import { useRouter } from 'next/navigation'; +import { routes } from 'shared/config'; + +interface ProjectSettingsSaveBarProps { + project: TProject.ProjectDetailResponse; +} + +export function ProjectSettingsSaveBar({ project }: ProjectSettingsSaveBarProps) { + const form = useFormContext(); + const { isDirty, dirtyFields, isValidating, isValid } = useFormState({ control: form.control }); + const router = useRouter(); + const updateProject = useUpdateProject({ + slug: project.slug, + onSuccess: (_d, vars) => { + form.reset(form.getValues()); + if (vars.slug) { + router.replace(routes.team.projects.projectSettings(vars.slug)); + } + }, + }); + + const onSubmit = (data: ProjectSettingsFormValues) => { + const body: TProject.UpdateProjectBody = { + ...(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) { + return; + } + + updateProject.mutate(body); + }; + + return ( + form.reset(mapProjectToFormValues(project))} + pending={updateProject.isPending || isValidating} + disabledSave={!isValid} + /> + ); +} diff --git a/src/pages/projects/model/get-projects-count-text.ts b/src/pages/projects/lib/get-projects-count-text.ts similarity index 100% rename from src/pages/projects/model/get-projects-count-text.ts rename to src/pages/projects/lib/get-projects-count-text.ts diff --git a/src/pages/projects/ui/ProjectsPageContent.tsx b/src/pages/projects/ui/ProjectsPageContent.tsx index c01e980..5aab3a4 100644 --- a/src/pages/projects/ui/ProjectsPageContent.tsx +++ b/src/pages/projects/ui/ProjectsPageContent.tsx @@ -6,7 +6,7 @@ import { CreateProjectDialog } from 'features/projects/create'; import { Plus } from 'lucide-react'; import { Button } from 'shared/ui'; import { PageWrapper } from 'widgets/page-wrapper'; -import { getProjectsCountText } from '../model/get-projects-count-text'; +import { getProjectsCountText } from '../lib/get-projects-count-text'; import { ProjectCard } from './ProjectCard'; export function ProjectsPageContent({ teamId }: { teamId: string }) { diff --git a/src/pages/teams/model/get-teams-count-text.ts b/src/pages/teams/lib/get-teams-count-text.ts similarity index 100% rename from src/pages/teams/model/get-teams-count-text.ts rename to src/pages/teams/lib/get-teams-count-text.ts diff --git a/src/pages/teams/ui/TeamsPageContent.tsx b/src/pages/teams/ui/TeamsPageContent.tsx index 62c75b0..16c11b2 100644 --- a/src/pages/teams/ui/TeamsPageContent.tsx +++ b/src/pages/teams/ui/TeamsPageContent.tsx @@ -7,7 +7,7 @@ import { CreateTeamDialog } from 'features/teams/create'; import { Plus } from 'lucide-react'; import { Button } from 'shared/ui'; import { PageWrapper } from 'widgets/page-wrapper'; -import { getTeamsCountText } from '../model/get-teams-count-text'; +import { getTeamsCountText } from '../lib/get-teams-count-text'; import { TeamCard } from './TeamCard'; export function TeamsPageContent() { diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index 575504a..a091447 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -5,3 +5,4 @@ export { useIsMobile } from './useMobile'; export { useQueuedDebouncedMutation } from './useQueuedDebouncedMutation'; export { useZodValidationWithAsyncCheck } from './useZodValidationWithAsyncCheck'; export { useFixedHeightWithMax } from './useFixedHeightWithMax'; +export { useRouteParams, type RouteParams } from './useRouteParams'; diff --git a/src/pages/boards/model/useBoardParams.ts b/src/shared/lib/hooks/useRouteParams.ts similarity index 50% rename from src/pages/boards/model/useBoardParams.ts rename to src/shared/lib/hooks/useRouteParams.ts index f671864..14bd8c6 100644 --- a/src/pages/boards/model/useBoardParams.ts +++ b/src/shared/lib/hooks/useRouteParams.ts @@ -1,15 +1,19 @@ +'use client'; + import { useParams } from 'next/navigation'; -type BoardRouteParams = { +export type RouteParams = { projectSlug: string; boardSlug: string; + teamId: string; }; -export function useBoardParams(): BoardRouteParams { - const params = useParams(); +export function useRouteParams(): RouteParams { + const params = useParams>(); return { projectSlug: params?.projectSlug ?? '', boardSlug: params?.boardSlug ?? '', + teamId: params?.teamId ?? '', }; } diff --git a/src/shared/ui/Kanban.tsx b/src/shared/ui/Kanban.tsx index 00b3447..209d8ee 100644 --- a/src/shared/ui/Kanban.tsx +++ b/src/shared/ui/Kanban.tsx @@ -48,7 +48,11 @@ import { cn } from 'shared/lib/utils'; interface KanbanContextProps { columns: Record; - setColumns: (columns: Record) => void; + setColumns: ( + columns: Record, + draggedTaskId?: string, + draggedColumnId?: string + ) => void; getItemId: (item: T) => string; columnIds: string[]; activeId: UniqueIdentifier | null; @@ -124,12 +128,23 @@ export interface KanbanItemMoveEvent { toIndex: number; } +export interface KanbanColumnMoveEvent { + columnId: string; + fromIndex: number; + toIndex: number; +} + export interface KanbanRootProps extends HTMLAttributes { value: Record; - onValueChange: (value: Record) => void; + onValueChange: ( + value: Record, + draggedTaskId?: string, + draggedColumnId?: string + ) => void; getItemValue: (item: T) => string; children: ReactNode; onItemMoveEnd?: (event: KanbanItemMoveEvent) => void; + onColumnMoveEnd?: (event: KanbanColumnMoveEvent) => void; onMove?: (event: KanbanMoveEvent) => void; asChild?: boolean; modifiers?: Modifiers; @@ -143,6 +158,7 @@ function Kanban({ className, asChild = false, onItemMoveEnd, + onColumnMoveEnd, onMove, modifiers, ...props @@ -151,6 +167,7 @@ function Kanban({ const setColumns = onValueChange; const [activeId, setActiveId] = useState(null); const itemMoveRef = useRef | null>(null); + const columnMoveRef = useRef(null); const sensors = useSensors( useSensor(MouseSensor, { @@ -190,6 +207,14 @@ function Kanban({ setActiveId(active.id); if (!onItemMoveEnd || isColumn(active.id)) { + if (onColumnMoveEnd && isColumn(active.id)) { + const fromIndex = columnIds.indexOf(active.id as string); + columnMoveRef.current = { + columnId: active.id as string, + fromIndex, + toIndex: fromIndex, + }; + } return; } @@ -214,7 +239,7 @@ function Kanban({ toIndex: fromIndex, }; }, - [columns, findContainer, getItemValue, isColumn, onItemMoveEnd] + [columns, columnIds, findContainer, getItemValue, isColumn, onColumnMoveEnd, onItemMoveEnd] ); const handleDragOver = useCallback( @@ -222,6 +247,28 @@ function Kanban({ const { active, over } = event; if (!over) return; + if (isColumn(active.id) && isColumn(over.id)) { + const activeIndex = columnIds.indexOf(active.id as string); + const overIndex = columnIds.indexOf(over.id as string); + + if (onColumnMoveEnd && columnMoveRef.current?.columnId === active.id) { + columnMoveRef.current = { + ...columnMoveRef.current, + toIndex: overIndex, + }; + } + + if (activeIndex !== -1 && overIndex !== -1 && activeIndex !== overIndex) { + const newOrder = arrayMove(Object.keys(columns), activeIndex, overIndex); + const newColumns: Record = {}; + newOrder.forEach((key) => { + newColumns[key] = columns[key]; + }); + setColumns(newColumns, undefined, active.id as string); + } + return; + } + if (isColumn(active.id)) return; const activeContainer = findContainer(active.id); @@ -258,11 +305,14 @@ function Kanban({ const [movedItem] = newActiveItems.splice(activeIndex, 1); newOverItems.splice(overIndex, 0, movedItem); - setColumns({ - ...columns, - [activeContainer]: newActiveItems, - [overContainer]: newOverItems, - }); + setColumns( + { + ...columns, + [activeContainer]: newActiveItems, + [overContainer]: newOverItems, + }, + active.id as string + ); } else { const container = activeContainer; const activeIndex = columns[container].findIndex( @@ -271,19 +321,33 @@ function Kanban({ const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); if (activeIndex !== overIndex) { - setColumns({ - ...columns, - [container]: arrayMove(columns[container], activeIndex, overIndex), - }); + setColumns( + { + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }, + active.id as string + ); } } }, - [findContainer, getItemValue, isColumn, setColumns, columns, onMove, onItemMoveEnd] + [ + findContainer, + getItemValue, + isColumn, + setColumns, + columns, + onMove, + onItemMoveEnd, + onColumnMoveEnd, + columnIds, + ] ); const handleDragCancel = useCallback(() => { setActiveId(null); itemMoveRef.current = null; + columnMoveRef.current = null; }, []); const handleDragEnd = useCallback( @@ -294,6 +358,9 @@ function Kanban({ const itemMove = itemMoveRef.current; itemMoveRef.current = null; + const columnMove = columnMoveRef.current; + columnMoveRef.current = null; + if (!over) return; if ( @@ -305,6 +372,15 @@ function Kanban({ onItemMoveEnd(itemMove); } + if ( + onColumnMoveEnd && + columnMove && + isColumn(active.id) && + columnMove.fromIndex !== columnMove.toIndex + ) { + onColumnMoveEnd(columnMove); + } + // Handle item move callback if (onMove && !isColumn(active.id)) { const activeContainer = findContainer(active.id); @@ -331,16 +407,6 @@ function Kanban({ // Handle column reordering if (isColumn(active.id) && isColumn(over.id)) { - const activeIndex = columnIds.indexOf(active.id as string); - const overIndex = columnIds.indexOf(over.id as string); - if (activeIndex !== overIndex) { - const newOrder = arrayMove(Object.keys(columns), activeIndex, overIndex); - const newColumns: Record = {}; - newOrder.forEach((key) => { - newColumns[key] = columns[key]; - }); - setColumns(newColumns); - } return; } @@ -356,14 +422,26 @@ function Kanban({ const overIndex = columns[container].findIndex((item: T) => getItemValue(item) === over.id); if (activeIndex !== overIndex) { - setColumns({ - ...columns, - [container]: arrayMove(columns[container], activeIndex, overIndex), - }); + setColumns( + { + ...columns, + [container]: arrayMove(columns[container], activeIndex, overIndex), + }, + active.id as string + ); } } }, - [columnIds, columns, findContainer, getItemValue, isColumn, setColumns, onMove, onItemMoveEnd] + [ + columns, + findContainer, + getItemValue, + isColumn, + setColumns, + onMove, + onItemMoveEnd, + onColumnMoveEnd, + ] ); const contextValue = useMemo( diff --git a/src/widgets/app-sidebar/model/useTeamHotkeys.ts b/src/widgets/app-sidebar/lib/useTeamHotkeys.ts similarity index 62% rename from src/widgets/app-sidebar/model/useTeamHotkeys.ts rename to src/widgets/app-sidebar/lib/useTeamHotkeys.ts index af84e42..1a0f251 100644 --- a/src/widgets/app-sidebar/model/useTeamHotkeys.ts +++ b/src/widgets/app-sidebar/lib/useTeamHotkeys.ts @@ -1,5 +1,6 @@ import type { TUser } from 'entities/user'; import { useEffect } from 'react'; +import { MAX_VISIBLE_TEAMS } from '../model/const'; export function useTeamHotkeys( teams: TUser.UserTeamResponse[], @@ -8,11 +9,18 @@ export function useTeamHotkeys( useEffect(() => { const handler = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey)) return; - const index = parseInt(e.key, 10) - 1; - if (index >= 0 && index < teams.length) { - e.preventDefault(); - onSelect(teams[index].id); - } + + const slot = parseInt(e.key, 10); + + if (slot < 1 || slot > MAX_VISIBLE_TEAMS) return; + + e.preventDefault(); + + const team = teams[slot - 1]; + + if (!team) return; + + onSelect(team.id); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); diff --git a/src/widgets/app-sidebar/model/useTeamsDropdown.ts b/src/widgets/app-sidebar/model/useTeamsDropdown.ts index 956ffab..8ef0969 100644 --- a/src/widgets/app-sidebar/model/useTeamsDropdown.ts +++ b/src/widgets/app-sidebar/model/useTeamsDropdown.ts @@ -3,7 +3,7 @@ import { UserQueries } from 'entities/user'; import { useSwitchTeam } from 'features/teams/active-team'; import { useMemo, useState } from 'react'; import { MAX_VISIBLE_TEAMS } from './const'; -import { useTeamHotkeys } from './useTeamHotkeys'; +import { useTeamHotkeys } from '../lib/useTeamHotkeys'; export function useTeamsDropdown() { const [open, setOpen] = useState(false); diff --git a/src/widgets/app-sidebar/ui/MyTeams.tsx b/src/widgets/app-sidebar/ui/MyTeams.tsx index 9e3b2b2..7967ce8 100644 --- a/src/widgets/app-sidebar/ui/MyTeams.tsx +++ b/src/widgets/app-sidebar/ui/MyTeams.tsx @@ -10,7 +10,7 @@ import { SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from 'shared/ui' export function MyTeams() { const pathname = usePathname(); const invitationsQuery = useQuery(UserQueries.getMyInvitations()); - const invitationsCount = invitationsQuery.data?.items.length ?? 0; + const invitationsCount = invitationsQuery.data?.length ?? 0; return ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index 5143651..d7e9dca 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -3,7 +3,7 @@ import { type TProject } from 'entities/project'; import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive'; import { ShareProjectDialog } from 'features/projects/share'; -import { Archive, Link2 } from 'lucide-react'; +import { Archive, CogIcon, Link2 } from 'lucide-react'; import { ComponentProps, useState } from 'react'; import { DropdownMenu, @@ -12,6 +12,8 @@ import { DropdownMenuTrigger, useSidebar, } from 'shared/ui'; +import Link from 'next/link'; +import { routes } from 'shared/config'; interface ProjectActionsProps extends ComponentProps { project: TProject.ProjectListItemResponse; @@ -45,6 +47,14 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp Опубликовать + + + + + Настройки + + + {project.status === 'archived' ? ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx index 53339f0..3bc9fa8 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx @@ -8,6 +8,7 @@ import { BriefcaseBusiness, ChevronRight, MoreHorizontal, Plus } from 'lucide-re import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { routes } from 'shared/config'; +import { useRouteParams } from 'shared/lib/hooks'; import { Collapsible, CollapsibleContent, @@ -26,6 +27,7 @@ export function ProjectsContent() { const teamId = useTeamStore.use.teamId(); const router = useRouter(); const pathname = usePathname(); + const { projectSlug } = useRouteParams(); const { open, isMobile } = useSidebar(); const projects = useQuery({ ...ProjectQueries.getProjects(teamId!), enabled: !!teamId }); @@ -62,10 +64,7 @@ export function ProjectsContent() { {projectList.map((project) => ( - + {projectIconCodeToEmoji(project.icon)} {project.name} diff --git a/src/widgets/task/index.ts b/src/widgets/task/index.ts index e9ff89b..abe0075 100644 --- a/src/widgets/task/index.ts +++ b/src/widgets/task/index.ts @@ -1 +1 @@ -export { TaskWidgetContent } from './ui/TaskWidgetContent'; +export { Task } from './ui/Task'; diff --git a/src/widgets/task/ui/Task.tsx b/src/widgets/task/ui/Task.tsx new file mode 100644 index 0000000..d807039 --- /dev/null +++ b/src/widgets/task/ui/Task.tsx @@ -0,0 +1,19 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { TaskFallback } from './TaskFallback'; + +const TaskContent = dynamic(() => import('./TaskContent').then((mod) => mod.TaskContent), { + ssr: false, + loading: () => , +}); + +interface TaskProps { + taskId: string; + projectSlug: string; + boardSlug: string; +} + +export function Task({ taskId, projectSlug, boardSlug }: TaskProps) { + return ; +} diff --git a/src/widgets/task/ui/TaskContent.tsx b/src/widgets/task/ui/TaskContent.tsx new file mode 100644 index 0000000..0fc90b9 --- /dev/null +++ b/src/widgets/task/ui/TaskContent.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { TaskQueries } from 'entities/task'; +import { CalendarDays, CircleDot, FileText, GitBranch, Hash, User, UserPen } from 'lucide-react'; +import { formatDate } from 'shared/lib/utils'; +import { Badge, ScrollArea, Separator } from 'shared/ui'; +import { taskPriorityMap } from '../config/task-meta-map'; +import { TaskDescription } from './TaskDescription'; +import { TaskDetailRow } from './TaskDetailRow'; +import { TaskMember } from './TaskMember'; + +interface TaskContentProps { + taskId: string; + projectSlug: string; + boardSlug: string; +} + +export function TaskContent({ taskId, projectSlug, boardSlug }: TaskContentProps) { + const { data: task } = useSuspenseQuery( + TaskQueries.getTask(taskId, { slug: projectSlug, key: boardSlug }) + ); + + const priority = taskPriorityMap[task.priority]; + + return ( + +
+
+ {priority.label} + {task.labels.map((label) => ( + + {label} + + ))} +
+ +
+
+
+ +

Описание

+
+
+ +
+
+ + +
+ + + +
+ Создана {formatDate(task.createdAt)} + · + Обновлена {formatDate(task.updatedAt)} +
+
+
+ ); +} diff --git a/src/widgets/task/ui/TaskDescription.tsx b/src/widgets/task/ui/TaskDescription.tsx new file mode 100644 index 0000000..caa20e3 --- /dev/null +++ b/src/widgets/task/ui/TaskDescription.tsx @@ -0,0 +1,97 @@ +'use client'; + +import type { TTask } from 'entities/task'; +import { useUpdateTask } from 'features/task/update'; +import { Pencil, X } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from 'shared/lib/utils'; +import { Button, Textarea } from 'shared/ui'; + +interface TaskDescriptionProps { + task: TTask.Task; + projectSlug: string; + boardSlug: string; +} + +export function TaskDescription({ task, projectSlug, boardSlug }: TaskDescriptionProps) { + const [isEditing, setIsEditing] = useState(false); + const [description, setDescription] = useState(''); + const { mutateAsync: updateTask, isPending } = useUpdateTask(); + + const handleEdit = () => { + setDescription(task.description ?? ''); + setIsEditing(true); + }; + + const handleSave = async () => { + const descriptionValue = description.trim(); + + setIsEditing(false); + + if (descriptionValue === (task.description ?? '')) { + return; + } + + await updateTask({ + slug: projectSlug, + boardSlug, + taskId: task.id, + body: { description: descriptionValue || null }, + }); + }; + + const handleCancel = () => { + setIsEditing(false); + }; + + if (isEditing) { + return ( +
+