diff --git a/apps/backend/src/modules/projects/libs/types/types.ts b/apps/backend/src/modules/projects/libs/types/types.ts index 7024a7b39..06e61326f 100644 --- a/apps/backend/src/modules/projects/libs/types/types.ts +++ b/apps/backend/src/modules/projects/libs/types/types.ts @@ -3,4 +3,6 @@ export { type ProjectGetAllItemResponseDto, type ProjectGetAllRequestDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "@git-fit/shared"; diff --git a/apps/backend/src/modules/projects/libs/validation-schemas/validation-schemas.ts b/apps/backend/src/modules/projects/libs/validation-schemas/validation-schemas.ts index 8ce344c8a..057bd67d5 100644 --- a/apps/backend/src/modules/projects/libs/validation-schemas/validation-schemas.ts +++ b/apps/backend/src/modules/projects/libs/validation-schemas/validation-schemas.ts @@ -1 +1,4 @@ -export { projectCreateValidationSchema } from "@git-fit/shared"; +export { + projectCreateValidationSchema, + projectPatchValidationSchema, +} from "@git-fit/shared"; diff --git a/apps/backend/src/modules/projects/project.controller.ts b/apps/backend/src/modules/projects/project.controller.ts index 04e4ad90e..5bea487c6 100644 --- a/apps/backend/src/modules/projects/project.controller.ts +++ b/apps/backend/src/modules/projects/project.controller.ts @@ -11,8 +11,12 @@ import { ProjectsApiPath } from "./libs/enums/enums.js"; import { type ProjectCreateRequestDto, type ProjectGetAllRequestDto, + type ProjectPatchRequestDto, } from "./libs/types/types.js"; -import { projectCreateValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; +import { + projectCreateValidationSchema, + projectPatchValidationSchema, +} from "./libs/validation-schemas/validation-schemas.js"; import { type ProjectService } from "./project.service.js"; /** @@ -82,6 +86,21 @@ class ProjectController extends BaseController { method: "GET", path: ProjectsApiPath.$ID, }); + + this.addRoute({ + handler: (options) => + this.patch( + options as APIHandlerOptions<{ + body: ProjectPatchRequestDto; + params: { id: string }; + }>, + ), + method: "PATCH", + path: ProjectsApiPath.$ID, + validation: { + body: projectPatchValidationSchema, + }, + }); } /** @@ -189,6 +208,57 @@ class ProjectController extends BaseController { status: HTTPCode.OK, }; } + + /** + * @swagger + * /projects/{id}: + * patch: + * description: Update project info + * parameters: + * - in: path + * name: id + * required: true + * description: The ID of the project to update + * schema: + * type: integer + * requestBody: + * description: Project data + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: object + * $ref: "#/components/schemas/Project" + */ + + private async patch( + options: APIHandlerOptions<{ + body: ProjectPatchRequestDto; + params: { id: string }; + }>, + ): Promise { + const projectId = Number(options.params.id); + + return { + payload: await this.projectService.patch(projectId, options.body), + status: HTTPCode.OK, + }; + } } export { ProjectController }; diff --git a/apps/backend/src/modules/projects/project.repository.ts b/apps/backend/src/modules/projects/project.repository.ts index 23e95a379..344522aa4 100644 --- a/apps/backend/src/modules/projects/project.repository.ts +++ b/apps/backend/src/modules/projects/project.repository.ts @@ -1,6 +1,7 @@ import { SortType } from "~/libs/enums/enums.js"; import { type Repository } from "~/libs/types/types.js"; +import { type ProjectPatchRequestDto } from "./libs/types/types.js"; import { ProjectEntity } from "./project.entity.js"; import { type ProjectModel } from "./project.model.js"; @@ -65,6 +66,19 @@ class ProjectRepository implements Repository { return item ? ProjectEntity.initialize(item) : null; } + public async patch( + id: number, + projectData: ProjectPatchRequestDto, + ): Promise { + const { description, name } = projectData; + + const updatedItem = await this.projectModel + .query() + .patchAndFetchById(id, { description, name }); + + return ProjectEntity.initialize(updatedItem); + } + public update(): ReturnType { return Promise.resolve(null); } diff --git a/apps/backend/src/modules/projects/project.service.ts b/apps/backend/src/modules/projects/project.service.ts index 55b53f44c..bedc30d3d 100644 --- a/apps/backend/src/modules/projects/project.service.ts +++ b/apps/backend/src/modules/projects/project.service.ts @@ -8,6 +8,8 @@ import { type ProjectGetAllItemResponseDto, type ProjectGetAllRequestDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "./libs/types/types.js"; import { ProjectEntity } from "./project.entity.js"; import { type ProjectRepository } from "./project.repository.js"; @@ -77,6 +79,35 @@ class ProjectService implements Service { }; } + public async patch( + id: number, + projectData: ProjectPatchRequestDto, + ): Promise { + const targetProject = await this.projectRepository.find(id); + + if (!targetProject) { + throw new ProjectError({ + message: ExceptionMessage.PROJECT_NOT_FOUND, + status: HTTPCode.NOT_FOUND, + }); + } + + const existingProject = await this.projectRepository.findByName( + projectData.name, + ); + + if (existingProject && existingProject.toObject().id !== id) { + throw new ProjectError({ + message: ExceptionMessage.PROJECT_NAME_USED, + status: HTTPCode.CONFLICT, + }); + } + + const updatedItem = await this.projectRepository.patch(id, projectData); + + return updatedItem.toObject(); + } + public update(): ReturnType { return Promise.resolve(null); } diff --git a/apps/frontend/src/assets/images/icons/ellipsis.svg b/apps/frontend/src/assets/images/icons/ellipsis.svg new file mode 100644 index 000000000..faa361c03 --- /dev/null +++ b/apps/frontend/src/assets/images/icons/ellipsis.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/images/icons/pencil.svg b/apps/frontend/src/assets/images/icons/pencil.svg new file mode 100644 index 000000000..932a0ed3d --- /dev/null +++ b/apps/frontend/src/assets/images/icons/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/libs/components/components.ts b/apps/frontend/src/libs/components/components.ts index 1f5947afe..92800597a 100644 --- a/apps/frontend/src/libs/components/components.ts +++ b/apps/frontend/src/libs/components/components.ts @@ -8,6 +8,8 @@ export { IconButton } from "./icon-button/icon-button.js"; export { Input } from "./input/input.js"; export { Link, NavLink } from "./link/link.js"; export { Loader } from "./loader/loader.js"; +export { Menu } from "./menu/menu.js"; +export { MenuItem } from "./menu-item/menu-item.js"; export { Modal } from "./modal/modal.js"; export { PageLayout } from "./page-layout/page-layout.js"; export { Popover } from "./popover/popover.js"; diff --git a/apps/frontend/src/libs/components/header/header.tsx b/apps/frontend/src/libs/components/header/header.tsx index fa7d9bc94..99656cf06 100644 --- a/apps/frontend/src/libs/components/header/header.tsx +++ b/apps/frontend/src/libs/components/header/header.tsx @@ -1,12 +1,14 @@ import logoSrc from "~/assets/images/logo.svg"; import { Avatar, NavLink } from "~/libs/components/components.js"; import { AppRoute } from "~/libs/enums/enums.js"; -import { useAppSelector } from "~/libs/hooks/hooks.js"; +import { useAppSelector, usePopover } from "~/libs/hooks/hooks.js"; import { UserPopover } from "./libs/components/components.js"; import styles from "./styles.module.css"; const Header = (): JSX.Element => { + const { isOpened, onClose, onOpen } = usePopover(); + const authenticatedUser = useAppSelector( ({ auth }) => auth.authenticatedUser, ); @@ -25,8 +27,18 @@ const Header = (): JSX.Element => { Logo - - + + ); diff --git a/apps/frontend/src/libs/components/header/libs/components/user-popover/user-popover.tsx b/apps/frontend/src/libs/components/header/libs/components/user-popover/user-popover.tsx index cae9d3583..afad31cf4 100644 --- a/apps/frontend/src/libs/components/header/libs/components/user-popover/user-popover.tsx +++ b/apps/frontend/src/libs/components/header/libs/components/user-popover/user-popover.tsx @@ -10,10 +10,18 @@ import styles from "./styles.module.css"; type Properties = { children: React.ReactNode; email: string; + isOpened: boolean; name: string; + onClose: () => void; }; -const UserPopover = ({ children, email, name }: Properties): JSX.Element => { +const UserPopover = ({ + children, + email, + isOpened, + name, + onClose, +}: Properties): JSX.Element => { const dispatch = useAppDispatch(); const handleLogout = useCallback((): void => { @@ -38,6 +46,8 @@ const UserPopover = ({ children, email, name }: Properties): JSX.Element => { } + isOpened={isOpened} + onClose={onClose} > {children} diff --git a/apps/frontend/src/libs/components/header/styles.module.css b/apps/frontend/src/libs/components/header/styles.module.css index 79c27796d..a578ceba3 100644 --- a/apps/frontend/src/libs/components/header/styles.module.css +++ b/apps/frontend/src/libs/components/header/styles.module.css @@ -29,3 +29,13 @@ color: var(--color-text-primary); cursor: pointer; } + +.user-popover-trigger { + padding: 0; + margin: 0; + text-decoration: none; + cursor: pointer; + background: none; + border: none; + border-radius: 50%; +} diff --git a/apps/frontend/src/libs/components/icon-button/icon-button.tsx b/apps/frontend/src/libs/components/icon-button/icon-button.tsx index 98029d9aa..9fe7513af 100644 --- a/apps/frontend/src/libs/components/icon-button/icon-button.tsx +++ b/apps/frontend/src/libs/components/icon-button/icon-button.tsx @@ -1,7 +1,7 @@ import { Icon } from "~/libs/components/components.js"; import { getValidClassNames } from "~/libs/helpers/helpers.js"; +import { type IconName } from "~/libs/types/types.js"; -import { type IconName } from "../icon/libs/types/types.js"; import { ICON_SIZE } from "./libs/constants/constants.js"; import styles from "./styles.module.css"; diff --git a/apps/frontend/src/libs/components/icon/icon.tsx b/apps/frontend/src/libs/components/icon/icon.tsx index 3023b71e7..80e2c8cee 100644 --- a/apps/frontend/src/libs/components/icon/icon.tsx +++ b/apps/frontend/src/libs/components/icon/icon.tsx @@ -1,5 +1,6 @@ +import { type IconName } from "~/libs/types/types.js"; + import { iconNameToSvg } from "./libs/maps/maps.js"; -import { type IconName } from "./libs/types/types.js"; import styles from "./styles.module.css"; type Properties = { diff --git a/apps/frontend/src/libs/components/icon/libs/maps/icon-name-to-svg.map.ts b/apps/frontend/src/libs/components/icon/libs/maps/icon-name-to-svg.map.ts index e7709295e..a9cb5e9a1 100644 --- a/apps/frontend/src/libs/components/icon/libs/maps/icon-name-to-svg.map.ts +++ b/apps/frontend/src/libs/components/icon/libs/maps/icon-name-to-svg.map.ts @@ -4,25 +4,28 @@ import Access from "~/assets/images/icons/access.svg?react"; import Analytics from "~/assets/images/icons/analytics.svg?react"; import Contributors from "~/assets/images/icons/contributors.svg?react"; import Cross from "~/assets/images/icons/cross.svg?react"; +import Ellipsis from "~/assets/images/icons/ellipsis.svg?react"; import Eye from "~/assets/images/icons/eye.svg?react"; import LeftArrow from "~/assets/images/icons/left-arrow.svg?react"; import LeftDoubleArrow from "~/assets/images/icons/left-double-arrow.svg?react"; +import Pencil from "~/assets/images/icons/pencil.svg?react"; import Project from "~/assets/images/icons/project.svg?react"; import RightArrow from "~/assets/images/icons/right-arrow.svg?react"; import RightDoubleArrow from "~/assets/images/icons/right-double-arrow.svg?react"; import Search from "~/assets/images/icons/search.svg?react"; import StrikedEye from "~/assets/images/icons/striked-eye.svg?react"; - -import { type IconName } from "../types/types.js"; +import { type IconName } from "~/libs/types/types.js"; const iconNameToSvg: Record>> = { access: Access, analytics: Analytics, contributors: Contributors, cross: Cross, + ellipsis: Ellipsis, eye: Eye, leftArrow: LeftArrow, leftDoubleArrow: LeftDoubleArrow, + pencil: Pencil, project: Project, rightArrow: RightArrow, rightDoubleArrow: RightDoubleArrow, diff --git a/apps/frontend/src/libs/components/icon/libs/types/types.ts b/apps/frontend/src/libs/components/icon/libs/types/types.ts deleted file mode 100644 index 6b775f93d..000000000 --- a/apps/frontend/src/libs/components/icon/libs/types/types.ts +++ /dev/null @@ -1 +0,0 @@ -export { type IconName } from "./icon-name.type.js"; diff --git a/apps/frontend/src/libs/components/menu-item/menu-item.tsx b/apps/frontend/src/libs/components/menu-item/menu-item.tsx new file mode 100644 index 000000000..12ec8e452 --- /dev/null +++ b/apps/frontend/src/libs/components/menu-item/menu-item.tsx @@ -0,0 +1,38 @@ +import { Icon } from "~/libs/components/components.js"; +import { getValidClassNames } from "~/libs/helpers/helpers.js"; +import { type IconName } from "~/libs/types/types.js"; + +import styles from "./styles.module.css"; + +type Properties = { + iconName: IconName; + label: string; + onClick: () => void; + variant?: "danger" | "primary"; +}; + +const MenuItem = ({ + iconName, + label, + onClick, + variant = "primary", +}: Properties): JSX.Element => { + const buttonClassName = getValidClassNames( + styles["menu-item"], + styles[`menu-item-${variant}`], + ); + + return ( + + ); +}; + +export { MenuItem }; diff --git a/apps/frontend/src/libs/components/menu-item/styles.module.css b/apps/frontend/src/libs/components/menu-item/styles.module.css new file mode 100644 index 000000000..6de07ce1a --- /dev/null +++ b/apps/frontend/src/libs/components/menu-item/styles.module.css @@ -0,0 +1,31 @@ +.menu-item { + display: flex; + gap: 8px; + align-items: center; + padding: 11px 15px; + text-align: left; + cursor: pointer; + background-color: transparent; + border: none; + border-radius: 4px; +} + +.menu-item:hover { + background-color: var(--color-background-hover); +} + +.menu-item-text { + margin: 0; + font-family: Inter, sans-serif; + font-size: 16px; + font-weight: 500; + line-height: 1.2; +} + +.menu-item-primary { + color: var(--color-text-primary); +} + +.menu-item-danger { + color: var(--color-danger); +} diff --git a/apps/frontend/src/libs/components/menu/menu.tsx b/apps/frontend/src/libs/components/menu/menu.tsx new file mode 100644 index 000000000..c75c27ddc --- /dev/null +++ b/apps/frontend/src/libs/components/menu/menu.tsx @@ -0,0 +1,37 @@ +import { IconButton, Popover } from "~/libs/components/components.js"; + +import styles from "./styles.module.css"; + +type Properties = { + children: React.ReactNode; + isOpened: boolean; + onClose: () => void; + onOpen: () => void; +}; + +const Menu = ({ + children, + isOpened, + onClose, + onOpen, +}: Properties): JSX.Element => { + return ( + +
{children}
+ + } + isOpened={isOpened} + onClose={onClose} + > + +
+ ); +}; + +export { Menu }; diff --git a/apps/frontend/src/libs/components/menu/styles.module.css b/apps/frontend/src/libs/components/menu/styles.module.css new file mode 100644 index 000000000..78f336ffe --- /dev/null +++ b/apps/frontend/src/libs/components/menu/styles.module.css @@ -0,0 +1,13 @@ +.menu-options { + min-width: 200px; + color: var(--color-text-primary); + border: 1px solid var(--color-border-secondary); + border-radius: 8px; +} + +.options-content { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px; +} diff --git a/apps/frontend/src/libs/components/modal/modal.tsx b/apps/frontend/src/libs/components/modal/modal.tsx index c3b388e80..523ffc301 100644 --- a/apps/frontend/src/libs/components/modal/modal.tsx +++ b/apps/frontend/src/libs/components/modal/modal.tsx @@ -5,22 +5,22 @@ import styles from "./styles.module.css"; type Properties = { children: React.ReactNode; - isModalOpened: boolean; - onModalClose: () => void; + isOpened: boolean; + onClose: () => void; title: string; }; const Modal = ({ children, - isModalOpened, - onModalClose, + isOpened, + onClose, title, }: Properties): JSX.Element | null => { const dialogReference = useRef(null); - useHandleClickOutside(dialogReference, onModalClose); + useHandleClickOutside(dialogReference, onClose); - if (!isModalOpened) { + if (!isOpened) { return null; } @@ -37,11 +37,7 @@ const Modal = ({

{title}

- +
{children}
diff --git a/apps/frontend/src/libs/components/popover/popover.tsx b/apps/frontend/src/libs/components/popover/popover.tsx index ce613beaf..bc018ff0b 100644 --- a/apps/frontend/src/libs/components/popover/popover.tsx +++ b/apps/frontend/src/libs/components/popover/popover.tsx @@ -1,36 +1,27 @@ -import { - useCallback, - useHandleClickOutside, - useRef, - useState, -} from "~/libs/hooks/hooks.js"; +import { useHandleClickOutside, useRef } from "~/libs/hooks/hooks.js"; import styles from "./styles.module.css"; type Properties = { children: React.ReactNode; content: React.ReactNode; + isOpened: boolean; + onClose: () => void; }; -const Popover = ({ children, content }: Properties): JSX.Element => { - const [isOpened, setIsOpened] = useState(false); +const Popover = ({ + children, + content, + isOpened, + onClose, +}: Properties): JSX.Element => { const popoverReference = useRef(null); - const handleToggle = useCallback((): void => { - setIsOpened((previousState) => !previousState); - }, []); - - const handleClose = (): void => { - setIsOpened(false); - }; - - useHandleClickOutside(popoverReference, handleClose); + useHandleClickOutside(popoverReference, onClose); return (
- + {children} {isOpened && (
{content}
)} diff --git a/apps/frontend/src/libs/enums/notification-message.enum.ts b/apps/frontend/src/libs/enums/notification-message.enum.ts index d54486c2b..78ee18bfb 100644 --- a/apps/frontend/src/libs/enums/notification-message.enum.ts +++ b/apps/frontend/src/libs/enums/notification-message.enum.ts @@ -1,5 +1,6 @@ const NotificationMessage = { PROJECT_CREATE_SUCCESS: "Project was successfully created", + PROJECT_UPDATE_SUCCESS: "Project was successfully updated.", SUCCESS_PROFILE_UPDATE: "Successfully updated profile information.", } as const; diff --git a/apps/frontend/src/libs/hooks/hooks.ts b/apps/frontend/src/libs/hooks/hooks.ts index 80598cd58..0ce6ccb01 100644 --- a/apps/frontend/src/libs/hooks/hooks.ts +++ b/apps/frontend/src/libs/hooks/hooks.ts @@ -4,6 +4,7 @@ export { useAppSelector } from "./use-app-selector/use-app-selector.hook.js"; export { useHandleClickOutside } from "./use-handle-click-outside/use-handle-click-outside.hook.js"; export { useModal } from "./use-modal-state/use-modal-state.hook.js"; export { usePagination } from "./use-pagination/use-pagination.hook.js"; +export { usePopover } from "./use-popover-state/use-popover-state.hook.js"; export { useCallback, useEffect, useMemo, useRef, useState } from "react"; export { useController as useFormController, diff --git a/apps/frontend/src/libs/hooks/use-modal-state/use-modal-state.hook.ts b/apps/frontend/src/libs/hooks/use-modal-state/use-modal-state.hook.ts index eb19976a6..c9f39a350 100644 --- a/apps/frontend/src/libs/hooks/use-modal-state/use-modal-state.hook.ts +++ b/apps/frontend/src/libs/hooks/use-modal-state/use-modal-state.hook.ts @@ -1,26 +1,26 @@ import { useCallback, useState } from "~/libs/hooks/hooks.js"; type Properties = { - isModalOpened: boolean; - onModalClose: () => void; - onModalOpen: () => void; + isOpened: boolean; + onClose: () => void; + onOpen: () => void; }; const useModal = (): Properties => { - const [isModalOpened, setIsModalOpened] = useState(false); + const [isOpened, setIsOpened] = useState(false); const handleModalOpen = useCallback(() => { - setIsModalOpened(true); + setIsOpened(true); }, []); const handleModalClose = useCallback(() => { - setIsModalOpened(false); + setIsOpened(false); }, []); return { - isModalOpened, - onModalClose: handleModalClose, - onModalOpen: handleModalOpen, + isOpened, + onClose: handleModalClose, + onOpen: handleModalOpen, }; }; diff --git a/apps/frontend/src/libs/hooks/use-popover-state/use-popover-state.hook.ts b/apps/frontend/src/libs/hooks/use-popover-state/use-popover-state.hook.ts new file mode 100644 index 000000000..a39ac1302 --- /dev/null +++ b/apps/frontend/src/libs/hooks/use-popover-state/use-popover-state.hook.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from "~/libs/hooks/hooks.js"; + +type Properties = { + isOpened: boolean; + onClose: () => void; + onOpen: () => void; +}; + +const usePopover = (): Properties => { + const [isOpened, setIsOpened] = useState(false); + + const handlePopoverOpen = useCallback(() => { + setIsOpened(true); + }, []); + + const handlePopoverClose = useCallback(() => { + setIsOpened(false); + }, []); + + return { + isOpened, + onClose: handlePopoverClose, + onOpen: handlePopoverOpen, + }; +}; + +export { usePopover }; diff --git a/apps/frontend/src/libs/components/icon/libs/types/icon-name.type.ts b/apps/frontend/src/libs/types/icon-name.type.ts similarity index 89% rename from apps/frontend/src/libs/components/icon/libs/types/icon-name.type.ts rename to apps/frontend/src/libs/types/icon-name.type.ts index cf0e88f05..0733bce58 100644 --- a/apps/frontend/src/libs/components/icon/libs/types/icon-name.type.ts +++ b/apps/frontend/src/libs/types/icon-name.type.ts @@ -3,9 +3,11 @@ type IconName = | "analytics" | "contributors" | "cross" + | "ellipsis" | "eye" | "leftArrow" | "leftDoubleArrow" + | "pencil" | "project" | "rightArrow" | "rightDoubleArrow" diff --git a/apps/frontend/src/libs/types/navigation-item.type.ts b/apps/frontend/src/libs/types/navigation-item.type.ts index bf1bdb331..8b2b3752e 100644 --- a/apps/frontend/src/libs/types/navigation-item.type.ts +++ b/apps/frontend/src/libs/types/navigation-item.type.ts @@ -1,6 +1,5 @@ -import { type IconName } from "~/libs/components/icon/libs/types/types.js"; import { type AppRoute } from "~/libs/enums/app-route.enum.js"; -import { type ValueOf } from "~/libs/types/types.js"; +import { type IconName, type ValueOf } from "~/libs/types/types.js"; type NavigationItem = { href: ValueOf; diff --git a/apps/frontend/src/libs/types/types.ts b/apps/frontend/src/libs/types/types.ts index f6d3fc8ab..8960bbfc4 100644 --- a/apps/frontend/src/libs/types/types.ts +++ b/apps/frontend/src/libs/types/types.ts @@ -1,4 +1,5 @@ export { type AsyncThunkConfig } from "./async-thunk-config.type.js"; +export { type IconName } from "./icon-name.type.js"; export { type NavigationItem } from "./navigation-item.type.js"; export { type SelectOption } from "./select-option.type.js"; export { diff --git a/apps/frontend/src/modules/projects/libs/types/types.ts b/apps/frontend/src/modules/projects/libs/types/types.ts index 7024a7b39..06e61326f 100644 --- a/apps/frontend/src/modules/projects/libs/types/types.ts +++ b/apps/frontend/src/modules/projects/libs/types/types.ts @@ -3,4 +3,6 @@ export { type ProjectGetAllItemResponseDto, type ProjectGetAllRequestDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "@git-fit/shared"; diff --git a/apps/frontend/src/modules/projects/libs/validation-schemas/validation-schemas.ts b/apps/frontend/src/modules/projects/libs/validation-schemas/validation-schemas.ts index 8ce344c8a..057bd67d5 100644 --- a/apps/frontend/src/modules/projects/libs/validation-schemas/validation-schemas.ts +++ b/apps/frontend/src/modules/projects/libs/validation-schemas/validation-schemas.ts @@ -1 +1,4 @@ -export { projectCreateValidationSchema } from "@git-fit/shared"; +export { + projectCreateValidationSchema, + projectPatchValidationSchema, +} from "@git-fit/shared"; diff --git a/apps/frontend/src/modules/projects/projects-api.ts b/apps/frontend/src/modules/projects/projects-api.ts index 535209748..be580a435 100644 --- a/apps/frontend/src/modules/projects/projects-api.ts +++ b/apps/frontend/src/modules/projects/projects-api.ts @@ -8,6 +8,8 @@ import { type ProjectCreateRequestDto, type ProjectGetAllItemResponseDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "./libs/types/types.js"; type Constructor = { @@ -65,6 +67,23 @@ class ProjectApi extends BaseHTTPApi { return await response.json(); } + + public async patch( + id: number, + payload: ProjectPatchRequestDto, + ): Promise { + const response = await this.load( + this.getFullEndpoint(ProjectsApiPath.$ID, { id: String(id) }), + { + contentType: ContentType.JSON, + hasAuth: true, + method: "PATCH", + payload: JSON.stringify(payload), + }, + ); + + return await response.json(); + } } export { ProjectApi }; diff --git a/apps/frontend/src/modules/projects/projects.ts b/apps/frontend/src/modules/projects/projects.ts index 62ff26316..bb3bdfbfc 100644 --- a/apps/frontend/src/modules/projects/projects.ts +++ b/apps/frontend/src/modules/projects/projects.ts @@ -15,6 +15,11 @@ export { type ProjectCreateRequestDto, type ProjectGetAllItemResponseDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "./libs/types/types.js"; -export { projectCreateValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; +export { + projectCreateValidationSchema, + projectPatchValidationSchema, +} from "./libs/validation-schemas/validation-schemas.js"; export { actions, reducer } from "./slices/projects.js"; diff --git a/apps/frontend/src/modules/projects/slices/actions.ts b/apps/frontend/src/modules/projects/slices/actions.ts index 8dd09f9a3..f7145d798 100644 --- a/apps/frontend/src/modules/projects/slices/actions.ts +++ b/apps/frontend/src/modules/projects/slices/actions.ts @@ -6,6 +6,8 @@ import { type ProjectCreateRequestDto, type ProjectGetAllItemResponseDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "~/modules/projects/projects.js"; import { name as sliceName } from "./project.slice.js"; @@ -44,4 +46,18 @@ const create = createAsyncThunk< return response; }); -export { create, getById, loadAll }; +const patch = createAsyncThunk< + ProjectPatchResponseDto, + { id: number; payload: ProjectPatchRequestDto }, + AsyncThunkConfig +>(`${sliceName}/update`, async ({ id, payload }, { extra }) => { + const { projectApi, toastNotifier } = extra; + + const updatedProject = await projectApi.patch(id, payload); + + toastNotifier.showSuccess(NotificationMessage.PROJECT_UPDATE_SUCCESS); + + return updatedProject; +}); + +export { create, getById, loadAll, patch }; diff --git a/apps/frontend/src/modules/projects/slices/project.slice.ts b/apps/frontend/src/modules/projects/slices/project.slice.ts index eb4e75e91..63ff3090e 100644 --- a/apps/frontend/src/modules/projects/slices/project.slice.ts +++ b/apps/frontend/src/modules/projects/slices/project.slice.ts @@ -4,12 +4,13 @@ import { DataStatus } from "~/libs/enums/enums.js"; import { type ValueOf } from "~/libs/types/types.js"; import { type ProjectGetAllItemResponseDto } from "~/modules/projects/projects.js"; -import { create, getById, loadAll } from "./actions.js"; +import { create, getById, loadAll, patch } from "./actions.js"; type State = { dataStatus: ValueOf; project: null | ProjectGetAllItemResponseDto; projectCreateStatus: ValueOf; + projectPatchStatus: ValueOf; projects: ProjectGetAllItemResponseDto[]; projectStatus: ValueOf; }; @@ -18,6 +19,7 @@ const initialState: State = { dataStatus: DataStatus.IDLE, project: null, projectCreateStatus: DataStatus.IDLE, + projectPatchStatus: DataStatus.IDLE, projects: [], projectStatus: DataStatus.IDLE, }; @@ -56,6 +58,20 @@ const { actions, name, reducer } = createSlice({ builder.addCase(create.rejected, (state) => { state.projectCreateStatus = DataStatus.REJECTED; }); + builder.addCase(patch.pending, (state) => { + state.projectPatchStatus = DataStatus.PENDING; + }); + builder.addCase(patch.fulfilled, (state, action) => { + const updatedProject = action.payload; + state.projects = state.projects.map((project) => + project.id === updatedProject.id ? updatedProject : project, + ); + + state.projectPatchStatus = DataStatus.FULFILLED; + }); + builder.addCase(patch.rejected, (state) => { + state.projectPatchStatus = DataStatus.REJECTED; + }); }, initialState, name: "projects", diff --git a/apps/frontend/src/modules/projects/slices/projects.ts b/apps/frontend/src/modules/projects/slices/projects.ts index 36d354749..d667bfaa3 100644 --- a/apps/frontend/src/modules/projects/slices/projects.ts +++ b/apps/frontend/src/modules/projects/slices/projects.ts @@ -1,4 +1,4 @@ -import { create, getById, loadAll } from "./actions.js"; +import { create, getById, loadAll, patch } from "./actions.js"; import { actions } from "./project.slice.js"; const allActions = { @@ -6,6 +6,7 @@ const allActions = { create, getById, loadAll, + patch, }; export { allActions as actions }; diff --git a/apps/frontend/src/pages/projects/components/components.ts b/apps/frontend/src/pages/projects/components/components.ts index 09c2d3842..adbd33d35 100644 --- a/apps/frontend/src/pages/projects/components/components.ts +++ b/apps/frontend/src/pages/projects/components/components.ts @@ -1,3 +1,5 @@ export { ProjectCard } from "./project-card/project-card.js"; export { ProjectCreateForm } from "./project-create-form/project-create-form.js"; +export { ProjectMenu } from "./project-menu/project-menu.js"; export { ProjectsSearch } from "./project-search/project-search.js"; +export { ProjectUpdateForm } from "./project-update-form/project-update-form.js"; diff --git a/apps/frontend/src/pages/projects/components/project-card/project-card.tsx b/apps/frontend/src/pages/projects/components/project-card/project-card.tsx index 0000f6255..be1d0b717 100644 --- a/apps/frontend/src/pages/projects/components/project-card/project-card.tsx +++ b/apps/frontend/src/pages/projects/components/project-card/project-card.tsx @@ -2,23 +2,33 @@ import { NavLink } from "react-router-dom"; import { AppRoute } from "~/libs/enums/enums.js"; import { configureString } from "~/libs/helpers/helpers.js"; +import { useCallback } from "~/libs/hooks/hooks.js"; import { type ProjectGetAllItemResponseDto } from "~/modules/projects/projects.js"; +import { ProjectMenu } from "../components.js"; import styles from "./styles.module.css"; type Properties = { + onEdit: (project: ProjectGetAllItemResponseDto) => void; project: ProjectGetAllItemResponseDto; }; -const ProjectCard = ({ project }: Properties): JSX.Element => { +const ProjectCard = ({ onEdit, project }: Properties): JSX.Element => { const projectRoute = configureString(AppRoute.PROJECT, { id: project.id.toString(), }); + const handleEditClick = useCallback(() => { + onEdit(project); + }, [onEdit, project]); + return ( - - {project.name} - +
+ + {project.name} + + +
); }; diff --git a/apps/frontend/src/pages/projects/components/project-card/styles.module.css b/apps/frontend/src/pages/projects/components/project-card/styles.module.css index 33a383ca3..bf798609a 100644 --- a/apps/frontend/src/pages/projects/components/project-card/styles.module.css +++ b/apps/frontend/src/pages/projects/components/project-card/styles.module.css @@ -1,12 +1,27 @@ +.project-container { + position: relative; + display: flex; + align-items: center; + justify-content: end; + min-height: 81px; + padding: 16px 24px; + background-color: var(--color-background-secondary); + border: 1px solid var(--color-border-secondary); + border-radius: 8px; +} + .project { + position: absolute; + top: 0; + left: 0; display: flex; align-items: center; - height: 81px; - padding: 0 24px; + justify-content: start; + width: 100%; + height: 100%; + padding: 16px 24px; font-size: 16px; color: var(--color-text-primary); text-decoration: none; - background-color: var(--color-background-secondary); - border: 1px solid var(--color-border-secondary); - border-radius: 8px; + background: transparent; } diff --git a/apps/frontend/src/pages/projects/components/project-menu/project-menu.tsx b/apps/frontend/src/pages/projects/components/project-menu/project-menu.tsx new file mode 100644 index 000000000..caf5a1759 --- /dev/null +++ b/apps/frontend/src/pages/projects/components/project-menu/project-menu.tsx @@ -0,0 +1,23 @@ +import { Menu, MenuItem } from "~/libs/components/components.js"; +import { useCallback, usePopover } from "~/libs/hooks/hooks.js"; + +type Properties = { + onEdit: () => void; +}; + +const ProjectMenu = ({ onEdit }: Properties): JSX.Element => { + const { isOpened, onClose, onOpen } = usePopover(); + + const handleEditClick = useCallback(() => { + onEdit(); + onClose(); + }, [onEdit, onClose]); + + return ( + + + + ); +}; + +export { ProjectMenu }; diff --git a/apps/frontend/src/pages/projects/components/project-update-form/project-update-form.tsx b/apps/frontend/src/pages/projects/components/project-update-form/project-update-form.tsx new file mode 100644 index 000000000..afcc73991 --- /dev/null +++ b/apps/frontend/src/pages/projects/components/project-update-form/project-update-form.tsx @@ -0,0 +1,56 @@ +import { Button, Input } from "~/libs/components/components.js"; +import { useAppForm, useCallback } from "~/libs/hooks/hooks.js"; +import { + type ProjectGetAllItemResponseDto, + type ProjectPatchRequestDto, + projectPatchValidationSchema, +} from "~/modules/projects/projects.js"; + +import styles from "./styles.module.css"; + +type Properties = { + onSubmit: (payload: ProjectPatchRequestDto) => void; + project: ProjectGetAllItemResponseDto; +}; + +const ProjectUpdateForm = ({ onSubmit, project }: Properties): JSX.Element => { + const { description, name } = project; + + const { control, errors, handleSubmit } = useAppForm({ + defaultValues: { description, name }, + validationSchema: projectPatchValidationSchema, + }); + + const handleFormSubmit = useCallback( + (event_: React.BaseSyntheticEvent): void => { + void handleSubmit((formData: ProjectPatchRequestDto) => { + onSubmit(formData); + })(event_); + }, + [handleSubmit, onSubmit], + ); + + return ( +
+ + +
+
+
+ ); +}; + +export { ProjectUpdateForm }; diff --git a/apps/frontend/src/pages/projects/components/project-update-form/styles.module.css b/apps/frontend/src/pages/projects/components/project-update-form/styles.module.css new file mode 100644 index 000000000..c4a040516 --- /dev/null +++ b/apps/frontend/src/pages/projects/components/project-update-form/styles.module.css @@ -0,0 +1,12 @@ +.form-wrapper { + display: flex; + flex-direction: column; + gap: 16px; + align-items: flex-end; +} + +.button-wrapper { + display: flex; + flex-direction: column; + min-width: 150px; +} diff --git a/apps/frontend/src/pages/projects/projects.tsx b/apps/frontend/src/pages/projects/projects.tsx index 66ed9ec1c..857c744dc 100644 --- a/apps/frontend/src/pages/projects/projects.tsx +++ b/apps/frontend/src/pages/projects/projects.tsx @@ -12,25 +12,31 @@ import { useCallback, useEffect, useModal, + useState, } from "~/libs/hooks/hooks.js"; import { actions as projectActions, type ProjectCreateRequestDto, + type ProjectGetAllItemResponseDto, + type ProjectPatchRequestDto, } from "~/modules/projects/projects.js"; import { ProjectCard, ProjectCreateForm, ProjectsSearch, + ProjectUpdateForm, } from "./components/components.js"; import styles from "./styles.module.css"; const Projects = (): JSX.Element => { const dispatch = useAppDispatch(); - const { dataStatus, projectCreateStatus, projects } = useAppSelector( - ({ projects }) => projects, - ); + const [selectedProject, setSelectedProject] = + useState(null); + + const { dataStatus, projectCreateStatus, projectPatchStatus, projects } = + useAppSelector(({ projects }) => projects); useEffect(() => { void dispatch(projectActions.loadAll()); @@ -45,13 +51,36 @@ const Projects = (): JSX.Element => { const hasProject = projects.length === EMPTY_LENGTH; - const { isModalOpened, onModalClose, onModalOpen } = useModal(); + const { + isOpened: isCreateModalOpen, + onClose: handleCreateModalClose, + onOpen: handleCreateModalOpen, + } = useModal(); + const { + isOpened: isEditModalOpen, + onClose: handleEditModalClose, + onOpen: handleEditModalOpen, + } = useModal(); useEffect(() => { if (projectCreateStatus === DataStatus.FULFILLED) { - onModalClose(); + handleCreateModalClose(); + } + }, [projectCreateStatus, handleCreateModalClose]); + + useEffect(() => { + if (projectPatchStatus === DataStatus.FULFILLED) { + handleEditModalClose(); } - }, [projectCreateStatus, onModalClose]); + }, [projectPatchStatus, handleEditModalClose]); + + const handleEditClick = useCallback( + (project: ProjectGetAllItemResponseDto) => { + setSelectedProject(project); + handleEditModalOpen(); + }, + [handleEditModalOpen], + ); const handleProjectCreateSubmit = useCallback( (payload: ProjectCreateRequestDto) => { @@ -60,6 +89,17 @@ const Projects = (): JSX.Element => { [dispatch], ); + const handleProjectEditSubmit = useCallback( + (payload: ProjectPatchRequestDto) => { + if (selectedProject) { + void dispatch( + projectActions.patch({ id: selectedProject.id, payload }), + ); + } + }, + [dispatch, selectedProject], + ); + const isLoading = dataStatus === DataStatus.IDLE || (dataStatus === DataStatus.PENDING && hasProject); @@ -68,7 +108,7 @@ const Projects = (): JSX.Element => {

Projects

-
@@ -76,17 +116,33 @@ const Projects = (): JSX.Element => { ) : ( projects.map((project) => ( - + )) )}
+ + {selectedProject && ( + + )} +
); }; diff --git a/package-lock.json b/package-lock.json index 39d705d85..a6246920e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,6 @@ "apps/*", "packages/*" ], - "dependencies": { - "debounce": "2.1.0" - }, "devDependencies": { "@commitlint/cli": "19.4.0", "@commitlint/config-conventional": "19.2.2", @@ -55,7 +52,7 @@ }, "apps/backend": { "name": "@git-fit/backend", - "version": "1.0.1", + "version": "1.4.0", "dependencies": { "@fastify/static": "7.0.4", "@fastify/swagger": "8.15.0", @@ -133,7 +130,7 @@ }, "apps/frontend": { "name": "@git-fit/frontend", - "version": "1.2.0", + "version": "1.6.1", "dependencies": { "@git-fit/shared": "*", "@hookform/resolvers": "3.9.0", @@ -13301,10 +13298,11 @@ }, "packages/shared": { "name": "@git-fit/shared", - "version": "1.0.0", + "version": "1.4.0", "dependencies": { "change-case": "5.4.4", "date-fns": "3.6.0", + "debounce": "2.1.0", "zod": "3.23.8" }, "engines": { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1b7f8459a..6a1818dd3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -61,6 +61,9 @@ export { type ProjectGetAllItemResponseDto, type ProjectGetAllRequestDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, + projectPatchValidationSchema, ProjectsApiPath, } from "./modules/projects/projects.js"; export { diff --git a/packages/shared/src/modules/projects/libs/types/project-patch-request-dto.type.ts b/packages/shared/src/modules/projects/libs/types/project-patch-request-dto.type.ts new file mode 100644 index 000000000..0b379e28f --- /dev/null +++ b/packages/shared/src/modules/projects/libs/types/project-patch-request-dto.type.ts @@ -0,0 +1,6 @@ +type ProjectPatchRequestDto = { + description: string; + name: string; +}; + +export { type ProjectPatchRequestDto }; diff --git a/packages/shared/src/modules/projects/libs/types/project-patch-response-dto.type.ts b/packages/shared/src/modules/projects/libs/types/project-patch-response-dto.type.ts new file mode 100644 index 000000000..8fa29ab23 --- /dev/null +++ b/packages/shared/src/modules/projects/libs/types/project-patch-response-dto.type.ts @@ -0,0 +1,7 @@ +type ProjectPatchResponseDto = { + description: string; + id: number; + name: string; +}; + +export { type ProjectPatchResponseDto }; diff --git a/packages/shared/src/modules/projects/libs/types/types.ts b/packages/shared/src/modules/projects/libs/types/types.ts index 6b710df8b..42c1c82b6 100644 --- a/packages/shared/src/modules/projects/libs/types/types.ts +++ b/packages/shared/src/modules/projects/libs/types/types.ts @@ -2,3 +2,5 @@ export { type ProjectCreateRequestDto } from "./project-create-request-dto.type. export { type ProjectGetAllItemResponseDto } from "./project-get-all-item-response-dto.type.js"; export { type ProjectGetAllRequestDto } from "./project-get-all-request-dto.type.js"; export { type ProjectGetAllResponseDto } from "./project-get-all-response-dto.type.js"; +export { type ProjectPatchRequestDto } from "./project-patch-request-dto.type.js"; +export { type ProjectPatchResponseDto } from "./project-patch-response-dto.type.js"; diff --git a/packages/shared/src/modules/projects/libs/validation-schemas/project-patch.validation-schema.ts b/packages/shared/src/modules/projects/libs/validation-schemas/project-patch.validation-schema.ts new file mode 100644 index 000000000..fe39241aa --- /dev/null +++ b/packages/shared/src/modules/projects/libs/validation-schemas/project-patch.validation-schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { + ProjectValidationMessage, + ProjectValidationRule, +} from "../enums/enums.js"; +import { type ProjectPatchRequestDto } from "../types/types.js"; + +const projectPatch: z.ZodType = z + .object({ + description: z + .string() + .trim() + .max(ProjectValidationRule.DESCRIPTION_MAXIMUM_LENGTH, { + message: ProjectValidationMessage.DESCRIPTION_TOO_LONG, + }), + name: z + .string() + .trim() + .min(ProjectValidationRule.NAME_MINIMUM_LENGTH, { + message: ProjectValidationMessage.NAME_REQUIRED, + }) + .max(ProjectValidationRule.NAME_MAXIMUM_LENGTH, { + message: ProjectValidationMessage.NAME_TOO_LONG, + }), + }) + .required(); + +export { projectPatch }; diff --git a/packages/shared/src/modules/projects/libs/validation-schemas/validation-schemas.ts b/packages/shared/src/modules/projects/libs/validation-schemas/validation-schemas.ts index d6bd08d7c..726f8030d 100644 --- a/packages/shared/src/modules/projects/libs/validation-schemas/validation-schemas.ts +++ b/packages/shared/src/modules/projects/libs/validation-schemas/validation-schemas.ts @@ -1 +1,2 @@ export { projectCreate } from "./project-create.validation-schema.js"; +export { projectPatch } from "./project-patch.validation-schema.js"; diff --git a/packages/shared/src/modules/projects/projects.ts b/packages/shared/src/modules/projects/projects.ts index a27c11293..2629e1e47 100644 --- a/packages/shared/src/modules/projects/projects.ts +++ b/packages/shared/src/modules/projects/projects.ts @@ -5,5 +5,10 @@ export { type ProjectGetAllItemResponseDto, type ProjectGetAllRequestDto, type ProjectGetAllResponseDto, + type ProjectPatchRequestDto, + type ProjectPatchResponseDto, } from "./libs/types/types.js"; -export { projectCreate as projectCreateValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; +export { + projectCreate as projectCreateValidationSchema, + projectPatch as projectPatchValidationSchema, +} from "./libs/validation-schemas/validation-schemas.js";