├── frontend ├── src │ ├── common │ │ ├── style │ │ │ ├── mixin.scss │ │ │ ├── common.scss │ │ │ ├── reset.scss │ │ │ └── color.js │ │ ├── context │ │ │ ├── index.js │ │ │ └── UserContext.js │ │ ├── hook │ │ │ ├── index.js │ │ │ └── usePromise.js │ │ └── utils │ │ │ ├── index.js │ │ │ ├── waitAuthorization.js │ │ │ └── api.js │ ├── components │ │ ├── MainTemplate │ │ │ ├── index.js │ │ │ ├── MainContext │ │ │ │ ├── MainContext.js │ │ │ │ └── MainContentContext.js │ │ │ └── Top.jsx │ │ ├── Header │ │ │ ├── HeaderContext │ │ │ │ └── HeaderContext.jsx │ │ │ ├── HeaderPresenter │ │ │ │ ├── HeaderTitle │ │ │ │ │ └── HeaderTitle.jsx │ │ │ │ └── HeaderPresenter.jsx │ │ │ └── HeaderContainer │ │ │ │ └── HeaderContainer.jsx │ │ ├── FilterBar │ │ │ ├── FilterBarContext │ │ │ │ └── FilterBarContext.jsx │ │ │ ├── FilterDropmenu │ │ │ │ └── FilterDropmenu.jsx │ │ │ ├── FilterBarContainer │ │ │ │ └── FilterBarContainer.jsx │ │ │ └── FilterBarPresenter │ │ │ │ └── FilterBarPresenter.jsx │ │ ├── Checkbox │ │ │ └── Checkbox.jsx │ │ ├── IssueFilterMenu │ │ │ ├── IssueFilterMenuContext │ │ │ │ └── IssueFilterMenuContext.jsx │ │ │ ├── IssueFilterMenuContainer │ │ │ │ └── IssueFilterMenuContainer.jsx │ │ │ └── IssueFilterMenuPresenter │ │ │ │ └── IssueFilterMenuPresenter.jsx │ │ ├── IssueDetail │ │ │ ├── Top.jsx │ │ │ ├── Middle.jsx │ │ │ ├── index.js │ │ │ ├── MiddleRight.jsx │ │ │ └── MiddleLeft.jsx │ │ ├── Form │ │ │ ├── FormLabel.jsx │ │ │ ├── index.js │ │ │ ├── WarningMessage.jsx │ │ │ ├── FormInput.jsx │ │ │ ├── FormContainer.jsx │ │ │ └── FormSubmit.jsx │ │ ├── ListGroup │ │ │ ├── index.js │ │ │ ├── ListGroupArea.jsx │ │ │ ├── ListGroupHeader.jsx │ │ │ ├── ListGroupItem.jsx │ │ │ └── ListGroupItemList.jsx │ │ ├── UserProfile │ │ │ └── UserProfile.jsx │ │ ├── IssueIcon │ │ │ └── IssueIcon.jsx │ │ ├── Main │ │ │ └── Main.jsx │ │ ├── Caret │ │ │ └── Caret.jsx │ │ ├── ProgressBar │ │ │ └── ProgressBar.jsx │ │ ├── Label │ │ │ └── Label.jsx │ │ ├── ContentEditor │ │ │ ├── Preview │ │ │ │ └── Preview.jsx │ │ │ ├── ContentEditor.jsx │ │ │ └── Writer │ │ │ │ └── Writer.jsx │ │ ├── Button │ │ │ └── Button.jsx │ │ ├── OpenClosedTab │ │ │ └── OpenClosedTab.jsx │ │ ├── SidebarMenu │ │ │ └── SidebarMenu.jsx │ │ ├── index.js │ │ ├── LabelItem │ │ │ └── LabelItem.jsx │ │ ├── Comment │ │ │ └── Comment.jsx │ │ ├── PageNavButton │ │ │ └── PageNavButton.jsx │ │ ├── SignupForm │ │ │ └── SignupForm.jsx │ │ ├── LabelMilestoneHeader │ │ │ └── LabelMilestoneHeader.jsx │ │ └── LoginForm │ │ │ └── LoginForm.jsx │ ├── pages │ │ ├── NewMilestonePage.jsx │ │ ├── index.js │ │ ├── LoginPage.jsx │ │ ├── SignupPage.jsx │ │ ├── EditMilstonePage.jsx │ │ ├── MainPage.jsx │ │ ├── LoadingPage.jsx │ │ ├── LabelPage.jsx │ │ ├── MilestonePage.jsx │ │ └── NewIssuePage.jsx │ ├── client │ │ └── Root.jsx │ ├── index.jsx │ └── shared │ │ └── App.jsx ├── public │ ├── favicon.ico │ ├── imgs │ │ ├── logo.png │ │ ├── search-icon.png │ │ ├── check-black-icon.png │ │ ├── check-gray-icon.png │ │ ├── close-blue-icon.png │ │ ├── close-gray-icon.png │ │ ├── issue-gray-icon.png │ │ ├── issue-open-icon.png │ │ ├── issue-white-icon.png │ │ ├── label-black-icon.png │ │ ├── label-white-icon.png │ │ ├── calendar-gray-icon.png │ │ ├── change-black-icon.png │ │ ├── change-white-icon.png │ │ ├── emoticon-blue-icon.png │ │ ├── emoticon-gray-icon.png │ │ ├── issue-closed-icon.png │ │ ├── setting-blue-icon.png │ │ ├── setting-gray-icon.png │ │ ├── calendar-black-icon.png │ │ ├── milestone-black-icon.png │ │ ├── milestone-blue-icon.png │ │ ├── milestone-gray-icon.png │ │ ├── milestone-white-icon.png │ │ ├── snailHub-black-icon.png │ │ ├── snailHub-white-icon.png │ │ └── issue-closed-white-icon.png │ ├── images │ │ └── setting-icon.png │ └── index.html ├── env │ └── .env.sample ├── webpack.config.prod.js ├── .prettierrc.js ├── webpack.config.js ├── .eslintrc.js ├── config │ └── index.js ├── package.json └── webpack.config.common.js ├── backend ├── jsconfig.json ├── src │ ├── common │ │ ├── lib │ │ │ ├── authenticator │ │ │ │ ├── passport.js │ │ │ │ ├── passport │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── authenticator-manager.js │ │ │ │ ├── jwt-authenticator.js │ │ │ │ └── github-authenticator.js │ │ │ ├── index.js │ │ │ ├── token-generator.js │ │ │ ├── crypto.js │ │ │ └── query-parser.js │ │ ├── type │ │ │ ├── issue-state.js │ │ │ ├── user-type.js │ │ │ ├── milestone-state.js │ │ │ └── index.js │ │ ├── middleware │ │ │ ├── request-type.js │ │ │ ├── query-mapper.js │ │ │ ├── validator.js │ │ │ ├── transformer.js │ │ │ └── error-handler.js │ │ ├── error │ │ │ ├── business-error.js │ │ │ ├── forbidden-error.js │ │ │ ├── bad-request-error.js │ │ │ ├── unauthorized-error.js │ │ │ ├── entity-already-exist.js │ │ │ ├── entity-not-found-error.js │ │ │ └── error-code.js │ │ ├── env │ │ │ ├── env-type.js │ │ │ └── database-env.js │ │ └── config │ │ │ └── database │ │ │ ├── database-type.js │ │ │ └── connection-option-generator.js │ ├── dto │ │ ├── user.js │ │ ├── auth.js │ │ ├── milestone.js │ │ └── issue.js │ ├── main.js │ ├── application-factory.js │ ├── router │ │ ├── index.js │ │ ├── oauth.js │ │ ├── label.js │ │ ├── user.js │ │ ├── api.js │ │ ├── auth.js │ │ ├── label-to-issue.js │ │ ├── milestone.js │ │ └── issue.js │ ├── service │ │ ├── index.js │ │ ├── label-to-issue-service.js │ │ ├── milestone-service.js │ │ ├── label-service.js │ │ └── comment-service.js │ ├── controller │ │ ├── index.js │ │ ├── label-to-issue-controller.js │ │ ├── user-controller.js │ │ ├── auth-controller.js │ │ ├── comment-controller.js │ │ ├── label-controller.js │ │ └── milestone-controller.js │ ├── model │ │ ├── label-to-issue.js │ │ ├── user-to-issue.js │ │ ├── issue-content.js │ │ ├── comment-content.js │ │ ├── label.js │ │ ├── comment.js │ │ ├── milestone.js │ │ ├── user.js │ │ └── issue.js │ └── application.js ├── .eslintignore ├── .prettierrc.js ├── test │ ├── TransactionWrapper.js │ ├── common │ │ ├── lib │ │ │ └── query-parser.test.js │ │ └── middleware │ │ │ ├── validator.test.js │ │ │ └── transformer.test.js │ └── router │ │ ├── issue-milestone.test.js │ │ └── issue-label.test.js ├── .babelrc.js ├── docker-compose.yml ├── .eslintrc.js └── package.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ └── feature_request.md ├── .gitmessage.txt ├── LICENSE ├── README.md └── .gitignore /frontend/src/common/style/mixin.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/common/style/common.scss: -------------------------------------------------------------------------------- 1 | .hide { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/common/context/index.js: -------------------------------------------------------------------------------- 1 | export { UserContext } from "./UserContext"; 2 | -------------------------------------------------------------------------------- /frontend/src/common/hook/index.js: -------------------------------------------------------------------------------- 1 | export { default as usePromise } from "./usePromise"; 2 | -------------------------------------------------------------------------------- /backend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/passport.js: -------------------------------------------------------------------------------- 1 | import passport from "passport"; 2 | 3 | export default passport; 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/passport/index.js: -------------------------------------------------------------------------------- 1 | import passport from "passport"; 2 | 3 | export default passport; 4 | -------------------------------------------------------------------------------- /frontend/env/.env.sample: -------------------------------------------------------------------------------- 1 | ISSUE_TRACKER_APP_SERVER = "http://localhost:3000" 2 | ISSUE_TRACKER_APP_API_POST_LOGIN = "/login" 3 | -------------------------------------------------------------------------------- /frontend/public/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/logo.png -------------------------------------------------------------------------------- /backend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | !.babelrc.js 5 | !.eslintrc.js 6 | !.prettierrc.js 7 | !*.test.js 8 | -------------------------------------------------------------------------------- /frontend/public/imgs/search-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/search-icon.png -------------------------------------------------------------------------------- /frontend/src/common/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as waitAuthorizationApi } from "./waitAuthorization"; 2 | export * as API from "./api"; 3 | -------------------------------------------------------------------------------- /frontend/src/components/MainTemplate/index.js: -------------------------------------------------------------------------------- 1 | export { default as Top } from "./Top"; 2 | export { default as Content } from "./Content"; 3 | -------------------------------------------------------------------------------- /frontend/public/images/setting-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/images/setting-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/check-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/check-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/check-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/check-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/close-blue-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/close-blue-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/close-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/close-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/issue-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/issue-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/issue-open-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/issue-open-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/issue-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/issue-white-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/label-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/label-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/label-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/label-white-icon.png -------------------------------------------------------------------------------- /backend/src/common/type/issue-state.js: -------------------------------------------------------------------------------- 1 | const ISSUESTATE = { 2 | OPEN: "open", 3 | CLOSED: "closed" 4 | }; 5 | 6 | export default ISSUESTATE; 7 | -------------------------------------------------------------------------------- /frontend/public/imgs/calendar-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/calendar-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/change-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/change-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/change-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/change-white-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/emoticon-blue-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/emoticon-blue-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/emoticon-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/emoticon-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/issue-closed-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/issue-closed-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/setting-blue-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/setting-blue-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/setting-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/setting-gray-icon.png -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/index.js: -------------------------------------------------------------------------------- 1 | export { GithubAuthenticator, JwtAuthenticator, initializeAuthenticator } from "./authenticator-manager"; 2 | -------------------------------------------------------------------------------- /backend/src/common/type/user-type.js: -------------------------------------------------------------------------------- 1 | const USER_TYPE = { 2 | AUTHUOR: "author", 3 | ASSIGNEE: "assignee" 4 | }; 5 | 6 | export default USER_TYPE; 7 | -------------------------------------------------------------------------------- /frontend/public/imgs/calendar-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/calendar-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/milestone-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/milestone-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/milestone-blue-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/milestone-blue-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/milestone-gray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/milestone-gray-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/milestone-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/milestone-white-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/snailHub-black-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/snailHub-black-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/snailHub-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/snailHub-white-icon.png -------------------------------------------------------------------------------- /frontend/public/imgs/issue-closed-white-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcamp-2020/IssueTracker-40/HEAD/frontend/public/imgs/issue-closed-white-icon.png -------------------------------------------------------------------------------- /backend/src/common/type/milestone-state.js: -------------------------------------------------------------------------------- 1 | const MILESTONESTATE = { 2 | OPEN: "open", 3 | CLOSED: "closed" 4 | }; 5 | 6 | export default MILESTONESTATE; 7 | -------------------------------------------------------------------------------- /frontend/src/common/context/UserContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | const UserContext = createContext({}); 4 | 5 | export { UserContext }; 6 | -------------------------------------------------------------------------------- /backend/src/common/middleware/request-type.js: -------------------------------------------------------------------------------- 1 | const RequestType = { 2 | BODY: "body", 3 | PARAMS: "params", 4 | QUERY: "query" 5 | }; 6 | 7 | export { RequestType }; 8 | -------------------------------------------------------------------------------- /frontend/src/components/MainTemplate/MainContext/MainContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const MainContext = React.createContext(); 4 | 5 | export default MainContext; 6 | -------------------------------------------------------------------------------- /frontend/src/components/Header/HeaderContext/HeaderContext.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const HeaderContext = React.createContext(); 4 | 5 | export default HeaderContext; 6 | -------------------------------------------------------------------------------- /frontend/src/components/FilterBar/FilterBarContext/FilterBarContext.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const FilterBarContext = React.createContext(); 4 | 5 | export default FilterBarContext; 6 | -------------------------------------------------------------------------------- /frontend/src/components/MainTemplate/MainContext/MainContentContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const MainContentContext = React.createContext(); 4 | 5 | export default MainContentContext; 6 | -------------------------------------------------------------------------------- /backend/src/common/type/index.js: -------------------------------------------------------------------------------- 1 | export { default as ISSUESTATE } from "./issue-state"; 2 | export { default as MILESTONESTATE } from "./milestone-state"; 3 | export { default as USER_TYPE } from "./user-type"; 4 | -------------------------------------------------------------------------------- /frontend/src/components/Checkbox/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Checkbox = ({ ...rest }) => { 4 | return ; 5 | }; 6 | 7 | export default Checkbox; 8 | -------------------------------------------------------------------------------- /backend/src/dto/user.js: -------------------------------------------------------------------------------- 1 | import { IsString, IsOptional } from "class-validator"; 2 | 3 | class GetUserQuery { 4 | @IsOptional() 5 | @IsString() 6 | type; 7 | } 8 | 9 | export { GetUserQuery }; 10 | -------------------------------------------------------------------------------- /backend/src/common/lib/index.js: -------------------------------------------------------------------------------- 1 | export * as authenticator from "./authenticator"; 2 | export * as tokenGenerator from "./token-generator"; 3 | export * as crypto from "./crypto"; 4 | export { QueryParser } from "./query-parser"; 5 | -------------------------------------------------------------------------------- /frontend/src/components/IssueFilterMenu/IssueFilterMenuContext/IssueFilterMenuContext.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const IssueFilterMenuContext = React.createContext(); 4 | 5 | export default IssueFilterMenuContext; 6 | -------------------------------------------------------------------------------- /frontend/src/components/IssueDetail/Top.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Top = styled.div` 4 | width: 65%; 5 | display: flex; 6 | flex-direction: column; 7 | `; 8 | 9 | export default Top; 10 | -------------------------------------------------------------------------------- /backend/src/main.js: -------------------------------------------------------------------------------- 1 | import { ApplicationFactory } from "./application-factory"; 2 | 3 | async function main() { 4 | const app = await ApplicationFactory.create(); 5 | await app.listen(process.env.port || 5000); 6 | } 7 | 8 | main(); 9 | -------------------------------------------------------------------------------- /frontend/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | const common = require("./webpack.config.common.js"); 3 | 4 | module.exports = (env) => 5 | merge(common(env), { 6 | mode: "production" 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/src/components/IssueDetail/Middle.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Middle = styled.div` 4 | width: 65%; 5 | display: flex; 6 | justify-content: center; 7 | `; 8 | 9 | export default Middle; 10 | -------------------------------------------------------------------------------- /frontend/src/components/IssueDetail/index.js: -------------------------------------------------------------------------------- 1 | export { default as Top } from "./Top"; 2 | export { default as Middle } from "./Middle"; 3 | export { default as MiddleLeft } from "./MiddleLeft"; 4 | export { default as MiddleRight } from "./MiddleRight"; 5 | -------------------------------------------------------------------------------- /backend/src/common/error/business-error.js: -------------------------------------------------------------------------------- 1 | class BusinessError extends Error { 2 | constructor(errorCode, message) { 3 | super(message || errorCode.message); 4 | this.errorCode = errorCode; 5 | } 6 | } 7 | 8 | export { BusinessError }; 9 | -------------------------------------------------------------------------------- /frontend/src/components/Form/FormLabel.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledFormLabel = styled.label` 4 | margin-right: auto; 5 | margin-left: 15%; 6 | font-weight: bold; 7 | `; 8 | 9 | export default StyledFormLabel; 10 | -------------------------------------------------------------------------------- /frontend/src/components/ListGroup/index.js: -------------------------------------------------------------------------------- 1 | export { default as Area } from "./ListGroupArea"; 2 | export { default as Header } from "./ListGroupHeader"; 3 | export { default as Item } from "./ListGroupItem"; 4 | export { default as ItemList } from "./ListGroupItemList"; 5 | -------------------------------------------------------------------------------- /backend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 150, 3 | tabWidth: 4, 4 | singleQuote: false, 5 | trailingComma: "none", 6 | bracketSpacing: true, 7 | semi: true, 8 | useTabs: false, 9 | arrowParens: "always", 10 | endOfLine: "lf" 11 | }; 12 | -------------------------------------------------------------------------------- /backend/test/TransactionWrapper.js: -------------------------------------------------------------------------------- 1 | import { Transactional } from "typeorm-transactional-cls-hooked"; 2 | 3 | class TransactionWrapper { 4 | @Transactional() 5 | static async transaction(cb) { 6 | await cb(); 7 | } 8 | } 9 | 10 | export { TransactionWrapper }; 11 | -------------------------------------------------------------------------------- /frontend/src/components/IssueDetail/MiddleRight.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledMiddleRightContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | margin-left: 1rem; 7 | `; 8 | 9 | export default StyledMiddleRightContainer; 10 | -------------------------------------------------------------------------------- /backend/src/application-factory.js: -------------------------------------------------------------------------------- 1 | import { Application } from "./application"; 2 | 3 | export class ApplicationFactory { 4 | static async create() { 5 | const application = new Application(); 6 | await application.initialize(); 7 | return application; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📕 제목 2 | 3 | Github 이슈 제목 4 | 5 | 6 | ## 📗 작업 내용 7 | 8 | > 구현 내용 및 작업 했던 내역 9 | 10 | - [ ] 작업 내역 1 11 | - [ ] 작업 내역 2 12 | - [ ] 작업 내역 3 13 | - [ ] 작업 내역 4 14 | 15 | 16 | ## 📘 PR 특이 사항 17 | 18 | > PR을 볼 때 주의깊게 봐야하거나 말하고 싶은 점 19 | 20 | - 특이 사항 1 21 | - 특이 사항 2 -------------------------------------------------------------------------------- /frontend/src/components/Form/index.js: -------------------------------------------------------------------------------- 1 | export { default as Container } from "./FormContainer"; 2 | export { default as Input } from "./FormInput"; 3 | export { default as Label } from "./FormLabel"; 4 | export { default as Submit } from "./FormSubmit"; 5 | export { default as WarningMessage } from "./WarningMessage"; 6 | -------------------------------------------------------------------------------- /frontend/src/pages/NewMilestonePage.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MilestoneForm } from "@components"; 3 | 4 | const NewMilestonePage = () => { 5 | return ( 6 | <> 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default NewMilestonePage; 13 | -------------------------------------------------------------------------------- /backend/src/common/error/forbidden-error.js: -------------------------------------------------------------------------------- 1 | import { BusinessError } from "./business-error"; 2 | import { ErrorCode } from "./error-code"; 3 | 4 | class ForbiddenError extends BusinessError { 5 | constructor() { 6 | super(ErrorCode.FORBIDDEN); 7 | } 8 | } 9 | 10 | export { ForbiddenError }; 11 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /backend/src/common/error/bad-request-error.js: -------------------------------------------------------------------------------- 1 | import { BusinessError } from "./business-error"; 2 | import { ErrorCode } from "./error-code"; 3 | 4 | class BadRequestError extends BusinessError { 5 | constructor() { 6 | super(ErrorCode.BAD_REQUEST); 7 | } 8 | } 9 | 10 | export { BadRequestError }; 11 | -------------------------------------------------------------------------------- /backend/src/common/middleware/query-mapper.js: -------------------------------------------------------------------------------- 1 | const queryMapper = (queryParser) => { 2 | return (req, res, next) => { 3 | const queryMap = queryParser.parse(req?.query?.q); 4 | req.context = { ...req?.context, queryMap }; 5 | 6 | next(); 7 | }; 8 | }; 9 | 10 | export { queryMapper }; 11 | -------------------------------------------------------------------------------- /backend/src/common/error/unauthorized-error.js: -------------------------------------------------------------------------------- 1 | import { BusinessError } from "./business-error"; 2 | import { ErrorCode } from "./error-code"; 3 | 4 | class UnauthorizedError extends BusinessError { 5 | constructor() { 6 | super(ErrorCode.UNAUTHORIZED); 7 | } 8 | } 9 | 10 | export { UnauthorizedError }; 11 | -------------------------------------------------------------------------------- /frontend/src/client/Root.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter } from "react-router-dom"; 3 | import App from "../shared/App"; 4 | 5 | const Root = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Root; 14 | -------------------------------------------------------------------------------- /frontend/src/common/utils/waitAuthorization.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Config from "@config"; 3 | 4 | const waitAuthorizationApi = async () => { 5 | const userInfo = await axios.get(Config.API.GET_AUTH, { withCredentials: true }); 6 | return userInfo; 7 | }; 8 | 9 | export default waitAuthorizationApi; 10 | -------------------------------------------------------------------------------- /backend/src/common/error/entity-already-exist.js: -------------------------------------------------------------------------------- 1 | import { BusinessError } from "./business-error"; 2 | import { ErrorCode } from "./error-code"; 3 | 4 | class EntityAlreadyExist extends BusinessError { 5 | constructor() { 6 | super(ErrorCode.ENTITY_ALREADY_EXIST); 7 | } 8 | } 9 | 10 | export { EntityAlreadyExist }; 11 | -------------------------------------------------------------------------------- /backend/src/common/error/entity-not-found-error.js: -------------------------------------------------------------------------------- 1 | import { BusinessError } from "./business-error"; 2 | import { ErrorCode } from "./error-code"; 3 | 4 | class EntityNotFoundError extends BusinessError { 5 | constructor() { 6 | super(ErrorCode.ENTITY_NOT_FOUND); 7 | } 8 | } 9 | 10 | export { EntityNotFoundError }; 11 | -------------------------------------------------------------------------------- /backend/src/common/middleware/validator.js: -------------------------------------------------------------------------------- 1 | import { validateOrReject } from "class-validator"; 2 | 3 | const validator = (reqTypes) => { 4 | return async (req, res, next) => { 5 | await Promise.all(reqTypes.map((type) => validateOrReject(req[type]))); 6 | next(); 7 | }; 8 | }; 9 | 10 | export { validator }; 11 | -------------------------------------------------------------------------------- /frontend/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Root from "./client/Root"; 4 | import "@style/reset.scss"; 5 | import "@style/common.scss"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 150, 3 | tabWidth: 4, 4 | singleQuote: false, 5 | trailingComma: "none", 6 | bracketSpacing: true, 7 | semi: true, 8 | useTabs: false, 9 | arrowParens: "always", 10 | endOfLine: "lf", 11 | jsxBracketSameLine: true, 12 | jsxSingleQuote: false 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import apiRouter from "./api"; 3 | import oauthRouter from "./oauth"; 4 | import authRouter from "./auth"; 5 | 6 | const router = express.Router(); 7 | 8 | router.use("/api", apiRouter); 9 | router.use("/oauth", oauthRouter); 10 | router.use("/auth", authRouter); 11 | 12 | export { router }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/IssueFilterMenu/IssueFilterMenuContainer/IssueFilterMenuContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import IssueFilterMenuPresenter from "../IssueFilterMenuPresenter/IssueFilterMenuPresenter"; 3 | 4 | const IssueFilterMenuContainer = () => { 5 | return ; 6 | }; 7 | 8 | export default IssueFilterMenuContainer; 9 | -------------------------------------------------------------------------------- /frontend/src/components/IssueDetail/MiddleLeft.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledMiddleLeftContainer = styled.div` 4 | width: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | margin-right: 1rem; 8 | justify-content: center; 9 | align-items: center; 10 | `; 11 | 12 | export default StyledMiddleLeftContainer; 13 | -------------------------------------------------------------------------------- /backend/src/common/env/env-type.js: -------------------------------------------------------------------------------- 1 | const EnvType = { 2 | PRODUCTION: "production", 3 | DEVELOPMENT: "development", 4 | LOCAL: "local", 5 | TEST: "test", 6 | values: () => Object.values(EnvType).filter((value) => typeof value === "string"), 7 | contains: (env) => EnvType.values().filter((value) => value === env).length !== 0 8 | }; 9 | 10 | export { EnvType }; 11 | -------------------------------------------------------------------------------- /backend/src/common/middleware/transformer.js: -------------------------------------------------------------------------------- 1 | import { plainToClass } from "class-transformer"; 2 | 3 | const transformer = (reqTypes, TArr) => { 4 | return (req, res, next) => { 5 | reqTypes.forEach((type, index) => { 6 | req[type] = plainToClass(TArr[index], req[type]); 7 | }); 8 | next(); 9 | }; 10 | }; 11 | 12 | export { transformer }; 13 | -------------------------------------------------------------------------------- /backend/src/router/oauth.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { GithubAuthenticator } from "../common/lib/authenticator"; 3 | 4 | const router = express.Router(); 5 | 6 | router.get("/github", GithubAuthenticator.redirectToGithub); 7 | 8 | router.get("/github/login", GithubAuthenticator.validateState, GithubAuthenticator.sendTokenToClient); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /frontend/src/components/Form/WarningMessage.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { color } from "@style/color"; 3 | 4 | const WarningMessage = styled.div` 5 | display: none; 6 | color: ${color.form_warning_msg}; 7 | font-size: 0.3rem; 8 | margin-right: auto; 9 | margin-left: 15%; 10 | margin-bottom: 0.5rem; 11 | `; 12 | 13 | export default WarningMessage; 14 | -------------------------------------------------------------------------------- /backend/.babelrc.js: -------------------------------------------------------------------------------- 1 | const presets = ["@babel/env"]; 2 | const plugins = [ 3 | "@babel/plugin-transform-runtime", 4 | ["@babel/plugin-proposal-decorators", { legacy: true }], 5 | ["@babel/plugin-proposal-class-properties", { loose: true }], 6 | ["@babel/plugin-proposal-private-methods", { loose: true }], 7 | "babel-plugin-parameter-decorator" 8 | ]; 9 | 10 | module.exports = { presets, plugins }; 11 | -------------------------------------------------------------------------------- /backend/src/common/lib/token-generator.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | const generateJWTToken = ( 4 | payload, 5 | options = { 6 | expiresIn: process.env.JWT_OPTION_TOKEN_EXPIRES_IN, 7 | issuer: process.env.JWT_OPTION_TOKEN_ISSUER, 8 | subject: process.env.JWT_OPTION_TOKEN_SUBJECT 9 | } 10 | ) => jwt.sign(payload, process.env.JWT_SECRET, options); 11 | 12 | export { generateJWTToken }; 13 | -------------------------------------------------------------------------------- /frontend/src/components/Form/FormInput.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { color } from "@style/color"; 3 | 4 | const StyledFormInput = styled.input` 5 | width: 70%; 6 | border-radius: 0.2rem; 7 | border: 0.1rem solid ${color.signup_box_border}; 8 | padding: 0.5rem; 9 | margin-top: 0.5rem; 10 | margin-bottom: 0.5rem; 11 | box-sizing: border-box; 12 | `; 13 | 14 | export default StyledFormInput; 15 | -------------------------------------------------------------------------------- /frontend/src/components/MainTemplate/Top.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const TopSection = styled.section` 5 | display: flex; 6 | width: 100%; 7 | max-width: 1024px; 8 | justify-content: space-between; 9 | margin-bottom: 15px; 10 | `; 11 | 12 | const Top = ({ children, ...rest }) => { 13 | return {children} ; 14 | }; 15 | 16 | export default Top; 17 | -------------------------------------------------------------------------------- /backend/src/service/index.js: -------------------------------------------------------------------------------- 1 | import { UserService } from "./user-service"; 2 | import { LabelService } from "./label-service"; 3 | import { LabelToIssueService } from "./label-to-issue-service"; 4 | import { CommentService } from "./comment-service"; 5 | import { MilestoneService } from "./milestone-service"; 6 | import { IssueService } from "./issue-service"; 7 | 8 | export { UserService, LabelService, LabelToIssueService, CommentService, MilestoneService, IssueService }; 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 💁 설명 11 | 12 | Github 이슈 설명 13 | 14 | 15 | 16 | ## 📑 체크리스트 17 | 18 | > 구현해야하는 이슈 체크리스트 19 | 20 | - [ ] 체크 사항 1 21 | - [ ] 체크 사항 2 22 | - [ ] 체크 사항 3 23 | - [ ] 체크 사항 4 24 | 25 | 26 | 27 | ## 🚧 주의 사항 28 | 29 | > 이슈를 구현할 때 유의깊게 살펴볼 사항 30 | 31 | - 주의 사항 1 32 | - 주의 사항 2 33 | -------------------------------------------------------------------------------- /backend/src/router/label.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { labelController } from "../controller"; 3 | 4 | const router = express.Router(); 5 | 6 | router.post("/", labelController.validateLabelParam, labelController.addLabel); 7 | router.get("/", labelController.getLabels); 8 | router.put("/:labelId", labelController.validateLabelParam, labelController.changeLabel); 9 | router.delete("/:labelId", labelController.removeLabel); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /backend/src/dto/auth.js: -------------------------------------------------------------------------------- 1 | import { IsString, IsEmail, Length } from "class-validator"; 2 | 3 | class LoginRequestBody { 4 | @IsString() 5 | @IsEmail() 6 | email; 7 | 8 | @IsString() 9 | password; 10 | } 11 | 12 | class SignupRequestBody { 13 | @IsString() 14 | @IsEmail() 15 | email; 16 | 17 | @IsString() 18 | @Length(4, 20) 19 | name; 20 | 21 | @IsString() 22 | password; 23 | } 24 | 25 | export { LoginRequestBody, SignupRequestBody }; 26 | -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/authenticator-manager.js: -------------------------------------------------------------------------------- 1 | import passport from "./passport"; 2 | import * as GithubAuthenticator from "./github-authenticator"; 3 | import * as JwtAuthenticator from "./jwt-authenticator"; 4 | 5 | const initializeAuthenticator = (httpserver) => { 6 | GithubAuthenticator.setStrategy(); 7 | JwtAuthenticator.setStrategy(); 8 | httpserver.use(passport.initialize()); 9 | }; 10 | 11 | export { GithubAuthenticator, JwtAuthenticator, initializeAuthenticator }; 12 | -------------------------------------------------------------------------------- /backend/src/common/lib/crypto.js: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | 3 | const SALT_ROUNDS = 10; 4 | 5 | const encrypt = async (data) => { 6 | const genSalt = async (rounds) => await bcrypt.genSalt(rounds); 7 | const hash = async (data, salt) => await bcrypt.hash(data, salt); 8 | 9 | return await hash(data, await genSalt(SALT_ROUNDS)); 10 | }; 11 | 12 | const compare = async (data, hash) => await bcrypt.compare(data, hash); 13 | 14 | module.exports = { 15 | encrypt, 16 | compare 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/ListGroup/ListGroupArea.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const ListGroupSection = styled.section` 5 | width: 100%; 6 | & ul { 7 | margin: 0; 8 | padding: 0; 9 | li { 10 | list-style: none; 11 | } 12 | } 13 | `; 14 | 15 | const ListGroupArea = ({ children, ...rest }) => { 16 | return {children}; 17 | }; 18 | 19 | export default ListGroupArea; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Form/FormContainer.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { color } from "@style/color"; 3 | 4 | const StyledFormContainer = styled.div` 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | place-items: center; 9 | background-color: ${color.signup_container}; 10 | width: 30%; 11 | height: 40%; 12 | border-radius: 0.2rem; 13 | border: 0.1rem; solid ${color.signup_box_border}; 14 | `; 15 | 16 | export default StyledFormContainer; 17 | -------------------------------------------------------------------------------- /frontend/src/components/UserProfile/UserProfile.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledImg = styled.img` 5 | width: ${(props) => props.width}; 6 | height: ${(props) => props.height}; 7 | overflow: hidden; 8 | line-height: 1; 9 | vertical-align: middle; 10 | border-radius: 50% !important; 11 | `; 12 | 13 | const UserProfile = ({ imageUrl, width, height }) => { 14 | return ; 15 | }; 16 | 17 | export default UserProfile; 18 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { merge } = require("webpack-merge"); 3 | const common = require("./webpack.config.common.js"); 4 | 5 | module.exports = (env) => 6 | merge(common(env), { 7 | mode: "development", 8 | devtool: "eval", 9 | devServer: { 10 | contentBase: path.join(__dirname, "dist"), 11 | inline: true, 12 | hot: true, 13 | host: "localhost", 14 | port: 5500, 15 | historyApiFallback: true 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /backend/src/common/config/database/database-type.js: -------------------------------------------------------------------------------- 1 | const DatabaseType = { 2 | MYSQL: "mysql", 3 | SQLITE3: "sqlite", 4 | valueOf: (typeString) => { 5 | return Object.keys(DatabaseType) 6 | .filter((value) => DatabaseType[value] === typeString) 7 | .reduce((value) => value, null); 8 | }, 9 | values: () => Object.values(DatabaseType).filter((value) => typeof value === "string"), 10 | contains: (db) => DatabaseType.values().filter((value) => value === db).length !== 0 11 | }; 12 | 13 | export { DatabaseType }; 14 | -------------------------------------------------------------------------------- /frontend/src/components/IssueIcon/IssueIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import issueOpenIcon from "@imgs/issue-open-icon.png"; 3 | import issueClosedIcon from "@imgs/issue-closed-icon.png"; 4 | import styled from "styled-components"; 5 | 6 | const IssueIconImg = styled.img` 7 | width: 16px; 8 | height: 16px; 9 | `; 10 | 11 | const IssueIcon = ({ ...rest }) => { 12 | const { open, close } = rest; 13 | 14 | return ; 15 | }; 16 | 17 | export default IssueIcon; 18 | -------------------------------------------------------------------------------- /frontend/src/components/Main/Main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const MainContainer = styled.main` 6 | display: flex; 7 | flex-direction: column; 8 | justfy-content: center; 9 | align-items: center; 10 | min-height: 100%; 11 | margin-top: 20px; 12 | background-color: ${color.main_bg}; 13 | `; 14 | 15 | const Main = ({ children, ...rest }) => { 16 | return {children} ; 17 | }; 18 | 19 | export default Main; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Caret/Caret.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { color } from "@style/color"; 3 | 4 | const Caret = styled.span` 5 | display: inline-block; 6 | width: 0; 7 | height: 0; 8 | vertical-align: middle; 9 | content: ""; 10 | border-top-style: solid; 11 | border-top-width: 4px; 12 | border-right: 4px solid transparent; 13 | border-bottom: 0 solid transparent; 14 | border-left: 4px solid transparent; 15 | color: ${(props) => (props.primary ? color.caret_text : "#586069")}; 16 | `; 17 | 18 | export default Caret; 19 | -------------------------------------------------------------------------------- /backend/src/controller/index.js: -------------------------------------------------------------------------------- 1 | import * as userController from "./user-controller"; 2 | import * as labelController from "./label-controller"; 3 | import * as issueController from "./issue-controller"; 4 | import * as labelToIssueController from "./label-to-issue-controller"; 5 | import * as commentController from "./comment-controller"; 6 | import * as milestoneController from "./milestone-controller"; 7 | import * as authController from "./auth-controller"; 8 | 9 | export { userController, labelController,authController, issueController, labelToIssueController, commentController, milestoneController }; 10 | -------------------------------------------------------------------------------- /backend/src/router/user.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { userController } from "../controller"; 3 | import { RequestType } from "../common/middleware/request-type"; 4 | import { transformer } from "../common/middleware/transformer"; 5 | import { validator } from "../common/middleware/validator"; 6 | import { GetUserQuery } from "../dto/user"; 7 | 8 | const router = express.Router(); 9 | 10 | router.get("/", transformer([RequestType.QUERY], [GetUserQuery]), validator([RequestType.QUERY]), userController.sendUsers); 11 | 12 | router.get("/info", userController.sendUserInfo); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /backend/src/common/error/error-code.js: -------------------------------------------------------------------------------- 1 | const ErrorCode = { 2 | ENTITY_NOT_FOUND: { code: 1000, httpStatusCode: 404, message: "Entity is not found" }, 3 | ENTITY_ALREADY_EXIST: { code: 1001, httpStatusCode: 400, message: "Entity already exists" }, 4 | UNAUTHORIZED: { code: 1002, httpStatusCode: 401, message: "Unauthorized" }, 5 | FORBIDDEN: { code: 1003, httpStatusCode: 403, message: "Forbidden" }, 6 | BAD_REQUEST: { code: 1004, httpStatusCode: 400, message: "Bad Request" }, 7 | VALIDATION_ERROR: { code: 1005, httpStatusCode: 400, message: "Validation error occurs" } 8 | }; 9 | 10 | export { ErrorCode }; 11 | -------------------------------------------------------------------------------- /frontend/src/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as MainPage } from "./MainPage"; 2 | export { default as LoginPage } from "./LoginPage"; 3 | export { default as SignupPage } from "./SignupPage"; 4 | export { default as LoadingPage } from "./LoadingPage"; 5 | export { default as IssueDetailPage } from "./IssueDetailPage"; 6 | export { default as LabelPage } from "./LabelPage"; 7 | export { default as MilestonePage } from "./MilestonePage"; 8 | export { default as NewIssuePage } from "./NewIssuePage"; 9 | export { default as NewMilestonePage } from "./NewMilestonePage"; 10 | export { default as EditMilestonePage } from "./EditMilstonePage"; 11 | -------------------------------------------------------------------------------- /frontend/src/components/Form/FormSubmit.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { color } from "@style/color"; 3 | 4 | const StyledSubmitButton = styled.button` 5 | width: 70%; 6 | border-radius: 0.2rem; 7 | padding-top: 0.5rem; 8 | padding-bottom: 0.5rem; 9 | margin-top: 0.5rem; 10 | margin-bottom: 0.5rem; 11 | border: 0.1rem solid ${color.signup_box_border}; 12 | color: ${color.signup_submit_text}; 13 | font-weight: bold; 14 | box-sizing: border-box; 15 | background-color: ${(props) => (props.disabled ? color.signup_submit_disabled : color.signup_submit)}; 16 | `; 17 | 18 | export default StyledSubmitButton; 19 | -------------------------------------------------------------------------------- /backend/src/router/api.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { JwtAuthenticator } from "../common/lib/authenticator"; 3 | import userRouter from "./user"; 4 | import labelRouter from "./label"; 5 | import issueRouter from "./issue"; 6 | import labelToIssueRouter from "./label-to-issue"; 7 | import milestoneRouter from "./milestone"; 8 | 9 | const router = express.Router(); 10 | 11 | router.use("/", JwtAuthenticator.validateAuthorization); 12 | router.use("/user", userRouter); 13 | router.use("/label", labelRouter); 14 | router.use("/", labelToIssueRouter); 15 | router.use("/issue", issueRouter); 16 | router.use("/milestone", milestoneRouter); 17 | 18 | export default router; 19 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mysql: 4 | image: mysql:5.7 5 | # volumes: 6 | # - "./.mysql:/var/lib/mysql" 7 | restart: always 8 | ports: 9 | - ${MYSQL_PORT}:3306 10 | environment: 11 | MYSQL_DATABASE: ${MYSQL_DATABASE} 12 | MYSQL_USER: ${MYSQL_USER} 13 | MYSQL_PASSWORD: ${MYSQL_PASSWORD} 14 | MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} 15 | command: 16 | - --default-authentication-plugin=mysql_native_password 17 | - --character-set-server=utf8 18 | - --collation-server=utf8_unicode_ci 19 | adminer: 20 | image: adminer 21 | container_name: adminer 22 | ports: 23 | - 8888:8080 -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { LoginForm } from "@components"; 4 | 5 | const Main = styled.main` 6 | display: flex; 7 | flex-direction: column; 8 | height: 100%; 9 | background-color: #f2f2f2; 10 | justify-content: center; 11 | place-items: center; 12 | `; 13 | 14 | const LoginTitle = styled.div` 15 | font-size: 2rem; 16 | margin-bottom: 3rem; 17 | font-weight: bold; 18 | `; 19 | 20 | const LoginPage = () => { 21 | return ( 22 |
23 | 이슈 트래커 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default LoginPage; 30 | -------------------------------------------------------------------------------- /frontend/src/components/ListGroup/ListGroupHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const ListGroupHeaderArea = styled.div` 6 | display: flex; 7 | align-items: center; 8 | padding: 23px; 9 | border: 1px solid ${color.list_group_header_border}; 10 | border-top-left-radius: 6px; 11 | border-top-right-radius: 6px; 12 | background-color: ${color.list_group_header_bg}; 13 | & > input { 14 | margin-right: 17px; 15 | } 16 | `; 17 | 18 | const ListGroupHeader = ({ children, ...rest }) => { 19 | return {children}; 20 | }; 21 | 22 | export default ListGroupHeader; 23 | -------------------------------------------------------------------------------- /.gitmessage.txt: -------------------------------------------------------------------------------- 1 | # <타입>: [FE]/[BE] <제목> 2 | 3 | ##### 제목은 최대 50 글자까지만 입력 ############## -> | 4 | 5 | 6 | # 본문은 위에 작성 7 | ######## 본문은 한 줄에 최대 72 글자까지만 입력 ########################### -> | 8 | 9 | # 꼬릿말은 아래에 작성: ex) #이슈 번호 10 | 11 | # --- COMMIT END --- 12 | # <타입> 리스트 13 | # feat : 기능 (새로운 기능) 14 | # fix : 버그 (버그 수정) 15 | # refactor: 리팩토링 16 | # style : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음) 17 | # docs : 문서 (문서 추가, 수정, 삭제) 18 | # test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음) 19 | # chore : 기타 변경사항 (빌드 스크립트 수정 등) 20 | # ------------------ 21 | # 제목 첫 글자를 대문자로 22 | # 제목은 명령문으로 23 | # 제목 끝에 마침표(.) 금지 24 | # 제목과 본문을 한 줄 띄워 분리하기 25 | # 본문은 "어떻게" 보다 "무엇을", "왜"를 설명한다. 26 | # 본문에 여러줄의 메시지를 작성할 땐 "-"로 구분 27 | # ------------------ 28 | -------------------------------------------------------------------------------- /frontend/src/common/hook/usePromise.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | const usePromise = (promiseCreator, deps, ...args) => { 4 | const [resolved, setResolved] = useState(null); 5 | const [loading, setLoading] = useState(false); 6 | const [error, setError] = useState(null); 7 | 8 | const process = async () => { 9 | setLoading(true); 10 | try { 11 | const result = await promiseCreator(...args); 12 | setResolved(result); 13 | } catch (e) { 14 | setError(e); 15 | } 16 | setLoading(false); 17 | }; 18 | 19 | useEffect(() => { 20 | process(); 21 | }, deps); 22 | 23 | return [loading, resolved, error]; 24 | }; 25 | 26 | export default usePromise; 27 | -------------------------------------------------------------------------------- /frontend/src/pages/SignupPage.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { SignupForm } from "@components"; 4 | import { color } from "@style/color"; 5 | 6 | const Main = styled.main` 7 | display: flex; 8 | flex-direction: column; 9 | height: 100%; 10 | background-color: ${color.signup_page_bg}; 11 | justify-content: center; 12 | place-items: center; 13 | `; 14 | 15 | const SignupTitle = styled.div` 16 | font-size: 2rem; 17 | margin-bottom: 3rem; 18 | font-weight: bold; 19 | `; 20 | 21 | const SignupPage = () => { 22 | return ( 23 |
24 | 회원 가입 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default SignupPage; 31 | -------------------------------------------------------------------------------- /frontend/src/components/ListGroup/ListGroupItem.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const ListGroupItemLi = styled.li` 6 | padding: 23px; 7 | border-left: 1px solid #eaecef; 8 | border-right: 1px solid #eaecef; 9 | border-bottom: 1px solid #eaecef; 10 | background-color: ${color.list_group_item_bg}; 11 | &:last-of-type { 12 | border-bottom-left-radius: 6px; 13 | border-bottom-right-radius: 6px; 14 | } 15 | &:hover { 16 | background-color: ${color.list_group_item_hover_bg}; 17 | } 18 | `; 19 | 20 | const ListGroupItem = ({ children, ...rest }) => { 21 | return {children}; 22 | }; 23 | 24 | export default ListGroupItem; 25 | -------------------------------------------------------------------------------- /frontend/src/components/ProgressBar/ProgressBar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledProgressBar = styled.progress` 5 | -webkit-appearance: none; 6 | width: 95%; 7 | height: 8px; 8 | border-radius: 4px; 9 | ::-webkit-progress-bar { 10 | background-color: #eeeeee; 11 | border-radius: 4px; 12 | } 13 | ::-webkit-progress-value { 14 | background-color: #34d058; 15 | border-top-left-radius: 4px; 16 | border-bottom-left-radius: 4px; 17 | } 18 | `; 19 | 20 | const ProgressBar = ({ children, value, max }) => { 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | export default ProgressBar; 29 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: ["plugin:react/recommended", "airbnb", "prettier", "prettier/react"], 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | impliedStric: true 12 | }, 13 | ecmaVersion: 12, 14 | sourceType: "module" 15 | }, 16 | plugins: ["react", "prettier"], 17 | rules: { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | endOfLine: "auto" 22 | } 23 | ], 24 | "import/no-unresolved": "off", 25 | "no-param-reassign": "off", 26 | "import/prefer-default-export": "off", 27 | "react/prop-types": "off", 28 | "react/jsx-props-no-spreading": "off" 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /backend/src/router/auth.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { RequestType } from "../common/middleware/request-type"; 3 | import { transformer } from "../common/middleware/transformer"; 4 | import { validator } from "../common/middleware/validator"; 5 | import { LoginRequestBody, SignupRequestBody } from "../dto/auth"; 6 | import { JwtAuthenticator } from "../common/lib/authenticator"; 7 | import { authController } from "../controller"; 8 | 9 | const router = express.Router(); 10 | 11 | router.post("/signup", transformer([RequestType.BODY], [SignupRequestBody]), validator([RequestType.BODY]), authController.signup); 12 | 13 | router.post("/login", transformer([RequestType.BODY], [LoginRequestBody]), validator([RequestType.BODY]), authController.login); 14 | 15 | router.get("/logout", JwtAuthenticator.validateAuthorization, authController.logout); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/EditMilstonePage.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MilestoneForm } from "@components"; 3 | import { usePromise } from "@hook"; 4 | import { API } from "@utils"; 5 | import { LoadingPage } from "@pages"; 6 | 7 | const EditMilestonePage = ({ match }) => { 8 | const { milestoneId } = match.params; 9 | 10 | const getMilestone = async () => { 11 | const milestone = await API.getMilestone(milestoneId); 12 | return milestone; 13 | }; 14 | 15 | const [loading, resolved, error] = usePromise(getMilestone, []); 16 | 17 | if (loading) return ; 18 | if (error) window.location.href = "/"; 19 | if (!resolved) return null; 20 | 21 | const milestone = resolved.data; 22 | 23 | return ( 24 | <> 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default EditMilestonePage; 31 | -------------------------------------------------------------------------------- /backend/src/model/label-to-issue.js: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, JoinColumn } from "typeorm"; 2 | import { Issue } from "./issue"; 3 | import { Label } from "./label"; 4 | 5 | @Entity() 6 | class LabelToIssue { 7 | @PrimaryGeneratedColumn("increment", { type: "int" }) 8 | id; 9 | 10 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 11 | createdAt; 12 | 13 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 14 | updatedAt; 15 | 16 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 17 | deletedAt; 18 | 19 | @ManyToOne(() => Label, (label) => label.labelToIssues) 20 | @JoinColumn({ name: "label_id" }) 21 | label; 22 | 23 | @ManyToOne(() => Issue, (issue) => issue.labelToIssues) 24 | @JoinColumn({ name: "issue_id" }) 25 | issue; 26 | } 27 | 28 | export { LabelToIssue }; 29 | -------------------------------------------------------------------------------- /backend/src/model/user-to-issue.js: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, JoinColumn } from "typeorm"; 2 | import { Issue } from "./issue"; 3 | import { User } from "./user"; 4 | 5 | @Entity({ name: "user_to_issue" }) 6 | class UserToIssue { 7 | @PrimaryGeneratedColumn("increment", { type: "int" }) 8 | id; 9 | 10 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 11 | createdAt; 12 | 13 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 14 | updatedAt; 15 | 16 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 17 | deletedAt; 18 | 19 | @ManyToOne(() => User, (user) => user.userToIssues) 20 | @JoinColumn({ name: "user_id" }) 21 | user; 22 | 23 | @ManyToOne(() => Issue, (issue) => issue.userToIssues) 24 | @JoinColumn({ name: "issue_id" }) 25 | issue; 26 | } 27 | 28 | export { UserToIssue }; 29 | -------------------------------------------------------------------------------- /backend/src/common/middleware/error-handler.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "class-validator"; 2 | import { BusinessError } from "../error/business-error"; 3 | import { ErrorCode } from "../error/error-code"; 4 | 5 | const errorHandler = (err, req, res, next) => { 6 | if (err instanceof BusinessError) { 7 | res.status(err.errorCode.httpStatusCode).json({ 8 | error: { 9 | code: err.errorCode.code, 10 | message: err.errorCode.message 11 | } 12 | }); 13 | } else if (Array.isArray(err) && err[0] instanceof ValidationError) { 14 | res.status(ErrorCode.VALIDATION_ERROR.httpStatusCode).json({ 15 | error: { 16 | code: ErrorCode.VALIDATION_ERROR.code, 17 | message: ErrorCode.VALIDATION_ERROR.message 18 | } 19 | }); 20 | } else { 21 | throw err; 22 | } 23 | }; 24 | 25 | export { errorHandler }; 26 | -------------------------------------------------------------------------------- /frontend/src/components/ListGroup/ListGroupItemList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const ListGroupItemUl = styled.ul` 6 | height: 100%; 7 | max-height: ${(props) => (props.isEmpty ? "328px" : "none")}; 8 | border-right: ${(props) => (props.isEmpty ? `1px solid ${color.list_group_border}` : "none")}; 9 | border-left: ${(props) => (props.isEmpty ? `1px solid ${color.list_group_border}` : "none")}; 10 | border-bottom: ${(props) => (props.isEmpty ? `1px solid ${color.list_group_border}` : "none")}; 11 | border-bottom-left-radius: 6px; 12 | border-bottom-right-radius: 6px; 13 | `; 14 | 15 | const ListGroupItemList = ({ children, ...rest }) => { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default ListGroupItemList; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Header/HeaderPresenter/HeaderTitle/HeaderTitle.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Link } from "react-router-dom"; 4 | import { color } from "@style/color"; 5 | 6 | const HeaderLogoLink = styled(Link)` 7 | display: flex; 8 | align-items: center; 9 | font-size: 17px; 10 | font-weight: 600; 11 | `; 12 | 13 | const HeaderLogoImg = styled.img` 14 | width: 32px; 15 | height: 32px; 16 | margin-right: 5px; 17 | `; 18 | 19 | const HeaderLogoTitle = styled.span` 20 | color: ${color.header_title_text}; 21 | `; 22 | 23 | const HeaderTitle = () => { 24 | return ( 25 | 26 | 27 | SnailHub 28 | 29 | ); 30 | }; 31 | 32 | export default HeaderTitle; 33 | -------------------------------------------------------------------------------- /backend/src/controller/label-to-issue-controller.js: -------------------------------------------------------------------------------- 1 | import { LabelToIssueService } from "../service"; 2 | 3 | const addLabelToIssue = async (req, res, next) => { 4 | const { labelId, issueId } = req.params; 5 | 6 | try { 7 | const labelToIssueService = LabelToIssueService.getInstance(); 8 | await labelToIssueService.addLabelToIssue(parseInt(labelId, 10), parseInt(issueId, 10)); 9 | res.status(201).send("insert label-to-issue success"); 10 | } catch (error) { 11 | next(error); 12 | } 13 | }; 14 | 15 | const removeLabelToIssue = async (req, res, next) => { 16 | const { labelId, issueId } = req.params; 17 | 18 | try { 19 | const labelToIssueService = LabelToIssueService.getInstance(); 20 | await labelToIssueService.removeLabelToIssue(labelId, issueId); 21 | res.status(204).send("delete label-to-issue success"); 22 | } catch (error) { 23 | next(error); 24 | } 25 | }; 26 | 27 | export { addLabelToIssue, removeLabelToIssue }; 28 | -------------------------------------------------------------------------------- /backend/src/model/issue-content.js: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | import { PrimaryGeneratedColumn, Column, OneToOne, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Entity } from "typeorm"; 3 | import { DatabaseType } from "../common/config/database/database-type"; 4 | import { Issue } from "./issue"; 5 | 6 | @Entity({ name: "issue_content" }) 7 | class IssueContent { 8 | @PrimaryGeneratedColumn("increment", { type: "int" }) 9 | id; 10 | 11 | @Column({ name: "content", type: process.env.DATABASE_TYPE === DatabaseType.MYSQL ? "mediumtext" : "varchar" }) 12 | @IsString() 13 | content; 14 | 15 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 16 | createdAt; 17 | 18 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 19 | updatedAt; 20 | 21 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 22 | deletedAt; 23 | 24 | @OneToOne(() => Issue, (issue) => issue.content) 25 | issue; 26 | } 27 | 28 | export { IssueContent }; 29 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es6: true, 5 | jest: true, 6 | jasmine: true 7 | }, 8 | extends: ["airbnb-base", "prettier"], 9 | parserOptions: { 10 | ecmaVersion: 11, 11 | sourceType: "module", 12 | ecmaFeatures: { 13 | globalReturn: false 14 | }, 15 | babelOptions: { 16 | configFile: "./.babelrc.js" 17 | } 18 | }, 19 | plugins: ["import", "prettier"], 20 | parser: "@babel/eslint-parser", 21 | rules: { 22 | "prettier/prettier": [ 23 | "error", 24 | { 25 | endOfLine: "auto" 26 | } 27 | ], 28 | "import/prefer-default-export": "off", 29 | "class-methods-use-this": "off", 30 | "no-useless-constructor": "off", 31 | "no-plusplus": "off", 32 | "no-unused-vars": "warn", 33 | "import/no-cycle": "off", 34 | "max-classes-per-file": "off" 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /backend/src/router/label-to-issue.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { IsNumberString } from "class-validator"; 3 | import { transformer } from "../common/middleware/transformer"; 4 | import { validator } from "../common/middleware/validator"; 5 | import { RequestType } from "../common/middleware/request-type"; 6 | import { labelToIssueController } from "../controller"; 7 | 8 | class AddLabelToIssueRequest { 9 | @IsNumberString() 10 | issueId; 11 | 12 | @IsNumberString() 13 | labelId; 14 | } 15 | 16 | const router = express.Router(); 17 | 18 | router.post( 19 | "/issue/:issueId/label/:labelId", 20 | transformer([RequestType.PARAMS], [AddLabelToIssueRequest]), 21 | validator([RequestType.PARAMS]), 22 | labelToIssueController.addLabelToIssue 23 | ); 24 | 25 | router.delete( 26 | "/issue/:issueId/label/:labelId", 27 | transformer([RequestType.PARAMS], [AddLabelToIssueRequest]), 28 | validator([RequestType.PARAMS]), 29 | labelToIssueController.removeLabelToIssue 30 | ); 31 | export default router; 32 | -------------------------------------------------------------------------------- /backend/src/model/comment-content.js: -------------------------------------------------------------------------------- 1 | import { IsString } from "class-validator"; 2 | import { PrimaryGeneratedColumn, Column, OneToOne, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Entity } from "typeorm"; 3 | import { DatabaseType } from "../common/config/database/database-type"; 4 | import { Comment } from "./comment"; 5 | 6 | @Entity({ name: "comment_content" }) 7 | class CommentContent { 8 | @PrimaryGeneratedColumn("increment", { type: "int" }) 9 | id; 10 | 11 | @Column({ name: "content", type: process.env.DATABASE_TYPE === DatabaseType.MYSQL ? "mediumtext" : "varchar", charset: "utf-8" }) 12 | @IsString() 13 | content; 14 | 15 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 16 | createdAt; 17 | 18 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 19 | updatedAt; 20 | 21 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 22 | deletedAt; 23 | 24 | @OneToOne(() => Comment, (comment) => comment.content) 25 | comment; 26 | } 27 | 28 | export { CommentContent }; 29 | -------------------------------------------------------------------------------- /frontend/src/components/Label/Label.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const defineTextColor = (color) => { 5 | const r = parseInt(color.substring(1, 3), 16); 6 | const g = parseInt(color.substring(3, 5), 16); 7 | const b = parseInt(color.substring(5, 7), 16); 8 | const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 9 | return luminance > 0.5 ? "black" : "white"; 10 | }; 11 | 12 | const StyledLabel = styled.button` 13 | background-color: ${(props) => props.color}; 14 | color: ${(props) => defineTextColor(props.color)}; 15 | border-radius: 1rem; 16 | font-size: 10px; 17 | line-height: 2; 18 | font-weight: bold; 19 | cursor: pointer; 20 | border: none; 21 | margin-left: 2.5px; 22 | margin-right: 2.5px; 23 | padding-left: 10px; 24 | padding-right: 10px; 25 | &:hover { 26 | text-decoration: underline; 27 | } 28 | `; 29 | 30 | const Label = ({ children, ...rest }) => { 31 | return {children} ; 32 | }; 33 | 34 | export default Label; 35 | -------------------------------------------------------------------------------- /frontend/config/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | API: { 3 | GET_GITHUB_LOGIN: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_GET_GITHUB_LOGIN, 4 | GET_AUTH: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_GET_AUTH, 5 | POST_LOGIN: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_POST_LOGIN, 6 | GET_LOGOUT: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_GET_LOGOUT, 7 | POST_SIGNUP: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_POST_SIGNUP, 8 | GET_ISSUES: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_GET_ISSUES, 9 | POST_ISSUE: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_POST_ISSUES, 10 | LABLE: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_LABLE, 11 | GET_ISSUE: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_GET_ISSUE, 12 | MILESTONE: process.env.ISSUE_TRACKER_APP_SERVER + process.env.ISSUE_TRACKER_APP_API_MILESTONE 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 부스트캠프 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/components/Header/HeaderPresenter/HeaderPresenter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | import HeaderTitle from "./HeaderTitle/HeaderTitle"; 5 | import HeaderDropmenu from "./HeaderDropmenu/HeaderDropmenu"; 6 | 7 | const HeaderArea = styled.header` 8 | display: flex; 9 | justify-content: center; 10 | width: 100%; 11 | padding: 15px 30px; 12 | background-color: ${color.header_bg}; 13 | box-sizing: border-box; 14 | & ul { 15 | margin: 0; 16 | padding: 0; 17 | li { 18 | list-style: none; 19 | } 20 | } 21 | `; 22 | 23 | const HeaderContent = styled.div` 24 | display: flex; 25 | justify-content: center; 26 | position: relative; 27 | width: 100%; 28 | `; 29 | 30 | const HeaderPresenter = () => { 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default HeaderPresenter; 42 | -------------------------------------------------------------------------------- /backend/src/common/lib/query-parser.js: -------------------------------------------------------------------------------- 1 | class QueryParser { 2 | constructor(firstDelimiter, secondDelimiter) { 3 | this.firstDelimiter = typeof firstDelimiter === "string" ? firstDelimiter : " "; 4 | this.secondDelimiter = typeof secondDelimiter === "string" ? secondDelimiter : ":"; 5 | } 6 | 7 | parse(queryString) { 8 | if (typeof queryString !== "string") { 9 | return null; 10 | } 11 | 12 | const queryMap = new Map(); 13 | 14 | queryString.split(this.firstDelimiter).forEach((element) => { 15 | const kv = element.split(this.secondDelimiter); 16 | if (kv.length === 2) { 17 | const key = kv[0]; 18 | const value = kv[1]; 19 | 20 | const existedValue = queryMap.get(key); 21 | 22 | if (existedValue === undefined) { 23 | queryMap.set(key, [value]); 24 | } else if (Array.isArray(existedValue)) { 25 | existedValue.push(value); 26 | } 27 | } 28 | }); 29 | 30 | return queryMap; 31 | } 32 | } 33 | 34 | export { QueryParser }; 35 | -------------------------------------------------------------------------------- /frontend/src/pages/MainPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import { usePromise } from "@hook"; 4 | import { MainTemplate, FilterBar, PageNavButton, Button } from "@components"; 5 | import { API } from "@utils"; 6 | import { LoadingPage } from "@pages"; 7 | import MainContext from "../components/MainTemplate/MainContext/MainContext"; 8 | 9 | const MainPage = () => { 10 | const [loading, resolved] = usePromise(API.getIssues, [], { page: 0 }); 11 | const [issues, setIssues] = useState(resolved); 12 | 13 | useEffect(async () => { 14 | setIssues(resolved); 15 | }, [resolved]); 16 | 17 | if (loading) return ; 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default MainPage; 34 | -------------------------------------------------------------------------------- /backend/src/controller/user-controller.js: -------------------------------------------------------------------------------- 1 | import { UserService } from "../service"; 2 | import { USER_TYPE } from "../common/type"; 3 | 4 | const sendUserInfo = (req, res, next) => { 5 | const { user } = req; 6 | res.status(200).send({ 7 | name: user.name, 8 | email: user.email, 9 | photoImage: user.profileImage 10 | }); 11 | }; 12 | 13 | const sendUsers = async (req, res, next) => { 14 | try { 15 | const { type } = req.query; 16 | const userService = UserService.getInstance(); 17 | 18 | let users; 19 | switch (type) { 20 | case USER_TYPE.AUTHUOR: 21 | users = await userService.getAuthors(); 22 | break; 23 | case USER_TYPE.ASSIGNEE: 24 | users = await userService.getAssignees(); 25 | break; 26 | default: 27 | users = await userService.getUsers(); 28 | } 29 | 30 | const jsonData = users.reduce((acc, cur) => acc.concat({ id: cur.id, name: cur.name }), []); 31 | res.status(200).json({ users: jsonData }); 32 | } catch (error) { 33 | next(error); 34 | } 35 | }; 36 | 37 | export { sendUserInfo, sendUsers }; 38 | -------------------------------------------------------------------------------- /backend/src/dto/milestone.js: -------------------------------------------------------------------------------- 1 | import { IsDate, IsString, IsNumber, IsOptional } from "class-validator"; 2 | import { Transform } from "class-transformer"; 3 | 4 | class AddMilestoneRequestBody { 5 | @IsString() 6 | title; 7 | 8 | @IsString() 9 | description; 10 | 11 | @IsString() 12 | dueDate; 13 | } 14 | 15 | class GetMilestoneRequestParams { 16 | @Transform((value) => parseInt(value, 10)) 17 | @IsNumber() 18 | milestoneId; 19 | } 20 | 21 | class ChangeMilestoneRequestParams { 22 | @Transform((value) => parseInt(value, 10)) 23 | @IsNumber() 24 | milestoneId; 25 | } 26 | 27 | class ChangeMilestoneRequestBody { 28 | @IsOptional() 29 | @IsString() 30 | title; 31 | 32 | @IsOptional() 33 | @IsString() 34 | description; 35 | 36 | @IsOptional() 37 | @IsString() 38 | state; 39 | 40 | @IsOptional() 41 | @IsString() 42 | dueDate; 43 | } 44 | 45 | class RemoveMilestoneRequestParams { 46 | @Transform((value) => parseInt(value, 10)) 47 | @IsNumber() 48 | milestoneId; 49 | } 50 | 51 | export { AddMilestoneRequestBody, GetMilestoneRequestParams, ChangeMilestoneRequestBody, ChangeMilestoneRequestParams, RemoveMilestoneRequestParams }; 52 | -------------------------------------------------------------------------------- /frontend/src/components/ContentEditor/Preview/Preview.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { Remarkable } from "remarkable"; 4 | import { color } from "@style/color"; 5 | 6 | const PreviewContainer = styled.div` 7 | width: 100%; 8 | word-break: break-all; 9 | min-height: 150px; 10 | height: auto; 11 | padding-top: 10px; 12 | padding-bottom: 10px; 13 | background-color: white; 14 | font-size: auto; 15 | padding-left: 10px; 16 | border-bottom: 2px solid ${color.border_primary}; 17 | ${(props) => 18 | props.selected && 19 | css` 20 | display: none; 21 | `} 22 | `; 23 | 24 | const View = styled.div` 25 | padding: 0px; 26 | `; 27 | 28 | const TabMenu = ({ text, selected }) => { 29 | const md = new Remarkable(); 30 | const getRawMarkup = () => { 31 | return { 32 | __html: md.render(text) 33 | }; 34 | }; 35 | return ( 36 | 37 | {text === "" ? "Nothing to preview" : ""} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default TabMenu; 44 | -------------------------------------------------------------------------------- /backend/src/model/label.js: -------------------------------------------------------------------------------- 1 | import { IsString, IsHexColor, IsOptional, Length } from "class-validator"; 2 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, DeleteDateColumn, OneToMany } from "typeorm"; 3 | import { LabelToIssue } from "./label-to-issue"; 4 | 5 | @Entity({ name: "label" }) 6 | class Label { 7 | @PrimaryGeneratedColumn("increment", { type: "int" }) 8 | id; 9 | 10 | @Column({ name: "name", type: "varchar", unique: true, charset: "utf-8" }) 11 | @IsString() 12 | name; 13 | 14 | @Column({ name: "color", type: "varchar" }) 15 | @IsString() 16 | @IsHexColor() 17 | @Length(7, 7) 18 | color; 19 | 20 | @Column({ name: "description", type: "varchar", nullable: true, charset: "utf-8" }) 21 | @IsOptional() 22 | @IsString() 23 | description; 24 | 25 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 26 | createdAt; 27 | 28 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 29 | updatedAt; 30 | 31 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 32 | deletedAt; 33 | 34 | @OneToMany(() => LabelToIssue, (labelToIssues) => labelToIssues.label) 35 | labelToIssues; 36 | } 37 | 38 | export { Label }; 39 | -------------------------------------------------------------------------------- /backend/src/model/comment.js: -------------------------------------------------------------------------------- 1 | import { IsString, IsUrl } from "class-validator"; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | DeleteDateColumn, 10 | JoinColumn, 11 | OneToOne 12 | } from "typeorm"; 13 | import { CommentContent } from "./comment-content"; 14 | import { Issue } from "./issue"; 15 | import { User } from "./user"; 16 | 17 | @Entity({ name: "comment" }) 18 | class Comment { 19 | @PrimaryGeneratedColumn("increment", { type: "int" }) 20 | id; 21 | 22 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 23 | createdAt; 24 | 25 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 26 | updatedAt; 27 | 28 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 29 | deletedAt; 30 | 31 | @ManyToOne(() => Issue, (issue) => issue.comments) 32 | @JoinColumn({ name: "issue_id" }) 33 | issue; 34 | 35 | @ManyToOne(() => User, (user) => user.comments) 36 | @JoinColumn({ name: "user_id" }) 37 | user; 38 | 39 | @OneToOne(() => CommentContent, (content) => content.comment) 40 | @JoinColumn({ name: "content_id" }) 41 | content; 42 | } 43 | 44 | export { Comment }; 45 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const Btn = styled.button` 6 | background-color: ${color.btn_bg}; 7 | border-radius: 6px; 8 | color: ${color.btn_text}; 9 | font-size: 14px; 10 | font-weight: bold; 11 | cursor: pointer; 12 | line-height: 20px; 13 | padding: 5px 16px; 14 | border: 1px solid ${color.btn_primary_border}; 15 | box-shadow: 0 1px 0 ${color.btn_primary_shadow}; 16 | transition: 0.2s cubic-bezier(0.3, 0, 0.5, 1); 17 | transition-property: color, background-color, border-color; 18 | 19 | &:hover { 20 | background-color: ${color.btn_hover_bg}; 21 | transition-duration: 0.1s; 22 | } 23 | 24 | ${(props) => 25 | props.primary && 26 | css` 27 | background-color: ${color.btn_primary_bg}; 28 | color: ${color.btn_primary_text}; 29 | border: 1px solid; 30 | 31 | &:hover { 32 | background-color: ${color.btn_primary_hover_bg}; 33 | } 34 | `} 35 | `; 36 | 37 | const Button = ({ children, ...rest }) => { 38 | return {children} ; 39 | }; 40 | 41 | export default Button; 42 | -------------------------------------------------------------------------------- /backend/src/controller/auth-controller.js: -------------------------------------------------------------------------------- 1 | import { UserService } from "../service"; 2 | import { tokenGenerator } from "../common/lib"; 3 | 4 | const signup = async (req, res, next) => { 5 | try { 6 | const { email, name, password } = req.body; 7 | const userService = UserService.getInstance(); 8 | await userService.signup({ email, name, password }); 9 | res.status(200).send("ok"); 10 | } catch (error) { 11 | next(error); 12 | } 13 | }; 14 | 15 | const login = async (req, res, next) => { 16 | try { 17 | const { email, password } = req.body; 18 | const userService = UserService.getInstance(); 19 | const user = await userService.authenticate({ email, password }); 20 | 21 | const token = tokenGenerator.generateJWTToken({ 22 | userId: user.id, 23 | username: user.name, 24 | email: user.email, 25 | photos: user.profileImage 26 | }); 27 | 28 | const EXPIRED_DATE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); 29 | res.cookie("token", token, { expires: EXPIRED_DATE, httpOnly: true }); 30 | res.status(200).send("ok"); 31 | } catch (e) { 32 | next(e); 33 | } 34 | }; 35 | 36 | const logout = (req, res) => { 37 | res.clearCookie("token").send("ok"); 38 | }; 39 | 40 | export { signup, login, logout }; 41 | -------------------------------------------------------------------------------- /backend/src/model/milestone.js: -------------------------------------------------------------------------------- 1 | import { IsDate, IsOptional, IsString } from "class-validator"; 2 | import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, DeleteDateColumn } from "typeorm"; 3 | import { Issue } from "./issue"; 4 | import { MILESTONESTATE } from "../common/type"; 5 | 6 | @Entity({ name: "milestone" }) 7 | class Milestone { 8 | @PrimaryGeneratedColumn("increment", { type: "int" }) 9 | id; 10 | 11 | @Column({ name: "title", type: "varchar", unique: true, charset: "utf-8" }) 12 | @IsString() 13 | title; 14 | 15 | @Column({ name: "description", type: "varchar", nullable: true, charset: "utf-8" }) 16 | @IsOptional() 17 | @IsString() 18 | description; 19 | 20 | @Column({ name: "state", type: "varchar", default: MILESTONESTATE.OPEN }) 21 | @IsOptional() 22 | @IsString() 23 | state; 24 | 25 | @Column({ name: "due_date", type: "date", nullable: true }) 26 | @IsOptional() 27 | @IsDate() 28 | dueDate; 29 | 30 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 31 | createdAt; 32 | 33 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 34 | updatedAt; 35 | 36 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 37 | deletedAt; 38 | 39 | @OneToMany(() => Issue, (issue) => issue.milestone) 40 | issues; 41 | } 42 | 43 | export { Milestone }; 44 | -------------------------------------------------------------------------------- /backend/src/router/milestone.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { RequestType } from "../common/middleware/request-type"; 3 | import { transformer } from "../common/middleware/transformer"; 4 | import { validator } from "../common/middleware/validator"; 5 | import { milestoneController } from "../controller"; 6 | import { 7 | AddMilestoneRequestBody, 8 | GetMilestoneRequestParams, 9 | ChangeMilestoneRequestBody, 10 | ChangeMilestoneRequestParams, 11 | RemoveMilestoneRequestParams 12 | } from "../dto/milestone"; 13 | 14 | const router = express.Router(); 15 | 16 | router.post("/", transformer([RequestType.BODY], [AddMilestoneRequestBody]), validator([RequestType.BODY]), milestoneController.addMilestone); 17 | router.get("/", milestoneController.getMilestones); 18 | router.get( 19 | "/:milestoneId", 20 | transformer([RequestType.PARAMS], [GetMilestoneRequestParams]), 21 | validator([RequestType.PARAMS]), 22 | milestoneController.getMilestone 23 | ); 24 | router.patch( 25 | "/:milestoneId", 26 | transformer([RequestType.BODY, RequestType.PARAMS], [ChangeMilestoneRequestBody, ChangeMilestoneRequestParams]), 27 | validator([RequestType.BODY, RequestType.PARAMS]), 28 | milestoneController.changeMilestone 29 | ); 30 | router.delete( 31 | "/:milestoneId", 32 | transformer([RequestType.PARAMS], [RemoveMilestoneRequestParams]), 33 | validator([RequestType.PARAMS]), 34 | milestoneController.removeMilestone 35 | ); 36 | 37 | export default router; 38 | -------------------------------------------------------------------------------- /backend/src/common/config/database/connection-option-generator.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { DatabaseType } from "./database-type"; 3 | 4 | class ConnectionOptionGenerator { 5 | static SQLITE3_DATABASE = ":memory:"; 6 | 7 | constructor(databaseEnv) { 8 | this.databaseEnv = databaseEnv; 9 | } 10 | 11 | generateConnectionOption() { 12 | const connectionOption = { 13 | type: this.databaseEnv.getDatabaseType(), 14 | entities: [path.resolve(`${__dirname}/../../../model/*.js`)], 15 | logging: this.databaseEnv.getDatabaseLogging(), 16 | dropSchema: this.databaseEnv.getDatabaseDropSchema(), 17 | synchronize: this.databaseEnv.getDatabaseSynchronize(), 18 | extra: {} 19 | }; 20 | 21 | switch (this.databaseEnv.getDatabaseType()) { 22 | case DatabaseType.MYSQL: 23 | connectionOption.url = this.databaseEnv.getDatabaseUrl(); 24 | connectionOption.connectTimeout = 3000; 25 | connectionOption.acquireTimeout = 5000; 26 | connectionOption.extra.connectionLimit = this.databaseEnv.getDatabaseConnectionLimit(); 27 | break; 28 | case DatabaseType.SQLITE3: 29 | connectionOption.database = ConnectionOptionGenerator.SQLITE3_DATABASE; 30 | break; 31 | default: 32 | } 33 | 34 | return connectionOption; 35 | } 36 | } 37 | 38 | export { ConnectionOptionGenerator }; 39 | -------------------------------------------------------------------------------- /backend/src/model/user.js: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, DeleteDateColumn, OneToMany } from "typeorm"; 2 | import { IsEmail, Length, IsString, IsUrl, IsOptional } from "class-validator"; 3 | import { Issue } from "./issue"; 4 | import { Comment } from "./comment"; 5 | import { UserToIssue } from "./user-to-issue"; 6 | 7 | @Entity({ name: "user" }) 8 | class User { 9 | @PrimaryGeneratedColumn("increment", { type: "int" }) 10 | id; 11 | 12 | @Column({ name: "email", type: "varchar", unique: true }) 13 | @IsEmail() 14 | email; 15 | 16 | @Column({ name: "name", type: "varchar", unique: true, charset: "utf-8" }) 17 | @IsString() 18 | @Length(4, 20) 19 | name; 20 | 21 | @Column({ name: "password", type: "varchar", nullable: true }) 22 | @IsOptional() 23 | @IsString() 24 | password; 25 | 26 | @Column({ name: "profile_image", type: "varchar" }) 27 | @IsUrl() 28 | profileImage; 29 | 30 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 31 | createdAt; 32 | 33 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 34 | updatedAt; 35 | 36 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 37 | deletedAt; 38 | 39 | @OneToMany(() => UserToIssue, (userToIssue) => userToIssue.user) 40 | userToIssues; 41 | 42 | @OneToMany(() => Issue, (issue) => issue.author) 43 | issues; 44 | 45 | @OneToMany(() => Comment, (comment) => comment.user) 46 | comments; 47 | } 48 | 49 | export { User }; 50 | -------------------------------------------------------------------------------- /frontend/src/components/OpenClosedTab/OpenClosedTab.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import MilestoneGrayIcon from "@imgs/milestone-gray-icon.png"; 4 | import MilestoneBlackIcon from "@imgs/milestone-black-icon.png"; 5 | import CheckBlackIcon from "@imgs/check-black-icon.png"; 6 | import CheckGrayIcon from "@imgs/check-gray-icon.png"; 7 | 8 | const OpenClosedTabView = styled.div` 9 | display: flex; 10 | `; 11 | 12 | const Tab = styled.div` 13 | display: flex; 14 | margin-right: 1rem; 15 | &:hover { 16 | cursor: pointer; 17 | } 18 | `; 19 | 20 | const Icon = styled.img` 21 | width: 1rem; 22 | height: 1rem; 23 | `; 24 | 25 | const Text = styled.p` 26 | margin-left: 0.6rem; 27 | font-weight: bold; 28 | color: ${(props) => (props.status === "open" ? "black" : "grey")}; 29 | `; 30 | 31 | const OpenClosedTab = ({ open, close, status, setStatus }) => { 32 | return ( 33 | 34 | setStatus("open")}> 35 | 36 | {open || 0} Open 37 | 38 | setStatus("closed")}> 39 | 40 | {close || 0} Closed 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default OpenClosedTab; 47 | -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/jwt-authenticator.js: -------------------------------------------------------------------------------- 1 | import { Strategy as JwtStrategy } from "passport-jwt"; 2 | import passport from "./passport"; 3 | import { UserService } from "../../../service"; 4 | import { UnauthorizedError } from "../../error/unauthorized-error"; 5 | 6 | const cookieExtractor = (req) => { 7 | let token = null; 8 | if (req && req.cookies) { 9 | token = req.cookies.token; 10 | } 11 | return token; 12 | }; 13 | 14 | const setStrategy = () => { 15 | passport.use( 16 | new JwtStrategy( 17 | { 18 | jwtFromRequest: cookieExtractor, 19 | secretOrKey: process.env.JWT_SECRET 20 | }, 21 | async (jwtPayload, done) => { 22 | try { 23 | const userService = UserService.getInstance(); 24 | const user = await userService.getUserByName(jwtPayload.username); 25 | 26 | if (user) { 27 | return done(null, user); 28 | } 29 | return done(null, false); 30 | } catch (error) { 31 | return done(error, false); 32 | } 33 | } 34 | ) 35 | ); 36 | }; 37 | 38 | const validateAuthorization = (req, res, next) => { 39 | return passport.authenticate( 40 | "jwt", 41 | { 42 | sessions: false 43 | }, 44 | (error, user) => { 45 | if (!user) { 46 | next(new UnauthorizedError()); 47 | } 48 | req.user = user; 49 | next(); 50 | } 51 | )(req, res, next); 52 | }; 53 | 54 | export { setStrategy, validateAuthorization }; 55 | -------------------------------------------------------------------------------- /frontend/src/components/SidebarMenu/SidebarMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | import settingIcon from "../../../public/images/setting-icon.png"; 5 | 6 | const StyledSideBarMenu = styled.div` 7 | display: flex; 8 | flex-direction: column; 9 | border-bottom: 0.1px solid ${color.sidemenu_default}; 10 | padding-top: 0.5rem; 11 | padding-bottom: 0.5rem; 12 | margin-bottom: 0.5rem; 13 | box-sizing: border-box; 14 | width: 15rem; 15 | `; 16 | 17 | const StyledSideBarMenuHeader = styled.div` 18 | display: flex; 19 | justify-content: space-between; 20 | margin-bottom: 0.5rem; 21 | line-height: 1.5; 22 | font-weight: bold; 23 | font-size: 12px; 24 | color: ${color.sidemenu_default}; 25 | &:hover { 26 | cursor: pointer; 27 | text-decoration: none; 28 | webkit-filter: opacity(0.5) drop-shadow(0 0 0 ${color.sidemenu_hover}); 29 | filter: opacity(0.5) drop-shadow(0 0 0 ${color.sidemenu_hover}); 30 | } 31 | `; 32 | 33 | const StyledSideBarMenuBody = styled.div` 34 | display: flex; 35 | flex-direction: column; 36 | margin-bottom: 0.5rem; 37 | `; 38 | 39 | const SideBarMenu = ({ children, ...rest }) => { 40 | return ( 41 | 42 | 43 |
{rest.title}
44 |
45 | setting 46 |
47 |
48 | {children} 49 |
50 | ); 51 | }; 52 | 53 | export default SideBarMenu; 54 | -------------------------------------------------------------------------------- /frontend/src/pages/LoadingPage.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const Main = styled.main` 6 | height: 100%; 7 | background-color: white; 8 | `; 9 | 10 | const StyledDotsContainer = styled.div` 11 | padding: 0; 12 | position: absolute; 13 | text-align: center; 14 | top: 50%; 15 | width: 100%; 16 | 17 | .dots:nth-child(1) { 18 | animation-delay: 0.2s; 19 | } 20 | .dots:nth-child(2) { 21 | animation-delay: 0.4s; 22 | } 23 | .dots:nth-child(3) { 24 | animation-delay: 0.6s; 25 | } 26 | .dots:nth-child(4) { 27 | animation-delay: 0.8s; 28 | } 29 | .dots:nth-child(5) { 30 | animation-delay: 1s; 31 | } 32 | @keyframes bounce { 33 | 0% { 34 | transform: translateY(0); 35 | } 36 | 15% { 37 | transform: translateY(-15px); 38 | } 39 | 30% { 40 | transform: translateY(0); 41 | } 42 | } 43 | `; 44 | 45 | const StyledDots = styled.div.attrs({ 46 | className: "dots" 47 | })` 48 | animation: bounce 1.5s infinite linear; 49 | background: ${color.loading_dots}; 50 | border-radius: 50%; 51 | display: inline-block; 52 | height: 20px; 53 | text-align: center; 54 | width: 20px; 55 | margin: 0.5rem; 56 | `; 57 | const LoadingPage = () => { 58 | return ( 59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | ); 69 | }; 70 | 71 | export default LoadingPage; 72 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issuetracker-frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot --env.ENVIRONMENT=development", 8 | "build": "webpack --mode production --env.ENVIRONMENT=production", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/cli": "^7.12.1", 15 | "@babel/core": "^7.12.3", 16 | "@babel/plugin-proposal-class-properties": "^7.12.1", 17 | "@babel/plugin-transform-runtime": "^7.12.1", 18 | "@babel/preset-env": "^7.12.1", 19 | "@babel/preset-react": "^7.12.1", 20 | "babel-loader": "^8.1.0", 21 | "clean-webpack-plugin": "^3.0.0", 22 | "css-loader": "^5.0.0", 23 | "eslint": "^7.12.0", 24 | "eslint-config-airbnb": "^18.2.0", 25 | "eslint-config-prettier": "^6.14.0", 26 | "eslint-plugin-import": "^2.22.1", 27 | "eslint-plugin-jsx-a11y": "^6.4.0", 28 | "eslint-plugin-prettier": "^3.1.4", 29 | "eslint-plugin-react": "^7.21.5", 30 | "eslint-plugin-react-hooks": "^4.2.0", 31 | "file-loader": "^6.2.0", 32 | "html-webpack-plugin": "^4.5.0", 33 | "jest": "^26.6.1", 34 | "mini-css-extract-plugin": "^1.2.1", 35 | "prettier": "^2.1.2", 36 | "sass": "^1.26.3", 37 | "sass-loader": "^8.0.2", 38 | "url-loader": "^4.1.1", 39 | "webpack": "^4.44.2", 40 | "webpack-cli": "^3.3.12", 41 | "webpack-dev-server": "^3.11.0", 42 | "webpack-merge": "^5.2.0" 43 | }, 44 | "dependencies": { 45 | "axios": "^0.21.0", 46 | "dotenv": "^8.2.0", 47 | "lodash": "^4.17.20", 48 | "react": "^17.0.1", 49 | "react-dom": "^17.0.1", 50 | "react-router-dom": "^5.2.0", 51 | "react-scripts": "^4.0.0", 52 | "remarkable": "^2.0.1", 53 | "styled-components": "^5.2.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/pages/LabelPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { LabelMilestoneHeader, LabelEditor, ListGroup, LabelItem } from "@components"; 3 | import { API } from "@utils"; 4 | import { usePromise } from "@hook"; 5 | import styled from "styled-components"; 6 | import { LoadingPage } from "@pages"; 7 | 8 | const LabelView = styled.div` 9 | width: 100%; 10 | max-width: 1280px; 11 | `; 12 | 13 | const LabelPage = () => { 14 | const [isOpenNewLabel, setOpenNewLabel] = useState(false); 15 | const handlingOnButtonClick = () => { 16 | setOpenNewLabel(!isOpenNewLabel); 17 | }; 18 | 19 | const getLabels = async () => { 20 | const labels = await API.getLabels(); 21 | return labels; 22 | }; 23 | 24 | const [loading, resolved, error] = usePromise(getLabels, []); 25 | 26 | if (loading) return ; 27 | if (error) window.location.href = "/"; 28 | if (!resolved) return null; 29 | 30 | const labels = resolved.data.lables; 31 | 32 | const getLabelItems = () => 33 | labels.reduce( 34 | (acc, cur) => 35 | acc.concat( 36 | 37 | 38 | 39 | ), 40 | [] 41 | ); 42 | 43 | return ( 44 | 45 | 46 | {isOpenNewLabel ? : null} 47 | 48 | 49 |

50 | {labels.length} labels 51 |

52 |
53 | {getLabelItems()} 54 |
55 |
56 | ); 57 | }; 58 | 59 | export default LabelPage; 60 | -------------------------------------------------------------------------------- /backend/src/controller/comment-controller.js: -------------------------------------------------------------------------------- 1 | import { CommentService } from "../service/comment-service"; 2 | 3 | const addComment = async (req, res, next) => { 4 | const { content } = req.body; 5 | const { issueId } = req.params; 6 | 7 | try { 8 | const commentService = CommentService.getInstance(); 9 | await commentService.addComment(req.user.id, issueId, content); 10 | res.status(201).end(); 11 | } catch (error) { 12 | next(error); 13 | } 14 | }; 15 | 16 | const getComments = async (req, res, next) => { 17 | const { issueId } = req.params; 18 | 19 | try { 20 | const commentService = CommentService.getInstance(); 21 | const issueComments = await commentService.getComments(issueId); 22 | const comments = issueComments.map(({ id, user, content, createdAt }) => { 23 | return { 24 | id, 25 | author: user.name, 26 | profileImage: user.profileImage, 27 | content: content.content, 28 | createdAt 29 | }; 30 | }); 31 | 32 | res.status(200).send({ comments }); 33 | } catch (error) { 34 | next(error); 35 | } 36 | }; 37 | 38 | const changeComment = async (req, res, next) => { 39 | const { content } = req.body; 40 | const { commentId } = req.params; 41 | 42 | try { 43 | const commentService = CommentService.getInstance(); 44 | await commentService.changeComment(commentId, content); 45 | res.status(200).end(); 46 | } catch (error) { 47 | next(error); 48 | } 49 | }; 50 | 51 | const removeComment = async (req, res, next) => { 52 | const { commentId } = req.params; 53 | 54 | try { 55 | const commentService = CommentService.getInstance(); 56 | await commentService.removeComment(commentId); 57 | res.status(204).end(); 58 | } catch (error) { 59 | next(error); 60 | } 61 | }; 62 | 63 | export { addComment, getComments, changeComment, removeComment }; 64 | -------------------------------------------------------------------------------- /frontend/src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as LoginForm } from "./LoginForm/LoginForm"; 2 | export { default as SignupForm } from "./SignupForm/SignupForm"; 3 | export { default as Button } from "./Button/Button"; 4 | export { default as ContentEditor } from "./ContentEditor/ContentEditor"; 5 | export { default as Preview } from "./ContentEditor/Preview/Preview"; 6 | export { default as Writer } from "./ContentEditor/Writer/Writer"; 7 | export { default as SidebarMenu } from "./SidebarMenu/SidebarMenu"; 8 | export { default as Label } from "./Label/Label"; 9 | export { default as Header } from "./Header/HeaderContainer/HeaderContainer"; 10 | export { default as UserProfile } from "./UserProfile/UserProfile"; 11 | export { default as Caret } from "./Caret/Caret"; 12 | export * as ListGroup from "./ListGroup"; 13 | export { default as Checkbox } from "./Checkbox/Checkbox"; 14 | export { default as IssueIcon } from "./IssueIcon/IssueIcon"; 15 | export { default as IssueItem } from "./IssueItem/IssueItem"; 16 | export { default as IssueFilterMenu } from "./IssueFilterMenu/IssueFilterMenuContainer/IssueFilterMenuContainer"; 17 | export { default as Main } from "./Main/Main"; 18 | export * as MainTemplate from "./MainTemplate"; 19 | export { default as ProgressBar } from "./ProgressBar/ProgressBar"; 20 | export { default as LabelMilestoneHeader } from "./LabelMilestoneHeader/LabelMilestoneHeader"; 21 | export { default as LabelEditor } from "./LabelEditor/LabelEditor"; 22 | export * as Form from "./Form"; 23 | export { default as FilterBar } from "./FilterBar/FilterBarContainer/FilterBarContainer"; 24 | export { default as PageNavButton } from "./PageNavButton/PageNavButton"; 25 | export { default as LabelItem } from "./LabelItem/LabelItem"; 26 | export * as IssueDetail from "./IssueDetail"; 27 | export { default as Comment } from "./Comment/Comment"; 28 | export { default as MilestoneForm } from "./MilestoneForm/MilestoneForm"; 29 | export { default as OpenClosedTab } from "./OpenClosedTab/OpenClosedTab"; 30 | export { default as MilestoneItem } from "./MilestoneItem/MilestoneItem"; 31 | -------------------------------------------------------------------------------- /backend/src/controller/label-controller.js: -------------------------------------------------------------------------------- 1 | import { validate } from "class-validator"; 2 | import { BadRequestError } from "../common/error/bad-request-error"; 3 | import { LabelService } from "../service"; 4 | 5 | const validateLabelParam = async (req, res, next) => { 6 | const { name, color, description } = req.body; 7 | const labelService = LabelService.getInstance(); 8 | const newLabel = labelService.createLabel({ name, color, description }); 9 | 10 | const error = await validate(newLabel); 11 | if (error.length > 0) { 12 | next(new BadRequestError()); 13 | return; 14 | } 15 | req.newLabel = newLabel; 16 | next(); 17 | }; 18 | 19 | const addLabel = async (req, res, next) => { 20 | try { 21 | const labelService = LabelService.getInstance(); 22 | await labelService.addLabel(req.newLabel); 23 | res.status(200).send("ok"); 24 | } catch (error) { 25 | next(error); 26 | } 27 | }; 28 | 29 | const getLabels = async (req, res, next) => { 30 | try { 31 | const labelService = LabelService.getInstance(); 32 | const labels = await labelService.getLabels(); 33 | res.status(200).json({lables: labels}); 34 | } catch (error) { 35 | next(error); 36 | } 37 | } 38 | 39 | const changeLabel = async (req, res, next) => { 40 | try { 41 | const labelId = req.params.labelId; 42 | const labelService = LabelService.getInstance(); 43 | await labelService.changeLabel(labelId, req.newLabel); 44 | res.status(200).send("update success"); 45 | } catch (error) { 46 | next(error); 47 | } 48 | } 49 | 50 | const removeLabel = async (req, res, next) => { 51 | try { 52 | const labelId = req.params.labelId; 53 | const labelService = LabelService.getInstance(); 54 | await labelService.removeLabel(labelId, req.newLabel); 55 | res.status(200).send("remove success"); 56 | } catch (error) { 57 | next(error); 58 | } 59 | } 60 | 61 | export { validateLabelParam, addLabel, getLabels, changeLabel, removeLabel }; 62 | -------------------------------------------------------------------------------- /backend/src/model/issue.js: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | OneToMany, 8 | ManyToOne, 9 | DeleteDateColumn, 10 | JoinColumn, 11 | OneToOne 12 | } from "typeorm"; 13 | import { IsString, IsOptional } from "class-validator"; 14 | import { Comment } from "./comment"; 15 | import { User } from "./user"; 16 | import { UserToIssue } from "./user-to-issue"; 17 | import { Milestone } from "./milestone"; 18 | import { LabelToIssue } from "./label-to-issue"; 19 | import { ISSUESTATE } from "../common/type"; 20 | import { IssueContent } from "./issue-content"; 21 | 22 | @Entity({ name: "issue" }) 23 | class Issue { 24 | @PrimaryGeneratedColumn("increment", { type: "int" }) 25 | id; 26 | 27 | @Column({ name: "title", type: "varchar", charset: "utf-8" }) 28 | @IsString() 29 | title; 30 | 31 | @Column({ name: "state", type: "varchar", default: ISSUESTATE.OPEN }) 32 | @IsOptional() 33 | @IsString() 34 | state; 35 | 36 | @CreateDateColumn({ name: "created_at", type: "datetime" }) 37 | createdAt; 38 | 39 | @UpdateDateColumn({ name: "updated_at", type: "datetime" }) 40 | updatedAt; 41 | 42 | @DeleteDateColumn({ name: "deleted_at", type: "datetime" }) 43 | deletedAt; 44 | 45 | @OneToMany(() => Comment, (comment) => comment.issue) 46 | comments; 47 | 48 | @OneToMany(() => UserToIssue, (userToIssue) => userToIssue.issue, { cascade: ["insert"] }) 49 | userToIssues; 50 | 51 | @ManyToOne(() => User, (user) => user.id) 52 | @JoinColumn({ name: "author_id" }) 53 | author; 54 | 55 | @ManyToOne(() => Milestone, (milestone) => milestone.id) 56 | @JoinColumn({ name: "milestone_id" }) 57 | milestone; 58 | 59 | @OneToMany(() => LabelToIssue, (labelToIssue) => labelToIssue.issue, { cascade: ["insert"] }) 60 | labelToIssues; 61 | 62 | @OneToOne(() => IssueContent, (content) => content.issue, { cascade: ["insert", "update"] }) 63 | @JoinColumn({ name: "content_id" }) 64 | content; 65 | } 66 | export { Issue }; 67 | -------------------------------------------------------------------------------- /frontend/src/components/LabelItem/LabelItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "styled-components"; 3 | import { Label, LabelEditor } from "@components"; 4 | import { color } from "@style/color"; 5 | import { API } from "@utils"; 6 | 7 | const LabelItemContainer = styled.div` 8 | display: flex; 9 | width: 100%; 10 | `; 11 | 12 | const LabelItemBox = styled.div` 13 | width: ${(props) => props.width}; 14 | `; 15 | 16 | const LabelItemButton = styled.button` 17 | text-decoration: none; 18 | white-space: nowrap; 19 | cursor: pointer; 20 | user-select: none; 21 | background-color: initial; 22 | border: 0; 23 | &:hover { 24 | color: ${color.text_link}; 25 | text-decoration: underline; 26 | } 27 | `; 28 | 29 | const Wrap = styled.div` 30 | display: ${(props) => (props.show ? "flex" : "none")}; 31 | width: 100%; 32 | `; 33 | 34 | const EditWrap = styled.div` 35 | display: ${(props) => (props.show ? "flex" : "none")}; 36 | `; 37 | 38 | const LabelItem = ({ ...rest }) => { 39 | const deleteLabel = async () => { 40 | await API.deleteLabel(rest.id); 41 | window.location.reload(); 42 | }; 43 | const [isEdit, setEditMode] = useState(true); 44 | 45 | const handlingEditMode = () => { 46 | setEditMode(!isEdit); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | {rest.description} 56 | 57 | Edit 58 | Delete 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default LabelItem; 69 | -------------------------------------------------------------------------------- /frontend/src/shared/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch } from "react-router-dom"; 3 | import { 4 | MainPage, 5 | LoginPage, 6 | IssueDetailPage, 7 | LabelPage, 8 | MilestonePage, 9 | NewIssuePage, 10 | NewMilestonePage, 11 | SignupPage, 12 | LoadingPage, 13 | EditMilestonePage 14 | } from "@pages"; 15 | import { waitAuthorizationApi } from "@utils"; 16 | import { Header, Main } from "@components"; 17 | import { UserContext } from "@context"; 18 | import { usePromise } from "@hook"; 19 | 20 | const waitAuthorization = () => { 21 | if (window.location.pathname !== "/login") { 22 | return usePromise(waitAuthorizationApi, []); 23 | } 24 | return [null, null, null]; 25 | }; 26 | 27 | const App = () => { 28 | const [loading, resolved, error] = waitAuthorization(); 29 | 30 | if (window.location.pathname !== "/login") { 31 | if (loading) return ; 32 | if (error) window.location.href = "/login"; 33 | if (!resolved) return null; 34 | } 35 | 36 | return ( 37 | <> 38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 | 54 | 55 | ); 56 | }; 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /frontend/src/pages/MilestonePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { LabelMilestoneHeader, ListGroup, OpenClosedTab, MilestoneItem } from "@components"; 3 | import styled from "styled-components"; 4 | import { API } from "@utils"; 5 | import { usePromise } from "@hook"; 6 | import { useHistory } from "react-router-dom"; 7 | import { LoadingPage } from "@pages"; 8 | 9 | const MilestoneView = styled.div` 10 | width: 100%; 11 | max-width: 1280px; 12 | `; 13 | 14 | const MilestonePage = () => { 15 | const [status, setStatus] = useState("open"); 16 | const history = useHistory(); 17 | 18 | const handlingNewMilestoneButtonClick = () => { 19 | history.push("/milestones/new"); 20 | }; 21 | 22 | const getMilestones = async () => { 23 | const milestones = await API.getMilestones(); 24 | return milestones; 25 | }; 26 | 27 | const [loading, resolved, error] = usePromise(getMilestones, []); 28 | 29 | if (loading) return ; 30 | if (error) window.location.href = "/"; 31 | if (!resolved) return null; 32 | 33 | const { milestones, openMilestoneCount, closeMilestoneCount } = resolved.data; 34 | 35 | const getMilestoneItems = () => 36 | milestones.reduce( 37 | (acc, cur) => 38 | cur.state === status 39 | ? acc.concat( 40 | 41 | 42 | 43 | ) 44 | : acc.concat(null), 45 | [] 46 | ); 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | 55 | {getMilestoneItems()} 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default MilestonePage; 62 | -------------------------------------------------------------------------------- /frontend/src/common/style/reset.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | p, 9 | blockquote, 10 | pre, 11 | abbr, 12 | acronym, 13 | address, 14 | big, 15 | cite, 16 | code, 17 | del, 18 | dfn, 19 | em, 20 | img, 21 | ins, 22 | kbd, 23 | q, 24 | s, 25 | samp, 26 | small, 27 | strike, 28 | sub, 29 | sup, 30 | tt, 31 | var, 32 | center, 33 | dl, 34 | dt, 35 | dd, 36 | fieldset, 37 | form, 38 | label, 39 | legend, 40 | table, 41 | caption, 42 | tbody, 43 | tfoot, 44 | thead, 45 | tr, 46 | th, 47 | td, 48 | article, 49 | aside, 50 | canvas, 51 | details, 52 | embed, 53 | figure, 54 | figcaption, 55 | footer, 56 | header, 57 | hgroup, 58 | menu, 59 | nav, 60 | output, 61 | ruby, 62 | section, 63 | summary, 64 | time, 65 | mark, 66 | audio, 67 | video { 68 | margin: 0; 69 | padding: 0; 70 | border: 0; 71 | font-size: 100%; 72 | font: inherit; 73 | vertical-align: baseline; 74 | } 75 | 76 | article, 77 | aside, 78 | details, 79 | figcaption, 80 | figure, 81 | footer, 82 | header, 83 | hgroup, 84 | menu, 85 | nav, 86 | section { 87 | display: block; 88 | } 89 | 90 | body { 91 | line-height: 1; 92 | } 93 | 94 | body, 95 | input, 96 | textarea, 97 | select, 98 | button { 99 | font-size: 14px; 100 | font-family: Dotum, '돋움', Helvetica, "Apple SD Gothic Neo", sans-serif; 101 | } 102 | 103 | blockquote, 104 | q { 105 | quotes: none; 106 | } 107 | 108 | blockquote:before, 109 | blockquote:after, 110 | q:before, 111 | q:after { 112 | content: ''; 113 | content: none; 114 | } 115 | 116 | table { 117 | border-collapse: collapse; 118 | border-spacing: 0; 119 | } 120 | 121 | html, 122 | body { 123 | width: 100%; 124 | height: 100%; 125 | } 126 | 127 | button { 128 | background: none; 129 | border: none; 130 | cursor: pointer; 131 | outline: none; 132 | } 133 | 134 | em { 135 | font-style: normal 136 | } 137 | 138 | a { 139 | color: inherit; 140 | text-decoration: none; 141 | } 142 | 143 | img { 144 | vertical-align: top; 145 | } 146 | 147 | #root { 148 | width: 100%; 149 | height: 100%; 150 | } 151 | 152 | h2, h3, h4, h5, h6 { 153 | margin-left: 2px; 154 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issuetracker-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "build": "babel src --out-dir dist", 9 | "start": "babel-node src/main", 10 | "start:dev": "nodemon -e js --watch src --exec babel-node src/main", 11 | "start:prod": "node dist/main", 12 | "test": "jest" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "bcrypt": "^5.0.0", 19 | "class-transformer": "^0.3.1", 20 | "class-validator": "^0.12.2", 21 | "cookie-parser": "^1.4.5", 22 | "cors": "^2.8.5", 23 | "dotenv": "^8.2.0", 24 | "express": "^4.17.1", 25 | "express-async-errors": "^3.1.1", 26 | "express-session": "^1.17.1", 27 | "jsonwebtoken": "^8.5.1", 28 | "mysql2": "^2.1.0", 29 | "node-mocks-http": "^1.9.0", 30 | "passport": "^0.4.1", 31 | "passport-github": "^1.1.0", 32 | "passport-github2": "^0.1.12", 33 | "passport-jwt": "^4.0.0", 34 | "randomstring": "^1.1.5", 35 | "reflect-metadata": "^0.1.13", 36 | "rimraf": "^3.0.2", 37 | "typeorm": "^0.2.28", 38 | "typeorm-transactional-cls-hooked": "^0.1.12" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.11.6", 42 | "@babel/core": "^7.11.6", 43 | "@babel/eslint-parser": "^7.11.5", 44 | "@babel/node": "^7.10.5", 45 | "@babel/plugin-proposal-class-properties": "^7.10.4", 46 | "@babel/plugin-proposal-decorators": "^7.10.5", 47 | "@babel/plugin-proposal-private-methods": "^7.12.1", 48 | "@babel/plugin-transform-runtime": "^7.11.5", 49 | "@babel/preset-env": "^7.11.5", 50 | "@types/dotenv": "^8.2.0", 51 | "@types/express": "^4.17.8", 52 | "@types/jest": "^26.0.13", 53 | "@types/jsonwebtoken": "^8.5.0", 54 | "@types/mysql2": "github:types/mysql2", 55 | "babel-plugin-parameter-decorator": "^1.0.16", 56 | "eslint": "^7.9.0", 57 | "eslint-config-airbnb-base": "^14.2.0", 58 | "eslint-config-prettier": "^6.11.0", 59 | "eslint-plugin-import": "^2.22.0", 60 | "eslint-plugin-prettier": "^3.1.4", 61 | "jest": "^26.4.2", 62 | "nodemon": "^2.0.4", 63 | "prettier": "^2.1.1", 64 | "sqlite3": "^5.0.0", 65 | "supertest": "^5.0.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /backend/src/common/env/database-env.js: -------------------------------------------------------------------------------- 1 | import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsString, ValidateIf } from "class-validator"; 2 | import { DatabaseType } from "../config/database/database-type"; 3 | 4 | class DatabaseEnv { 5 | @IsNotEmpty() 6 | @IsIn(DatabaseType.values()) 7 | databaseType; 8 | 9 | @ValidateIf((o) => o.databaseType === DatabaseType.MYSQL) 10 | @IsNotEmpty() 11 | databaseUrl; 12 | 13 | @IsNotEmpty() 14 | @IsBoolean() 15 | databaseDropSchema; 16 | 17 | @IsNotEmpty() 18 | @IsBoolean() 19 | databaseSynchronize; 20 | 21 | @ValidateIf((o) => o.databaseType === DatabaseType.MYSQL) 22 | @IsNotEmpty() 23 | @IsNumber() 24 | databaseConnectionLimit; 25 | 26 | databaseLogging; 27 | 28 | constructor() { 29 | this.databaseType = process.env.DATABASE_TYPE; 30 | this.databaseUrl = process.env.DATABASE_URL ?? null; 31 | this.databaseConnectionLimit = process.env.DATABASE_CONNECTION_LIMIT === undefined ? 10 : parseInt(process.env.DATABASE_CONNECTION_LIMIT, 10); 32 | this.databaseDropSchema = process.env.DATABASE_DROP_SCHEMA === undefined ? false : JSON.parse(process.env.DATABASE_DROP_SCHEMA.toLowerCase()); 33 | this.databaseSynchronize = 34 | process.env.DATABASE_SYNCHRONIZE === undefined ? false : JSON.parse(process.env.DATABASE_SYNCHRONIZE.toLowerCase()); 35 | this.databaseLogging = process.env.DATABASE_LOGGING === undefined ? ["error"] : this.splitDatabaseLogging(); 36 | } 37 | 38 | splitDatabaseLogging() { 39 | if (process.env.DATABASE_LOGGING === "all") { 40 | return "all"; 41 | } 42 | 43 | return process.env.DATABASE_LOGGING.split(","); 44 | } 45 | 46 | getDatabaseType() { 47 | return this.databaseType; 48 | } 49 | 50 | getDatabaseUrl() { 51 | return this.databaseUrl; 52 | } 53 | 54 | getDatabaseDropSchema() { 55 | return this.databaseDropSchema; 56 | } 57 | 58 | getDatabaseSynchronize() { 59 | return this.databaseSynchronize; 60 | } 61 | 62 | getDatabaseConnectionLimit() { 63 | return this.databaseConnectionLimit; 64 | } 65 | 66 | getDatabaseLogging() { 67 | return this.databaseLogging; 68 | } 69 | } 70 | 71 | export { DatabaseEnv }; 72 | -------------------------------------------------------------------------------- /backend/src/service/label-to-issue-service.js: -------------------------------------------------------------------------------- 1 | import { getRepository } from "typeorm"; 2 | import { Transactional } from "typeorm-transactional-cls-hooked"; 3 | import { LabelToIssue } from "../model/label-to-issue"; 4 | import { Issue } from "../model/issue"; 5 | import { Label } from "../model/label"; 6 | import { EntityNotFoundError } from "../common/error/entity-not-found-error"; 7 | 8 | class LabelToIssueService { 9 | constructor() { 10 | this.labelToIssueRepository = getRepository(LabelToIssue); 11 | this.labelRepository = getRepository(Label); 12 | this.issueRepository = getRepository(Issue); 13 | } 14 | 15 | static instance = null; 16 | 17 | static getInstance() { 18 | if (LabelToIssueService.instance === null) { 19 | LabelToIssueService.instance = new LabelToIssueService(); 20 | } 21 | return LabelToIssueService.instance; 22 | } 23 | 24 | async getLabelById(id) { 25 | const label = await this.labelRepository.findOne(id); 26 | return label; 27 | } 28 | 29 | async getIssueById(id) { 30 | const issue = await this.issueRepository.findOne(id); 31 | return issue; 32 | } 33 | 34 | @Transactional() 35 | async addLabelToIssue(labelId, issueId) { 36 | const targetLabel = await this.getLabelById(labelId); 37 | const targetIssue = await this.getIssueById(issueId); 38 | if (targetLabel === undefined || targetIssue === undefined) { 39 | throw new EntityNotFoundError(); 40 | } 41 | 42 | const newLabelToIssue = this.labelToIssueRepository.create({ label: targetLabel, issue: targetIssue }); 43 | await this.labelToIssueRepository.save(newLabelToIssue); 44 | 45 | return newLabelToIssue; 46 | } 47 | 48 | @Transactional() 49 | async removeLabelToIssue(labelId, issueId) { 50 | const targetLabel = await this.getLabelById(labelId); 51 | const targetIssue = await this.getIssueById(issueId); 52 | const targetIssueLabel = await this.labelToIssueRepository.findOne({ label: targetLabel, issue: targetIssue }); 53 | if (targetIssueLabel === undefined) { 54 | throw new EntityNotFoundError(); 55 | } 56 | await this.labelToIssueRepository.remove(targetIssueLabel); 57 | } 58 | } 59 | 60 | export { LabelToIssueService }; 61 | -------------------------------------------------------------------------------- /backend/test/common/lib/query-parser.test.js: -------------------------------------------------------------------------------- 1 | import { QueryParser } from "../../../src/common/lib"; 2 | 3 | describe("QueryParser Test", () => { 4 | const queryParser = new QueryParser(); 5 | 6 | test("is:open 파싱", () => { 7 | // given 8 | const queryString = "is:open"; 9 | 10 | // when 11 | const queryMap = queryParser.parse(queryString); 12 | 13 | // then 14 | expect(queryMap.get("is")).toEqual(["open"]); 15 | expect(queryMap.size).toEqual(1); 16 | }); 17 | 18 | test("is:open label:frontend label:backend 파싱", () => { 19 | // given 20 | const queryString = "is:open label:frontend label:backend"; 21 | 22 | // when 23 | const queryMap = queryParser.parse(queryString); 24 | 25 | // then 26 | expect(queryMap.get("is")).toEqual(["open"]); 27 | expect(queryMap.get("label")).toEqual(["frontend", "backend"]); 28 | expect(queryMap.size).toEqual(2); 29 | }); 30 | 31 | test("숫자 파싱했을 때 null 반환", () => { 32 | // given 33 | const queryString = 127381; 34 | 35 | // when 36 | const queryMap = queryParser.parse(queryString); 37 | 38 | // then 39 | expect(queryMap).toEqual(null); 40 | }); 41 | 42 | test("객체 파싱했을 때 null 반환", () => { 43 | // given 44 | const queryString = {}; 45 | 46 | // when 47 | const queryMap = queryParser.parse(queryString); 48 | 49 | // then 50 | expect(queryMap).toEqual(null); 51 | }); 52 | 53 | test("undefined 파싱했을 때 null 반환", () => { 54 | // given 55 | const queryString = undefined; 56 | 57 | // when 58 | const queryMap = queryParser.parse(queryString); 59 | 60 | // then 61 | expect(queryMap).toEqual(null); 62 | }); 63 | 64 | test("null 파싱했을 때 null 반환", () => { 65 | // given 66 | const queryString = null; 67 | 68 | // when 69 | const queryMap = queryParser.parse(queryString); 70 | 71 | // then 72 | expect(queryMap).toEqual(null); 73 | }); 74 | 75 | test("delimeter가 들어가지 않은 문자열 파싱하는 경우 비어있는 맵 반환", () => { 76 | // given 77 | const queryString = "sdjfksl"; 78 | 79 | // when 80 | const queryMap = queryParser.parse(queryString); 81 | 82 | // then 83 | expect(queryMap.size).toEqual(0); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IssueTracker-40 2 | > 환영합니다. 저희는 B4예요! 3 | 4 | ![image](https://user-images.githubusercontent.com/33643752/97582420-10aa8200-1a39-11eb-8a30-bddcab3b8a8f.png) 5 | 6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | Documentation 14 | 15 | 16 | 17 | issue tracking 18 | 19 | 20 | 21 | pr tracking 22 | 23 |

24 |

25 | 26 | 27 | 28 | 29 | 30 | 31 |

32 | 33 | 34 | ## 인원 소개 35 | 36 | ![boostcamp4](https://user-images.githubusercontent.com/33643752/97582232-da6d0280-1a38-11eb-909a-a584e6665924.png) 37 | 38 | 39 | | 김도호
[@Do-ho](http://github.com/Do-ho) | 신우진
[@wooojini](https://github.com/wooojini) | 이건홍
[@youngxpepp](https://github.com/youngxpepp) | 최진혁
[@jinhyukoo](https://github.com/jinhyukoo) | 40 | | :----------------------------------------------------------: | :---------------------------------------------: | :-------------------------------------------------: | ----------------------------------------------------------- | 41 | | 나는야 성장하는
괴물 김도호
얼마나 성장할지
벌써부터 두렵다... :alien: | 적극적인 커넥션을 만들자 :fire: | 말은 쉽지.
코드로 보여줄래? :eyes: | 한다면 하는 남자 한남 최진혁
리액트를 정복하러 왔다. 🏃‍ | 42 | 43 | 44 | 45 | ### 우리 B4의 프로젝트가 궁금해? [Wiki](https://github.com/boostcamp-2020/IssueTracker-40/wiki)로 :airplane: 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env* 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | .vscode 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | /frontend/env/* 120 | !frontend/env/.env.sample 121 | 122 | -------------------------------------------------------------------------------- /backend/src/dto/issue.js: -------------------------------------------------------------------------------- 1 | import { Transform } from "class-transformer"; 2 | import { IsArray, IsIn, IsNumber, IsNumberString, IsOptional, IsString } from "class-validator"; 3 | import { ISSUESTATE } from "../common/type"; 4 | 5 | class AddIssueRequestBody { 6 | @IsString() 7 | title; 8 | 9 | @IsString() 10 | content; 11 | 12 | @IsOptional() 13 | @IsArray() 14 | assignees; 15 | 16 | @IsOptional() 17 | @IsArray() 18 | labels; 19 | 20 | @IsOptional() 21 | @IsNumber() 22 | milestone; 23 | } 24 | 25 | class GetIssuesRequestQuery { 26 | @IsOptional() 27 | @IsString() 28 | q; 29 | 30 | @Transform((value) => parseInt(value, 10)) 31 | @IsNumber() 32 | page; 33 | } 34 | 35 | class GetIssueByIdParams { 36 | @Transform((value) => parseInt(value, 10)) 37 | @IsNumber() 38 | issueId; 39 | } 40 | 41 | class ModifyIssueByIdBody { 42 | @IsOptional() 43 | @IsString() 44 | title; 45 | 46 | @IsOptional() 47 | @IsString() 48 | content; 49 | 50 | @IsOptional() 51 | @IsIn([ISSUESTATE.OPEN, ISSUESTATE.CLOSED]) 52 | state; 53 | } 54 | 55 | class ModifyIssueByIdParams { 56 | @Transform((value) => parseInt(value, 10)) 57 | @IsNumber() 58 | issueId; 59 | } 60 | 61 | class RemoveIssueByIdParams { 62 | @Transform((value) => parseInt(value, 10)) 63 | @IsNumber() 64 | issueId; 65 | } 66 | 67 | class UserToIssueRequestParams { 68 | @IsNumberString() 69 | issueId; 70 | 71 | @IsNumberString() 72 | assigneeId; 73 | } 74 | 75 | class CreateReadCommentRequestParams { 76 | @IsNumberString() 77 | issueId; 78 | } 79 | 80 | class UpdateDeleteCommentRequestParams { 81 | @IsNumberString() 82 | issueId; 83 | 84 | @IsNumberString() 85 | commentId; 86 | } 87 | 88 | class AddCommentRequestBody { 89 | @IsString() 90 | content; 91 | } 92 | 93 | class IssueMilestoneRequestParams { 94 | @IsNumberString() 95 | issueId; 96 | 97 | @IsNumberString() 98 | milestoneId; 99 | } 100 | 101 | export { 102 | AddIssueRequestBody, 103 | UserToIssueRequestParams, 104 | CreateReadCommentRequestParams, 105 | AddCommentRequestBody, 106 | UpdateDeleteCommentRequestParams, 107 | IssueMilestoneRequestParams, 108 | GetIssuesRequestQuery, 109 | GetIssueByIdParams, 110 | ModifyIssueByIdBody, 111 | ModifyIssueByIdParams, 112 | RemoveIssueByIdParams 113 | }; 114 | -------------------------------------------------------------------------------- /frontend/src/components/FilterBar/FilterDropmenu/FilterDropmenu.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import { ListGroup } from "@components"; 4 | import { color } from "@style/color"; 5 | import FilterBarContext from "../FilterBarContext/FilterBarContext"; 6 | 7 | const FilterDropmenuArea = styled(ListGroup.Area)` 8 | display: ${(props) => (props.isHidden ? "none" : "block")}; 9 | position: absolute; 10 | top: 110%; 11 | left: 0; 12 | width: 250px; 13 | z-index: 2000; 14 | `; 15 | 16 | const FilterDropmenuHeader = styled(ListGroup.Header)` 17 | padding: 13px 18px; 18 | span { 19 | color: ${color.filterbar_dropmenu_text}; 20 | font-size: 12px; 21 | font-weight: 600; 22 | } 23 | `; 24 | 25 | const FilterDropmenuItem = styled(ListGroup.Item)` 26 | padding: 13px 18px; 27 | 28 | span { 29 | color: ${color.filterbar_dropmenu_text}; 30 | font-size: 12px; 31 | white-space: nowrap; 32 | cursor: pointer; 33 | } 34 | `; 35 | 36 | const DropmenuModalBackground = styled.div` 37 | display: ${(props) => (props.isHidden ? "none" : "block")}; 38 | position: fixed; 39 | top: 0; 40 | right: 0; 41 | width: 100%; 42 | height: 100%; 43 | background-color: ${color.header_dropmenu_modal_bg}; 44 | z-index: 1000; 45 | `; 46 | 47 | const FilterDropmenu = () => { 48 | const { filterBarState, eventListeners } = useContext(FilterBarContext); 49 | 50 | const getFiltermenuItems = () => 51 | filterBarState.filterMenus.reduce( 52 | (acc, cur) => 53 | acc.concat( 54 | 55 | 56 | {cur.title} 57 | 58 | 59 | ), 60 | [] 61 | ); 62 | 63 | return ( 64 | <> 65 | 66 | 67 | 68 | Filter Issues 69 | 70 | {getFiltermenuItems()} 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default FilterDropmenu; 77 | -------------------------------------------------------------------------------- /backend/src/common/lib/authenticator/github-authenticator.js: -------------------------------------------------------------------------------- 1 | import * as rs from "randomstring"; 2 | import * as qs from "querystring"; 3 | import { Strategy as GitHubStrategy } from "passport-github"; 4 | import { generateJWTToken } from "../token-generator"; 5 | import { UserService } from "../../../service"; 6 | import { ForbiddenError } from "../../error/forbidden-error"; 7 | import passport from "./passport"; 8 | 9 | const setStrategy = () => { 10 | passport.use( 11 | new GitHubStrategy( 12 | { 13 | clientID: process.env.GITHUB_CLIENT_ID, 14 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 15 | callbackURL: process.env.GITHUB_CLIENT_CALLBACK_URL 16 | }, 17 | async (accessToken, refreshToken, profile, cb) => { 18 | const userService = UserService.getInstance(); 19 | const user = await userService.signupWithGitHub(profile); 20 | 21 | const token = generateJWTToken({ 22 | userId: user.id, 23 | username: profile.username, 24 | email: `${profile.username}@github.com`, 25 | photos: profile.photos[0].value 26 | }); 27 | 28 | return cb(null, token); 29 | } 30 | ) 31 | ); 32 | }; 33 | 34 | const redirectToGithub = (req, res, next) => { 35 | const randomStr = rs.generate(); 36 | const url = process.env.GITHUB_USER_AUTH_REDIRECT_URL; 37 | const query = qs.stringify({ 38 | client_id: process.env.GITHUB_CLIENT_ID, 39 | redirect_uri: process.env.GITHUB_CLIENT_CALLBACK_URL, 40 | state: randomStr, 41 | scope: "user:email" 42 | }); 43 | 44 | req.session.state = randomStr; 45 | res.redirect(url + query); 46 | }; 47 | 48 | const validateState = (req, res, next) => { 49 | if (req.query.state !== req.session.state) { 50 | next(new ForbiddenError()); 51 | } 52 | req.session.destroy(); 53 | next(); 54 | }; 55 | 56 | const sendTokenToClient = (req, res, next) => { 57 | return passport.authenticate( 58 | "github", 59 | { 60 | sessions: false 61 | }, 62 | (error, token) => { 63 | const EXPIRED_DATE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); 64 | if (token) res.cookie("token", token, { expires: EXPIRED_DATE, httpOnly: true }); 65 | res.redirect(process.env.ISSUE_TRACKER_CLIENT_URL); 66 | } 67 | )(req, res, next); 68 | }; 69 | 70 | export { setStrategy, redirectToGithub, validateState, sendTokenToClient }; 71 | -------------------------------------------------------------------------------- /backend/test/common/middleware/validator.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { IsNumber, ValidationError } from "class-validator"; 3 | import { createRequest, createResponse } from "node-mocks-http"; 4 | import { RequestType } from "../../../src/common/middleware/request-type"; 5 | import { transformer } from "../../../src/common/middleware/transformer"; 6 | import { validator } from "../../../src/common/middleware/validator"; 7 | 8 | class TestRequest { 9 | @IsNumber() 10 | firstField; 11 | 12 | @IsNumber() 13 | secondField; 14 | 15 | @IsNumber() 16 | thirdField; 17 | 18 | @IsNumber() 19 | fourthField; 20 | 21 | constructor() { 22 | this.firstField = null; 23 | this.secondField = null; 24 | this.thirdField = null; 25 | this.fourthField = null; 26 | } 27 | } 28 | 29 | describe("Validator Test", () => { 30 | test("Request Body 유효성 검사 통과", async () => { 31 | // given 32 | const request = createRequest({ 33 | body: { 34 | firstField: 0, 35 | secondField: 1, 36 | thirdField: 2, 37 | fourthField: 3 38 | } 39 | }); 40 | const response = createResponse(); 41 | const trasfromMiddleware = transformer([RequestType.BODY], [TestRequest]); 42 | const validateMiddleware = validator([RequestType.BODY]); 43 | trasfromMiddleware(request, response, () => {}); 44 | 45 | // when 46 | // then 47 | try { 48 | await validateMiddleware(request, response, () => {}); 49 | } catch (errors) { 50 | fail(); 51 | } 52 | }); 53 | 54 | test("Request Body 유효성 검사 에러 발생", async () => { 55 | // given 56 | const request = createRequest({ 57 | body: { 58 | firstField: 0, 59 | secondField: 1, 60 | thirdField: 2, 61 | fourthField: "error" 62 | } 63 | }); 64 | const response = createResponse(); 65 | const trasfromMiddleware = transformer([RequestType.BODY], [TestRequest]); 66 | const validateMiddleware = validator([RequestType.BODY]); 67 | trasfromMiddleware(request, response, () => {}); 68 | 69 | // when 70 | // then 71 | try { 72 | await validateMiddleware(request, response, () => {}); 73 | fail(); 74 | } catch (errors) { 75 | errors.forEach((error) => { 76 | expect(error).toBeInstanceOf(ValidationError); 77 | }); 78 | } 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /frontend/src/common/style/color.js: -------------------------------------------------------------------------------- 1 | const color = { 2 | btn_bg: "#fafbfc", 3 | btn_hover_bg: "#f3f4f6", 4 | btn_text: "#24292e", 5 | btn_primary_bg: "#2ea44f", 6 | btn_primary_hover_bg: "#2c974b", 7 | btn_primary_border: "rgba(27, 31, 35, 0.15)", 8 | btn_primary_shadow: "rgba(27, 31, 35, 0.04)", 9 | btn_primary_text: "#ffffff", 10 | textarea_bg: "#fafbfc", 11 | textarea_border: "#e1e4e8", 12 | textarea_focus_border: "#0366d6", 13 | textarea_text: "#586069", 14 | tab_border: "#e1e4e8", 15 | tab_bg: "#f6f8fa", 16 | tab_selected_bg: "#ffffff", 17 | tab_container_gb: "#ffffff", 18 | border_primary: "#e1e4e8", 19 | register_btn: "rgb(46, 154, 254)", 20 | register_btn_disabled: "rgba(46, 154, 254, 0.5)", 21 | loading_dots: "#d8d8d8", 22 | sidemenu_hover: "#2e9afe", 23 | sidemenu_default: "#6e6e6e", 24 | header_bg: "#24292e", 25 | header_title_text: "#ffffff", 26 | header_dropmenu_bg: "#ffffff", 27 | header_dropmenu_hover_bg: "#0366d6", 28 | header_dropmenu_text: "#1b1f23", 29 | header_dropmenu_hover_text: "#ffffff", 30 | header_dropmenu_boader: "#e1e4e8", 31 | header_dropmenu_modal_bg: "transparent", 32 | caret_text: "#ffffff", 33 | main_bg: "#ffffff", 34 | signup_box_border: "#e6e6e6", 35 | signup_submit_disabled: "rgb(164, 164, 164, 0.5)", 36 | signup_submit: "rgb(164, 164, 164)", 37 | signup_container: "white", 38 | form_warning_msg: "red", 39 | signup_page_bg: "#f2f2f2", 40 | signup_submit_text: "white", 41 | issue_drop_btn: "#6a737d", 42 | issue_drop_hover_btn: "#444d56", 43 | issue_drop_modal_bg: "transparent", 44 | issue_item_title: "#0366d6", 45 | issue_item_detail_info_text: "#6a737d", 46 | issue_item_milestone_title: "#6a737d", 47 | list_group_header_border: "#eaecef", 48 | list_group_header_bg: "#f6f8fa", 49 | list_group_item_bg: "#ffffff", 50 | list_group_item_hover_bg: "#f6f8fa", 51 | list_group_border: "#eaecef", 52 | container_border: "#e1e4e8", 53 | title_input_bg: "#fafbfc", 54 | title_input_border: "#e1e4e8", 55 | title_input_focus_border: "#0366d6", 56 | filterbar_menu_bg: "#f6f8fa", 57 | filterbar_menu_input_text: "#444d56", 58 | filterbar_menu_input_bg: "#F6F8FA", 59 | filterbar_menu_btn_text: "#444d56", 60 | filterbar_menu_border: "#e1e4e8", 61 | filterbar_dropmenu_text: "#444d56", 62 | page_nav_btn_text: "#444d56", 63 | page_nav_btn_bg: "#ffffff", 64 | page_nav_btn_hover_bg: "#f6f8fa", 65 | list_group_border: "#eaecef", 66 | primary_hover_bg: "#f3f4f6", 67 | tertiary_border: "#d1d5da", 68 | tertiary_bg: "#f6f8fa", 69 | text_link: "#0366d6" 70 | }; 71 | 72 | export { color }; 73 | -------------------------------------------------------------------------------- /frontend/src/components/Comment/Comment.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { Remarkable } from "remarkable"; 4 | import { color } from "@style/color"; 5 | import { Button } from "@components"; 6 | 7 | const PreviewContainer = styled.div` 8 | width: 100%; 9 | word-break: break-all; 10 | height: auto; 11 | padding: 0.5rem; 12 | background-color: white; 13 | font-size: auto; 14 | border: 1px solid ${color.border_primary}; 15 | margin-bottom: 1rem; 16 | border-bottom-left-radius: 0.5rem; 17 | border-bottom-right-radius: 0.5rem; 18 | `; 19 | 20 | const PreviewHeader = styled.div` 21 | display: flex; 22 | justify-content: space-between; 23 | font-size: 12px; 24 | width: 100%; 25 | border-left: 1px solid ${color.border_primary}; 26 | border-right: 1px solid ${color.border_primary}; 27 | border-top: 1px solid ${color.border_primary}; 28 | border-top-left-radius: 0.5rem; 29 | border-top-right-radius: 0.5rem; 30 | padding-left: 0.5rem; 31 | padding-right: 0.5rem; 32 | padding-top: 0.3rem; 33 | padding-bottom: 0.3rem; 34 | background-color: ${color.tab_bg}; 35 | `; 36 | 37 | const HeaderAuthorContainer = styled.div` 38 | display: flex; 39 | line-height: 1.8; 40 | `; 41 | 42 | const HeaderMenuContainer = styled.div` 43 | display: flex; 44 | `; 45 | 46 | const AuthorBox = styled.div` 47 | font-weight: bold; 48 | margin-right: 0.2rem; 49 | `; 50 | 51 | const View = styled.div` 52 | padding: 0px; 53 | `; 54 | 55 | const MenuButton = styled.div` 56 | border: 1px solid ${color.border_primary}; 57 | border-radius: 1rem; 58 | padding: 0.2rem; 59 | line-height: 1; 60 | margin-left: 0.1rem; 61 | margin-right: 0.1rem; 62 | `; 63 | 64 | const Comment = ({ author, text }) => { 65 | const md = new Remarkable(); 66 | const getRawMarkup = () => { 67 | return { 68 | __html: md.render(text) 69 | }; 70 | }; 71 | return ( 72 | <> 73 | 74 | 75 | {author} 76 |
left a comment
77 |
78 | 79 | Member 80 | Edit 81 | 82 |
83 | 84 | {text === "" ? "Nothing to preview" : ""} 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default Comment; 92 | -------------------------------------------------------------------------------- /frontend/src/components/PageNavButton/PageNavButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import styled from "styled-components"; 3 | import MilestoneIcon from "@imgs/milestone-black-icon.png"; 4 | import Labelcon from "@imgs/label-black-icon.png"; 5 | import { NavLink } from "react-router-dom"; 6 | import { color } from "@style/color"; 7 | 8 | const PageNavButtonArea = styled.div` 9 | display: flex; 10 | & > a:first-child { 11 | border-top-right-radius: 0; 12 | border-bottom-right-radius: 0; 13 | border-right: none; 14 | } 15 | & > a:last-child { 16 | border-top-left-radius: 0; 17 | border-bottom-left-radius: 0; 18 | } 19 | `; 20 | 21 | const NavigationLinkButton = styled(NavLink)` 22 | display: flex; 23 | align-items: center; 24 | padding: 5px 16px; 25 | border-top: 1px solid #e1e4e8; 26 | border-bottom: 1px solid #e1e4e8; 27 | border-right: 1px solid #e1e4e8; 28 | border-left: 1px solid #e1e4e8; 29 | border-radius: 6px; 30 | background-color: ${color.page_nav_btn_bg}; 31 | color: ${color.page_nav_btn_text}; 32 | font-weight: 500; 33 | 34 | &:hover { 35 | background-color: ${color.page_nav_btn_hover_bg}; 36 | } 37 | 38 | * { 39 | margin-right: 5px; 40 | } 41 | `; 42 | 43 | const LabelIconImg = styled.img.attrs({ src: Labelcon })` 44 | width: 16px; 45 | height: 16px; 46 | `; 47 | 48 | const MilestoneIconImg = styled.img.attrs({ src: MilestoneIcon })` 49 | width: 16px; 50 | height: 16px; 51 | `; 52 | 53 | const NavigationButton = ({ children, ...rest }) => { 54 | const { icon } = rest; 55 | 56 | const getNavButtonIconImg = () => (icon === "label" ? : ); 57 | 58 | return ( 59 | 60 | {getNavButtonIconImg()} 61 | {children} 62 | 63 | ); 64 | }; 65 | 66 | const PageNavButton = () => { 67 | const [navButtonState, setNavButtonState] = useState([ 68 | { 69 | id: 1, 70 | value: "Label", 71 | icon: "label", 72 | to: "/labels" 73 | }, 74 | { 75 | id: 2, 76 | value: "Milestones", 77 | icon: "milestone", 78 | to: "/milestones" 79 | } 80 | ]); 81 | 82 | const getNavigationButtons = () => 83 | navButtonState.reduce( 84 | (acc, cur) => 85 | acc.concat( 86 | 87 | {cur.value} 88 | 89 | ), 90 | [] 91 | ); 92 | 93 | return {getNavigationButtons()}; 94 | }; 95 | 96 | export default PageNavButton; 97 | -------------------------------------------------------------------------------- /backend/src/service/milestone-service.js: -------------------------------------------------------------------------------- 1 | import { getRepository } from "typeorm"; 2 | import { Transactional } from "typeorm-transactional-cls-hooked"; 3 | import { EntityAlreadyExist } from "../common/error/entity-already-exist"; 4 | import { EntityNotFoundError } from "../common/error/entity-not-found-error"; 5 | import { Milestone } from "../model/milestone"; 6 | import { BadRequestError } from "../common/error/bad-request-error"; 7 | 8 | class MilestoneService { 9 | static instance = null; 10 | 11 | static getInstance() { 12 | if (MilestoneService.instance === null) { 13 | MilestoneService.instance = new MilestoneService(); 14 | } 15 | return MilestoneService.instance; 16 | } 17 | 18 | constructor() { 19 | this.milestoneRepository = getRepository(Milestone); 20 | } 21 | 22 | @Transactional() 23 | async addMilestone({ title, description, dueDate }) { 24 | try { 25 | const milestone = this.milestoneRepository.create({ title, description, dueDate }); 26 | await this.milestoneRepository.save(milestone); 27 | return milestone; 28 | } catch (error) { 29 | throw new EntityAlreadyExist(); 30 | } 31 | } 32 | 33 | @Transactional() 34 | async getMilestone(milestoneId) { 35 | const milestone = await this.milestoneRepository.findOne(milestoneId); 36 | 37 | if (milestone === undefined) { 38 | throw new EntityNotFoundError(); 39 | } 40 | 41 | return milestone; 42 | } 43 | 44 | @Transactional() 45 | async getMilestones() { 46 | const milestones = await this.milestoneRepository.find({ relations: ["issues"] }); 47 | 48 | return milestones; 49 | } 50 | 51 | @Transactional() 52 | async changeMilestone({ milestoneId, title, state, description, dueDate }) { 53 | const milestone = await this.milestoneRepository.findOne({ id: milestoneId }); 54 | 55 | if (!milestone) throw new EntityNotFoundError(); 56 | if ((!state && !title) || (state && title)) throw new BadRequestError(); 57 | 58 | if (state) { 59 | await this.milestoneRepository.save({ ...milestone, state }); 60 | return; 61 | } 62 | 63 | const fieldsToChange = { title, description, dueDate }; 64 | Object.keys(fieldsToChange).forEach((key) => { 65 | if (fieldsToChange[`${key}`] !== undefined) milestone[`${key}`] = fieldsToChange[`${key}`]; 66 | }); 67 | await this.milestoneRepository.save(milestone); 68 | } 69 | 70 | @Transactional() 71 | async removeMilestone({ milestoneId }) { 72 | const milestone = await this.milestoneRepository.findOne({ id: milestoneId }); 73 | if (!milestone) throw new EntityNotFoundError(); 74 | await this.milestoneRepository.remove(milestone); 75 | } 76 | } 77 | 78 | export { MilestoneService }; 79 | -------------------------------------------------------------------------------- /backend/src/service/label-service.js: -------------------------------------------------------------------------------- 1 | import { getRepository } from "typeorm"; 2 | import { Transactional } from "typeorm-transactional-cls-hooked"; 3 | import { Label } from "../model/label"; 4 | import { EntityAlreadyExist } from "../common/error/entity-already-exist"; 5 | import { EntityNotFoundError } from "../common/error/entity-not-found-error"; 6 | 7 | class LabelService { 8 | constructor() { 9 | this.labelRepository = getRepository(Label); 10 | } 11 | 12 | static instance = null; 13 | 14 | static getInstance() { 15 | if (LabelService.instance === null) { 16 | LabelService.instance = new LabelService(); 17 | } 18 | return LabelService.instance; 19 | } 20 | 21 | createLabel({ name, color, description }) { 22 | const newLabel = new Label(); 23 | newLabel.name = name; 24 | newLabel.color = color; 25 | newLabel.description = description; 26 | 27 | return newLabel; 28 | } 29 | 30 | async isLabelExistById({ id }) { 31 | const label = await this.getLabelById(id); 32 | return label === undefined; 33 | } 34 | 35 | async isLabelExistByName({ name }) { 36 | const label = await this.getLabelByName(name); 37 | return label === undefined; 38 | } 39 | 40 | async getLabelByName(labelname) { 41 | const label = await this.labelRepository.findOne({ name: labelname }); 42 | return label; 43 | } 44 | 45 | async getLabelById(labelid) { 46 | const label = await this.labelRepository.findOne({ id: labelid }); 47 | return label; 48 | } 49 | 50 | async getLabels() { 51 | const label = await this.labelRepository.find(); 52 | return label; 53 | } 54 | 55 | @Transactional() 56 | async addLabel(newLabel) { 57 | const { name } = newLabel; 58 | 59 | if (!(await this.isLabelExistByName({ name }))) { 60 | throw new EntityAlreadyExist(); 61 | } 62 | 63 | const result = await this.labelRepository.save(newLabel); 64 | } 65 | 66 | @Transactional() 67 | async changeLabel(labelid, newLabel) { 68 | const { name, color, description } = newLabel; 69 | const targetLabel = await this.getLabelById(labelid); 70 | 71 | if (targetLabel === undefined) { 72 | throw new EntityNotFoundError(); 73 | } 74 | 75 | targetLabel.name = name; 76 | targetLabel.color = color; 77 | targetLabel.description = description; 78 | await this.labelRepository.save(targetLabel); 79 | } 80 | 81 | @Transactional() 82 | async removeLabel(labelid) { 83 | const targetLabel = await this.getLabelById(labelid); 84 | if (targetLabel === undefined) { 85 | throw new EntityNotFoundError(); 86 | } 87 | await this.labelRepository.remove(targetLabel); 88 | } 89 | } 90 | 91 | export { LabelService }; 92 | -------------------------------------------------------------------------------- /frontend/src/components/FilterBar/FilterBarContainer/FilterBarContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useContext } from "react"; 2 | import { API } from "@utils"; 3 | import { UserContext } from "@context"; 4 | import FilterBarPresenter from "../FilterBarPresenter/FilterBarPresenter"; 5 | import FilterBarContext from "../FilterBarContext/FilterBarContext"; 6 | import MainContext from "../../MainTemplate/MainContext/MainContext"; 7 | 8 | const FILTER_BAR_ACTION_TYPE = { 9 | SHOW_DROPMENU: "showDropmenu", 10 | HIDE_DROPMENU: "hideDropmenu" 11 | }; 12 | 13 | const reducer = (filterBarState, action) => { 14 | switch (action.type) { 15 | case FILTER_BAR_ACTION_TYPE.SHOW_DROPMENU: 16 | return { ...filterBarState, isFilterDropHidden: false }; 17 | case FILTER_BAR_ACTION_TYPE.HIDE_DROPMENU: 18 | return { ...filterBarState, isFilterDropHidden: true }; 19 | default: 20 | throw new Error(); 21 | } 22 | }; 23 | 24 | const FilterBarContainer = () => { 25 | const { name } = useContext(UserContext); 26 | const { setIssues } = useContext(MainContext); 27 | const initialState = { 28 | filterMenus: [ 29 | { id: 1, title: "Open issues", query: "is:open" }, 30 | { id: 2, title: "Your issues", query: `author:${name}` }, 31 | { id: 3, title: "Everything assigned to you", query: `assignee:${name}` }, 32 | { id: 4, title: "Everything mentioning to you" }, 33 | { id: 5, title: "Closed issues", query: `is:closed` } 34 | ], 35 | isFilterDropHidden: true 36 | }; 37 | const [filterBarState, dispatch] = useReducer(reducer, initialState); 38 | 39 | const eventListeners = { 40 | onFilterDropmenuClickListener: (e) => { 41 | e.preventDefault(); 42 | if (filterBarState.isFilterDropHidden) { 43 | dispatch({ type: FILTER_BAR_ACTION_TYPE.SHOW_DROPMENU }); 44 | return; 45 | } 46 | dispatch({ type: FILTER_BAR_ACTION_TYPE.HIDE_DROPMENU }); 47 | }, 48 | onModalBackgrondClickListener: (e) => { 49 | e.preventDefault(); 50 | if (!filterBarState.isHiddenDropmenu) dispatch({ type: FILTER_BAR_ACTION_TYPE.HIDE_DROPMENU }); 51 | }, 52 | onFilterQueryMenuClickListener: async (e) => { 53 | const spanNode = e.target; 54 | const { id } = spanNode.dataset; 55 | const { filterMenus } = filterBarState; 56 | const issues = await API.getIssues({ page: 0, q: filterMenus[parseInt(id, 10) - 1].query }); 57 | setIssues(issues); 58 | dispatch({ type: FILTER_BAR_ACTION_TYPE.HIDE_DROPMENU }); 59 | } 60 | }; 61 | 62 | return ( 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default FilterBarContainer; 70 | -------------------------------------------------------------------------------- /frontend/src/components/ContentEditor/ContentEditor.jsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useState } from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { color } from "@style/color"; 4 | import { debounce } from "lodash"; 5 | import Writer from "./Writer/Writer"; 6 | import Preview from "./Preview/Preview"; 7 | 8 | const TabMenuContainer = styled.div` 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | padding-bottom: 5px; 14 | width: 100%; 15 | background-color: ${color.tab_container_gb}; 16 | `; 17 | 18 | const TabMenuHeader = styled.div` 19 | width: 95%; 20 | display: flex; 21 | height: 35px; 22 | padding-top: 10px; 23 | padding-left: 5%; 24 | background-color: ${color.tab_bg}; 25 | border-bottom: 1px solid ${color.tab_border}; 26 | `; 27 | 28 | const Tab = styled.div` 29 | margin-right: 5px; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | height: 100%; 34 | width: 70px; 35 | border-radius: 10px 10px 0px 0px; 36 | ${(props) => 37 | props.selected && 38 | css` 39 | background-color: ${color.tab_selected_bg}; 40 | border-top: 1px solid ${color.tab_border}; 41 | border-left: 1px solid ${color.tab_border}; 42 | border-right: 1px solid ${color.tab_border}; 43 | `} 44 | `; 45 | 46 | const reducer = (state, action) => { 47 | switch (action.type) { 48 | case "write_mode": 49 | return { isTab: true }; 50 | case "preview_mode": 51 | return { isTab: false }; 52 | default: 53 | throw new Error(); 54 | } 55 | }; 56 | 57 | const ContentEditor = () => { 58 | const initialState = { isTab: true }; 59 | const [state, dispatch] = useReducer(reducer, initialState); 60 | const [text, setText] = useState(""); 61 | 62 | const handlingOnClick = (e) => { 63 | const { innerText } = e.target; 64 | if (state.isTab && innerText === "Preview") dispatch({ type: "preview_mode" }); 65 | else if (!state.isTab && innerText === "Write") dispatch({ type: "write_mode" }); 66 | }; 67 | 68 | const handlingChange = (e) => { 69 | setText(e.target.value); 70 | }; 71 | 72 | const debouncedhandlingChange = debounce(handlingChange, 500); 73 | 74 | return ( 75 | 76 | 77 | 78 | Write 79 | 80 | 81 | Preview 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default ContentEditor; 91 | -------------------------------------------------------------------------------- /frontend/src/components/ContentEditor/Writer/Writer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import styled, { css } from "styled-components"; 3 | import { color } from "@style/color"; 4 | 5 | const WriterContainer = styled.div` 6 | margin-top: 0.5rem; 7 | padding: 0px; 8 | width: 95%; 9 | border-radius: 10px; 10 | font-size: 13px; 11 | color: ${color.textarea_text}; 12 | background-color: ${color.textarea_bg}; 13 | border: 1px solid ${color.textarea_border}; 14 | 15 | &:focus { 16 | background-color: white; 17 | border: 1px solid red; 18 | } 19 | ${(props) => 20 | props.selected && 21 | css` 22 | display: none; 23 | `} 24 | `; 25 | 26 | const WriterTextarea = styled.textarea` 27 | -webkit-box-sizing: border-box; 28 | -moz-box-sizing: border-box; 29 | box-sizing: border-box; 30 | padding: 10px; 31 | width: 100%; 32 | height: 90px; 33 | min-height: 90px; 34 | max-height: 300px; 35 | font-size: 13px; 36 | background-color: ${color.textarea_bg}; 37 | border-radius: 10px 10px 0px 0px; 38 | border: none; 39 | border-bottom: 1px dashed ${color.textarea_border}; 40 | outline: none; 41 | resize: vertical; 42 | &:focus { 43 | background-color: white; 44 | border-bottom: 1px dashed ${color.textarea_focus_border}; 45 | } 46 | `; 47 | 48 | const WriterDropAndDropZone = styled.div` 49 | padding: 10px; 50 | width: auto; 51 | height: 20px; 52 | border-radius: 0px 0px 10px 10px; 53 | cursor: pointer; 54 | `; 55 | 56 | const HiddenFileInput = styled.input` 57 | display: none; 58 | `; 59 | 60 | const Writer = ({ setText, selected }) => { 61 | const HiddenFileInputEl = useRef(null); 62 | const WriterContainerEl = useRef(null); 63 | 64 | const handlingClickDropZone = () => { 65 | HiddenFileInputEl.current.click(); 66 | }; 67 | 68 | const onFocus = () => { 69 | const containerStyle = WriterContainerEl.current.style; 70 | containerStyle.boxShadow = "inset 0 1px 2px rgba(27,31,35,0.075), 0 0 0 3px rgba(3,102,214,0.3)"; 71 | containerStyle.border = `1px solid ${color.textarea_focus_border}`; 72 | }; 73 | 74 | const onBlur = () => { 75 | const containerStyle = WriterContainerEl.current.style; 76 | containerStyle.boxShadow = "none"; 77 | containerStyle.border = `1px solid ${color.textarea_border}`; 78 | }; 79 | 80 | return ( 81 | 82 | 83 | 84 |

Attach files by dragging & dropping, selecting or pasting them.

85 |
86 | 87 |
88 | ); 89 | }; 90 | 91 | export default Writer; 92 | -------------------------------------------------------------------------------- /frontend/src/components/SignupForm/SignupForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { debounce } from "lodash"; 4 | import { Form } from "@components"; 5 | import { API } from "@utils"; 6 | 7 | const SignupForm = () => { 8 | const history = useHistory(); 9 | const idWarning = useRef(); 10 | const passwordWarning = useRef(); 11 | const signupWarning = useRef(); 12 | const submitButton = useRef(); 13 | const nameInput = useRef(); 14 | const emailInput = useRef(); 15 | const passwordInput = useRef(); 16 | const [idInputFilled, setIdInputFilled] = useState(false); 17 | const [passwordInputFilled, setPasswordInputFilled] = useState(false); 18 | 19 | const inputOnChange = (event) => { 20 | const inputLength = event.target.value.length; 21 | const targetDOM = event.target.id === "user-input" ? idWarning.current : passwordWarning.current; 22 | const maxLength = event.target.id === "user-input" ? 26 : 12; 23 | const targetState = event.target.id === "user-input" ? setIdInputFilled : setPasswordInputFilled; 24 | if ((inputLength > 0 && inputLength < 6) || inputLength > maxLength) { 25 | targetDOM.style.display = "block"; 26 | targetState(false); 27 | } else if (inputLength === 0) { 28 | targetDOM.style.display = "none"; 29 | targetState(false); 30 | } else { 31 | targetDOM.style.display = "none"; 32 | targetState(true); 33 | } 34 | }; 35 | 36 | const debouncedInputOnChange = debounce(inputOnChange, 500); 37 | 38 | const SubmitClick = async () => { 39 | try { 40 | await API.postSignup(emailInput.current.value, nameInput.current.value, passwordInput.current.value); 41 | history.push("/login"); 42 | } catch (e) { 43 | signupWarning.current.style.display = "block"; 44 | } 45 | }; 46 | return ( 47 | 48 | 이름 49 | 50 | 이메일 51 | 52 | 이메일은 6~26자 사이로 입력해주세요. 53 | 비밀번호 54 | 55 | 비밀번호는 6~12자 사이로 입력해주세요. 56 | 57 | 가입하기 58 | 59 | 입력 형식이 맞는지 확인해주세요. 60 | 61 | ); 62 | }; 63 | 64 | export default SignupForm; 65 | -------------------------------------------------------------------------------- /frontend/src/components/LabelMilestoneHeader/LabelMilestoneHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | import { Button } from "@components"; 5 | import LabelWhiteIcon from "@imgs/label-white-icon.png"; 6 | import LabelBlackIcon from "@imgs/label-black-icon.png"; 7 | import MilestoneWhiteIcon from "@imgs/milestone-white-icon.png"; 8 | import MilestoneBlackIcon from "@imgs/milestone-black-icon.png"; 9 | import { Link } from "react-router-dom"; 10 | 11 | const HeaderContainer = styled.div` 12 | display: flex; 13 | justify-content: space-between; 14 | width: 100%; 15 | max-width: 1280px; 16 | margin-bottom: 1rem; 17 | `; 18 | 19 | const TabContainer = styled.div` 20 | display: flex; 21 | height: 2.3rem; 22 | width: 14rem; 23 | border-radius: 10px; 24 | border: 1px solid ${color.border_primary}; 25 | cursor: pointer; 26 | `; 27 | 28 | const LeftTab = styled.div` 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | height: 100%; 33 | width: 6rem; 34 | background-color: ${(props) => (props.value === "label" ? "#0366d6" : "white")}; 35 | border-radius: 10px 0px 0px 10px; 36 | ${(props) => (props.value !== "label" ? `&:hover { background-color: ${color.primary_hover_bg} }` : "")}; 37 | `; 38 | 39 | const RightTab = styled.div` 40 | display: flex; 41 | justify-content: center; 42 | align-items: center; 43 | height: 100%; 44 | width: 8rem; 45 | background-color: ${(props) => (props.value === "milestone" ? "#0366d6" : "white")}; 46 | border-radius: 0px 10px 10px 0px; 47 | ${(props) => (props.value !== "milestone" ? `&:hover { background-color: ${color.primary_hover_bg} }` : "")}; 48 | `; 49 | 50 | const Icon = styled.img` 51 | width: 1rem; 52 | height: 1rem; 53 | margin-right: 0.3rem; 54 | `; 55 | 56 | const LeftText = styled.p` 57 | color: ${(props) => (props.value === "label" ? "white" : "black")}; 58 | `; 59 | 60 | const RightText = styled.p` 61 | color: ${(props) => (props.value === "milestone" ? "white" : "black")}; 62 | `; 63 | 64 | const LabelMilestoneHeader = ({ value, buttonClick }) => { 65 | return ( 66 | 67 | 68 | 69 | 70 | 71 | Labels 72 | 73 | 74 | 75 | 76 | 77 | Milestones 78 | 79 | 80 | 81 | 84 | 85 | ); 86 | }; 87 | 88 | export default LabelMilestoneHeader; 89 | -------------------------------------------------------------------------------- /backend/test/common/middleware/transformer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { createRequest, createResponse } from "node-mocks-http"; 3 | import { RequestType } from "../../../src/common/middleware/request-type"; 4 | import { transformer } from "../../../src/common/middleware/transformer"; 5 | 6 | class TestRequest { 7 | constructor() { 8 | this.firstField = null; 9 | this.secondField = null; 10 | this.thirdField = null; 11 | this.fourthField = null; 12 | } 13 | } 14 | 15 | describe("Transformer Test", () => { 16 | test("Request Body에 포함된 내용을 TestRequest로 변환", async () => { 17 | // given 18 | const request = createRequest({ 19 | body: { 20 | firstField: 0, 21 | secondField: 1, 22 | thirdField: 2, 23 | fourthField: 3 24 | } 25 | }); 26 | const response = createResponse(); 27 | const middleware = transformer([RequestType.BODY], [TestRequest]); 28 | 29 | // when 30 | middleware(request, response, () => {}); 31 | 32 | // then 33 | expect(request.body).toBeInstanceOf(TestRequest); 34 | expect(request.body.firstField).toEqual(0); 35 | expect(request.body.secondField).toEqual(1); 36 | expect(request.body.thirdField).toEqual(2); 37 | expect(request.body.fourthField).toEqual(3); 38 | }); 39 | 40 | test("Request Query에 포함된 내용을 TestRequest로 변환", async () => { 41 | // given 42 | const request = createRequest({ 43 | query: { 44 | firstField: 0, 45 | secondField: 1, 46 | thirdField: 2, 47 | fourthField: 3 48 | } 49 | }); 50 | const response = createResponse(); 51 | const middleware = transformer([RequestType.QUERY], [TestRequest]); 52 | 53 | // when 54 | middleware(request, response, () => {}); 55 | 56 | // then 57 | expect(request.query).toBeInstanceOf(TestRequest); 58 | expect(request.query.firstField).toEqual(0); 59 | expect(request.query.secondField).toEqual(1); 60 | expect(request.query.thirdField).toEqual(2); 61 | expect(request.query.fourthField).toEqual(3); 62 | }); 63 | 64 | test("Request Params에 포함된 내용을 TestRequest로 변환", async () => { 65 | // given 66 | const request = createRequest({ 67 | params: { 68 | firstField: 0, 69 | secondField: 1, 70 | thirdField: 2, 71 | fourthField: 3 72 | } 73 | }); 74 | const response = createResponse(); 75 | const middleware = transformer([RequestType.PARAMS], [TestRequest]); 76 | 77 | // when 78 | middleware(request, response, () => {}); 79 | 80 | // then 81 | expect(request.params).toBeInstanceOf(TestRequest); 82 | expect(request.params.firstField).toEqual(0); 83 | expect(request.params.secondField).toEqual(1); 84 | expect(request.params.thirdField).toEqual(2); 85 | expect(request.params.fourthField).toEqual(3); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /backend/src/application.js: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import "express-async-errors"; 3 | import dotenv from "dotenv"; 4 | import express from "express"; 5 | import path from "path"; 6 | import cors from "cors"; 7 | import cookieParser from "cookie-parser"; 8 | import session from "express-session"; 9 | import { validateOrReject } from "class-validator"; 10 | import { createConnection } from "typeorm"; 11 | import { initializeTransactionalContext, patchTypeORMRepositoryWithBaseRepository } from "typeorm-transactional-cls-hooked"; 12 | import { errorHandler } from "./common/middleware/error-handler"; 13 | import { router } from "./router"; 14 | import { EnvType } from "./common/env/env-type"; 15 | import { DatabaseEnv } from "./common/env/database-env"; 16 | import { ConnectionOptionGenerator } from "./common/config/database/connection-option-generator"; 17 | import * as authenticator from "./common/lib/authenticator"; 18 | 19 | export class Application { 20 | constructor() { 21 | this.httpServer = express(); 22 | this.databaseEnv = null; 23 | this.connectionOptionGenerator = null; 24 | } 25 | 26 | listen(port) { 27 | return new Promise((resolve) => { 28 | this.httpServer.listen(port, () => { 29 | resolve(); 30 | }); 31 | }); 32 | } 33 | 34 | async initialize() { 35 | try { 36 | await this.initEnvironment(); 37 | this.registerMiddleware(); 38 | await this.initDatabase(); 39 | authenticator.initializeAuthenticator(this.httpServer); 40 | } catch (error) { 41 | console.error(error); 42 | process.exit(); 43 | } 44 | } 45 | 46 | async initEnvironment() { 47 | dotenv.config(); 48 | if (!EnvType.contains(process.env.NODE_ENV)) { 49 | throw new Error("잘못된 NODE_ENV 입니다. {production, development, local, test} 중 하나를 선택하십시오."); 50 | } 51 | dotenv.config({ 52 | path: path.join(`${process.cwd()}/.env.${process.env.NODE_ENV}`) 53 | }); 54 | 55 | this.databaseEnv = new DatabaseEnv(); 56 | await validateOrReject(this.databaseEnv); 57 | this.connectionOptionGenerator = new ConnectionOptionGenerator(this.databaseEnv); 58 | } 59 | 60 | async initDatabase() { 61 | initializeTransactionalContext(); 62 | patchTypeORMRepositoryWithBaseRepository(); 63 | await createConnection(this.connectionOptionGenerator.generateConnectionOption()); 64 | } 65 | 66 | registerMiddleware() { 67 | this.httpServer.use( 68 | cors({ 69 | origin: true, 70 | credentials: true 71 | }) 72 | ); 73 | this.httpServer.use(cookieParser()); 74 | this.httpServer.use(express.json()); 75 | this.httpServer.use(express.urlencoded({ extended: false })); 76 | this.httpServer.use( 77 | session({ 78 | secret: process.env.SESSION_SECRET, 79 | resave: false, 80 | saveUninitialized: true 81 | }) 82 | ); 83 | this.httpServer.use(router); 84 | this.httpServer.use(errorHandler); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/components/FilterBar/FilterBarPresenter/FilterBarPresenter.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import SearchIcon from "@imgs/search-icon.png"; 4 | import { Caret } from "@components"; 5 | import { color } from "@style/color"; 6 | import { API } from "@utils"; 7 | import FilterDropmenu from "../FilterDropmenu/FilterDropmenu"; 8 | import FilterBarContext from "../FilterBarContext/FilterBarContext"; 9 | import MainContext from "../../MainTemplate/MainContext/MainContext"; 10 | 11 | const FilterForm = styled.form` 12 | display: flex; 13 | width: 65%; 14 | `; 15 | 16 | const FilterInputArea = styled.div` 17 | position: relative; 18 | width: 100%; 19 | `; 20 | 21 | const FilterBarMenuButtonArea = styled.div` 22 | position: relative; 23 | `; 24 | 25 | const FilterBarMenuButton = styled.button` 26 | display: flex; 27 | align-items: center; 28 | height: 100%; 29 | padding: 5px 16px; 30 | color: ${color.filterbar_menu_btn_text}; 31 | background-color: ${color.filterbar_menu_bg}; 32 | border: 1px solid ${color.filterbar_menu_border}; 33 | border-radius: 6px; 34 | border-top-right-radius: 0; 35 | border-bottom-right-radius: 0; 36 | * { 37 | margin-right: 5px; 38 | } 39 | `; 40 | 41 | const FilterBarMenuTitle = styled.span` 42 | font-weight: 500; 43 | `; 44 | 45 | const FilterInput = styled.input` 46 | width: 100%; 47 | height: 100%; 48 | padding 5px 16px; 49 | padding-left: 32px; 50 | color:${color.filterbar_menu_input_text}; 51 | background-color: ${color.filterbar_menu_input_bg}; 52 | border: 1px solid ${color.filterbar_menu_border}; 53 | border-radius: 6px; 54 | border-top-left-radius: 0; 55 | border-bottom-left-radius: 0; 56 | box-sizing: border-box; 57 | font-size: 16px; 58 | font-weight: 500; 59 | outline: none; 60 | `; 61 | 62 | const SearchIconImg = styled.img.attrs({ src: SearchIcon })` 63 | position: absolute; 64 | top: 8px; 65 | left: 12px; 66 | width: 16px; 67 | height: 16px; 68 | `; 69 | 70 | const FilterBarPresenter = () => { 71 | const { eventListeners } = useContext(FilterBarContext); 72 | const { setIssues } = useContext(MainContext); 73 | const onFilterSubmitListener = async (e) => { 74 | e.preventDefault(); 75 | const formNode = e.target; 76 | const { query } = formNode; 77 | const issues = await API.getIssues({ page: 0, q: query.value }); 78 | setIssues(issues); 79 | }; 80 | 81 | return ( 82 | 83 | 84 | 85 | Filter 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | export default FilterBarPresenter; 99 | -------------------------------------------------------------------------------- /frontend/src/common/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Config from "@config"; 3 | import * as qs from "querystring"; 4 | 5 | axios.defaults.withCredentials = true; 6 | 7 | const postLogin = async (email, password) => { 8 | const response = await axios.post(Config.API.POST_LOGIN, { email, password }); 9 | return response; 10 | }; 11 | 12 | const getLogout = async () => { 13 | const response = await axios.get(Config.API.GET_LOGOUT); 14 | return response; 15 | }; 16 | 17 | const postSignup = async (email, name, password) => { 18 | const response = await axios.post(Config.API.POST_SIGNUP, { email, name, password }); 19 | return response; 20 | }; 21 | 22 | const getLabels = async () => { 23 | const response = await axios.get(Config.API.LABLE); 24 | return response; 25 | }; 26 | 27 | const getIssues = async ({ page, q }) => { 28 | const query = qs.stringify({ 29 | page, 30 | q 31 | }); 32 | const response = await axios.get(`${Config.API.GET_ISSUES}?${query}`); 33 | return response?.data?.issues; 34 | }; 35 | 36 | const postIssue = async ({ title, content }) => { 37 | const response = await axios.post(Config.API.POST_ISSUE, { title, content }); 38 | return response; 39 | }; 40 | 41 | const patchIssue = async ({ issueId, title, content, state }) => { 42 | const response = await axios.patch(`${Config.API.POST_ISSUE}/${issueId}`, { title, content, state }); 43 | return response; 44 | }; 45 | 46 | const postLabel = async (data) => { 47 | const response = await axios.post(Config.API.LABLE, data); 48 | return response; 49 | }; 50 | 51 | const putLabel = async (id, data) => { 52 | const response = await axios.put(`${Config.API.LABLE}/${id}`, data); 53 | return response; 54 | }; 55 | 56 | const deleteLabel = async (id) => { 57 | const response = await axios.delete(`${Config.API.LABLE}/${id}`); 58 | return response; 59 | }; 60 | 61 | const getIssueById = async (issueId) => { 62 | const response = await axios.get(`${Config.API.GET_ISSUE}/${issueId}`); 63 | return response?.data?.issue; 64 | }; 65 | 66 | const getMilestone = async (milestoneId) => { 67 | const response = await axios.get(`${Config.API.MILESTONE}/${milestoneId}`); 68 | return response; 69 | }; 70 | 71 | const getMilestones = async () => { 72 | const response = await axios.get(`${Config.API.MILESTONE}`); 73 | return response; 74 | }; 75 | 76 | const postMilestone = async (data) => { 77 | const response = await axios.post(Config.API.MILESTONE, data); 78 | return response; 79 | }; 80 | 81 | const patchMilestone = async (milestoneId, data) => { 82 | const response = await axios.patch(`${Config.API.MILESTONE}/${milestoneId}`, data); 83 | return response; 84 | }; 85 | 86 | const deleteMilestone = async (milestoneId) => { 87 | const response = await axios.delete(`${Config.API.MILESTONE}/${milestoneId}`); 88 | return response; 89 | }; 90 | 91 | export { 92 | postLogin, 93 | postSignup, 94 | getLabels, 95 | getLogout, 96 | postLabel, 97 | putLabel, 98 | deleteLabel, 99 | getIssueById, 100 | getIssues, 101 | getMilestone, 102 | getMilestones, 103 | postMilestone, 104 | patchMilestone, 105 | deleteMilestone, 106 | postIssue, 107 | patchIssue 108 | }; 109 | -------------------------------------------------------------------------------- /backend/src/controller/milestone-controller.js: -------------------------------------------------------------------------------- 1 | import { MilestoneService } from "../service"; 2 | 3 | const addMilestone = async (req, res, next) => { 4 | const { title, description, dueDate } = req.body; 5 | 6 | try { 7 | const milestoneService = MilestoneService.getInstance(); 8 | await milestoneService.addMilestone({ title, description, dueDate }); 9 | res.status(201).end(); 10 | } catch (error) { 11 | next(error); 12 | } 13 | }; 14 | 15 | const getMilestones = async (req, res, next) => { 16 | try { 17 | const milestoneService = MilestoneService.getInstance(); 18 | const receivedMilestones = await milestoneService.getMilestones(); 19 | const milestones = receivedMilestones.map(({ id, title, state, description, dueDate, updatedAt, issues }) => { 20 | const [openIssueCount, closedIssueCount] = issues.reduce( 21 | (acc, cur) => { 22 | if (cur.state === "open") return [acc[0] + 1, acc[1]]; 23 | return [acc[0], acc[1] + 1]; 24 | }, 25 | [0, 0] 26 | ); 27 | return { 28 | id, 29 | title, 30 | state, 31 | description, 32 | dueDate, 33 | updatedAt, 34 | openIssueCount, 35 | closedIssueCount 36 | }; 37 | }); 38 | const [openMilestoneCount, closeMilestoneCount] = milestones.reduce( 39 | (acc, cur) => { 40 | if (cur.state === "open") return [acc[0] + 1, acc[1]]; 41 | return [acc[0], acc[1] + 1]; 42 | }, 43 | [0, 0] 44 | ); 45 | 46 | res.status(200).send({ milestones, openMilestoneCount, closeMilestoneCount }); 47 | } catch (error) { 48 | next(error); 49 | } 50 | }; 51 | 52 | const getMilestone = async (req, res, next) => { 53 | const { milestoneId } = req.params; 54 | 55 | try { 56 | const milestoneService = MilestoneService.getInstance(); 57 | const milestone = await milestoneService.getMilestone(milestoneId); 58 | res.status(200).json({ 59 | id: milestone.id, 60 | title: milestone.title, 61 | state: milestone.state, 62 | description: milestone.description, 63 | dueDate: milestone.dueDate 64 | }); 65 | } catch (error) { 66 | next(error); 67 | } 68 | }; 69 | 70 | const changeMilestone = async (req, res, next) => { 71 | try { 72 | const { milestoneId } = req.params; 73 | const { title, state, description, dueDate } = req.body; 74 | const milestoneService = MilestoneService.getInstance(); 75 | await milestoneService.changeMilestone({ milestoneId, title, state, description, dueDate }); 76 | res.status(201).end(); 77 | } catch (error) { 78 | next(error); 79 | } 80 | }; 81 | 82 | const removeMilestone = async (req, res, next) => { 83 | try { 84 | const { milestoneId } = req.params; 85 | const milestoneService = MilestoneService.getInstance(); 86 | await milestoneService.removeMilestone({ milestoneId }); 87 | res.status(204).end(); 88 | } catch (error) { 89 | next(error); 90 | } 91 | }; 92 | 93 | export { addMilestone, getMilestones, getMilestone, changeMilestone, removeMilestone }; 94 | -------------------------------------------------------------------------------- /backend/src/service/comment-service.js: -------------------------------------------------------------------------------- 1 | import { getRepository } from "typeorm"; 2 | import { Transactional } from "typeorm-transactional-cls-hooked"; 3 | import { EntityNotFoundError } from "../common/error/entity-not-found-error"; 4 | import { User } from "../model/user"; 5 | import { Issue } from "../model/issue"; 6 | import { Comment } from "../model/comment"; 7 | import { CommentContent } from "../model/comment-content"; 8 | 9 | class CommentService { 10 | static instance = null; 11 | 12 | static getInstance() { 13 | if (CommentService.instance === null) { 14 | CommentService.instance = new CommentService(); 15 | } 16 | return CommentService.instance; 17 | } 18 | 19 | constructor() { 20 | this.userRepository = getRepository(User); 21 | this.issueRepository = getRepository(Issue); 22 | this.commentRepository = getRepository(Comment); 23 | this.commentContentRepository = getRepository(CommentContent); 24 | } 25 | 26 | async getUserById(id) { 27 | const user = await this.userRepository.findOne(id); 28 | return user; 29 | } 30 | 31 | async getIssueById(id) { 32 | const issue = await this.issueRepository.findOne(id); 33 | return issue; 34 | } 35 | 36 | async getContent(commentId) { 37 | const comment = await this.commentRepository.findOne(commentId, { relations: ["content"] }); 38 | return comment.content.content; 39 | } 40 | 41 | @Transactional() 42 | async addComment(userId, issueId, content) { 43 | const targetUser = await this.getUserById(userId); 44 | const targetIssue = await this.getIssueById(issueId); 45 | if (targetUser === undefined || targetIssue === undefined) { 46 | throw new EntityNotFoundError(); 47 | } 48 | 49 | const commentContent = this.commentContentRepository.create({ content }); 50 | await this.commentContentRepository.save(commentContent); 51 | 52 | const comment = this.commentRepository.create({ user: targetUser, issue: targetIssue, content: commentContent }); 53 | await this.commentRepository.save(comment); 54 | 55 | return comment; 56 | } 57 | 58 | @Transactional() 59 | async getComments(issueId) { 60 | const comments = await this.commentRepository.find({ issue: issueId, relations: ["user", "content"] }); 61 | return comments; 62 | } 63 | 64 | @Transactional() 65 | async changeComment(commentId, content) { 66 | const targetComment = await this.commentRepository.findOne({ id: commentId, relations: ["content"] }); 67 | const targetCommentContent = await this.commentContentRepository.findOne(targetComment.id); 68 | 69 | if (targetComment === undefined || targetCommentContent === undefined) { 70 | throw new EntityNotFoundError(); 71 | } 72 | 73 | targetCommentContent.content = content; 74 | 75 | await this.commentContentRepository.save(targetCommentContent); 76 | } 77 | 78 | @Transactional() 79 | async removeComment(commentId) { 80 | const targetComment = await this.commentRepository.findOne({ id: commentId, relations: ["content"] }); 81 | if (targetComment === undefined) { 82 | throw new EntityNotFoundError(); 83 | } 84 | await this.commentRepository.remove(targetComment); 85 | } 86 | } 87 | 88 | export { CommentService }; 89 | -------------------------------------------------------------------------------- /frontend/src/pages/NewIssuePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { UserContext } from "@context"; 3 | import { ContentEditor, SidebarMenu, Button, UserProfile } from "@components"; 4 | import styled from "styled-components"; 5 | import { color } from "@style/color"; 6 | import { API } from "@utils"; 7 | import { useHistory } from "react-router-dom"; 8 | 9 | const StyledNewIssueContainer = styled.form` 10 | display: flex; 11 | justify-content: center; 12 | align-items: space-between; 13 | `; 14 | 15 | const StyledLeftContainer = styled.div` 16 | margin-top: 3rem; 17 | `; 18 | 19 | const CenterContainer = styled.div` 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | align-items: center; 24 | margin-top: 3rem; 25 | margin-left: 1rem; 26 | margin-right: 1rem; 27 | border: 1px solid ${color.container_border}; 28 | border-radius: 0.5rem; 29 | padding: 0.5rem; 30 | `; 31 | 32 | const CenterButtonContainer = styled.div` 33 | width: 95%; 34 | display: flex; 35 | justify-content: space-between; 36 | padding-bottom: 0.5rem; 37 | `; 38 | 39 | const RightContainer = styled.div` 40 | display: flex; 41 | flex-direction: column; 42 | margin-top: 3rem; 43 | `; 44 | 45 | const TitleInput = styled.input` 46 | width: 95%; 47 | line-height: 2; 48 | margin-bottom: 1rem; 49 | background-color: ${color.title_input_bg}; 50 | border: 1px solid ${color.title_input_border}; 51 | border-radius: 7px; 52 | padding-left: 0.5rem; 53 | &:focus { 54 | background-color: ${color.main_bg}; 55 | border: 1px solid ${color.title_input_focus_border}; 56 | outline: none; 57 | box-shadow: inset 0 1px 2px rgba(27, 31, 35, 0.075), 0 0 0 3px rgba(3, 102, 214, 0.3); 58 | } 59 | `; 60 | 61 | const LeftContainer = () => { 62 | const { photoImage } = useContext(UserContext); 63 | return ( 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | const NewIssuePage = () => { 71 | const history = useHistory(); 72 | 73 | const onSubmitListener = async (e) => { 74 | e.preventDefault(); 75 | const formNode = e.target; 76 | const { title, content } = formNode; 77 | 78 | try { 79 | await API.postIssue({ title: title.value, content: content.value }); 80 | history.push({ pathname: "/" }); 81 | } catch (err) { 82 | alert("이슈를 생성하는데 문제가 발생했습니다!"); 83 | } 84 | }; 85 | 86 | return ( 87 | <> 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | ); 106 | }; 107 | 108 | export default NewIssuePage; 109 | -------------------------------------------------------------------------------- /frontend/src/components/Header/HeaderContainer/HeaderContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useContext } from "react"; 2 | import { UserContext } from "@context"; 3 | import { API } from "@utils"; 4 | import { useHistory } from "react-router-dom"; 5 | import HeaderPresenter from "../HeaderPresenter/HeaderPresenter"; 6 | import HeaderContext from "../HeaderContext/HeaderContext"; 7 | 8 | const HEADER_ACTION_TYPE = { 9 | SHOW_DROPMENU: "showDropmenu", 10 | HIDE_DROPMENU: "hideDropmenu" 11 | }; 12 | 13 | const HEADER_DROP_MENU_EVENT_TYPE = { 14 | SIGN_OUT: "signout" 15 | }; 16 | 17 | const reducer = (headerState, action) => { 18 | switch (action.type) { 19 | case HEADER_ACTION_TYPE.SHOW_DROPMENU: 20 | return { ...headerState, isHiddenDropmenu: false }; 21 | case HEADER_ACTION_TYPE.HIDE_DROPMENU: 22 | return { ...headerState, isHiddenDropmenu: true }; 23 | default: 24 | throw new Error(); 25 | } 26 | }; 27 | 28 | const HeaderContainer = () => { 29 | const { photoImage, name, email } = useContext(UserContext); 30 | const initialState = { 31 | userProfileImage: photoImage, 32 | userStatusMenus: { 33 | id: 1, 34 | menus: [ 35 | { id: 1, title: `Signed in as ${name}` }, 36 | { id: 2, title: `${email}` } 37 | ] 38 | }, 39 | navigationMenus: { 40 | id: 2, 41 | menus: [ 42 | { id: 1, title: "Your profile" }, 43 | { id: 2, title: "Your repository" }, 44 | { id: 3, title: "Your enterprises" }, 45 | { id: 4, title: "Your stars" }, 46 | { id: 5, title: "Your gists" } 47 | ] 48 | }, 49 | serviceMenus: { 50 | id: 3, 51 | menus: [ 52 | { id: 1, title: "Help" }, 53 | { id: 2, title: "Sign out", eventType: HEADER_DROP_MENU_EVENT_TYPE.SIGN_OUT } 54 | ] 55 | }, 56 | isHiddenDropmenu: true 57 | }; 58 | const [headerState, dispatch] = useReducer(reducer, initialState); 59 | const history = useHistory(); 60 | 61 | const eventListeners = { 62 | onDropmenuClickListner: (e) => { 63 | e.preventDefault(); 64 | 65 | if (headerState.isHiddenDropmenu) { 66 | dispatch({ type: HEADER_ACTION_TYPE.SHOW_DROPMENU }); 67 | return; 68 | } 69 | dispatch({ type: HEADER_ACTION_TYPE.HIDE_DROPMENU }); 70 | }, 71 | 72 | onModalBackgrondClickListener: (e) => { 73 | e.preventDefault(); 74 | if (!headerState.isHiddenDropmenu) dispatch({ type: HEADER_ACTION_TYPE.HIDE_DROPMENU }); 75 | }, 76 | 77 | onHeaderDropmenuClickListner: async (e) => { 78 | e.stopPropagation(); 79 | const targetNode = e.target; 80 | if (!targetNode.dataset.event) return; 81 | 82 | switch (targetNode.dataset.event) { 83 | case HEADER_DROP_MENU_EVENT_TYPE.SIGN_OUT: 84 | await API.getLogout(); 85 | history.push({ pathname: "/login" }); 86 | break; 87 | default: 88 | break; 89 | } 90 | } 91 | }; 92 | 93 | return ( 94 | 95 | 96 | 97 | ); 98 | }; 99 | 100 | export default HeaderContainer; 101 | -------------------------------------------------------------------------------- /backend/test/router/issue-milestone.test.js: -------------------------------------------------------------------------------- 1 | import { agent } from "supertest"; 2 | import { getEntityManagerOrTransactionManager } from "typeorm-transactional-cls-hooked"; 3 | import { ApplicationFactory } from "../../src/application-factory"; 4 | import { TransactionWrapper } from "../TransactionWrapper"; 5 | import { generateJWTToken } from "../../src/common/lib/token-generator"; 6 | import { User } from "../../src/model/user"; 7 | import { Issue } from "../../src/model/issue"; 8 | import { Milestone } from "../../src/model/milestone"; 9 | 10 | const mockUser = { email: "Do-ho@github.com", name: "Do-ho", profileImage: "profile image" }; 11 | const mockIssue = { title: "issue title" }; 12 | const mockMilestone = { title: "title", description: "description", due_date: new Date() }; 13 | 14 | describe("Comment Router Test", () => { 15 | let app = null; 16 | 17 | beforeAll(async () => { 18 | app = await ApplicationFactory.create(); 19 | }); 20 | 21 | test("이슈 마일스톤 생성 라우팅 테스트", async () => { 22 | await TransactionWrapper.transaction(async () => { 23 | const entityManager = getEntityManagerOrTransactionManager(); 24 | await entityManager.query("SAVEPOINT STARTPOINT"); 25 | 26 | // given 27 | const user = entityManager.create(User, mockUser); 28 | await entityManager.save(User, user); 29 | 30 | const token = generateJWTToken({ 31 | userId: user.id, 32 | username: user.name, 33 | email: user.email, 34 | photos: user.profileImage 35 | }); 36 | 37 | const issue = entityManager.create(Issue, { ...mockIssue, author: user }); 38 | await entityManager.save(Issue, issue); 39 | 40 | const milestone = entityManager.create(Milestone, mockMilestone); 41 | await entityManager.save(Milestone, milestone); 42 | 43 | // when 44 | const response = await agent(app.httpServer) 45 | .post(`/api/issue/${issue.id}/milestone/${milestone.id}`) 46 | .set("Cookie", [`token=${token}`]) 47 | .send(); 48 | 49 | // then 50 | expect(response.status).toEqual(201); 51 | 52 | await entityManager.query("ROLLBACK TO STARTPOINT"); 53 | }); 54 | }); 55 | 56 | test("이슈 마일스톤 삭제 라우팅 테스트", async () => { 57 | await TransactionWrapper.transaction(async () => { 58 | const entityManager = getEntityManagerOrTransactionManager(); 59 | await entityManager.query("SAVEPOINT STARTPOINT"); 60 | 61 | // given 62 | const user = entityManager.create(User, mockUser); 63 | await entityManager.save(User, user); 64 | 65 | const token = generateJWTToken({ 66 | userId: user.id, 67 | username: user.name, 68 | email: user.email, 69 | photos: user.profileImage 70 | }); 71 | 72 | const issue = entityManager.create(Issue, { ...mockIssue, author: user }); 73 | await entityManager.save(Issue, issue); 74 | 75 | const milestone = entityManager.create(Milestone, mockMilestone); 76 | await entityManager.save(Milestone, milestone); 77 | 78 | // when 79 | const response = await agent(app.httpServer) 80 | .delete(`/api/issue/${issue.id}/milestone/${milestone.id}`) 81 | .set("Cookie", [`token=${token}`]) 82 | .send(); 83 | 84 | // then 85 | expect(response.status).toEqual(204); 86 | 87 | await entityManager.query("ROLLBACK TO STARTPOINT"); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /frontend/src/components/IssueFilterMenu/IssueFilterMenuPresenter/IssueFilterMenuPresenter.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import styled from "styled-components"; 3 | import { color } from "@style/color"; 4 | import { Checkbox, Caret } from "@components"; 5 | import IssueFilterDropmenu from "../IssueFilterDropmenu/IssueFilterDropmenu"; 6 | import MainContentContext from "../../MainTemplate/MainContext/MainContentContext"; 7 | 8 | const IssueFilterMenuArea = styled.div` 9 | display: flex; 10 | width: 100%; 11 | justify-content: space-between; 12 | `; 13 | 14 | const IssueFileterMenuList = styled.ul` 15 | display: flex; 16 | `; 17 | 18 | const IssueFilterItem = styled.li` 19 | margin-right: 16px; 20 | &:last-of-type { 21 | margin: 0; 22 | } 23 | `; 24 | 25 | const IssueDropButton = styled.button` 26 | position: relative; 27 | & > span { 28 | margin-right: 5px; 29 | color: ${color.issue_drop_btn}; 30 | } 31 | &:hover > span { 32 | color: ${color.issue_drop_hover_btn}; 33 | } 34 | `; 35 | 36 | const IssueFileterDropButton = ({ children, ...rest }) => { 37 | return ( 38 | 39 | {children} 40 | 41 | 42 | ); 43 | }; 44 | 45 | const FilterDropmenuModalBackground = styled.div` 46 | display: ${(props) => (props.isHidden ? "none" : "block")}; 47 | position: fixed; 48 | top: 0; 49 | right: 0; 50 | width: 100%; 51 | height: 100%; 52 | background-color: ${color.issue_drop_modal_bg}; 53 | z-index: 1000; 54 | `; 55 | 56 | const FilterDropmenuArea = styled.div` 57 | position: absolute; 58 | display: ${(props) => (props.isHidden ? "none" : "block")}; 59 | z-index: 2000; 60 | `; 61 | 62 | const FilterCheckboxArea = styled.div` 63 | & > * { 64 | margin-right: 16px; 65 | } 66 | `; 67 | 68 | const IssueFilterMenuPresenter = () => { 69 | const { contentState, contentEventListeners } = useContext(MainContentContext); 70 | const { totalCheckBox } = contentState; 71 | 72 | const getIssueFilterMenus = () => 73 | contentState.issueFilterMenus.reduce( 74 | (acc, cur) => 75 | acc.concat( 76 | 77 | 78 | {cur.title} 79 | 80 | 84 | 85 | 86 | 87 | 88 | ), 89 | [] 90 | ); 91 | 92 | const getTotalSelected = () => (totalCheckBox > 0 ? {totalCheckBox} selected : ""); 93 | 94 | return ( 95 | 96 | 97 | 98 | {getTotalSelected()} 99 | 100 | {getIssueFilterMenus()} 101 | 102 | ); 103 | }; 104 | 105 | export default IssueFilterMenuPresenter; 106 | -------------------------------------------------------------------------------- /backend/src/router/issue.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { QueryParser } from "../common/lib"; 3 | import { queryMapper } from "../common/middleware/query-mapper"; 4 | import { RequestType } from "../common/middleware/request-type"; 5 | import { transformer } from "../common/middleware/transformer"; 6 | import { validator } from "../common/middleware/validator"; 7 | import { issueController, commentController } from "../controller"; 8 | import { 9 | AddIssueRequestBody, 10 | UserToIssueRequestParams, 11 | CreateReadCommentRequestParams, 12 | AddCommentRequestBody, 13 | UpdateDeleteCommentRequestParams, 14 | IssueMilestoneRequestParams, 15 | GetIssuesRequestQuery, 16 | GetIssueByIdParams, 17 | ModifyIssueByIdBody, 18 | ModifyIssueByIdParams, 19 | RemoveIssueByIdParams 20 | } from "../dto/issue"; 21 | 22 | const router = express.Router(); 23 | 24 | router.post("/", transformer([RequestType.BODY], [AddIssueRequestBody]), validator([RequestType.BODY]), issueController.addIssue); 25 | 26 | router.get( 27 | "/", 28 | transformer([RequestType.QUERY], [GetIssuesRequestQuery]), 29 | validator([RequestType.QUERY]), 30 | queryMapper(new QueryParser(" ", ":")), 31 | issueController.getIssues 32 | ); 33 | 34 | router.get("/:issueId", transformer([RequestType.PARAMS], [GetIssueByIdParams]), validator([RequestType.PARAMS]), issueController.getIssueById); 35 | 36 | router.patch( 37 | "/:issueId", 38 | transformer([RequestType.BODY, RequestType.PARAMS], [ModifyIssueByIdBody, ModifyIssueByIdParams]), 39 | validator([RequestType.BODY, RequestType.PARAMS]), 40 | issueController.modifyIssueById 41 | ); 42 | 43 | router.delete( 44 | "/:issueId", 45 | transformer([RequestType.PARAMS], [RemoveIssueByIdParams]), 46 | validator([RequestType.PARAMS]), 47 | issueController.removeIssueById 48 | ); 49 | 50 | router.post( 51 | "/:issueId/assignee/:assigneeId", 52 | transformer([RequestType.PARAMS], [UserToIssueRequestParams]), 53 | validator([RequestType.PARAMS]), 54 | issueController.addAssignee 55 | ); 56 | 57 | router.delete( 58 | "/:issueId/assignee/:assigneeId", 59 | transformer([RequestType.PARAMS], [UserToIssueRequestParams]), 60 | validator([RequestType.PARAMS]), 61 | issueController.removeAssignee 62 | ); 63 | 64 | router.post( 65 | "/:issueId/comment", 66 | transformer([RequestType.BODY, RequestType.PARAMS], [AddCommentRequestBody, CreateReadCommentRequestParams]), 67 | validator([RequestType.BODY, RequestType.PARAMS]), 68 | commentController.addComment 69 | ); 70 | 71 | router.get( 72 | "/:issueId/comment", 73 | transformer([RequestType.PARAMS], [CreateReadCommentRequestParams]), 74 | validator([RequestType.PARAMS]), 75 | commentController.getComments 76 | ); 77 | 78 | router.patch( 79 | "/:issueId/comment/:commentId", 80 | transformer([RequestType.BODY, RequestType.PARAMS], [AddCommentRequestBody, UpdateDeleteCommentRequestParams]), 81 | validator([RequestType.BODY, RequestType.PARAMS]), 82 | commentController.changeComment 83 | ); 84 | 85 | router.delete( 86 | "/:issueId/comment/:commentId", 87 | transformer([RequestType.PARAMS], [UpdateDeleteCommentRequestParams]), 88 | validator([RequestType.PARAMS]), 89 | commentController.removeComment 90 | ); 91 | 92 | router.post( 93 | "/:issueId/milestone/:milestoneId", 94 | transformer([RequestType.PARAMS], [IssueMilestoneRequestParams]), 95 | validator([RequestType.PARAMS]), 96 | issueController.addMilestone 97 | ); 98 | 99 | router.delete( 100 | "/:issueId/milestone/:milestoneId", 101 | transformer([RequestType.PARAMS], [IssueMilestoneRequestParams]), 102 | validator([RequestType.PARAMS]), 103 | issueController.removeMilestone 104 | ); 105 | 106 | export default router; 107 | -------------------------------------------------------------------------------- /backend/test/router/issue-label.test.js: -------------------------------------------------------------------------------- 1 | import { agent } from "supertest"; 2 | import { getEntityManagerOrTransactionManager } from "typeorm-transactional-cls-hooked"; 3 | import { ApplicationFactory } from "../../src/application-factory"; 4 | import { generateJWTToken } from "../../src/common/lib/token-generator"; 5 | import { Label } from "../../src/model/label"; 6 | import { User } from "../../src/model/user"; 7 | import { Issue } from "../../src/model/issue"; 8 | import { LabelToIssue } from "../../src/model/label-to-issue"; 9 | import { TransactionWrapper } from "../TransactionWrapper"; 10 | 11 | describe("IssueLabel Router Test", () => { 12 | let app = null; 13 | 14 | beforeAll(async () => { 15 | app = await ApplicationFactory.create(); 16 | }); 17 | 18 | test("사용자가 이슈와 라벨을 만들고, 라벨을 이슈에 등록", async () => { 19 | await TransactionWrapper.transaction(async () => { 20 | const entityManager = getEntityManagerOrTransactionManager(); 21 | await entityManager.query("SAVEPOINT STARTPOINT"); 22 | 23 | // given 24 | const user = entityManager.create(User, { email: "youngxpepp@gmail.com", name: "youngxpepp", profileImage: "profile image" }); 25 | await entityManager.save(User, user); 26 | const token = generateJWTToken({ 27 | userId: user.id, 28 | username: user.name, 29 | email: user.email, 30 | photos: user.profileImage 31 | }); 32 | 33 | const issue = entityManager.create(Issue, { title: "issue title", author: user }); 34 | await entityManager.save(Issue, issue); 35 | 36 | const label = entityManager.create(Label, { name: "label name", color: "#000000" }); 37 | await entityManager.save(Label, label); 38 | 39 | // when 40 | const response = await agent(app.httpServer) 41 | .post("/api/issue/1/label/1") 42 | .set("Cookie", [`token=${token}`]) 43 | .send(); 44 | 45 | // then 46 | expect(response.status).toEqual(201); 47 | 48 | await entityManager.query("ROLLBACK TO STARTPOINT"); 49 | }); 50 | }); 51 | 52 | test("사용자가 이슈와 라벨을 만들고, 라벨을 이슈에 등록 후 이슈에서 라벨 삭제", async () => { 53 | await TransactionWrapper.transaction(async () => { 54 | const entityManager = getEntityManagerOrTransactionManager(); 55 | await entityManager.query("SAVEPOINT STARTPOINT"); 56 | 57 | // given 58 | const user = entityManager.create(User, { email: "youngxpepp@gmail.com", name: "youngxpepp", profileImage: "profile image" }); 59 | await entityManager.save(User, user); 60 | const token = generateJWTToken({ 61 | userId: user.id, 62 | username: user.name, 63 | email: user.email, 64 | photos: user.profileImage 65 | }); 66 | 67 | const issueInstance = entityManager.create(Issue, { title: "issue title", author: user }); 68 | await entityManager.save(Issue, issueInstance); 69 | 70 | const labelInstance = entityManager.create(Label, { name: "label name", color: "#000000" }); 71 | await entityManager.save(Label, labelInstance); 72 | 73 | const labelToIssue = entityManager.create(LabelToIssue, { label: labelInstance, issue: issueInstance }); 74 | await entityManager.save(LabelToIssue, labelToIssue); 75 | // when 76 | const response = await agent(app.httpServer) 77 | .delete("/api/issue/1/label/1") 78 | .set("Cookie", [`token=${token}`]) 79 | .send(); 80 | 81 | // then 82 | expect(response.status).toEqual(204); 83 | 84 | await entityManager.query("ROLLBACK TO STARTPOINT"); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /frontend/src/components/LoginForm/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { useHistory, Link } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | import { debounce } from "lodash"; 5 | import config from "@config"; 6 | import { color } from "@style/color"; 7 | import { Form } from "@components"; 8 | import { API } from "@utils"; 9 | 10 | const RegisterButtonContainer = styled.div` 11 | width: 70%; 12 | display: flex; 13 | justify-content: space-between; 14 | margin-bottom: 1.5rem; 15 | `; 16 | 17 | const RegisterButton = styled.button` 18 | border: none; 19 | background-color: none; 20 | color: ${color.register_btn}; 21 | color: ${(props) => (props.disabled ? color.register_btn_disabled : color.register_btn)}; 22 | font-weight: bold; 23 | margin-left: auto; 24 | margin-right: auto; 25 | `; 26 | 27 | const GitHubLoginClick = () => { 28 | window.location.href = config.API.GET_GITHUB_LOGIN; 29 | }; 30 | 31 | const LoginForm = () => { 32 | const history = useHistory(); 33 | const idWarning = useRef(); 34 | const passwordWarning = useRef(); 35 | const loginWarning = useRef(); 36 | const submitButton = useRef(); 37 | const emailInput = useRef(); 38 | const passwordInput = useRef(); 39 | const [idInputFilled, setIdInputFilled] = useState(false); 40 | const [passwordInputFilled, setPasswordInputFilled] = useState(false); 41 | 42 | const inputOnChange = (event) => { 43 | const inputLength = event.target.value.length; 44 | const targetDOM = event.target.id === "user-input" ? idWarning.current : passwordWarning.current; 45 | const maxLength = event.target.id === "user-input" ? 26 : 12; 46 | const targetState = event.target.id === "user-input" ? setIdInputFilled : setPasswordInputFilled; 47 | if ((inputLength > 0 && inputLength < 6) || inputLength > maxLength) { 48 | targetDOM.style.display = "block"; 49 | targetState(false); 50 | } else if (inputLength === 0) { 51 | targetDOM.style.display = "none"; 52 | targetState(false); 53 | } else { 54 | targetDOM.style.display = "none"; 55 | targetState(true); 56 | } 57 | }; 58 | 59 | const SubmitClick = async () => { 60 | try { 61 | await API.postLogin(emailInput.current.value, passwordInput.current.value); 62 | history.push("/"); 63 | } catch (e) { 64 | loginWarning.current.style.display = "block"; 65 | } 66 | }; 67 | 68 | const debouncedInputOnChange = debounce(inputOnChange, 500); 69 | 70 | return ( 71 | 72 | 이메일 73 | 74 | 이메일은 6~26자 사이로 입력해주세요. 75 | 비밀번호 76 | 77 | 비밀번호는 6~12자 사이로 입력해주세요. 78 | 79 | 80 | 로그인 81 | 82 | 83 | 회원가입 84 | 85 | 86 | 아이디 또는 비밀번호가 잘못됐습니다. 다시 확인해주세요. 87 | 88 | Sign in with GitHub 89 | 90 | 91 | ); 92 | }; 93 | 94 | export default LoginForm; 95 | -------------------------------------------------------------------------------- /frontend/webpack.config.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const dotenv = require("dotenv"); 4 | const webpack = require("webpack"); 5 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 6 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 8 | 9 | const parseEnvKeys = (env) => { 10 | const currentPath = path.join(__dirname); 11 | const basePath = `${currentPath}/env/.env`; 12 | const envPath = `${basePath}.${env.ENVIRONMENT}`; 13 | const finalPath = fs.existsSync(envPath) ? envPath : basePath; 14 | const fileEnv = dotenv.config({ 15 | path: finalPath 16 | }).parsed; 17 | 18 | return Object.keys(fileEnv).reduce((keys, key) => { 19 | keys[`process.env.${key}`] = JSON.stringify(fileEnv[key]); 20 | return keys; 21 | }, {}); 22 | }; 23 | 24 | module.exports = (env) => { 25 | const envKeys = parseEnvKeys(env); 26 | 27 | return { 28 | resolve: { 29 | extensions: [".jsx", ".js"], 30 | alias: { 31 | "@config": path.resolve(__dirname, "config"), 32 | "@components": path.resolve(__dirname, "src/components"), 33 | "@pages": path.resolve(__dirname, "src/pages"), 34 | "@style": path.resolve(__dirname, "src/common/style"), 35 | "@utils": path.resolve(__dirname, "src/common/utils"), 36 | "@imgs": path.resolve(__dirname, "public/imgs"), 37 | "@hook": path.resolve(__dirname, "src/common/hook"), 38 | "@context": path.resolve(__dirname, "src/common/context") 39 | } 40 | }, 41 | entry: { 42 | index: "./src/index" 43 | }, 44 | module: { 45 | rules: [ 46 | { 47 | test: /\.(js|jsx)/, 48 | loader: "babel-loader", 49 | exclude: /node_modules/, 50 | options: { 51 | presets: [ 52 | [ 53 | "@babel/preset-env", 54 | { 55 | modules: false, 56 | targets: { 57 | browsers: ["> 1% in KR"] 58 | } 59 | } 60 | ], 61 | ["@babel/preset-react"] 62 | ], 63 | plugins: ["@babel/plugin-transform-runtime"] 64 | } 65 | }, 66 | { 67 | test: /\.scss$/, 68 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"] 69 | }, 70 | { 71 | test: /\.(png|jpe?g|gif)$/i, 72 | use: { 73 | loader: "url-loader", 74 | options: { 75 | publicPath: "/", 76 | name: "[name].[ext]?[hash]", 77 | limit: 10000 78 | } 79 | } 80 | } 81 | ] 82 | }, 83 | plugins: [ 84 | new HtmlWebpackPlugin({ 85 | template: "./public/index.html", 86 | filename: "index.html", 87 | chunks: ["index"], 88 | hash: true 89 | }), 90 | new CleanWebpackPlugin(), 91 | new webpack.DefinePlugin(envKeys), 92 | new MiniCssExtractPlugin({ 93 | filename: "[name].css" 94 | }) 95 | ], 96 | output: { 97 | path: path.join(__dirname, "dist"), 98 | publicPath: "/", 99 | filename: "[name].js" 100 | } 101 | }; 102 | }; 103 | --------------------------------------------------------------------------------