diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e371c99 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.woff binary +*.woff2 binary \ No newline at end of file diff --git a/src/features/dnd-board/index.tsx b/src/features/dnd-board/index.tsx index 1c2e465..06a1e4b 100644 --- a/src/features/dnd-board/index.tsx +++ b/src/features/dnd-board/index.tsx @@ -1,8 +1,12 @@ export { BoardActions } from "./ui/board-actions"; export { Board } from "./ui/board/board"; +export { BoardUsers } from "./ui/board-users/board-users"; +export { BoardSearchBar } from "./ui/board-search-bar/board-search-bar"; export type { Board as BoardType } from "./model/types"; export { boardStoreContext, useBoardStore } from "./model/use-board-store"; export { boardDepsContext } from "./deps"; export { useSaveBoard } from "./model/use-save-board"; export { useBoard } from "./model/use-board"; export { useBoardStoreFactory } from "./model/use-board-store-factory"; +export { useBoardSearchStoreFactory } from "./model/use-board-search-store-factory"; +export { boardSearchStoreContext, useBoardSearchStore} from "./model/use-board-search-store"; diff --git a/src/features/dnd-board/lib/use-board-users.ts b/src/features/dnd-board/lib/use-board-users.ts new file mode 100644 index 0000000..ced39c0 --- /dev/null +++ b/src/features/dnd-board/lib/use-board-users.ts @@ -0,0 +1,32 @@ +import { BoardUser } from "../model/types.tsx" +import { useBoardStore } from "../model/use-board-store"; +import { useGetUserById } from "./use-get-user-by-id"; + +type UseBoardUsersReturn = { + owner: BoardUser | null; + editors: BoardUser[] +} + +export const useBoardUsers = (): UseBoardUsersReturn => { + const { board } = useBoardStore(); + const { ownerId, editorsIds} = board; + + const getUserById = useGetUserById(); + + const owner = getUserById(ownerId) ?? null; + + const editors: BoardUser[] = [] + + editorsIds.forEach((id) => { + const editor = getUserById(id); + + if (editor) { + editors.push(editor) + } + }) + + return { + owner, + editors + } +} \ No newline at end of file diff --git a/src/features/dnd-board/lib/use-can-be-assigned-as-editor.ts b/src/features/dnd-board/lib/use-can-be-assigned-as-editor.ts new file mode 100644 index 0000000..d977351 --- /dev/null +++ b/src/features/dnd-board/lib/use-can-be-assigned-as-editor.ts @@ -0,0 +1,17 @@ +import { useBoardStore } from "../model/use-board-store.tsx"; + +export const useCanBeAssignedAsEditor = ({ editorsIds }: { editorsIds: string[]}) => { + const { board } = useBoardStore(); + + return ({id}: {id: string}): boolean => { + const isOwner = id === board.ownerId; + + if (isOwner) { + return false + } + + const isEditor = editorsIds.some((editorId) => editorId === id); + + return !isEditor; + } +} \ No newline at end of file diff --git a/src/features/dnd-board/lib/use-can-update-board-editors.ts b/src/features/dnd-board/lib/use-can-update-board-editors.ts new file mode 100644 index 0000000..6ac7565 --- /dev/null +++ b/src/features/dnd-board/lib/use-can-update-board-editors.ts @@ -0,0 +1,18 @@ +import { useSesssion as useSession} from "@/entities/session"; +import { useBoardStore } from "../model/use-board-store"; + +export const useCanUpdateBoardEditors = (): boolean => { + const { board } = useBoardStore(); + const { ownerId} = board; + const session = useSession(); + + if (!session) { + return false; + } + + const { userId} = session; + + const isOwner = ownerId === userId; + + return isOwner; +} \ No newline at end of file diff --git a/src/features/dnd-board/lib/use-get-user-by-id.ts b/src/features/dnd-board/lib/use-get-user-by-id.ts new file mode 100644 index 0000000..19c55d4 --- /dev/null +++ b/src/features/dnd-board/lib/use-get-user-by-id.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { UserDto } from '@/shared/api/modules/user'; +import { usersListQuery } from '@/entities/user'; + +export function useGetUserById(): (userId: string) => UserDto | null { + const { data: users } = useQuery({ + ...usersListQuery(), + initialData: [], + }); + + return (userId) => users.find((user) => user.id === userId) ?? null; +} \ No newline at end of file diff --git a/src/features/dnd-board/lib/use-has-board-access.ts b/src/features/dnd-board/lib/use-has-board-access.ts new file mode 100644 index 0000000..90d0d41 --- /dev/null +++ b/src/features/dnd-board/lib/use-has-board-access.ts @@ -0,0 +1,25 @@ +import { useSesssion as useSession } from "@/entities/session"; +import { useBoardStore } from "../model/use-board-store"; + +export const hasBoardAccess = (userId: string, {ownerId, editorsIds}: {ownerId: string, editorsIds: string[]}): boolean => { + const isOwner = ownerId === userId; + + if (isOwner) { + return true; + } + + const isEditor = editorsIds.some((editorId) => editorId === userId); + + return isEditor; +} + +export const useHasBoardAccess = (): boolean => { + const { board } = useBoardStore(); + const session = useSession(); + + if (!session) { + return false; + } + + return hasBoardAccess(session.userId, board); +} \ No newline at end of file diff --git a/src/features/dnd-board/lib/use-possible-editors.ts b/src/features/dnd-board/lib/use-possible-editors.ts new file mode 100644 index 0000000..4a67660 --- /dev/null +++ b/src/features/dnd-board/lib/use-possible-editors.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { usersListQuery} from '@/entities/user'; +import { BoardUser } from "../model/types.tsx" +import { useCanBeAssignedAsEditor } from "./use-can-be-assigned-as-editor.ts"; + +export const usePossibleEditors = ({ editorsIds }: { editorsIds: string[]}): BoardUser[] => { + const { data: users } = useQuery({ + ...usersListQuery(), + initialData: [], + }); + + const canBeAssignedAsEditor = useCanBeAssignedAsEditor({ editorsIds }) + + return users.filter((user) => canBeAssignedAsEditor(user)) +} diff --git a/src/features/dnd-board/lib/use-shown-board.ts b/src/features/dnd-board/lib/use-shown-board.ts new file mode 100644 index 0000000..2dae544 --- /dev/null +++ b/src/features/dnd-board/lib/use-shown-board.ts @@ -0,0 +1,24 @@ +import { Board } from "../model/types"; +import { useBoardStore} from "../model/use-board-store"; +import { useBoardSearchStore } from "../model/use-board-search-store"; + +export const useShownBoard = (): Board => { + const { board} = useBoardStore(); + const { submittedQuery: query } = useBoardSearchStore(); + + if (!query) { + return board; + } + + const loweredQuery = query.toLowerCase(); + + const shownBoardCols = board.cols.map(({ items, ...restCol }) => ({ + ...restCol, + items: items.filter((item) => item.title.toLowerCase().includes(loweredQuery)) + })); + + return { + ...board, + cols: shownBoardCols, + } +} \ No newline at end of file diff --git a/src/features/dnd-board/model/types.tsx b/src/features/dnd-board/model/types.tsx index 2234366..ded8a1e 100644 --- a/src/features/dnd-board/model/types.tsx +++ b/src/features/dnd-board/model/types.tsx @@ -33,3 +33,9 @@ export type UpdateBoardData = { ownerId?: string; editorsIds?: string[]; }; + +export type BoardUser = { + id: string; + name: string; + avatarId: string; +} diff --git a/src/features/dnd-board/model/use-board-search-store-factory.ts b/src/features/dnd-board/model/use-board-search-store-factory.ts new file mode 100644 index 0000000..15751e5 --- /dev/null +++ b/src/features/dnd-board/model/use-board-search-store-factory.ts @@ -0,0 +1,24 @@ +import { BoardSearchStore } from "./use-board-search-store"; +import {useRef, useState} from 'react'; + +export const useBoardSearchStoreFactory = (initialQuery = ''): BoardSearchStore => { + const initialQueryRef = useRef(initialQuery); + const [query, setQuery] = useState(initialQueryRef.current); + const [submittedQuery, setSubmittedQuery] = useState(initialQueryRef.current); + + const submitQuery = () => { + setSubmittedQuery(query); + } + const resetQuery = () => { + setQuery(initialQueryRef.current); + setSubmittedQuery(initialQueryRef.current); + }; + + return { + query, + submittedQuery, + setQuery, + submitQuery, + resetQuery + } +} \ No newline at end of file diff --git a/src/features/dnd-board/model/use-board-search-store.tsx b/src/features/dnd-board/model/use-board-search-store.tsx new file mode 100644 index 0000000..2c5947b --- /dev/null +++ b/src/features/dnd-board/model/use-board-search-store.tsx @@ -0,0 +1,13 @@ +import { createStrictContext, useStrictContext } from "@/shared/lib/react"; + +export type BoardSearchStore = { + query: string; + submittedQuery: string; + setQuery: (newQuery: string) => void; + submitQuery: () => void; + resetQuery: () => void; +}; + +export const boardSearchStoreContext = createStrictContext(); + +export const useBoardSearchStore = () => useStrictContext(boardSearchStoreContext); diff --git a/src/features/dnd-board/model/use-board-store-factory.ts b/src/features/dnd-board/model/use-board-store-factory.ts index a4f5939..2743f9d 100644 --- a/src/features/dnd-board/model/use-board-store-factory.ts +++ b/src/features/dnd-board/model/use-board-store-factory.ts @@ -138,6 +138,14 @@ export const useBoardStoreFactory = (initalBoard: Board): BoardStore => { ); }; + const updateBoardEditors: BoardStore["updateBoardEditors"] = async (editorsIds: string[]) => { + setBoard( + produce((draft) => { + draft.editorsIds = editorsIds; + }) + ); + } + return { board, addColumn, @@ -148,5 +156,6 @@ export const useBoardStoreFactory = (initalBoard: Board): BoardStore => { updateBoardCard, moveBoardCard, addBoardCard, + updateBoardEditors, }; }; diff --git a/src/features/dnd-board/model/use-board-store.tsx b/src/features/dnd-board/model/use-board-store.tsx index 2a5447f..bfee4af 100644 --- a/src/features/dnd-board/model/use-board-store.tsx +++ b/src/features/dnd-board/model/use-board-store.tsx @@ -16,6 +16,8 @@ export type BoardStore = { start: { colId: string; index: number }, end: { colId: string; index: number }, ) => Promise; + + updateBoardEditors: (editorsIds: string[]) => Promise; }; export const boardStoreContext = createStrictContext(); diff --git a/src/features/dnd-board/ui/board-search-bar/board-search-bar.tsx b/src/features/dnd-board/ui/board-search-bar/board-search-bar.tsx new file mode 100644 index 0000000..5b57dab --- /dev/null +++ b/src/features/dnd-board/ui/board-search-bar/board-search-bar.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import { FormEventHandler } from 'react'; +import { UiButton } from "@/shared/ui/ui-button"; +import { UiTextField } from "@/shared/ui/ui-text-field"; +import { useBoardSearchStore } from '@/features/dnd-board'; + +export function BoardSearchBar({ className }: { className?: string }) { + const { query, submittedQuery, setQuery, submitQuery, resetQuery } = useBoardSearchStore(); + const onSearch: FormEventHandler = (evt) => { + evt?.preventDefault(); + submitQuery(); + }; + + const onReset: FormEventHandler = (evt) => { + evt?.preventDefault(); + resetQuery(); + }; + + const isSearchDisabled = query === submittedQuery; + + return
+ setQuery(event.currentTarget.value) + }} /> + Поиск + Сбросить + +} \ No newline at end of file diff --git a/src/features/dnd-board/ui/board-users/board-users.tsx b/src/features/dnd-board/ui/board-users/board-users.tsx new file mode 100644 index 0000000..eccd3f5 --- /dev/null +++ b/src/features/dnd-board/ui/board-users/board-users.tsx @@ -0,0 +1,45 @@ +import clsx from "clsx"; +import { UserPreview } from "@/entities/user"; +import { useBoardUsers } from '../../lib/use-board-users.ts'; +import { useHasBoardAccess } from "../../lib/use-has-board-access.ts"; +import { UpdateBoardEditorsButton } from "./update-board-editors-button.tsx" + +export function BoardUsers({ className }: { className?: string }) { + const { owner, editors} = useBoardUsers(); + const hasBoardAccess = useHasBoardAccess(); + + if (!hasBoardAccess) { + return
+

Пользователи доски

+

У Bас нет прав для просмотра пользователей этой доски

+
+ } + + return ( +
+

Пользователи доски

+ +
+

Владелец:

+ {!owner ? +

Не назначен

: +
+ +
} +
+ +
+

Редакторы:

+
+ {!editors.length ? +

Не назначены

: + editors.map((editor) => +
+ +
)} +
+ +
+
+ ); +} diff --git a/src/features/dnd-board/ui/board-users/update-board-editors-button.tsx b/src/features/dnd-board/ui/board-users/update-board-editors-button.tsx new file mode 100644 index 0000000..b6709bd --- /dev/null +++ b/src/features/dnd-board/ui/board-users/update-board-editors-button.tsx @@ -0,0 +1,25 @@ +import clsx from 'clsx'; +import { UpdateIcon } from '@/shared/ui/ui-icons.tsx'; +import { useCanUpdateBoardEditors } from "../../lib/use-can-update-board-editors"; +import { + useUpdateBoardEditorsModal +} from "../update-board-editors/use-update-board-editors-modal"; + +export function UpdateBoardEditorsButton({ className }: { className?: string}) { + const canUpdateBoardEditors = useCanUpdateBoardEditors(); + const { modal, updateBoardEditors } = useUpdateBoardEditorsModal(); + + if (!canUpdateBoardEditors) { + return null; + } + + return <> + + {modal} + +} \ No newline at end of file diff --git a/src/features/dnd-board/ui/board/board.tsx b/src/features/dnd-board/ui/board/board.tsx index 1b0400f..c92aeb2 100644 --- a/src/features/dnd-board/ui/board/board.tsx +++ b/src/features/dnd-board/ui/board/board.tsx @@ -1,11 +1,13 @@ import clsx from "clsx"; -import { BoardColumn } from "./board-column"; import { DragDropContext, Droppable } from "react-beautiful-dnd"; +import { BoardColumn } from "./board-column"; import { useBoardStore } from "../.."; +import { useShownBoard } from "../../lib/use-shown-board"; export function Board({ className }: { className?: string }) { - const { board, moveColumn, moveBoardCard } = useBoardStore(); - const columns = board.cols; + const { moveColumn, moveBoardCard } = useBoardStore(); + const shownBoard = useShownBoard(); + const columns = shownBoard.cols; return ( void; +}) { + const { updateBoardEditors } = useBoardStore(); + const { editors} = useBoardUsers(); + + const form = useForm({ + defaultValues: { + editorsIds: editors.map(({ id }) => id) + }, + }); + + const handleSubmit = form.handleSubmit(async (data) => { + await updateBoardEditors(data.editorsIds); + onSuccess(); + }); + + return ( + +
{children}
; +
+ ); +} + +function EditorCard({ editorId }: { editorId: string }) { + const { setValue, getValues } = useFormContext(); + const getUserById = useGetUserById(); + const editor = getUserById(editorId); + + if (!editor) { + return null; + } + + const onRemoveEditor = () => { + const currentEditorsIds = getValues('editorsIds'); + const newEditorsIds = currentEditorsIds.filter((currentEditorId) => currentEditorId !== editorId); + setValue('editorsIds', newEditorsIds); + } + + return
+ + +
+} + +UpdateBoardEditorsForm.Fields = function Fields() { + const { watch, setValue, getValues } = useFormContext(); + const editorsIds = watch('editorsIds'); + const canBeAssignedAsEditor = useCanBeAssignedAsEditor({ editorsIds }); + const possibleEditors = usePossibleEditors({ editorsIds }); + + const onAddEditor = (newEditorId?: string) => { + if (newEditorId) { + const currentEditorsIds = getValues('editorsIds'); + const newEditorsIds = [...currentEditorsIds, newEditorId]; + setValue('editorsIds', newEditorsIds); + } + } + + const editorsCards = editorsIds.map((editorId) => ); + const editorSelect = ; + + return ( + <> + {editorsIds.length ? editorsCards :
Нет назначенных редакторов
} + {possibleEditors.length ? editorSelect :
Все возможные редакторы назначены
} + + ); +}; + +UpdateBoardEditorsForm.SubmitButton = function SubmitButton() { + return ( + + Обновить + + ); +}; diff --git a/src/features/dnd-board/ui/update-board-editors/update-board-editors-modal.tsx b/src/features/dnd-board/ui/update-board-editors/update-board-editors-modal.tsx new file mode 100644 index 0000000..9cdf4f9 --- /dev/null +++ b/src/features/dnd-board/ui/update-board-editors/update-board-editors-modal.tsx @@ -0,0 +1,28 @@ +import { UiModal } from "@/shared/ui/ui-modal"; +import { UiButton } from "@/shared/ui/ui-button"; +import { UpdateBoardEditorsForm } from "./update-board-editors-form.tsx"; + +export function UpdateBoardEditorsModal({ + onClose, +}: { + onClose: (updated?: boolean) => void; +}) { + return ( + + onClose(true)}> + +

Редактирование списка редакторов

+
+ + + + + onClose()}> + Отмена + + + +
+
+ ); +} diff --git a/src/features/dnd-board/ui/update-board-editors/use-update-board-editors-modal.tsx b/src/features/dnd-board/ui/update-board-editors/use-update-board-editors-modal.tsx new file mode 100644 index 0000000..3775afe --- /dev/null +++ b/src/features/dnd-board/ui/update-board-editors/use-update-board-editors-modal.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; +import { UpdateBoardEditorsModal } from "./update-board-editors-modal.tsx"; + +export function useUpdateBoardEditorsModal() { + const [modalProps, setModalProps] = useState<{ + onClose: (updated?: boolean) => void; + }>(); + + const modal = modalProps ? : undefined; + + const updateBoardEditors = () => { + return new Promise((res) => { + setModalProps({ + onClose: (updated) => { + res(updated); + setModalProps(undefined); + }, + }); + }); + }; + + return { + modal, + updateBoardEditors, + }; +} diff --git a/src/pages/board/board.page.tsx b/src/pages/board/board.page.tsx index ca176bd..cebdbdb 100644 --- a/src/pages/board/board.page.tsx +++ b/src/pages/board/board.page.tsx @@ -1,22 +1,22 @@ -import { Board, BoardActions, useBoard } from "@/features/dnd-board"; +import { useParams } from "react-router-dom"; import { ComposeChildren } from "@/shared/lib/react"; import { UiPageSpinner } from "@/shared/ui/ui-page-spinner"; -import { useParams } from "react-router-dom"; +import { useSesssion } from "@/entities/session"; +import { Board, BoardActions, useBoard, BoardUsers, BoardSearchBar } from "@/features/dnd-board"; import { - BoardDepsProvider, + BoardDepsProvider, BoardSearchStoreProvider, BoardStoreProvider, TaskEditorProvider, } from "./providers"; -import { useSesssion } from "@/entities/session"; export function BoardPage() { const params = useParams<"boardId">(); const boardId = params.boardId; - const sesson = useSesssion(); + const session = useSesssion(); const board = useBoard(boardId); - if (!sesson) { + if (!session) { return
Не авторизован
; } @@ -29,9 +29,12 @@ export function BoardPage() { +

{board?.title}

+ +
diff --git a/src/pages/board/providers.tsx b/src/pages/board/providers.tsx index 8ac41c2..d8fd789 100644 --- a/src/pages/board/providers.tsx +++ b/src/pages/board/providers.tsx @@ -1,17 +1,19 @@ +import { memo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { listToRecord } from "@/shared/lib/record"; import { usersListQuery } from "@/entities/user"; import { BoardType, boardDepsContext, boardStoreContext, + boardSearchStoreContext, useBoardStoreFactory, + useBoardSearchStoreFactory } from "@/features/dnd-board"; import { updateTaskModalDeps, useUpdateTaskModal, } from "@/features/update-task-modal"; -import { listToRecord } from "@/shared/lib/record"; -import { useQuery } from "@tanstack/react-query"; -import { memo } from "react"; export function TaskEditorProvider({ children, @@ -78,3 +80,18 @@ export const BoardStoreProvider = memo(function BoardStoreProvider({ ); }); + +export const BoardSearchStoreProvider = memo(function BoardStoreProvider({ + children, + query = '', +}: { + children?: React.ReactNode; + query?: string; +}) { + const boardSearchStore = useBoardSearchStoreFactory(query); + return ( + + {children} + + ); +});