diff --git a/FE/.gitignore b/FE/.gitignore index b5cab4ddb..3d966900f 100644 --- a/FE/.gitignore +++ b/FE/.gitignore @@ -1,3 +1,5 @@ +.env.production + # Logs logs *.log diff --git a/FE/README.md b/FE/README.md index b311c9c41..08aa1f4f7 100644 --- a/FE/README.md +++ b/FE/README.md @@ -38,3 +38,7 @@ Web 용 react-router-dom을 React 애플리케이션 프로젝트에 설치해 ### 스피너 `$ npm install --save react-spinners` + +### 색상 유효성 체크 + +`$ npm i validate-color --save` diff --git a/FE/package-lock.json b/FE/package-lock.json index edb3f32e6..55d8c3ca6 100644 --- a/FE/package-lock.json +++ b/FE/package-lock.json @@ -18,7 +18,8 @@ "react-spinners": "^0.13.8", "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0", - "styled-components": "^6.1.10" + "styled-components": "^6.1.10", + "validate-color": "^2.2.4" }, "devDependencies": { "@types/react": "^18.2.66", @@ -6805,6 +6806,11 @@ "punycode": "^2.1.0" } }, + "node_modules/validate-color": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/validate-color/-/validate-color-2.2.4.tgz", + "integrity": "sha512-Znolz+b6CwW6eBXYld7MFM3O7funcdyRfjKC/X9hqYV/0VcC5LB/L45mff7m3dIn9wdGdNOAQ/fybNuD5P/HDw==" + }, "node_modules/vfile": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", diff --git a/FE/package.json b/FE/package.json index d476deea1..50f243167 100644 --- a/FE/package.json +++ b/FE/package.json @@ -20,7 +20,8 @@ "react-spinners": "^0.13.8", "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0", - "styled-components": "^6.1.10" + "styled-components": "^6.1.10", + "validate-color": "^2.2.4" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/FE/src/api/fetchIssueData.js b/FE/src/api/fetchIssueData.js index 8833c52b8..b6d27b357 100644 --- a/FE/src/api/fetchIssueData.js +++ b/FE/src/api/fetchIssueData.js @@ -351,3 +351,38 @@ export const fetchUploadFile = async (formData) => { throw error; } }; + +/** + * 새로운 이슈 생성 + * @param {*String} title - 제목 + * @param {*String} content - 내용 + * @param {*String} authorId - 작성자 + * @param {*String} milestoneId - 마일스톤 아이디 + * @param {*String} fileId - 파일아이디(없을 시 null) + * @param {*Array} labelIds - 레이블 아이디(없을 시 []) + * @param {*Array} assigneeIds - 담당자 아이디(없을 시 []) + * @returns {*jsonObject} +* - 성공: 200 + - 바인딩 에러시: 400 + - 서버 내부 오류시: 500 + */ +export const fetchCreateNewIssue = async (title, content, authorId, milestoneId, fileIdParam, labelIds, assigneeIds) => { + const fileId = fileIdParam || null; + try { + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${ISSUE_DEFAULT_API_URI}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title, content, authorId, milestoneId, fileId, labelIds, assigneeIds }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + if (response.status === 200 || response.status === 201) { + return await response.json(); + } + } catch (error) { + throw error; + } +}; diff --git a/FE/src/api/fetchLabelData.js b/FE/src/api/fetchLabelData.js new file mode 100644 index 000000000..4463c841c --- /dev/null +++ b/FE/src/api/fetchLabelData.js @@ -0,0 +1,141 @@ +const LABEL_DEFAULT_API_URI = '/api/labels'; +const HOME_DEFAULT_API_URI = '/api/home/components'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * 레이블 마일스톤 - 개수 조회 + * @param {*String} issueId + * @returns {jsonObject} + * - 성공: 200 + */ +export const fetchLabelMilestoneCountData = async () => { + try { + // await delay(2000); + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${HOME_DEFAULT_API_URI}`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + if (response.status === 200 || response.status === 201) { + return await response.json(); + } + } catch (error) { + throw error; + } +}; +/** + * 레이블 조회 + * @param {*String} issueId + * @returns {jsonObject} + */ +export const fetchLabelDetailData = async () => { + try { + // await delay(2000); + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${LABEL_DEFAULT_API_URI}`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + if (response.status === 200 || response.status === 201) { + return await response.json(); + } + } catch (error) { + throw error; + } +}; + +/** + * 레이블 수정 + * @param {*String} name + * @param {*String} descriptionParam 없을 경우 null + * @param {*String} textColor + * @param {*String} bgColor + * @param {*String} labelId + * @returns + * - 성공: 200 + - 데이터 바인딩(즉, 이름이나 배경 색깔이 비어져서 왔을 때) 실패: 400 + - 유효하지 않은 배경 색깔: 400 + - 존재하지 않는 라벨 아이디: 404 + */ +export const fetchModifyLabel = async (name, descriptionParam, textColor, bgColor, labelId) => { + const description = descriptionParam || null; + try { + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${LABEL_DEFAULT_API_URI}/${labelId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, description, textColor, bgColor }), + }); + const result = await response.json(); + + if (response.status === 400 || response.status === 404 || response.status === 409) { + throw new Error({ code: response.status, result }); + } + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + if (response.status === 200 || response.status === 201) { + return result; + } + } catch (error) { + throw error; + } +}; + +/** + * 레이블 삭제 + * @param {*String} labelId + * @returns +- 성공: 200 +- 존재하지 않는 라벨 아이디: 404 + */ +export const fetchDeleteLabel = async (labelId) => { + try { + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${LABEL_DEFAULT_API_URI}/${labelId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + return { + status: response.status, + statusText: response.statusText, + }; + } catch (error) { + throw error; + } +}; + +/** + * 새로운 레이블 생성 + * @param {*String} name + * @param {*String} descriptionParam 없을 경우 null + * @param {*String} textColor + * @param {*String} bgColor + * @returns + * - 성공: 201 + - 데이터 바인딩(즉, 이름이나 배경 색깔이 비어져서 왔을 때) 실패: 400 + - 유효하지 않은 배경 색깔: 400 + - 라벨 이름 중복: 409 + */ +export const fetchCreateNewLabel = async (name, descriptionParam, textColor, bgColor) => { + const description = descriptionParam || null; + try { + const response = await fetch(`${import.meta.env.VITE_TEAM_SERVER}${LABEL_DEFAULT_API_URI}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name, description, textColor, bgColor }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + if (response.status === 200 || response.status === 201) { + return await response.json(); + } + } catch (error) { + throw error; + } +}; diff --git a/FE/src/api/fetchMembers.js b/FE/src/api/fetchMembers.js index 8a53431d7..b0b4d654d 100644 --- a/FE/src/api/fetchMembers.js +++ b/FE/src/api/fetchMembers.js @@ -28,3 +28,32 @@ export const fetchLogin = async ({ id, password }) => { throw error; } }; +/** + * 깃허브 로그인 + * @param {Object} - {id, password} + * @returns {Object} - { result:boolean, data:{email, id, nickname} }; + - 성공: 200 + - 데이터 바인딩 실패: 400 + - 로그인 실패 : 401 + */ +export const fetchGithubLogin = async () => { + try { + const CLIENT_ID = 'Ov23liTzJL66RbPZt3fg'; + const REDIRECT_URI = `${import.meta.env.VITE_TEAM_CLIENT}/members/callback`; + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}`; + + // https://github.com/login/oauth/authorize?client_id=Ov23liTzJL66RbPZt3fg&redirect_uri=https://api.issue-tracker.site/api/oauth/github/callback + // https://github.com/login/oauth/authorize?client_id=Ov23liTzJL66RbPZt3fg&redirect_uri={server 주소}/api/oauth/github/callback + + const response = await fetch(githubAuthUrl, { + method: 'GET', + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const result = await response.json(); + return { result: true, data: result }; + } catch (error) { + throw error; + } +}; diff --git a/FE/src/assets/CustomTextEditor.jsx b/FE/src/assets/CustomTextEditor.jsx index a505c5e04..65c52e93f 100644 --- a/FE/src/assets/CustomTextEditor.jsx +++ b/FE/src/assets/CustomTextEditor.jsx @@ -13,6 +13,7 @@ export default function CustomTextEditor({ $fileOnClick = () => {}, $fileOnChange = () => {}, $isFileUploaded = false, + $height = '100', }) { const fileInputRef = useRef(null); @@ -22,7 +23,7 @@ export default function CustomTextEditor({ return ( <> - + 띄어쓰기 포함 {$value?.length ?? 0}자 @@ -60,7 +61,7 @@ const StyledTextArea = styled.textarea` position: relative; resize: none; width: 100%; - min-height: 100px; + min-height: ${(props) => props.$height}px; padding: 15px; background-color: transparent; color: ${(props) => props.theme.fontColor}; diff --git a/FE/src/components/issues/DropDownFilter.jsx b/FE/src/components/issues/DropDownFilter.jsx index 46638b3d3..fbf2b3f84 100644 --- a/FE/src/components/issues/DropDownFilter.jsx +++ b/FE/src/components/issues/DropDownFilter.jsx @@ -127,7 +127,7 @@ export default function DropDownFilter({ filterTitle, filterItems, dispatchTypeB key: 'nonSelected', }; - const defaultTypeItems = () => { + const defaultTypeItems = (checkType) => { return filterItems ? filterItems.reduce((acc, cur) => { acc.push({ @@ -135,7 +135,7 @@ export default function DropDownFilter({ filterTitle, filterItems, dispatchTypeB
- {cur.title} {filterTitle === 'state' && isClosedState(selectedFilters) ? '열기' : '닫기'} + {checkType ? (isClosedState(selectedFilters) ? `${cur.title} 열기` : `${cur.title} 닫기`) : cur.title}
@@ -202,12 +202,12 @@ export default function DropDownFilter({ filterTitle, filterItems, dispatchTypeB }; const itemByType = { - issue: [titleItem, ...defaultTypeItems()], + issue: [titleItem, ...defaultTypeItems(false)], assignee: [titleItem, clearTypeItem, ...imageTypeItems()], label: [titleItem, clearTypeItem, ...labelTypeItems()], - milestone: [titleItem, clearTypeItem, ...defaultTypeItems()], + milestone: [titleItem, clearTypeItem, ...defaultTypeItems(false)], author: [titleItem, ...imageTypeItems()], - state: [titleItem, , ...defaultTypeItems()], + state: [titleItem, , ...defaultTypeItems(true)], }; const items = itemByType[filterTitle]; diff --git a/FE/src/components/issues/IssueDetail.jsx b/FE/src/components/issues/IssueDetail.jsx index 52cb88c8f..ee59540f0 100644 --- a/FE/src/components/issues/IssueDetail.jsx +++ b/FE/src/components/issues/IssueDetail.jsx @@ -67,7 +67,8 @@ export default function IssueDetail() { // const remainFileIds = remainFiles.map((file) => file.id); const remainFileIds = fileMeta.map((file) => file.id).filter((e) => e !== ''); - createIssueComment({ writerId: getUserId(), content: newCommentArea, fileId: remainFileIds[0] }); + + createIssueComment({ writerId: getUserId(), content: newCommentArea, fileId: remainFileIds?.[0] }); setNewCommentArea(''); setFileMeta(initFileDatas); }; @@ -156,7 +157,6 @@ export default function IssueDetail() { $fileOnChange={handleFileChange} $onFocus={handleFocus} $onBlur={handleBlur} - setFileMeta={setFileMeta} /> diff --git a/FE/src/components/issues/IssueDetailSidebar.jsx b/FE/src/components/issues/IssueDetailSidebar.jsx index 7f83a5457..863ef376d 100644 --- a/FE/src/components/issues/IssueDetailSidebar.jsx +++ b/FE/src/components/issues/IssueDetailSidebar.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { FlexRow } from '../../styles/theme'; import { Popconfirm, message } from 'antd'; @@ -7,6 +7,13 @@ import { useNavigate } from 'react-router-dom'; import OptionSidebar from './OptionSidebar'; import { IconTrash } from '../../assets/icons/IconTrash'; +const initCheckedData = { + assignee: [], + label: [], + milestone: '', +}; + + export default function IssueDetailSidebar({ milestone, assignees, labels, issueId, isEditable = false }) { const navigate = useNavigate(); const onSuccess = () => { @@ -14,19 +21,38 @@ export default function IssueDetailSidebar({ milestone, assignees, labels, issue navigate('/'); }; const { mutate: deleteIssue } = useDeleteIssue(issueId, onSuccess); + const [checkedDatas, setCheckedDatas] = useState(initCheckedData); const deleteConfirm = () => deleteIssue(); return ( - + 담당자 - + 레이블 - + 마일스톤 diff --git a/FE/src/components/issues/Main.jsx b/FE/src/components/issues/Main.jsx index d34316110..1cc4164d4 100644 --- a/FE/src/components/issues/Main.jsx +++ b/FE/src/components/issues/Main.jsx @@ -8,10 +8,14 @@ import { useFilterContext } from '../../context/FilterContext'; import NavStateType from './NavStateType'; import NavFilterType from './NavFilterType'; import { MainContainer } from '../../styles/theme'; +import { TagsOutlined, PlusOutlined } from '@ant-design/icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useLabelsFilter, useMembersFilter, useMilestonesFilter } from '../../hooks/useFiltersData'; import { usefilteredIssueData } from '../../hooks/usefilteredIssueData'; import ClipLoader from 'react-spinners/ClipLoader'; +import { useLabelMilestoneCountData } from '../../hooks/useLabelData'; +import { CustomButton } from '../../assets/CustomButton'; +import { IconMilestone } from '../../assets/icons/IconMilestone'; const stateModifyFilters = [{ title: '선택한 이슈' }]; @@ -66,11 +70,16 @@ export default function Main() { const [checkedItems, setCheckedItems] = useState([]); const [filterItemsByType, setFilterItemsByType] = useState(initFilterItems); const [issueDatas, setIssueDatas] = useState(initIssueDatas); + const [hasFetched, setHasFetched] = useState(initFetched); + const [labelCount, setLabelCount] = useState(0); + const [milestoneCount, setMilestoneCount] = useState(0); + const { data: countData } = useLabelMilestoneCountData(); const { data: issueList, isLoading: issueListIsLoading } = usefilteredIssueData(); - const { data: labelsFilter, isLoading: labelsFilterIsLoading } = useLabelsFilter({}); - const { data: milestonesFilter, isLoading: milestonesFilterIsLoading } = useMilestonesFilter({}); - const { data: membersFilter, isLoading: membersFilterIsLoading } = useMembersFilter({}); + const { data: labelsFilter, isLoading: labelsFilterIsLoading } = useLabelsFilter({ enabled: hasFetched.label }); + const { data: milestonesFilter, isLoading: milestonesFilterIsLoading } = useMilestonesFilter({ enabled: hasFetched.milestone }); + const { data: membersFilter, isLoading: membersFilterIsLoading } = useMembersFilter({ enabled: hasFetched.assignee }); + const clearFilter = () => dispatch({ type: 'SET_CLEAR_FILTER', payload: '' }); @@ -111,8 +120,6 @@ export default function Main() { return checkedItems.includes(key); }; - const [hasFetched, setHasFetched] = useState(initFetched); - const setterFechedByType = { assignee: () => setHasFetched((prev) => ({ ...prev, assignee: true })), label: () => setHasFetched((prev) => ({ ...prev, label: true })), @@ -190,6 +197,18 @@ export default function Main() { setIssueDatas((prev) => ({ ...prev, list: newIssueList })); }, [issueList]); + useEffect(() => { + if (!countData) return; + setLabelCount(countData.labelCount.count); + setMilestoneCount(countData.milestoneCount.isOpened + countData.milestoneCount.isClosed); + }, [countData]); + + // useEffect(() => { + // const url = new URL(window.location.href); + // const authorizationCode = url.searchParams.get('code'); + // console.log(authorizationCode); //인증 코드 + // }, []); + return ( @@ -211,11 +230,19 @@ export default function Main() { - navigate('/labels')}>레이블() - navigate('/milestones')}>마일스톤() - navigate('/issues/new')} type="primary"> - + 이슈작성 - + navigate('/labels')}> + + 레이블({labelCount}) + + navigate('/milestones')}> + + 마일스톤({milestoneCount}) + + + navigate('/issues/new')} size={'large'} isDisabled={false}> + + 이슈작성 + @@ -276,6 +303,29 @@ export default function Main() { ); } + +const StyledNewIssueBtn = styled(CustomButton)` + width: 120px; + font-size: 15px; + font-weight: 500; + color: #fff; +`; + +const StyledMilestoneBtn = styled(CustomButton)` + border-top-left-radius: 0; + border-bottom-left-radius: 0; + width: 130px; + font-size: 15px; + font-weight: 500; +`; +const StyledLabelBtn = styled(CustomButton)` + border-top-right-radius: 0; + border-bottom-right-radius: 0; + width: 130px; + font-size: 15px; + font-weight: 500; +`; + const StyledLoader = styled.div` height: 100px; align-content: center; @@ -404,6 +454,14 @@ const StyledNav = styled.nav` } `; -const NavBtnContainer = styled.div` - width: 380px; +const NavBtnContainer = styled(FlexRow)` + justify-content: end; + margin-left: 10px; + + & .createBtn { + margin-left: 10px; + } + button { + font-size: 16px; + } `; diff --git a/FE/src/components/issues/NewIssue.jsx b/FE/src/components/issues/NewIssue.jsx index 7e7d6321b..9b19fabd1 100644 --- a/FE/src/components/issues/NewIssue.jsx +++ b/FE/src/components/issues/NewIssue.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { FlexCol, FlexRow, IndexContainer, MainContainer, StyledInput } from '../../styles/theme'; import Header from '../header/Header'; import styled from 'styled-components'; @@ -6,13 +6,37 @@ import CustomTextEditor from '../../assets/CustomTextEditor'; import { CustomButton } from '../../assets/CustomButton'; import { IconPlus } from '../../assets/icons/IconPlus'; import IssueDetailSidebar from './IssueDetailSidebar'; +import { CustomProfile } from '../../assets/CustomProfile'; +import { getUserId, getUserImg } from '../../utils/userUtils'; +import OptionSidebar from './OptionSidebar'; +import { fetchUploadFile } from '../../api/fetchIssueData'; +import { useNavigate } from 'react-router-dom'; +import { useCreateNewIssue } from '../../hooks/useIssueDetailData'; +import { message } from 'antd'; + +const initFileDatas = [{ id: '', url: '', uploadName: '' }]; //*[{id:String, url:String, uploadName:String}] +const initCheckedData = { + assignee: [], + label: [], + milestone: '', +}; export default function NewIssue() { + const navigate = useNavigate(); + + const onSuccess = (issueId) => { + message.success('생성되었습니다.'); + navigate(`/issues/${issueId}`); + }; + const { mutate: createNewIssue } = useCreateNewIssue(onSuccess); + const [checkedDatas, setCheckedDatas] = useState(initCheckedData); + //TODO: Ref로 작업 const [newTitle, setNewTitle] = useState(''); const [newCommentArea, setNewCommentArea] = useState(''); const [isNewCommentFocused, setIsNewCommentFocused] = useState(false); const [isNewCommetDisabled, setIsNewCommetDisabled] = useState(true); + const [fileMeta, setFileMeta] = useState(initFileDatas); //*[{id:String, url:String}] //현재는 파일"1개만" 등록가능 const handleCommentChange = ({ target }) => { const { value } = target; setNewCommentArea(value); @@ -24,46 +48,112 @@ export default function NewIssue() { const handleFocus = () => setIsNewCommentFocused(true); const handleBlur = () => setIsNewCommentFocused(false); + const handleFileChange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const responseData = await fetchUploadFile(formData); + + if (responseData && responseData.url && responseData.uploadName && responseData.id) { + setNewCommentArea((prev) => `${prev}\n![${responseData.uploadName}](${responseData.url})`); + setFileMeta((prev) => [...prev, { id: String(responseData.id), url: responseData.url, uploadName: responseData.uploadName }]); + } + } catch (error) { + console.error(error); + } + }; //TODO: fileId - const submitNewComment = () => { - // createIssueComment({ writerId: getUserId(), content: newCommentArea }); - setNewCommentArea(''); + const submitNewIssue = () => { + const remainFileIds = fileMeta.map((file) => file.id).filter((e) => e !== ''); + createNewIssue({ + title: newTitle, + content: newCommentArea, + authorId: getUserId(), + milestoneId: checkedDatas.milestone, + fileId: remainFileIds?.[0], + labelIds: checkedDatas.label, + assigneeIds: checkedDatas.assignee, + }); }; + useEffect(() => { + if (newTitle.length > 0 && newCommentArea.length > 0) setIsNewCommetDisabled(false); + else setIsNewCommetDisabled(true); + }, [newCommentArea]); + return (
+ -

🚧새로운 이슈 작성🚧

+

새로운 이슈 작성

- + + + + + -
- - 제목 - - -
+ + 제목 + + - +
+ +
- - - - 코멘트 작성 - -
- {/* */} + + + 담당자 + + + 레이블 + + + 마일스톤 + +
+ - + 완료 @@ -71,13 +161,24 @@ export default function NewIssue() { ); } +const SidebarContainer = styled.div` + flex-basis: 25%; + min-width: 200px; + min-height: 500px; + border: 1px solid ${(props) => props.theme.borderColor}; + border-radius: 20px; + /* background-color: red; */ +`; + const StyledTitle = styled(FlexRow)` position: relative; + margin-bottom: 10px; + width: 100%; `; const MainBtnContainer = styled(FlexRow)` justify-content: end; width: 100%; - margin-bottom: 20px; + margin: 20px 0; `; const Content = styled.div` /* display: flex; @@ -108,10 +209,15 @@ const PlaceholdText = styled.span` font-size: 13px; `; +const StyledProfile = styled(FlexCol)` + margin-right: 2cap; +`; + const StyledComments = styled(FlexCol)` /* background-color: azure; */ - flex-basis: 70%; - margin-right: 30px; + flex-basis: 65%; + /* margin-right: 30px; */ + margin-right: 20px; min-width: 700px; min-height: 200px; /* display: flex; @@ -125,7 +231,7 @@ const ContentsContainer = styled.div` justify-content: space-between; margin-top: 15px; width: 100%; - height: 700px; //??? + /* height: 700px; //??? /* background-color: aquamarine; */ `; const StyledMainContainer = styled(MainContainer)` @@ -141,10 +247,22 @@ const HeaderShow = styled.div` } `; const StyledDetailContainer = styled(IndexContainer)` - .title { + & .title { + position: relative; + } + & .title::after { + content: ''; + position: absolute; + width: 100%; + height: 1px; + left: 0; + bottom: 0; + background-color: ${(props) => props.theme.borderColor}; + } + & .contents { position: relative; } - .title::after { + & .contents::after { content: ''; position: absolute; width: 100%; diff --git a/FE/src/components/issues/OptionSidebar.jsx b/FE/src/components/issues/OptionSidebar.jsx index 22449488b..708bd0e51 100644 --- a/FE/src/components/issues/OptionSidebar.jsx +++ b/FE/src/components/issues/OptionSidebar.jsx @@ -15,6 +15,9 @@ const initActivePopup = { label: false, milestone: false, }; + + +export default function OptionSidebar({ filterName, filterData, issueId, children, isNew = false, checkedDatas, setCheckedDatas }) { const initCheckedData = { assignee: [], label: [], @@ -25,9 +28,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre const openIssueCount = filterData?.openIssueCount ?? 0; const closedIssueCount = filterData?.closedIssueCount ?? 0; const popupRef = useRef(null); - - const [checkedDatas, setCheckedDatas] = useState(initCheckedData); - const [isActivePopup, setIsActivePopup] = useState(initActivePopup); const [isAssigneeFetchPossible, setIsAssigneeFetchPossible] = useState(false); const [isLabelFetchPossible, setIsLabelFetchPossible] = useState(false); @@ -64,7 +64,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre }; const handleMouseEnter = (type) => { - // setIsActivePopup(true); toggleEnableByType[type](); }; @@ -77,6 +76,11 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre setCheckedDatas((prev) => ({ ...prev, [type]: checkedDatas })); }; + const getCorrectAssignee = (checkedId) => assigneesData.filter(({ id }) => id === checkedId)?.[0]; + const getCorrectLabel = (checkedId) => labelsData.labels.filter(({ id }) => String(id) === checkedId)?.[0]; + const getCorrectMilestone = (checkedId) => + [...openMilestonesData.milestoneDetailDtos, ...closedMilestonesData.milestoneDetailDtos].filter(({ id }) => String(id) === checkedId)?.[0]; + useEffect(() => { if (!filterData) return; @@ -89,7 +93,6 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre }, [filterData]); useEffect(() => { - // 팝업 외부 클릭을 감지 const handleClickOutside = ({ target }) => { if (popupRef.current && !popupRef.current.contains(target)) { setIsActivePopup({ ...initActivePopup }); @@ -115,16 +118,42 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre {filterName === 'assignee' && - filterData.map(({ id, imgUrl }) => ( + isNew && + checkedDatas.assignee.map((id) => { + const correctAssignee = getCorrectAssignee(id); + return ( + + {correctAssignee?.imgUrl ? : } + {correctAssignee?.id} + + ); + })} + {filterName === 'assignee' && + !isNew && + filterData?.map(({ id, imgUrl }) => ( {imgUrl ? : } {id} ))} - {filterName === 'label' && ( + + {filterName === 'label' && isNew && ( - {filterData.map(({ id, name, description, textColor, bgColor }) => ( + {checkedDatas.label.map((id) => { + const correctLabel = getCorrectLabel(id); + return ( + + {correctLabel.name} + + ); + })} + + )} + {filterName === 'label' && !isNew && ( + + {filterData?.map(({ id, name, description, textColor, bgColor }) => ( + {name} @@ -132,7 +161,16 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre )} - {filterName === 'milestone' && ( + + {filterName === 'milestone' && isNew && openMilestonesData && closedMilestonesData && ( + <> + + + + {getCorrectMilestone(checkedDatas.milestone)?.name} + + )} + {filterName === 'milestone' && !isNew && ( <> @@ -153,6 +191,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre checkedDatas={checkedDatas?.assignee} setCheckedDatas={setCheckedDatas} issueId={issueId} + isNew={isNew} + /> )} {filterName === 'label' && labelsData && ( @@ -163,6 +203,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre checkedDatas={checkedDatas?.label} setCheckedDatas={setCheckedDatas} issueId={issueId} + isNew={isNew} + /> )} {filterName === 'milestone' && openMilestonesData && closedMilestonesData && ( @@ -173,6 +215,8 @@ export default function OptionSidebar({ filterName, filterData, issueId, childre checkedDatas={checkedDatas?.milestone} setCheckedDatas={setCheckedDatas} issueId={issueId} + isNew={isNew} + /> )} diff --git a/FE/src/components/issues/OptionSidebarContents.jsx b/FE/src/components/issues/OptionSidebarContents.jsx index 768cee938..5fbea697d 100644 --- a/FE/src/components/issues/OptionSidebarContents.jsx +++ b/FE/src/components/issues/OptionSidebarContents.jsx @@ -7,7 +7,9 @@ import { Checkbox, Radio } from 'antd'; import { useModifyIssueAssignees, useModifyIssueLabels, useModifyIssueMilestone } from '../../hooks/useIssueDetailData'; import CustomNoProfile from '../../assets/CustomNoProfile'; -export default function OptionSidebarContents({ contents, filterName, filterData, checkedDatas, setCheckedDatas, issueId }) { + +export default function OptionSidebarContents({ contents, filterName, filterData, checkedDatas, setCheckedDatas, issueId, isNew }) { + const { mutate: modifyIssueLabels } = useModifyIssueLabels(String(issueId)); //labelIds const { mutate: modifyIssueAssignees } = useModifyIssueAssignees(String(issueId)); //assigneeIds const { mutate: modifyIssueMilestone } = useModifyIssueMilestone(String(issueId)); //milestoneId @@ -60,7 +62,9 @@ export default function OptionSidebarContents({ contents, filterName, filterData useEffect(() => { return () => { - if (isModifyed(filterData, checkedDatas)) modifyActionByType[filterName](); + + if (!isNew && isModifyed(filterData, checkedDatas)) modifyActionByType[filterName](); + }; }, [checkedDatas]); diff --git a/FE/src/components/labels/LabelEditor.jsx b/FE/src/components/labels/LabelEditor.jsx new file mode 100644 index 000000000..f0f946563 --- /dev/null +++ b/FE/src/components/labels/LabelEditor.jsx @@ -0,0 +1,367 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { PlusOutlined, RedoOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; +import { CustomLabelBadge } from '../../assets/CustomLabelBadge'; +import { FlexCol, FlexRow, StyledInput } from '../../styles/theme'; +import { CustomButton } from '../../assets/CustomButton'; +import { Radio, message } from 'antd'; +import validateColor from 'validate-color'; +import { useCreateNewLabel, useModifyLabel } from '../../hooks/useLabelData'; +import { IconEdit } from '../../assets/icons/IconEdit'; + +export default function LabelEditor({ isNew = true, togglePlusLabelState, toggleEditLabelState, id, bgColor, textColor, name, description }) { + const clearInputForm = () => { + setNewBgColor('#'); + setNewBgColorValue('#'); + setIsNewBgColorValidate(false); + setIsNewLabelDisabled(false); + setNewLabelName(''); + setNewFontColor(''); + discriptionRef.current.value = ''; + }; + const onSuccessCallback = () => { + clearInputForm(); + if (!isNew) toggleEditLabelState(); + message.success(`레이블이 ${isNew ? '생성' : '수정'}되었습니다.`); + }; + const onErrorCallback = () => { + message.error(`수정 실패! 다시 시도해주세요.`); + }; + const { mutate: createLabel } = useCreateNewLabel({ + onSuccessCallback, + }); + const { mutate: modifyLabel } = useModifyLabel({ + onSuccessCallback, + onErrorCallback, + }); + + const [isPopupVisible, setIsPopupVisible] = useState(false); + const [isNewLabelDisabled, setIsNewLabelDisabled] = useState(true); + const [isEditLabelDisabled, setIsEditLabelDisabled] = useState(true); + const [newLabelName, setNewLabelName] = useState(''); + + const [newBgColor, setNewBgColor] = useState('#'); + const [newBgColorValue, setNewBgColorValue] = useState('#'); + const [isNewBgColorValidate, setIsNewBgColorValidate] = useState(false); + + const [newFontColor, setNewFontColor] = useState(''); // 'light' | 'dark' + const [editDiscriptionValue, setEditDiscriptionValue] = useState(''); + const discriptionRef = useRef(null); + const popupRef = useRef(null); + + const handleNameChange = ({ target }) => { + const { value } = target; + setNewLabelName(value); + }; + + const isValidateColor = (value) => { + return value && validateColor(value); + }; + + const handleBgColorChange = ({ target }) => { + const { value } = target; + setNewBgColorValue(value); + + if (isValidateColor(value)) { + setNewBgColor(value); + setIsNewBgColorValidate(true); + } else { + setNewBgColor(''); + setIsNewBgColorValidate(false); + } + }; + const handleFontColorChange = (e) => { + const fontColor = e.currentTarget.dataset.id ?? ''; + setNewFontColor(fontColor); + }; + + const togglePopup = () => setIsPopupVisible((prevState) => !prevState); + + const submitEditLabel = () => { + modifyLabel({ + name: newLabelName, + description: discriptionRef.current.value === undefined || discriptionRef.current.value === '' ? null : discriptionRef.current.value, + textColor: newFontColor === 'light' ? '#fff' : '#000', + bgColor: newBgColor, + labelId: id, + }); + }; + + const submitLabel = () => { + createLabel({ + name: newLabelName, + description: discriptionRef.current.value === undefined || discriptionRef.current.value === '' ? null : discriptionRef.current.value, + textColor: newFontColor === 'light' ? '#fff' : '#000', + bgColor: newBgColor, + }); + }; + + const setRandomColors = () => { + const letters = '0123456789ABCDEF'; + const randomColor = Array.from({ length: 6 }).reduce((acc, _, idx) => { + if (idx === 0) acc += '#'; + return (acc += letters[Math.floor(Math.random() * 16)]); + }, ''); + + if (isValidateColor(randomColor)) { + setNewBgColor(randomColor); + setNewBgColorValue(randomColor); + setIsNewBgColorValidate(true); + } + }; + + const settingEditValues = (textColor, bgColor, name, description) => { + textColor === '#fff' ? setNewFontColor('light') : setNewFontColor('dark'); + setNewBgColor(bgColor); + setNewBgColorValue(bgColor); + setNewLabelName(name); + discriptionRef.current.value = description; + setEditDiscriptionValue(description); + }; + + const handleEditDescriptionChange = () => { + if (isNew) return; + setEditDiscriptionValue(discriptionRef.current.value); + }; + + const settingEditButtonDisable = (newLabelName, newBgColorValue, newFontColor, editDiscriptionValue) => { + const convertTextColor = textColor === '#fff' ? 'light' : 'dark'; + if ( + newLabelName !== name || + (newBgColorValue !== bgColor && isValidateColor(newBgColorValue)) || + newFontColor !== convertTextColor || + editDiscriptionValue !== description + ) + setIsEditLabelDisabled(false); + else setIsEditLabelDisabled(true); + }; + + const settingNewButtonDisable = (newLabelName, newBgColor, newFontColor) => { + if (newLabelName && newBgColor && newFontColor && isNewBgColorValidate) setIsNewLabelDisabled(false); + else setIsNewLabelDisabled(true); + }; + + useEffect(() => { + if (isNew) return; + settingEditButtonDisable(newLabelName, newBgColorValue, newFontColor, editDiscriptionValue); + }, [newLabelName, newBgColorValue, newFontColor, editDiscriptionValue]); + + useEffect(() => { + if (isNew) return; + settingEditValues(textColor, bgColor, name, description); + }, [textColor, bgColor, name, description]); + + useEffect(() => { + settingNewButtonDisable(newLabelName, newBgColor, newFontColor); + }, [newLabelName, newBgColor, newFontColor]); + + return ( + + {isNew ? '새로운 레이블 추가' : '레이블 편집'} + + +
+ + {newLabelName} + +
+
+ + + 이름 + + + + 설명(선택) + + + + + 배경 색상 + + + + + +
+ + {newFontColor ? (newFontColor === 'light' ? '밝은 색' : '어두운 색') : '텍스트 색상'} + {' '} + + {isPopupVisible && ( + +
    + 텍스트 색상 + + 밝은 색 + + + + + + 어두운 색 + + + + +
+
+ )} +
+
+
+
+ + + + + 취소 + + + + {isNew ? ( + <> + + '완료' + + ) : ( + <> + + '편집 완료' + + )} + + +
+ ); +} +const StyledList = styled.li` + display: flex; + justify-content: space-between; + padding: 5px 12px; + width: 100%; + + & .itemTitle { + /* width: 80px; */ + } + & .titleName { + margin-left: 10px; + } +`; + +const StyledTextColorBtn = styled.span` + position: relative; + display: inline-block; + cursor: pointer; +`; +const PopupContainer = styled.div` + position: absolute; + min-width: 180px; + min-height: 65px; + border: 2px solid ${(props) => props.theme.borderColor}; + border-radius: 20px; + background-color: ${(props) => props.theme.bgColorBody}; + opacity: 90%; + z-index: 5; + & .title { + border-radius: 20px 20px 0 0; + background-color: ${(props) => props.theme.listHeaderColor}; + } +`; + +const NavBtnContainer = styled(FlexRow)` + justify-content: end; + & .createBtn { + margin-left: 10px; + } + button { + font-size: 16px; + } +`; + +const StyledColorOption = styled(FlexRow)` + justify-content: flex-start; + & .backgroudColorOption { + margin-right: 20px; + } +`; +const StyledTitle = styled(FlexRow)` + position: relative; + /* background-color: red; */ +`; +const RandomIcon = styled.span` + position: absolute; + top: 15px; + right: 15px; + color: var(--secondary-color); + font-size: 13px; + cursor: pointer; +`; +const PlaceholdText = styled.span` + position: absolute; + top: 15px; + left: 15px; + color: var(--secondary-color); + font-size: 13px; +`; +const ModifyInput = styled(StyledInput)` + width: 100%; + height: 100%; + padding: 10px 20px 10px 90px; + color: var(--secondary-color); +`; + +const InputContainer = styled(FlexCol)` + justify-content: space-between; + width: 100%; + align-self: normal; + /* background-color: aquamarine; */ + + & ::placeholder { + font-size: 13px; + font-style: italic; + /* color: ${(props) => props.theme.fontColor}; */ + } +`; +const Preview = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + min-width: 200px; + height: 150px; + flex-basis: 30%; + border-radius: 10px; + border: 1px solid; + border-color: ${(props) => (props.$isfocused ? 'var(--primary-color)' : props.theme.borderColor)}; + margin-right: 20px; + background-color: white; +`; + +const AddContents = styled(FlexRow)` + margin-bottom: 20px; + /* justify-content: space-between; */ +`; + +const AddTitle = styled.div` + width: 100%; + display: flex; + font-size: 20px; + font-weight: bold; + margin-bottom: 15px; +`; + +const AddContainer = styled.div` + width: 100%; + border-radius: ${(props) => (props.$isNew ? '10px' : 0)}; + border: 2px solid; + border-color: ${(props) => (props.$isNew ? 'var(--primary-color)' : props.theme.borderColor)}; + color: ${(props) => props.theme.fontColor}; + padding: 30px; + background-color: ${(props) => props.theme.bgColorBody}; +`; diff --git a/FE/src/components/labels/LabelList.jsx b/FE/src/components/labels/LabelList.jsx new file mode 100644 index 000000000..d03924d82 --- /dev/null +++ b/FE/src/components/labels/LabelList.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { CustomLabelBadge } from '../../assets/CustomLabelBadge'; +import { EditOutlined } from '@ant-design/icons'; +import { IconTrash } from '../../assets/icons/IconTrash'; +import { FlexRow } from '../../styles/theme'; +import LabelEditor from './LabelEditor'; +import { Popconfirm, message } from 'antd'; +import { useDeleteLabel } from '../../hooks/useLabelData'; + +export default function LabelList({ id, name, description, textColor, bgColor, isNoList = false }) { + const [isLabelEditState, setLabelEditState] = useState(false); + const toggleEditLabelState = () => setLabelEditState((prev) => !prev); + + const onErrorCallback = () => message.error(`삭제 실패! 다시 시도해주세요.`); + const onSuccessCallback = () => message.success(`삭제 완료!`); + const { mutate: deleteLabel } = useDeleteLabel({ + onSuccessCallback, + onErrorCallback, + }); + const handleDeleteLabel = (labelId) => deleteLabel({ labelId }); + + return ( + <> + {isNoList ? ( + +
등록된 레이블이 없습니다.
+
+ ) : isLabelEditState ? ( + + ) : ( + + + + + {name} + + + {description || ''} + + + + + + 편집 + + + handleDeleteLabel(id)} okText="Yes" cancelText="No"> + + + 삭제 + + + + + )} + + ); +} + +const StyledBtn = styled.div` + display: flex; + cursor: pointer; + color: ${(props) => props.theme.fontColor}; + margin-left: 20px; +`; +const BadgeContainer = styled(FlexRow)` + min-width: 200px; + margin-right: 10px; +`; +const TitleContainer = styled(FlexRow)` + flex-basis: 80%; + justify-content: flex-start; +`; + +const StyledBtnContainer = styled.div` + display: flex; +`; + +const StyledDescription = styled.div``; + +const ListContainer = styled(FlexRow)` + min-height: 80px; + align-items: center; + justify-content: space-between; + border-top: 1px solid; + border-color: ${(props) => props.theme.borderColor}; + padding: 0 30px; + + & .noList { + width: 100%; + height: 100%; + align-content: center; + margin-top: 20px; + } +`; diff --git a/FE/src/components/labels/Labels.jsx b/FE/src/components/labels/Labels.jsx deleted file mode 100644 index 56a2dedcd..000000000 --- a/FE/src/components/labels/Labels.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function labels() { - return
라벨 생성 페이지 입니다.
; -} diff --git a/FE/src/components/labels/Main.jsx b/FE/src/components/labels/Main.jsx new file mode 100644 index 000000000..0b205eb48 --- /dev/null +++ b/FE/src/components/labels/Main.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import Header from '../header/Header'; +import { FlexCol, FlexRow, IndexContainer, MainContainer, StyledInput } from '../../styles/theme'; +import styled from 'styled-components'; +import { CustomButton } from '../../assets/CustomButton'; +import { IconMilestone } from '../../assets/icons/IconMilestone'; +import { TagsOutlined, PlusOutlined } from '@ant-design/icons'; +import LabelList from './LabelList'; +import LabelEditor from './LabelEditor'; +import { useNavigate } from 'react-router-dom'; +import { useLabelDetailData, useLabelMilestoneCountData } from '../../hooks/useLabelData'; +import ClipLoader from 'react-spinners/ClipLoader'; + +export default function LabelMain() { + const naivgate = useNavigate(); + const clickMileStone = () => naivgate('/milestones'); + const clickLabel = () => naivgate('/labels'); + const { data: labelData, isLoading: labelDataIsLoading } = useLabelDetailData(); + const { data: countData } = useLabelMilestoneCountData(); + const [isPlusLabelState, setIsPlusLabelState] = useState(false); + const [labelCount, setLabelCount] = useState(0); + const [milestoneCount, setMilestoneCount] = useState(0); + + const togglePlusLabelState = () => setIsPlusLabelState((prev) => !prev); + + useEffect(() => { + if (!countData) return; + setLabelCount(countData.labelCount.count); + setMilestoneCount(countData.milestoneCount.isOpened + countData.milestoneCount.isClosed); + }, [countData]); + + return ( + +
+ + + + + + 레이블({labelCount}) + + + + 마일스톤({milestoneCount}) + + + + + 레이블 추가 + + + + {isPlusLabelState && } + + + {labelCount}개의 레이블 + + {labelDataIsLoading && ( + + + + )} + {labelData && + labelData.labels.map(({ id, name, description, textColor, bgColor }) => ( + + ))} + {labelData?.labels.length === 0 && } + + + + + ); +} + +const StyledMilestoneBtn = styled(CustomButton)` + border-top-left-radius: 0; + border-bottom-left-radius: 0; + width: 150px; +`; +const StyledLabelBtn = styled(CustomButton)` + border-top-right-radius: 0; + border-bottom-right-radius: 0; + width: 150px; +`; +const NavBtnContainer = styled(FlexRow)` + justify-content: end; + & .createBtn { + margin-left: 10px; + } + button { + font-size: 16px; + } +`; + +const NavContainer = styled(FlexRow)` + justify-content: space-between; + width: 100%; + height: 70px; + margin-bottom: 10px; + button { + font-size: 16px; + } +`; +const StyledBoxBody = styled.div` + min-height: 80px; + /* background-color: skyblue; */ +`; + +const StyledBoxHeader = styled.div` + background-color: ${(props) => props.theme.listHeaderColor}; + display: flex; + flex-direction: row; + align-items: center; + height: 60px; + color: ${(props) => props.theme.fontColor}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + padding: 0 30px; + font-weight: bold; +`; + +const ContentsContainer = styled.div` + min-height: 160px; + border-radius: 10px; + border: 1px solid; + border-color: ${(props) => props.theme.borderColor}; + background-color: ${(props) => props.theme.bgColorBody}; + color: ${(props) => props.theme.fontColor}; + margin-top: 10px; + /* background-color: azure; */ +`; + +const StyledLoader = styled.div` + height: 100px; + align-content: center; +`; diff --git a/FE/src/components/members/AuthLoadingPage.jsx b/FE/src/components/members/AuthLoadingPage.jsx new file mode 100644 index 000000000..30fa11c1d --- /dev/null +++ b/FE/src/components/members/AuthLoadingPage.jsx @@ -0,0 +1,12 @@ +import React, { useEffect } from 'react'; + +export default function AuthLoadingPage() { + useEffect(() => { + const url = new URL(window.location.href); + const authorizationCode = url.searchParams.get('code'); + + console.log(authorizationCode); //인증 코드 + }, []); + + return
GitHub OAuth 로그인 처리 중...
; +} diff --git a/FE/src/components/members/Login.jsx b/FE/src/components/members/Login.jsx index b474f90a8..e797907c7 100644 --- a/FE/src/components/members/Login.jsx +++ b/FE/src/components/members/Login.jsx @@ -5,7 +5,8 @@ import { Link, useNavigate } from 'react-router-dom'; import DarkLogotypeLarge from '../../assets/DarkLogotypeLarge.svg'; import LightLogotypeLarge from '../../assets/LightLogotypeLarge.svg'; import { DarkModeContext } from '../../context/DarkModeContext'; -import { fetchLogin } from '../../api/fetchMembers'; +import { fetchGithubLogin, fetchLogin } from '../../api/fetchMembers'; +import { CustomButton } from '../../assets/CustomButton'; export default function Login() { const { isDarkMode } = useContext(DarkModeContext); @@ -59,6 +60,21 @@ export default function Login() { } }; + const handleGithubLogin = async () => { + // const loginResult = await fetchGithubLogin(); + // localStorage.setItem('storeUserData', JSON.stringify(loginResult.data)); + // console.log(loginResult); + + const CLIENT_ID = 'Ov23liTzJL66RbPZt3fg'; + // const REDIRECT_URI = `${import.meta.env.VITE_TEAM_SERVER}/api/oauth/github/callback`; + const REDIRECT_URI = `${import.meta.env.VITE_TEAM_CLIENT}/members/callback`; + + const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}`; + + // window.location.assign(githubAuthUrl); + window.location.href = githubAuthUrl; + }; + useEffect(() => { setIdInput(''); setPwInput(''); @@ -75,9 +91,10 @@ export default function Login() { logo - + + GitHub 계정으로 로그인 - + or
@@ -105,7 +122,12 @@ export default function Login() { ); } -//TODO: className props 지정하고, StyledButton 상속 받아서 margin 커스텀하기 +const GithubButton = styled(CustomButton)` + margin-bottom: 20px; + width: 320px; + height: 56px; + padding: 10px 20px; +`; const linkStyle = { textDecorationLine: 'none', diff --git a/FE/src/components/milestones/Main.jsx b/FE/src/components/milestones/Main.jsx new file mode 100644 index 000000000..a90dbdd04 --- /dev/null +++ b/FE/src/components/milestones/Main.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import Header from '../header/Header'; +import { FlexRow, IndexContainer, MainContainer } from '../../styles/theme'; +import { TagsOutlined, PlusOutlined } from '@ant-design/icons'; +import { CustomButton } from '../../assets/CustomButton'; +import { IconMilestone } from '../../assets/icons/IconMilestone'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router-dom'; + +export default function MilestoneMain() { + const naivgate = useNavigate(); + const clickMileStone = () => naivgate('/milestones'); + const clickLabel = () => naivgate('/labels'); + + return ( + +
+ + + + + + 레이블(0) + + + + 마일스톤(0) + + + + + 마일스톤 추가 + + + 🚧마일스톤 페이지 입니다🚧 + + + ); +} +const NavBtnContainer = styled(FlexRow)` + justify-content: end; + & .createBtn { + margin-left: 10px; + } + button { + font-size: 16px; + } +`; +const StyledMilestoneBtn = styled(CustomButton)` + border-top-left-radius: 0; + border-bottom-left-radius: 0; + width: 150px; +`; +const StyledLabelBtn = styled(CustomButton)` + border-top-right-radius: 0; + border-bottom-right-radius: 0; + width: 150px; +`; +const NavContainer = styled(FlexRow)` + justify-content: space-between; + width: 100%; + height: 70px; + margin-bottom: 10px; + button { + font-size: 16px; + } +`; diff --git a/FE/src/components/milestones/Milestones.jsx b/FE/src/components/milestones/Milestones.jsx deleted file mode 100644 index 0338ae1e4..000000000 --- a/FE/src/components/milestones/Milestones.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export default function Milestones() { - return
마일스톤 생성 페이지입니다.
; -} diff --git a/FE/src/hooks/useIssueDetailData.js b/FE/src/hooks/useIssueDetailData.js index ed51fae76..57deb1aeb 100644 --- a/FE/src/hooks/useIssueDetailData.js +++ b/FE/src/hooks/useIssueDetailData.js @@ -1,6 +1,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchCreateIssueComment, + fetchCreateNewIssue, fetchDeleteComment, fetchDeleteIssue, fetchIssueDetailData, @@ -248,3 +249,23 @@ export const useModifyIssueMilestone = (issueId) => { // }, }); }; + + +/** + * 새로운 이슈 - 생성 + * @param {*fn} onSuccessCallBack - 성공 시 로직 + * @returns + * key는 무조건 String으로 통일 + */ +export const useCreateNewIssue = (onSuccessCallBack) => { + return useMutation({ + mutationFn: async ({ title, content, authorId, milestoneId, fileId = null, labelIds, assigneeIds }) => + await fetchCreateNewIssue(title, content, authorId, milestoneId, fileId, labelIds, assigneeIds), + onSuccess: (res) => { + if (onSuccessCallBack) onSuccessCallBack(res.id); + }, + // onError: () => { + // }, + }); +}; + diff --git a/FE/src/hooks/useLabelData.js b/FE/src/hooks/useLabelData.js new file mode 100644 index 000000000..d9d42e65d --- /dev/null +++ b/FE/src/hooks/useLabelData.js @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { fetchCreateNewLabel, fetchDeleteLabel, fetchLabelDetailData, fetchLabelMilestoneCountData, fetchModifyLabel } from '../api/fetchLabelData'; + +/** + * 레이블, 마일스톤 - 개수 조회 + * @returns + */ +export const useLabelMilestoneCountData = () => { + return useQuery({ + queryKey: ['label', 'milestone', 'count'], + queryFn: async () => await fetchLabelMilestoneCountData(), + staleTime: 1000 * 60 * 5, // 데이터를 신선하게 유지하는 시간 + cacheTime: 1000 * 60 * 10, // 캐시에서 데이터를 유지하는 시간 + }); +}; +/** + * 레이블 - 조회 + * @returns + */ +export const useLabelDetailData = () => { + return useQuery({ + queryKey: ['labelDetail'], + queryFn: async () => await fetchLabelDetailData(), + staleTime: 1000 * 60 * 5, // 데이터를 신선하게 유지하는 시간 + cacheTime: 1000 * 60 * 10, // 캐시에서 데이터를 유지하는 시간 + }); +}; + +/** + * 새로운 레이블 - 생성 + * @param {*fn} onSuccessCallback - 성공 시 로직 + * @returns + * key는 무조건 String으로 통일 + */ +export const useCreateNewLabel = ({ onSuccessCallback }) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ name, description, textColor, bgColor }) => await fetchCreateNewLabel(name, description, textColor, bgColor), + onSuccess: (res) => { + if (onSuccessCallback) onSuccessCallback(); + queryClient.invalidateQueries({ queryKey: ['labelDetail'], refetchType: 'active' }); + queryClient.invalidateQueries({ queryKey: ['label', 'milestone', 'count'], refetchType: 'active' }); + }, + // onError: () => { + // }, + }); +}; + +/** + * 레이블 - 수정 + * @param {*fn} onSuccessCallback - 성공 시 로직 + * @param {*fn} onErrorCallback - 실패 시 로직 + * @returns + * key는 무조건 String으로 통일 + */ +export const useModifyLabel = ({ onSuccessCallback, onErrorCallback }) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ name, description, textColor, bgColor, labelId }) => + await fetchModifyLabel(name, description, textColor, bgColor, labelId), + onSuccess: (res) => { + if (onSuccessCallback) onSuccessCallback(); + queryClient.invalidateQueries({ queryKey: ['labelDetail'], refetchType: 'active' }); + queryClient.invalidateQueries({ queryKey: ['label', 'milestone', 'count'], refetchType: 'active' }); + }, + onError: (e) => { + onErrorCallback(); + }, + }); +}; + +/** + * 레이블 - 삭제 + * @param {*fn} onSuccessCallback - 성공 시 로직 + * @param {*fn} onErrorCallback - 실패 시 로직 + * @returns + * key는 무조건 String으로 통일 + */ +export const useDeleteLabel = ({ onSuccessCallback, onErrorCallback }) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ labelId }) => await fetchDeleteLabel(labelId), + onSuccess: (res) => { + if ((res.status === 200 || res.status === 201) && onSuccessCallback) onSuccessCallback(); + queryClient.invalidateQueries({ queryKey: ['labelDetail'], refetchType: 'active' }); + queryClient.invalidateQueries({ queryKey: ['label', 'milestone', 'count'], refetchType: 'active' }); + }, + onError: (e) => { + onErrorCallback(); + }, + }); +}; diff --git a/FE/src/hooks/usefilteredIssueData.js b/FE/src/hooks/usefilteredIssueData.js index 8d84baee9..dcf3dc142 100644 --- a/FE/src/hooks/usefilteredIssueData.js +++ b/FE/src/hooks/usefilteredIssueData.js @@ -1,7 +1,6 @@ import { useQueries, useQuery } from '@tanstack/react-query'; -import { fetchIssueListData, fetchLabelsData, fetchMembersData, fetchMilestonesData } from '../api/fetchFilterData'; +import { fetchIssueListData } from '../api/fetchFilterData'; import { useFilterContext } from '../context/FilterContext'; -import labels from '../components/labels/Labels'; import { getUserId } from '../utils/userUtils'; const filterTypeIsClosed = (selectedFilters) => { diff --git a/FE/src/router/routes.jsx b/FE/src/router/routes.jsx index f54800120..97f4ce035 100644 --- a/FE/src/router/routes.jsx +++ b/FE/src/router/routes.jsx @@ -3,20 +3,36 @@ import Join from '../components/members/Join'; import Login from '../components/members/Login'; import Index from '../components/Index'; import NotFound from '../components/NotFound'; -import Milestones from '../components/milestones/Milestones'; -import Labels from '../components/labels/Labels'; +import MilestoneMain from '../components/milestones/Main'; +import LabelMain from '../components/labels/Main'; import ProtectedRoute from './ProtectedRoute'; import NewIssue from '../components/issues/NewIssue'; import IssueDetail from '../components/issues/IssueDetail'; +import AuthLoadingPage from '../components/members/AuthLoadingPage'; export const AppRoutes = () => { return ( + } /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + />