├── 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 |

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 | 
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | ## 인원 소개
35 |
36 | 
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 |
--------------------------------------------------------------------------------