diff --git a/frontend/src/AppRouter.tsx b/frontend/src/AppRouter.tsx index 21f66d2..57fa031 100644 --- a/frontend/src/AppRouter.tsx +++ b/frontend/src/AppRouter.tsx @@ -22,6 +22,7 @@ import UnfinishedStoryPage from "./pages/backlog/UnfinishedStoryPage"; import BacklogPage from "./pages/backlog/BacklogPage"; import FinishedStoryPage from "./pages/backlog/FinishedStoryPage"; import EpicPage from "./pages/backlog/EpicPage"; +import SettingPage from "./pages/setting/SettingPage"; type RouteType = "PRIVATE" | "PUBLIC"; @@ -96,7 +97,7 @@ const router = createBrowserRouter([ element: , }, { path: ROUTER_URL.SPRINT, element:
sprint Page
}, - { path: ROUTER_URL.SETTINGS, element:
setting Page
}, + { path: ROUTER_URL.SETTINGS, element: }, ], }, ]), diff --git a/frontend/src/components/backlog/BacklogHeader.tsx b/frontend/src/components/backlog/BacklogHeader.tsx index 8cab6d7..12deb19 100644 --- a/frontend/src/components/backlog/BacklogHeader.tsx +++ b/frontend/src/components/backlog/BacklogHeader.tsx @@ -14,7 +14,9 @@ const BacklogHeader = () => { return (
-

{TAB_TITLE[lastPath]} 백로그

+

+ {TAB_TITLE[lastPath]} 백로그 +

우선순위 내림차순

diff --git a/frontend/src/components/landing/member/LandingMember.tsx b/frontend/src/components/landing/member/LandingMember.tsx index b26ed7a..4dd8245 100644 --- a/frontend/src/components/landing/member/LandingMember.tsx +++ b/frontend/src/components/landing/member/LandingMember.tsx @@ -94,12 +94,20 @@ const LandingMember = ({ projectTitle }: LandingMemberProps) => { />

| 함께하는 사람들

- + {myInfo.role === "LEADER" && ( +
+ + | + +
+ )}
{memberList.map((memberData: LandingMemberDTO) => ( diff --git a/frontend/src/components/setting/InformationInput.tsx b/frontend/src/components/setting/InformationInput.tsx new file mode 100644 index 0000000..13efe75 --- /dev/null +++ b/frontend/src/components/setting/InformationInput.tsx @@ -0,0 +1,39 @@ +import { ChangeEvent } from "react"; + +interface InformationInputProps { + label: string; + inputId: string; + inputValue: string; + errorMessage: string; + onChange: (event: ChangeEvent) => void; +} + +const InformationInput = ({ + label, + inputId, + inputValue, + errorMessage, + onChange, +}: InformationInputProps) => ( +
+ +
+ +

{errorMessage}

+
+
+); + +export default InformationInput; diff --git a/frontend/src/components/setting/InformationSettingSection.tsx b/frontend/src/components/setting/InformationSettingSection.tsx new file mode 100644 index 0000000..551ea9e --- /dev/null +++ b/frontend/src/components/setting/InformationSettingSection.tsx @@ -0,0 +1,116 @@ +import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import InformationInput from "./InformationInput"; +import { useOutletContext } from "react-router-dom"; +import { Socket } from "socket.io-client"; +import useSettingProjectInfoSocket from "../../hooks/pages/setting/useSettingProjectInfoSocket"; + +interface InformationSettingSectionProps { + title: string; + subject: string; +} + +const InformationSettingSection = ({ + title, + subject, +}: InformationSettingSectionProps) => { + const { socket }: { socket: Socket } = useOutletContext(); + const { emitProjectInfoUpdateEvent } = useSettingProjectInfoSocket(socket); + const [titleValue, setTitleValue] = useState(title); + const [titleErrorMessage, setTitleErrorMessage] = useState(""); + const [subjectValue, setSubjectValue] = useState(subject); + const [subjectErrorMessage, setSubjectErrorMessage] = useState(""); + const submitActivated = useMemo( + () => + !( + !titleValue || + !subjectValue || + (titleValue === title && subjectValue === subject) + ), + [title, subject, titleValue, subjectValue] + ); + + const handleTitleChange = (event: ChangeEvent) => { + const { value } = event.target; + + setTitleValue(value); + + if (!value.trim()) { + setTitleErrorMessage("프로젝트 이름을 입력해주세요."); + return; + } + + if (value.length > 255) { + setTitleErrorMessage("프로젝트 이름이 너무 깁니다."); + return; + } + + setTitleErrorMessage(""); + }; + + const handleSubjectChange = (event: ChangeEvent) => { + const { value } = event.target; + + setSubjectValue(value); + + if (!value.trim()) { + setSubjectErrorMessage("프로젝트 주제를 입력해주세요."); + return; + } + + if (value.length > 255) { + setSubjectErrorMessage("프로젝트 주제가 너무 깁니다."); + return; + } + + setSubjectErrorMessage(""); + }; + + const handleSubmit = () => { + emitProjectInfoUpdateEvent({ title: titleValue, subject: subjectValue }); + }; + + useEffect(() => { + setTitleValue(title); + setSubjectValue(subject); + }, [title, subject]); + + return ( + <> +
+

프로젝트 설정

+
+
+ + +
+ {!submitActivated && ( +
+ )} + + +
+
+ + ); +}; + +export default InformationSettingSection; diff --git a/frontend/src/components/setting/MemberBlock.tsx b/frontend/src/components/setting/MemberBlock.tsx new file mode 100644 index 0000000..e4c319f --- /dev/null +++ b/frontend/src/components/setting/MemberBlock.tsx @@ -0,0 +1,32 @@ +import useMemberStore from "../../stores/useMemberStore"; +import { LandingMemberDTO } from "../../types/DTO/landingDTO"; + +interface MemberBlockProps extends LandingMemberDTO {} + +const MemberBlock = ({ username, imageUrl, role }: MemberBlockProps) => { + const myRole = useMemberStore((state) => state.myInfo.role); + + return ( +
+
+ {username} +

{username}

+
+
+

{role}

+
+
+ {myRole === "LEADER" && ( + + )} +
+
+ ); +}; + +export default MemberBlock; diff --git a/frontend/src/components/setting/MemberSettingSection.tsx b/frontend/src/components/setting/MemberSettingSection.tsx new file mode 100644 index 0000000..82bdfaa --- /dev/null +++ b/frontend/src/components/setting/MemberSettingSection.tsx @@ -0,0 +1,26 @@ +import { LandingMemberDTO } from "../../types/DTO/landingDTO"; +import MemberBlock from "./MemberBlock"; + +interface MemberSettingSectionProps { + memberList: LandingMemberDTO[]; +} + +const MemberSettingSection = ({ memberList }: MemberSettingSectionProps) => ( +
+
+

멤버 관리

+
+
+
+

닉네임

+

역할

+

작업

+
+
+ {...memberList.map((member) => )} +
+
+
+); + +export default MemberSettingSection; diff --git a/frontend/src/components/setting/ProjectDeleteModal.tsx b/frontend/src/components/setting/ProjectDeleteModal.tsx new file mode 100644 index 0000000..1cb8ac0 --- /dev/null +++ b/frontend/src/components/setting/ProjectDeleteModal.tsx @@ -0,0 +1,97 @@ +import { ChangeEvent, MouseEventHandler, useState } from "react"; +import useSettingProjectSocket from "../../hooks/pages/setting/useSettingProjectSocket"; +import Closed from "../../assets/icons/closed.svg?react"; +import { Socket } from "socket.io-client"; + +interface ProjectDeleteModalProps { + projectTitle: string; + socket: Socket; + close: () => void; +} + +const ProjectDeleteModal = ({ + projectTitle, + socket, + close, +}: ProjectDeleteModalProps) => { + const [inputValue, setInputValue] = useState(""); + const [confirmed, setConfirmed] = useState(false); + + const { emitProjectDeleteEvent } = useSettingProjectSocket(socket); + + const handleInputChange = (event: ChangeEvent) => { + const { value } = event.target; + + setInputValue(value); + + if (value === projectTitle) { + setConfirmed(true); + } else { + setConfirmed(false); + } + }; + + const handleCloseClick: MouseEventHandler< + HTMLButtonElement | HTMLDivElement + > = ({ target, currentTarget }: React.MouseEvent) => { + if (target !== currentTarget) { + return; + } + close(); + }; + + const handleDeleteButtonClick = () => { + if (confirmed) { + emitProjectDeleteEvent(); + } + }; + + return ( +
+
+
+

|프로젝트 삭제

+ +
+

프로젝트 삭제 후 되돌릴 수 없습니다.

+
+

+ 삭제하시려면 "{projectTitle}"을(를) 입력해주세요. +

+ +
+ {!confirmed && ( +
+ )} + +
+
+
+
+ ); +}; + +export default ProjectDeleteModal; diff --git a/frontend/src/components/setting/ProjectDeleteSection.tsx b/frontend/src/components/setting/ProjectDeleteSection.tsx new file mode 100644 index 0000000..1bfcfaf --- /dev/null +++ b/frontend/src/components/setting/ProjectDeleteSection.tsx @@ -0,0 +1,34 @@ +import { useOutletContext } from "react-router-dom"; +import { Socket } from "socket.io-client"; +import { useModal } from "../../hooks/common/modal/useModal"; +import ProjectDeleteModal from "./ProjectDeleteModal"; + +interface ProjectDeleteSectionProps { + projectTitle: string; +} + +const ProjectDeleteSection = ({ projectTitle }: ProjectDeleteSectionProps) => { + const { open, close } = useModal(true); + const { socket }: { socket: Socket } = useOutletContext(); + const handleDeleteButtonClick = () => { + open(); + }; + + return ( +
+
+

프로젝트 삭제

+

프로젝트를 삭제한 후 되돌릴 수 없습니다.

+
+ +
+ ); +}; + +export default ProjectDeleteSection; diff --git a/frontend/src/hooks/common/landing/useLandingProjectSocket.ts b/frontend/src/hooks/common/landing/useLandingProjectSocket.ts index a86f4ab..098b9a4 100644 --- a/frontend/src/hooks/common/landing/useLandingProjectSocket.ts +++ b/frontend/src/hooks/common/landing/useLandingProjectSocket.ts @@ -6,6 +6,11 @@ import { LandingSocketData, LandingSocketDomain, } from "../../../types/common/landing"; +import { + SettingSocketData, + SettingSocketDomain, +} from "../../../types/common/setting"; +import { SettingProjectDTO } from "../../../types/DTO/settingDTO"; const useLandingProjectSocket = (socket: Socket) => { const [project, setProject] = useState( @@ -17,11 +22,22 @@ const useLandingProjectSocket = (socket: Socket) => { setProject(project); }; - const handleOnLanding = ({ domain, content }: LandingSocketData) => { - if (domain !== LandingSocketDomain.INIT) { + const handleProjectInfoEvent = (content: SettingProjectDTO) => { + setProject({ ...project, title: content.title, subject: content.subject }); + }; + + const handleOnLanding = ({ + domain, + content, + }: LandingSocketData | SettingSocketData) => { + if (domain === SettingSocketDomain.PROJECT_INFO) { + handleProjectInfoEvent(content); + } + + if (domain === LandingSocketDomain.INIT) { + handleInitEvent(content); return; } - handleInitEvent(content); }; useEffect(() => { diff --git a/frontend/src/hooks/common/socket/useSocket.ts b/frontend/src/hooks/common/socket/useSocket.ts index de0fada..f575984 100644 --- a/frontend/src/hooks/common/socket/useSocket.ts +++ b/frontend/src/hooks/common/socket/useSocket.ts @@ -1,7 +1,9 @@ -import { io } from "socket.io-client"; -import { BASE_URL } from "../../../constants/path"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { io } from "socket.io-client"; +import { BASE_URL, ROUTER_URL } from "../../../constants/path"; import { getAccessToken } from "../../../apis/utils/authAPI"; +import { SettingSocketData } from "../../../types/common/setting"; const useSocket = (projectId: string) => { const WS_URL = `${BASE_URL}/project-${projectId}`; @@ -15,6 +17,16 @@ const useSocket = (projectId: string) => { }) ); const [connected, setConnected] = useState(false); + const navigate = useNavigate(); + + const handleProjectDeleted = ({ domain, action }: SettingSocketData) => { + if (domain === "projectInfo" && action === "delete") { + alert("프로젝트가 삭제되었습니다."); + setTimeout(() => { + navigate(ROUTER_URL.PROJECTS); + }, 1000); + } + }; useEffect(() => { const handleOnConnect = () => { @@ -27,11 +39,13 @@ const useSocket = (projectId: string) => { socket.connect(); socket.on("connect", handleOnConnect); socket.on("disconnect", handleOnDisconnect); + socket.on("main", handleProjectDeleted); return () => { socket.disconnect(); socket.off("connect", handleOnConnect); socket.off("disconnect", handleOnDisconnect); + socket.off("main", handleProjectDeleted); }; }, []); diff --git a/frontend/src/hooks/pages/setting/useSettingProjectInfoSocket.ts b/frontend/src/hooks/pages/setting/useSettingProjectInfoSocket.ts new file mode 100644 index 0000000..00ec667 --- /dev/null +++ b/frontend/src/hooks/pages/setting/useSettingProjectInfoSocket.ts @@ -0,0 +1,14 @@ +import { Socket } from "socket.io-client"; + +const useSettingProjectInfoSocket = (socket: Socket) => { + const emitProjectInfoUpdateEvent = (content: { + title: string; + subject: string; + }) => { + socket.emit("projectInfo", { action: "update", content }); + }; + + return { emitProjectInfoUpdateEvent }; +}; + +export default useSettingProjectInfoSocket; diff --git a/frontend/src/hooks/pages/setting/useSettingProjectSocket.ts b/frontend/src/hooks/pages/setting/useSettingProjectSocket.ts new file mode 100644 index 0000000..2423f6a --- /dev/null +++ b/frontend/src/hooks/pages/setting/useSettingProjectSocket.ts @@ -0,0 +1,11 @@ +import { Socket } from "socket.io-client"; + +const useSettingProjectSocket = (socket: Socket) => { + const emitProjectDeleteEvent = () => { + socket.emit("projectInfo", { action: "delete", content: {} }); + }; + + return { emitProjectDeleteEvent }; +}; + +export default useSettingProjectSocket; diff --git a/frontend/src/hooks/pages/setting/useSettingSocket.ts b/frontend/src/hooks/pages/setting/useSettingSocket.ts new file mode 100644 index 0000000..7f1f549 --- /dev/null +++ b/frontend/src/hooks/pages/setting/useSettingSocket.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Socket } from "socket.io-client"; +import { + SettingSocketData, + SettingSocketDomain, + SettingSocketProjectInfoAction, +} from "../../../types/common/setting"; +import { SettingDTO, SettingProjectDTO } from "../../../types/DTO/settingDTO"; + +const useSettingSocket = (socket: Socket) => { + const [projectInfo, setProjectInfo] = useState({ + title: "", + subject: "", + }); + + const handleInitEvent = (content: SettingDTO) => { + const { project } = content; + setProjectInfo(project); + }; + + const handleProjectInfoEvent = ( + action: SettingSocketProjectInfoAction, + content: SettingProjectDTO + ) => { + switch (action) { + case SettingSocketProjectInfoAction.UPDATE: + setProjectInfo(content); + break; + } + }; + + const handleOnProjectInfoSetting = ({ + domain, + action, + content, + }: SettingSocketData) => { + switch (domain) { + case SettingSocketDomain.INIT: + handleInitEvent(content); + break; + case SettingSocketDomain.PROJECT_INFO: + handleProjectInfoEvent(action, content); + break; + } + }; + + useEffect(() => { + socket.emit("joinSetting"); + socket.on("setting", handleOnProjectInfoSetting); + + return () => { + socket.off("setting", handleOnProjectInfoSetting); + }; + }, []); + + return { projectInfo }; +}; + +export default useSettingSocket; diff --git a/frontend/src/pages/setting/SettingPage.tsx b/frontend/src/pages/setting/SettingPage.tsx new file mode 100644 index 0000000..990bce2 --- /dev/null +++ b/frontend/src/pages/setting/SettingPage.tsx @@ -0,0 +1,42 @@ +import { Socket } from "socket.io-client"; +import InformationSettingSection from "../../components/setting/InformationSettingSection"; +import MemberSettingSection from "../../components/setting/MemberSettingSection"; +import ProjectDeleteSection from "../../components/setting/ProjectDeleteSection"; +import { LandingMemberDTO } from "../../types/DTO/landingDTO"; +import { useOutletContext } from "react-router-dom"; +import useSettingSocket from "../../hooks/pages/setting/useSettingSocket"; + +const memberList: LandingMemberDTO[] = [ + { id: 1, username: "lesserTest", role: "LEADER", imageUrl: "", status: "on" }, + { + id: 2, + username: "lesserTest2", + role: "MEMBER", + imageUrl: "", + status: "on", + }, + { + id: 3, + username: "lesserTest3", + role: "MEMBER", + imageUrl: "", + status: "on", + }, +]; + +const SettingPage = () => { + const { socket }: { socket: Socket } = useOutletContext(); + const { + projectInfo: { title, subject }, + } = useSettingSocket(socket); + + return ( +
+ + + +
+ ); +}; + +export default SettingPage; diff --git a/frontend/src/stores/useMemberStore.ts b/frontend/src/stores/useMemberStore.ts index ba76552..4ec6cef 100644 --- a/frontend/src/stores/useMemberStore.ts +++ b/frontend/src/stores/useMemberStore.ts @@ -15,7 +15,7 @@ interface MemberState extends InitialMemberState { } const initialState: InitialMemberState = { - myInfo: { id: -1, username: "", imageUrl: "", status: "on" }, + myInfo: { id: -1, username: "", imageUrl: "", status: "on", role: "MEMBER" }, memberList: [], }; diff --git a/frontend/src/test/sortMemberByStatus.test.ts b/frontend/src/test/sortMemberByStatus.test.ts index cbf6504..f205cc0 100644 --- a/frontend/src/test/sortMemberByStatus.test.ts +++ b/frontend/src/test/sortMemberByStatus.test.ts @@ -4,12 +4,12 @@ import sortMemberByStatus from "../utils/sortMemberByStatus"; describe("sortMemberByStatus test", () => { it("on, away, off 순 정렬 테스트", () => { const memberList: LandingMemberDTO[] = [ - { id: 1, username: "", imageUrl: "", status: "off" }, - { id: 2, username: "", imageUrl: "", status: "on" }, - { id: 3, username: "", imageUrl: "", status: "away" }, - { id: 4, username: "", imageUrl: "", status: "on" }, - { id: 5, username: "", imageUrl: "", status: "off" }, - { id: 6, username: "", imageUrl: "", status: "away" }, + { id: 1, username: "", imageUrl: "", status: "off", role: "LEADER" }, + { id: 2, username: "", imageUrl: "", status: "on", role: "MEMBER" }, + { id: 3, username: "", imageUrl: "", status: "away", role: "MEMBER" }, + { id: 4, username: "", imageUrl: "", status: "on", role: "MEMBER" }, + { id: 5, username: "", imageUrl: "", status: "off", role: "MEMBER" }, + { id: 6, username: "", imageUrl: "", status: "away", role: "MEMBER" }, ]; const sortedMemberIdList = memberList diff --git a/frontend/src/types/DTO/landingDTO.ts b/frontend/src/types/DTO/landingDTO.ts index 4d152ae..7d3230c 100644 --- a/frontend/src/types/DTO/landingDTO.ts +++ b/frontend/src/types/DTO/landingDTO.ts @@ -2,6 +2,8 @@ import { MemoColorType } from "../common/landing"; export type MemberStatus = "on" | "off" | "away"; +export type MemberRole = "LEADER" | "MEMBER"; + export interface LandingProjectDTO { title: string; subject: string; @@ -13,6 +15,7 @@ export interface LandingMemberDTO { username: string; imageUrl: string; status: MemberStatus; + role: MemberRole; } export interface LandingSprintDTO { diff --git a/frontend/src/types/DTO/settingDTO.ts b/frontend/src/types/DTO/settingDTO.ts new file mode 100644 index 0000000..2ffe4bc --- /dev/null +++ b/frontend/src/types/DTO/settingDTO.ts @@ -0,0 +1,18 @@ +import { MemberRole } from "./landingDTO"; + +export interface SettingProjectDTO { + title: string; + subject: string; +} + +export interface SettingMemberDTO { + id: number; + username: string; + imageUrl: string; + role: MemberRole; +} + +export interface SettingDTO { + project: SettingProjectDTO; + member: SettingMemberDTO[]; +} diff --git a/frontend/src/types/common/setting.ts b/frontend/src/types/common/setting.ts new file mode 100644 index 0000000..6780efb --- /dev/null +++ b/frontend/src/types/common/setting.ts @@ -0,0 +1,27 @@ +import { SettingDTO, SettingProjectDTO } from "../DTO/settingDTO"; + +export enum SettingSocketDomain { + INIT = "setting", + PROJECT_INFO = "projectInfo", +} + +export enum SettingSocketProjectInfoAction { + UPDATE = "update", + DELETE = "delete", +} + +interface SettingSocketInitData { + domain: SettingSocketDomain.INIT; + action: "init"; + content: SettingDTO; +} + +interface SettingSocketProjectInfoData { + domain: SettingSocketDomain.PROJECT_INFO; + action: SettingSocketProjectInfoAction; + content: SettingProjectDTO; +} + +export type SettingSocketData = + | SettingSocketInitData + | SettingSocketProjectInfoData;