1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

Initial commit

This commit is contained in:
Maksim Eltyshev 2019-08-31 04:07:25 +05:00
commit 5ffef61fe7
613 changed files with 91659 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Maksim Eltyshev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# Planka
A Trello-like application built with React and Redux.
![](https://github.com/meltyshev/planka/blob/master/demo.gif?raw=true)
Online demo is coming soon.
### Features
- Create projects, boards, lists, cards, labels and tasks
- Add card members, track time, set a deadline, write comments
- Filter by members and labels
- User notifications
- Internationalization
### Tech stack
- React, Redux, Redux-Saga, Redux-ORM, Semantic UI React, react-beautiful-dnd
- Sails.js, Knex.js
- PostgreSQL
### Run in development
```bash
git clone https://github.com/meltyshev/planka.git
```
Server:
```bash
cd server
npm install
```
Create a database and edit DATABASE_URL in .env file.
```bash
npm run db:migrate
npm run db:seed
npm run start:dev
```
Client (second terminal window):
```bash
cd client
npm install
npm start
```
Demo user: demo@demo.demo demo
## Run in production
In progress..
## Todo
In progress..
## License
Planka is [MIT licensed](./LICENSE).

21
client/.gitignore vendored Executable file
View file

@ -0,0 +1,21 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

1
client/README.md Executable file
View file

@ -0,0 +1 @@
# Planka client

14258
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

70
client/package.json Executable file
View file

@ -0,0 +1,70 @@
{
"private": true,
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"lint": "eslint src",
"start": "react-scripts start",
"test": "react-scripts test"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"eslintConfig": {
"env": {
"browser": true
},
"extends": [
"airbnb",
"airbnb/hooks"
],
"parser": "babel-eslint"
},
"eslintIgnore": [
"src/service-worker.js"
],
"dependencies": {
"axios": "^0.19.0",
"classnames": "^2.2.6",
"connected-react-router": "^6.5.2",
"date-fns": "^2.0.1",
"dequal": "^1.0.0",
"history": "^4.9.0",
"i18next": "^17.0.13",
"i18next-browser-languagedetector": "^3.0.3",
"initials": "^3.0.1",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"react": "^16.9.0",
"react-beautiful-dnd": "^11.0.5",
"react-datepicker": "^2.9.3",
"react-dom": "^16.9.0",
"react-i18next": "^10.12.2",
"react-input-mask": "^2.0.4",
"react-redux": "^7.1.1",
"react-router-dom": "^5.0.1",
"react-scripts": "3.1.1",
"react-textarea-autosize": "^7.1.0",
"redux": "^4.0.4",
"redux-logger": "^3.0.6",
"redux-orm": "^0.13.3",
"redux-saga": "^1.0.5",
"reselect": "^4.0.0",
"sails.io.js": "^1.2.1",
"semantic-ui-react": "^0.88.0",
"socket.io-client": "^2.2.0",
"validator": "^11.1.0",
"whatwg-fetch": "^3.0.0"
},
"devDependencies": {
"eslint": "^6.2.2",
"eslint-config-airbnb": "^18.0.1",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^2.0.1"
}
}

BIN
client/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

30
client/public/index.html Executable file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Planka</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

15
client/public/manifest.json Executable file
View file

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,24 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const createActionReceived = (action) => ({
type: ActionTypes.ACTION_CREATE_RECEIVED,
payload: {
action,
},
});
export const updateActionReceived = (action) => ({
type: ActionTypes.ACTION_UPDATE_RECEIVED,
payload: {
action,
},
});
export const deleteActionReceived = (action) => ({
type: ActionTypes.ACTION_DELETE_RECEIVED,
payload: {
action,
},
});

View file

@ -0,0 +1,27 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const fetchActionsRequested = (cardId) => ({
type: ActionTypes.ACTIONS_FETCH_REQUESTED,
payload: {
cardId,
},
});
export const fetchActionsSucceeded = (cardId, actions, users) => ({
type: ActionTypes.ACTIONS_FETCH_SUCCEEDED,
payload: {
cardId,
actions,
users,
},
});
export const fetchActionsFailed = (cardId, error) => ({
type: ActionTypes.ACTIONS_FETCH_FAILED,
payload: {
cardId,
error,
},
});

View file

@ -0,0 +1,9 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
// eslint-disable-next-line import/prefer-default-export
export const appInitialized = () => ({
type: ActionTypes.APP_INITIALIZED,
payload: {},
});

157
client/src/actions/board.js Normal file
View file

@ -0,0 +1,157 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createBoard = (board) => ({
type: ActionTypes.BOARD_CREATE,
payload: {
board,
},
});
export const updateBoard = (id, data) => ({
type: ActionTypes.BOARD_UPDATE,
payload: {
id,
data,
},
});
export const deleteBoard = (id) => ({
type: ActionTypes.BOARD_DELETE,
payload: {
id,
},
});
/* Events */
export const createBoardRequested = (localId, data) => ({
type: ActionTypes.BOARD_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createBoardSucceeded = (localId, board, lists, labels) => ({
type: ActionTypes.BOARD_CREATE_SUCCEEDED,
payload: {
localId,
board,
lists,
labels,
},
});
export const createBoardFailed = (localId, error) => ({
type: ActionTypes.BOARD_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createBoardReceived = (board, lists, labels) => ({
type: ActionTypes.BOARD_CREATE_RECEIVED,
payload: {
board,
lists,
labels,
},
});
export const fetchBoardRequested = (id) => ({
type: ActionTypes.BOARD_FETCH_REQUESTED,
payload: {
id,
},
});
export const fetchBoardSucceeded = (
board,
lists,
labels,
cards,
cardMemberships,
cardLabels,
tasks,
) => ({
type: ActionTypes.BOARD_FETCH_SUCCEEDED,
payload: {
board,
lists,
labels,
cards,
cardMemberships,
cardLabels,
tasks,
},
});
export const fetchBoardFailed = (id, error) => ({
type: ActionTypes.BOARD_FETCH_FAILED,
payload: {
id,
error,
},
});
export const updateBoardRequested = (id, data) => ({
type: ActionTypes.BOARD_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateBoardSucceeded = (board) => ({
type: ActionTypes.BOARD_UPDATE_SUCCEEDED,
payload: {
board,
},
});
export const updateBoardFailed = (id, error) => ({
type: ActionTypes.BOARD_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateBoardReceived = (board) => ({
type: ActionTypes.BOARD_UPDATE_RECEIVED,
payload: {
board,
},
});
export const deleteBoardRequested = (id) => ({
type: ActionTypes.BOARD_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteBoardSucceeded = (board) => ({
type: ActionTypes.BOARD_DELETE_SUCCEEDED,
payload: {
board,
},
});
export const deleteBoardFailed = (id, error) => ({
type: ActionTypes.BOARD_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteBoardReceived = (board) => ({
type: ActionTypes.BOARD_DELETE_RECEIVED,
payload: {
board,
},
});

View file

@ -0,0 +1,62 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const createCardLabelRequested = (data) => ({
type: ActionTypes.CARD_LABEL_CREATE_REQUESTED,
payload: {
data,
},
});
export const createCardLabelSucceeded = (cardLabel) => ({
type: ActionTypes.CARD_LABEL_CREATE_SUCCEEDED,
payload: {
cardLabel,
},
});
export const createCardLabelFailed = (error) => ({
type: ActionTypes.CARD_LABEL_CREATE_FAILED,
payload: {
error,
},
});
export const createCardLabelReceived = (cardLabel) => ({
type: ActionTypes.CARD_LABEL_CREATE_RECEIVED,
payload: {
cardLabel,
},
});
export const deleteCardLabelRequested = (cardId, labelId) => ({
type: ActionTypes.CARD_LABEL_DELETE_REQUESTED,
payload: {
cardId,
labelId,
},
});
export const deleteCardLabelSucceeded = (cardLabel) => ({
type: ActionTypes.CARD_LABEL_DELETE_SUCCEEDED,
payload: {
cardLabel,
},
});
export const deleteCardLabelFailed = (cardId, labelId, error) => ({
type: ActionTypes.CARD_LABEL_DELETE_FAILED,
payload: {
cardId,
labelId,
error,
},
});
export const deleteCardLabelReceived = (cardLabel) => ({
type: ActionTypes.CARD_LABEL_DELETE_RECEIVED,
payload: {
cardLabel,
},
});

View file

@ -0,0 +1,62 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const createCardMembershipRequested = (data) => ({
type: ActionTypes.CARD_MEMBERSHIP_CREATE_REQUESTED,
payload: {
data,
},
});
export const createCardMembershipSucceeded = (cardMembership) => ({
type: ActionTypes.CARD_MEMBERSHIP_CREATE_SUCCEEDED,
payload: {
cardMembership,
},
});
export const createCardMembershipFailed = (error) => ({
type: ActionTypes.CARD_MEMBERSHIP_CREATE_FAILED,
payload: {
error,
},
});
export const createCardMembershipReceived = (cardMembership) => ({
type: ActionTypes.CARD_MEMBERSHIP_CREATE_RECEIVED,
payload: {
cardMembership,
},
});
export const deleteCardMembershipRequested = (cardId, userId) => ({
type: ActionTypes.CARD_MEMBERSHIP_DELETE_REQUESTED,
payload: {
cardId,
userId,
},
});
export const deleteCardMembershipSucceeded = (cardMembership) => ({
type: ActionTypes.CARD_MEMBERSHIP_DELETE_SUCCEEDED,
payload: {
cardMembership,
},
});
export const deleteCardMembershipFailed = (cardId, userId, error) => ({
type: ActionTypes.CARD_MEMBERSHIP_DELETE_FAILED,
payload: {
cardId,
userId,
error,
},
});
export const deleteCardMembershipReceived = (cardMembership) => ({
type: ActionTypes.CARD_MEMBERSHIP_DELETE_RECEIVED,
payload: {
cardMembership,
},
});

139
client/src/actions/card.js Normal file
View file

@ -0,0 +1,139 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createCard = (card) => ({
type: ActionTypes.CARD_CREATE,
payload: {
card,
},
});
export const updateCard = (id, data) => ({
type: ActionTypes.CARD_UPDATE,
payload: {
id,
data,
},
});
export const deleteCard = (id) => ({
type: ActionTypes.CARD_DELETE,
payload: {
id,
},
});
/* Events */
export const createCardRequested = (localId, data) => ({
type: ActionTypes.CARD_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createCardSucceeded = (localId, card) => ({
type: ActionTypes.CARD_CREATE_SUCCEEDED,
payload: {
localId,
card,
},
});
export const createCardFailed = (localId, error) => ({
type: ActionTypes.CARD_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createCardReceived = (card) => ({
type: ActionTypes.CARD_CREATE_RECEIVED,
payload: {
card,
},
});
export const fetchCardRequested = (id) => ({
type: ActionTypes.CARD_FETCH_REQUESTED,
payload: {
id,
},
});
export const fetchCardSucceeded = (card) => ({
type: ActionTypes.CARD_FETCH_SUCCEEDED,
payload: {
card,
},
});
export const fetchCardFailed = (id, error) => ({
type: ActionTypes.CARD_FETCH_FAILED,
payload: {
id,
error,
},
});
export const updateCardRequested = (id, data) => ({
type: ActionTypes.CARD_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateCardSucceeded = (card) => ({
type: ActionTypes.CARD_UPDATE_SUCCEEDED,
payload: {
card,
},
});
export const updateCardFailed = (id, error) => ({
type: ActionTypes.CARD_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateCardReceived = (card) => ({
type: ActionTypes.CARD_UPDATE_RECEIVED,
payload: {
card,
},
});
export const deleteCardRequested = (id) => ({
type: ActionTypes.CARD_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteCardSucceeded = (card) => ({
type: ActionTypes.CARD_DELETE_SUCCEEDED,
payload: {
card,
},
});
export const deleteCardFailed = (id, error) => ({
type: ActionTypes.CARD_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteCardReceived = (card) => ({
type: ActionTypes.CARD_DELETE_RECEIVED,
payload: {
card,
},
});

View file

@ -0,0 +1,96 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createCommentAction = (action) => ({
type: ActionTypes.COMMENT_ACTION_CREATE,
payload: {
action,
},
});
export const updateCommentAction = (id, data) => ({
type: ActionTypes.COMMENT_ACTION_UPDATE,
payload: {
id,
data,
},
});
export const deleteCommentAction = (id) => ({
type: ActionTypes.COMMENT_ACTION_DELETE,
payload: {
id,
},
});
/* Events */
export const createCommentActionRequested = (localId, data) => ({
type: ActionTypes.COMMENT_ACTION_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createCommentActionSucceeded = (localId, action) => ({
type: ActionTypes.COMMENT_ACTION_CREATE_SUCCEEDED,
payload: {
localId,
action,
},
});
export const createCommentActionFailed = (localId, error) => ({
type: ActionTypes.COMMENT_ACTION_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const updateCommentActionRequested = (id, data) => ({
type: ActionTypes.COMMENT_ACTION_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateCommentActionSucceeded = (action) => ({
type: ActionTypes.COMMENT_ACTION_UPDATE_SUCCEEDED,
payload: {
action,
},
});
export const updateCommentActionFailed = (id, error) => ({
type: ActionTypes.COMMENT_ACTION_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const deleteCommentActionRequested = (id) => ({
type: ActionTypes.COMMENT_ACTION_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteCommentActionSucceeded = (action) => ({
type: ActionTypes.COMMENT_ACTION_DELETE_SUCCEEDED,
payload: {
action,
},
});
export const deleteCommentActionFailed = (id, error) => ({
type: ActionTypes.COMMENT_ACTION_DELETE_FAILED,
payload: {
id,
error,
},
});

View file

@ -0,0 +1,7 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
// eslint-disable-next-line import/prefer-default-export
export const fetchActionsInCurrentCard = () => ({
type: EntryActionTypes.ACTIONS_IN_CURRENT_CARD_FETCH,
payload: {},
});

View file

@ -0,0 +1,31 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createBoardInCurrentProject = (data) => ({
type: EntryActionTypes.BOARD_IN_CURRENT_PROJECT_CREATE,
payload: {
data,
},
});
export const updateBoard = (id, data) => ({
type: EntryActionTypes.BOARD_UPDATE,
payload: {
id,
data,
},
});
export const moveBoard = (id, index) => ({
type: EntryActionTypes.BOARD_MOVE,
payload: {
id,
index,
},
});
export const deleteBoard = (id) => ({
type: EntryActionTypes.BOARD_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,45 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createCard = (listId, data) => ({
type: EntryActionTypes.CARD_CREATE,
payload: {
listId,
data,
},
});
export const updateCard = (id, data) => ({
type: EntryActionTypes.CARD_UPDATE,
payload: {
id,
data,
},
});
export const updateCurrentCard = (data) => ({
type: EntryActionTypes.CURRENT_CARD_UPDATE,
payload: {
data,
},
});
export const moveCard = (id, listId, index) => ({
type: EntryActionTypes.CARD_MOVE,
payload: {
id,
listId,
index,
},
});
export const deleteCard = (id) => ({
type: EntryActionTypes.CARD_DELETE,
payload: {
id,
},
});
export const deleteCurrentCard = () => ({
type: EntryActionTypes.CURRENT_CARD_DELETE,
payload: {},
});

View file

@ -0,0 +1,23 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createCommentActionInCurrentCard = (data) => ({
type: EntryActionTypes.COMMENT_ACTION_IN_CURRENT_CARD_CREATE,
payload: {
data,
},
});
export const updateCommentAction = (id, data) => ({
type: EntryActionTypes.COMMENT_ACTION_UPDATE,
payload: {
id,
data,
},
});
export const deleteCommentAction = (id) => ({
type: EntryActionTypes.COMMENT_ACTION_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,13 @@
export * from './login';
export * from './modal';
export * from './user';
export * from './project';
export * from './project-membership';
export * from './board';
export * from './list';
export * from './label';
export * from './card';
export * from './task';
export * from './actions';
export * from './comment-action';
export * from './notification';

View file

@ -0,0 +1,67 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createLabelInCurrentBoard = (data) => ({
type: EntryActionTypes.LABEL_IN_CURRENT_BOARD_CREATE,
payload: {
data,
},
});
export const updateLabel = (id, data) => ({
type: EntryActionTypes.LABEL_UPDATE,
payload: {
id,
data,
},
});
export const deleteLabel = (id) => ({
type: EntryActionTypes.LABEL_DELETE,
payload: {
id,
},
});
export const addLabelToCard = (id, cardId) => ({
type: EntryActionTypes.LABEL_TO_CARD_ADD,
payload: {
id,
cardId,
},
});
export const addLabelToCurrentCard = (id) => ({
type: EntryActionTypes.LABEL_TO_CURRENT_CARD_ADD,
payload: {
id,
},
});
export const removeLabelFromCard = (id, cardId) => ({
type: EntryActionTypes.LABEL_FROM_CARD_REMOVE,
payload: {
id,
cardId,
},
});
export const removeLabelFromCurrentCard = (id) => ({
type: EntryActionTypes.LABEL_FROM_CURRENT_CARD_REMOVE,
payload: {
id,
},
});
export const addLabelToFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LABEL_TO_FILTER_IN_CURRENT_BOARD_ADD,
payload: {
id,
},
});
export const removeLabelFromFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LABEL_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
payload: {
id,
},
});

View file

@ -0,0 +1,31 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createListInCurrentBoard = (data) => ({
type: EntryActionTypes.LIST_IN_CURRENT_BOARD_CREATE,
payload: {
data,
},
});
export const updateList = (id, data) => ({
type: EntryActionTypes.LIST_UPDATE,
payload: {
id,
data,
},
});
export const moveList = (id, index) => ({
type: EntryActionTypes.LIST_MOVE,
payload: {
id,
index,
},
});
export const deleteList = (id) => ({
type: EntryActionTypes.LIST_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,18 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const authenticate = (data) => ({
type: EntryActionTypes.AUTHENTICATE,
payload: {
data,
},
});
export const clearAuthenticationError = () => ({
type: EntryActionTypes.AUTHENTICATION_ERROR_CLEAR,
payload: {},
});
export const logout = () => ({
type: EntryActionTypes.LOGOUT,
payload: {},
});

View file

@ -0,0 +1,21 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
import ModalTypes from '../../constants/ModalTypes';
export const openUsersModal = () => ({
type: EntryActionTypes.MODAL_OPEN,
payload: {
type: ModalTypes.USERS,
},
});
export const openAddProjectModal = () => ({
type: EntryActionTypes.MODAL_OPEN,
payload: {
type: ModalTypes.ADD_PROJECT,
},
});
export const closeModal = () => ({
type: EntryActionTypes.MODAL_CLOSE,
payload: {},
});

View file

@ -0,0 +1,9 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
// eslint-disable-next-line import/prefer-default-export
export const deleteNotification = (id) => ({
type: EntryActionTypes.NOTIFICATION_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,15 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createMembershipInCurrentProject = (data) => ({
type: EntryActionTypes.MEMBERSHIP_IN_CURRENT_PROJECT_CREATE,
payload: {
data,
},
});
export const deleteProjectMembership = (id) => ({
type: EntryActionTypes.PROJECT_MEMBERSHIP_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,20 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createProject = (data) => ({
type: EntryActionTypes.PROJECT_CREATE,
payload: {
data,
},
});
export const updateCurrentProject = (data) => ({
type: EntryActionTypes.CURRENT_PROJECT_UPDATE,
payload: {
data,
},
});
export const deleteCurrentProject = () => ({
type: EntryActionTypes.CURRENT_PROJECT_DELETE,
payload: {},
});

View file

@ -0,0 +1,23 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createTaskInCurrentCard = (data) => ({
type: EntryActionTypes.TASK_IN_CURRENT_CARD_CREATE,
payload: {
data,
},
});
export const updateTask = (id, data) => ({
type: EntryActionTypes.TASK_UPDATE,
payload: {
id,
data,
},
});
export const deleteTask = (id) => ({
type: EntryActionTypes.TASK_DELETE,
payload: {
id,
},
});

View file

@ -0,0 +1,86 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
export const createUser = (data) => ({
type: EntryActionTypes.USER_CREATE,
payload: {
data,
},
});
export const clearUserCreationError = () => ({
type: EntryActionTypes.USER_CREATION_ERROR_CLEAR,
payload: {},
});
export const updateUser = (id, data) => ({
type: EntryActionTypes.USER_UPDATE,
payload: {
id,
data,
},
});
export const updateCurrentUser = (data) => ({
type: EntryActionTypes.CURRENT_USER_UPDATE,
payload: {
data,
},
});
export const uploadCurrentUserAvatar = (file) => ({
type: EntryActionTypes.CURRENT_USER_AVATAR_UPLOAD,
payload: {
file,
},
});
export const deleteUser = (id) => ({
type: EntryActionTypes.USER_DELETE,
payload: {
id,
},
});
export const addUserToCard = (id, cardId) => ({
type: EntryActionTypes.USER_TO_CARD_ADD,
payload: {
id,
cardId,
},
});
export const addUserToCurrentCard = (id) => ({
type: EntryActionTypes.USER_TO_CURRENT_CARD_ADD,
payload: {
id,
},
});
export const removeUserFromCard = (id, cardId) => ({
type: EntryActionTypes.USER_FROM_CARD_REMOVE,
payload: {
id,
cardId,
},
});
export const removeUserFromCurrentCard = (id) => ({
type: EntryActionTypes.USER_FROM_CURRENT_CARD_REMOVE,
payload: {
id,
},
});
export const addUserToFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.USER_TO_FILTER_IN_CURRENT_BOARD_ADD,
payload: {
id,
},
});
export const removeUserFromFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.USER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
payload: {
id,
},
});

View file

@ -0,0 +1,21 @@
export * from './socket';
export * from './login';
export * from './app';
export * from './modal';
export * from './users';
export * from './user';
export * from './projects';
export * from './project';
export * from './project-membership';
export * from './board';
export * from './list';
export * from './label';
export * from './card';
export * from './card-membership';
export * from './card-label';
export * from './task';
export * from './actions';
export * from './action';
export * from './comment-action';
export * from './notifications';
export * from './notification';

149
client/src/actions/label.js Normal file
View file

@ -0,0 +1,149 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createLabel = (label) => ({
type: ActionTypes.LABEL_CREATE,
payload: {
label,
},
});
export const updateLabel = (id, data) => ({
type: ActionTypes.LABEL_UPDATE,
payload: {
id,
data,
},
});
export const deleteLabel = (id) => ({
type: ActionTypes.LABEL_DELETE,
payload: {
id,
},
});
export const addLabelToCard = (id, cardId) => ({
type: ActionTypes.LABEL_TO_CARD_ADD,
payload: {
id,
cardId,
},
});
export const removeLabelFromCard = (id, cardId) => ({
type: ActionTypes.LABEL_FROM_CARD_REMOVE,
payload: {
id,
cardId,
},
});
export const addLabelToBoardFilter = (id, boardId) => ({
type: ActionTypes.LABEL_TO_BOARD_FILTER_ADD,
payload: {
id,
boardId,
},
});
export const removeLabelFromBoardFilter = (id, boardId) => ({
type: ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE,
payload: {
id,
boardId,
},
});
/* Events */
export const createLabelRequested = (localId, data) => ({
type: ActionTypes.LABEL_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createLabelSucceeded = (localId, label) => ({
type: ActionTypes.LABEL_CREATE_SUCCEEDED,
payload: {
localId,
label,
},
});
export const createLabelFailed = (localId, error) => ({
type: ActionTypes.LABEL_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createLabelReceived = (label) => ({
type: ActionTypes.LABEL_CREATE_RECEIVED,
payload: {
label,
},
});
export const updateLabelRequested = (id, data) => ({
type: ActionTypes.LABEL_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateLabelSucceeded = (label) => ({
type: ActionTypes.LABEL_UPDATE_SUCCEEDED,
payload: {
label,
},
});
export const updateLabelFailed = (id, error) => ({
type: ActionTypes.LABEL_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateLabelReceived = (label) => ({
type: ActionTypes.LABEL_UPDATE_RECEIVED,
payload: {
label,
},
});
export const deleteLabelRequested = (id) => ({
type: ActionTypes.LABEL_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteLabelSucceeded = (label) => ({
type: ActionTypes.LABEL_DELETE_SUCCEEDED,
payload: {
label,
},
});
export const deleteLabelFailed = (id, error) => ({
type: ActionTypes.LABEL_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteLabelReceived = (label) => ({
type: ActionTypes.LABEL_DELETE_RECEIVED,
payload: {
label,
},
});

117
client/src/actions/list.js Normal file
View file

@ -0,0 +1,117 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createList = (list) => ({
type: ActionTypes.LIST_CREATE,
payload: {
list,
},
});
export const updateList = (id, data) => ({
type: ActionTypes.LIST_UPDATE,
payload: {
id,
data,
},
});
export const deleteList = (id) => ({
type: ActionTypes.LIST_DELETE,
payload: {
id,
},
});
/* Events */
export const createListRequested = (localId, data) => ({
type: ActionTypes.LIST_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createListSucceeded = (localId, list) => ({
type: ActionTypes.LIST_CREATE_SUCCEEDED,
payload: {
localId,
list,
},
});
export const createListFailed = (localId, error) => ({
type: ActionTypes.LIST_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createListReceived = (list) => ({
type: ActionTypes.LIST_CREATE_RECEIVED,
payload: {
list,
},
});
export const updateListRequested = (id, data) => ({
type: ActionTypes.LIST_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateListSucceeded = (list) => ({
type: ActionTypes.LIST_UPDATE_SUCCEEDED,
payload: {
list,
},
});
export const updateListFailed = (id, error) => ({
type: ActionTypes.LIST_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateListReceived = (list) => ({
type: ActionTypes.LIST_UPDATE_RECEIVED,
payload: {
list,
},
});
export const deleteListRequested = (id) => ({
type: ActionTypes.LIST_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteListSucceeded = (list) => ({
type: ActionTypes.LIST_DELETE_SUCCEEDED,
payload: {
list,
},
});
export const deleteListFailed = (id, error) => ({
type: ActionTypes.LIST_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteListReceived = (list) => ({
type: ActionTypes.LIST_DELETE_RECEIVED,
payload: {
list,
},
});

View file

@ -0,0 +1,43 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const authenticate = (data) => ({
type: ActionTypes.AUTHENTICATE,
payload: {
data,
},
});
export const clearAuthenticationError = () => ({
type: ActionTypes.AUTHENTICATION_ERROR_CLEAR,
payload: {},
});
export const logout = () => ({
type: ActionTypes.LOGOUT,
payload: {},
});
/* Events */
export const authenticateRequested = (data) => ({
type: ActionTypes.AUTHENTICATE_REQUESTED,
payload: {
data,
},
});
export const authenticateSucceeded = (accessToken) => ({
type: ActionTypes.AUTHENTICATE_SUCCEEDED,
payload: {
accessToken,
},
});
export const authenticateFailed = (error) => ({
type: ActionTypes.AUTHENTICATE_FAILED,
payload: {
error,
},
});

View file

@ -0,0 +1,15 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const openModal = (type) => ({
type: ActionTypes.MODAL_OPEN,
payload: {
type,
},
});
export const closeModal = () => ({
type: ActionTypes.MODAL_CLOSE,
payload: {},
});

View file

@ -0,0 +1,20 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const createNotificationReceived = (notification, user, card, action) => ({
type: ActionTypes.NOTIFICATION_CREATE_RECEIVED,
payload: {
notification,
user,
card,
action,
},
});
export const deleteNotificationReceived = (notification) => ({
type: ActionTypes.NOTIFICATION_DELETE_RECEIVED,
payload: {
notification,
},
});

View file

@ -0,0 +1,56 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const deleteNotifications = (ids) => ({
type: ActionTypes.NOTIFICATIONS_DELETE,
payload: {
ids,
},
});
/* Events */
export const fetchNotificationsRequested = () => ({
type: ActionTypes.NOTIFICATIONS_FETCH_REQUESTED,
payload: {},
});
export const fetchNotificationsSucceeded = (notifications, users, cards, actions) => ({
type: ActionTypes.NOTIFICATIONS_FETCH_SUCCEEDED,
payload: {
notifications,
users,
cards,
actions,
},
});
export const fetchNotificationsFailed = (error) => ({
type: ActionTypes.NOTIFICATIONS_FETCH_FAILED,
payload: {
error,
},
});
export const deleteNotificationsRequested = (ids) => ({
type: ActionTypes.NOTIFICATIONS_DELETE_REQUESTED,
payload: {
ids,
},
});
export const deleteNotificationsSucceeded = (notifications) => ({
type: ActionTypes.NOTIFICATIONS_DELETE_SUCCEEDED,
payload: {
notifications,
},
});
export const deleteNotificationsFailed = (ids, error) => ({
type: ActionTypes.NOTIFICATIONS_DELETE_FAILED,
payload: {
ids,
error,
},
});

View file

@ -0,0 +1,80 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createProjectMembership = (projectMembership) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_CREATE,
payload: {
projectMembership,
},
});
export const deleteProjectMembership = (id) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_DELETE,
payload: {
id,
},
});
/* Events */
export const createProjectMembershipRequested = (localId, data) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createProjectMembershipSucceeded = (localId, projectMembership) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_CREATE_SUCCEEDED,
payload: {
localId,
projectMembership,
},
});
export const createProjectMembershipFailed = (localId, error) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createProjectMembershipReceived = (projectMembership, user) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_CREATE_RECEIVED,
payload: {
projectMembership,
user,
},
});
export const deleteProjectMembershipRequested = (id) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteProjectMembershipSucceeded = (projectMembership) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_DELETE_SUCCEEDED,
payload: {
projectMembership,
},
});
export const deleteProjectMembershipFailed = (id, error) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteProjectMembershipReceived = (projectMembership) => ({
type: ActionTypes.PROJECT_MEMBERSHIP_DELETE_RECEIVED,
payload: {
projectMembership,
},
});

View file

@ -0,0 +1,120 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createProject = (data) => ({
type: ActionTypes.PROJECT_CREATE,
payload: {
data,
},
});
export const updateProject = (id, data) => ({
type: ActionTypes.PROJECT_UPDATE,
payload: {
id,
data,
},
});
export const deleteProject = (id) => ({
type: ActionTypes.PROJECT_DELETE,
payload: {
id,
},
});
/* Events */
export const createProjectRequested = (data) => ({
type: ActionTypes.PROJECT_CREATE_REQUESTED,
payload: {
data,
},
});
export const createProjectSucceeded = (project, users, projectMemberships, boards) => ({
type: ActionTypes.PROJECT_CREATE_SUCCEEDED,
payload: {
project,
users,
projectMemberships,
boards,
},
});
export const createProjectFailed = (error) => ({
type: ActionTypes.PROJECT_CREATE_FAILED,
payload: {
error,
},
});
export const createProjectReceived = (project, users, projectMemberships, boards) => ({
type: ActionTypes.PROJECT_CREATE_RECEIVED,
payload: {
project,
users,
projectMemberships,
boards,
},
});
export const updateProjectRequested = (id, data) => ({
type: ActionTypes.PROJECT_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateProjectSucceeded = (project) => ({
type: ActionTypes.PROJECT_UPDATE_SUCCEEDED,
payload: {
project,
},
});
export const updateProjectFailed = (id, error) => ({
type: ActionTypes.PROJECT_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateProjectReceived = (project) => ({
type: ActionTypes.PROJECT_UPDATE_RECEIVED,
payload: {
project,
},
});
export const deleteProjectRequested = (id) => ({
type: ActionTypes.PROJECT_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteProjectSucceeded = (project) => ({
type: ActionTypes.PROJECT_DELETE_SUCCEEDED,
payload: {
project,
},
});
export const deleteProjectFailed = (id, error) => ({
type: ActionTypes.PROJECT_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteProjectReceived = (project) => ({
type: ActionTypes.PROJECT_DELETE_RECEIVED,
payload: {
project,
},
});

View file

@ -0,0 +1,25 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const fetchProjectsRequested = () => ({
type: ActionTypes.PROJECTS_FETCH_REQUESTED,
payload: {},
});
export const fetchProjectsSucceeded = (projects, users, projectMemberships, boards) => ({
type: ActionTypes.PROJECTS_FETCH_SUCCEEDED,
payload: {
projects,
users,
projectMemberships,
boards,
},
});
export const fetchProjectsFailed = (error) => ({
type: ActionTypes.PROJECTS_FETCH_FAILED,
payload: {
error,
},
});

View file

@ -0,0 +1,18 @@
import ActionTypes from '../constants/ActionTypes';
import SocketStatuses from '../constants/SocketStatuses';
/* Events */
export const socketDisconnected = () => ({
type: ActionTypes.SOCKET_STATUS_CHANGED,
payload: {
status: SocketStatuses.DISCONNECTED,
},
});
export const socketReconnected = () => ({
type: ActionTypes.SOCKET_STATUS_CHANGED,
payload: {
status: SocketStatuses.RECONNECTED,
},
});

117
client/src/actions/task.js Normal file
View file

@ -0,0 +1,117 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createTask = (task) => ({
type: ActionTypes.TASK_CREATE,
payload: {
task,
},
});
export const updateTask = (id, data) => ({
type: ActionTypes.TASK_UPDATE,
payload: {
id,
data,
},
});
export const deleteTask = (id) => ({
type: ActionTypes.TASK_DELETE,
payload: {
id,
},
});
/* Events */
export const createTaskRequested = (localId, data) => ({
type: ActionTypes.TASK_CREATE_REQUESTED,
payload: {
localId,
data,
},
});
export const createTaskSucceeded = (localId, task) => ({
type: ActionTypes.TASK_CREATE_SUCCEEDED,
payload: {
localId,
task,
},
});
export const createTaskFailed = (localId, error) => ({
type: ActionTypes.TASK_CREATE_FAILED,
payload: {
localId,
error,
},
});
export const createTaskReceived = (task) => ({
type: ActionTypes.TASK_CREATE_RECEIVED,
payload: {
task,
},
});
export const updateTaskRequested = (id, data) => ({
type: ActionTypes.TASK_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateTaskSucceeded = (task) => ({
type: ActionTypes.TASK_UPDATE_SUCCEEDED,
payload: {
task,
},
});
export const updateTaskFailed = (id, error) => ({
type: ActionTypes.TASK_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateTaskReceived = (task) => ({
type: ActionTypes.TASK_UPDATE_RECEIVED,
payload: {
task,
},
});
export const deleteTaskRequested = (id) => ({
type: ActionTypes.TASK_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteTaskSucceeded = (task) => ({
type: ActionTypes.TASK_DELETE_SUCCEEDED,
payload: {
task,
},
});
export const deleteTaskFailed = (id, error) => ({
type: ActionTypes.TASK_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteTaskReceived = (task) => ({
type: ActionTypes.TASK_DELETE_RECEIVED,
payload: {
task,
},
});

193
client/src/actions/user.js Normal file
View file

@ -0,0 +1,193 @@
import ActionTypes from '../constants/ActionTypes';
/* Actions */
export const createUser = (data) => ({
type: ActionTypes.USER_CREATE,
payload: {
data,
},
});
export const clearUserCreationError = () => ({
type: ActionTypes.USER_CREATION_ERROR_CLEAR,
payload: {},
});
export const updateUser = (id, data) => ({
type: ActionTypes.USER_UPDATE,
payload: {
id,
data,
},
});
export const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE,
payload: {
id,
},
});
export const addUserToCard = (id, cardId, isCurrent) => ({
type: ActionTypes.USER_TO_CARD_ADD,
payload: {
id,
cardId,
isCurrent,
},
});
export const removeUserFromCard = (id, cardId) => ({
type: ActionTypes.USER_FROM_CARD_REMOVE,
payload: {
id,
cardId,
},
});
export const addUserToBoardFilter = (id, boardId) => ({
type: ActionTypes.USER_TO_BOARD_FILTER_ADD,
payload: {
id,
boardId,
},
});
export const removeUserFromBoardFilter = (id, boardId) => ({
type: ActionTypes.USER_FROM_BOARD_FILTER_REMOVE,
payload: {
id,
boardId,
},
});
/* Events */
export const createUserRequested = (data) => ({
type: ActionTypes.USER_CREATE_REQUESTED,
payload: {
data,
},
});
export const createUserSucceeded = (user) => ({
type: ActionTypes.USER_CREATE_SUCCEEDED,
payload: {
user,
},
});
export const createUserFailed = (error) => ({
type: ActionTypes.USER_CREATE_FAILED,
payload: {
error,
},
});
export const createUserReceived = (user) => ({
type: ActionTypes.USER_CREATE_RECEIVED,
payload: {
user,
},
});
export const fetchCurrentUserRequested = () => ({
type: ActionTypes.CURRENT_USER_FETCH_REQUESTED,
payload: {},
});
export const fetchCurrentUserSucceeded = (user) => ({
type: ActionTypes.CURRENT_USER_FETCH_SUCCEEDED,
payload: {
user,
},
});
export const fetchCurrentUserFailed = (error) => ({
type: ActionTypes.CURRENT_USER_FETCH_FAILED,
payload: {
error,
},
});
export const updateUserRequested = (id, data) => ({
type: ActionTypes.USER_UPDATE_REQUESTED,
payload: {
id,
data,
},
});
export const updateUserSucceeded = (user) => ({
type: ActionTypes.USER_UPDATE_SUCCEEDED,
payload: {
user,
},
});
export const updateUserFailed = (id, error) => ({
type: ActionTypes.USER_UPDATE_FAILED,
payload: {
id,
error,
},
});
export const updateUserReceived = (user) => ({
type: ActionTypes.USER_UPDATE_RECEIVED,
payload: {
user,
},
});
export const uploadUserAvatarRequested = (id) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_REQUESTED,
payload: {
id,
},
});
export const uploadUserAvatarSucceeded = (user) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_SUCCEEDED,
payload: {
user,
},
});
export const uploadUserAvatarFailed = (id, error) => ({
type: ActionTypes.USER_AVATAR_UPLOAD_FAILED,
payload: {
id,
error,
},
});
export const deleteUserRequested = (id) => ({
type: ActionTypes.USER_DELETE_REQUESTED,
payload: {
id,
},
});
export const deleteUserSucceeded = (user) => ({
type: ActionTypes.USER_DELETE_SUCCEEDED,
payload: {
user,
},
});
export const deleteUserFailed = (id, error) => ({
type: ActionTypes.USER_DELETE_FAILED,
payload: {
id,
error,
},
});
export const deleteUserReceived = (user) => ({
type: ActionTypes.USER_DELETE_RECEIVED,
payload: {
user,
},
});

View file

@ -0,0 +1,22 @@
import ActionTypes from '../constants/ActionTypes';
/* Events */
export const fetchUsersRequested = () => ({
type: ActionTypes.USERS_FETCH_REQUESTED,
payload: {},
});
export const fetchUsersSucceeded = (users) => ({
type: ActionTypes.USERS_FETCH_SUCCEEDED,
payload: {
users,
},
});
export const fetchUsersFailed = (error) => ({
type: ActionTypes.USERS_FETCH_FAILED,
payload: {
error,
},
});

View file

@ -0,0 +1,9 @@
import http from './http';
/* Actions */
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
export default {
createAccessToken,
};

35
client/src/api/actions.js Executable file
View file

@ -0,0 +1,35 @@
import socket from './socket';
/* Transformers */
export const transformAction = (action) => ({
...action,
createdAt: new Date(action.createdAt),
});
/* Actions */
const getActions = (cardId, data, headers) => socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformAction),
}));
/* Event handlers */
const makeHandleActionCreate = (next) => (body) => {
next({
...body,
item: transformAction(body.item),
});
};
const makeHandleActionUpdate = makeHandleActionCreate;
const makeHandleActionDelete = makeHandleActionCreate;
export default {
getActions,
makeHandleActionCreate,
makeHandleActionUpdate,
makeHandleActionDelete,
};

25
client/src/api/boards.js Executable file
View file

@ -0,0 +1,25 @@
import socket from './socket';
import { transformCard } from './cards';
/* Actions */
const createBoard = (projectId, data, headers) => socket.post(`/projects/${projectId}/boards`, data, headers);
const getBoard = (id, headers) => socket.get(`/boards/${id}`, undefined, headers).then((body) => ({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
},
}));
const updateBoard = (id, data, headers) => socket.patch(`/boards/${id}`, data, headers);
const deleteBoard = (id, headers) => socket.delete(`/boards/${id}`, undefined, headers);
export default {
createBoard,
getBoard,
updateBoard,
deleteBoard,
};

View file

@ -0,0 +1,12 @@
import socket from './socket';
/* Actions */
const createCardLabel = (cardId, data, headers) => socket.post(`/cards/${cardId}/labels`, data, headers);
const deleteCardLabel = (cardId, labelId, headers) => socket.delete(`/cards/${cardId}/labels/${labelId}`, undefined, headers);
export default {
createCardLabel,
deleteCardLabel,
};

View file

@ -0,0 +1,12 @@
import socket from './socket';
/* Actions */
const createCardMembership = (cardId, data, headers) => socket.post(`/cards/${cardId}/memberships`, data, headers);
const deleteCardMembership = (cardId, userId, headers) => socket.delete(`/cards/${cardId}/memberships?userId=${userId}`, undefined, headers);
export default {
createCardMembership,
deleteCardMembership,
};

70
client/src/api/cards.js Executable file
View file

@ -0,0 +1,70 @@
import socket from './socket';
/* Transformers */
export const transformCard = (card) => ({
...card,
deadline: card.deadline && new Date(card.deadline),
timer: card.timer && {
...card.timer,
startedAt: card.timer.startedAt && new Date(card.timer.startedAt),
},
});
export const transformCardData = (data) => ({
...data,
...(data.deadline && {
deadline: data.deadline.toISOString(),
}),
...(data.timer && {
...data.timer,
...(data.timer.startedAt && {
startedAt: data.timer.startedAt.toISOString(),
}),
}),
});
/* Actions */
const createCard = (listId, data, headers) => socket.post(`/lists/${listId}/cards`, transformCardData(data), headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
const getCard = (id, headers) => socket.get(`/cards/${id}`, undefined, headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
const updateCard = (id, data, headers) => socket.patch(`/cards/${id}`, transformCardData(data), headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
const deleteCard = (id, headers) => socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
/* Event handlers */
const makeHandleCardCreate = (next) => (body) => {
next({
...body,
item: transformCard(body.item),
});
};
const makeHandleCardUpdate = makeHandleCardCreate;
const makeHandleCardDelete = makeHandleCardCreate;
export default {
createCard,
getCard,
updateCard,
deleteCard,
makeHandleCardCreate,
makeHandleCardUpdate,
makeHandleCardDelete,
};

View file

@ -0,0 +1,25 @@
import socket from './socket';
import { transformAction } from './actions';
/* Actions */
const createCommentAction = (cardId, data, headers) => socket.post(`/cards/${cardId}/comment-actions`, data, headers).then((body) => ({
...body,
item: transformAction(body.item),
}));
const updateCommentAction = (id, data, headers) => socket.patch(`/comment-actions/${id}`, data, headers).then((body) => ({
...body,
item: transformAction(body.item),
}));
const deleteCommentAction = (id, headers) => socket.delete(`/comment-actions/${id}`, undefined, headers).then((body) => ({
...body,
item: transformAction(body.item),
}));
export default {
createCommentAction,
updateCommentAction,
deleteCommentAction,
};

35
client/src/api/http.js Executable file
View file

@ -0,0 +1,35 @@
import { fetch } from 'whatwg-fetch';
import Config from '../constants/Config';
const http = {};
// TODO: all methods
['POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => {
const formData = Object.keys(data).reduce((result, key) => {
result.append(key, data[key]);
return result;
}, new FormData());
return fetch(`${Config.API_URL}${Config.API_PATH}${url}`, {
method,
headers,
body: formData,
})
.then((response) => response.json().then((body) => ({
body,
isError: response.status !== 200,
})))
.then(({ body, isError }) => {
if (isError) {
throw body;
}
return body;
});
};
});
export default http;

35
client/src/api/index.js Executable file
View file

@ -0,0 +1,35 @@
import http from './http';
import socket from './socket';
import accessTokens from './access-tokens';
import users from './users';
import projects from './projects';
import projectMemberships from './project-memberships';
import boards from './boards';
import lists from './lists';
import labels from './labels';
import cards from './cards';
import cardMemberships from './card-memberships';
import cardLabels from './card-labels';
import tasks from './tasks';
import actions from './actions';
import commentActions from './comment-actions';
import notifications from './notifications';
export { http, socket };
export default {
...accessTokens,
...users,
...projects,
...projectMemberships,
...boards,
...lists,
...labels,
...cards,
...cardMemberships,
...cardLabels,
...tasks,
...actions,
...commentActions,
...notifications,
};

15
client/src/api/labels.js Executable file
View file

@ -0,0 +1,15 @@
import socket from './socket';
/* Actions */
const createLabel = (boardId, data, headers) => socket.post(`/boards/${boardId}/labels`, data, headers);
const updateLabel = (id, data, headers) => socket.patch(`/labels/${id}`, data, headers);
const deleteLabel = (id, headers) => socket.delete(`/labels/${id}`, undefined, headers);
export default {
createLabel,
updateLabel,
deleteLabel,
};

15
client/src/api/lists.js Executable file
View file

@ -0,0 +1,15 @@
import socket from './socket';
/* Actions */
const createList = (boardId, data, headers) => socket.post(`/boards/${boardId}/lists`, data, headers);
const updateList = (id, data, headers) => socket.patch(`/lists/${id}`, data, headers);
const deleteList = (id, headers) => socket.delete(`/lists/${id}`, undefined, headers);
export default {
createList,
updateList,
deleteList,
};

35
client/src/api/notifications.js Executable file
View file

@ -0,0 +1,35 @@
import socket from './socket';
import { transformCard } from './cards';
import { transformAction } from './actions';
/* Actions */
const getNotifications = (headers) => socket.get('/notifications', undefined, headers).then((body) => ({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
actions: body.included.actions.map(transformAction),
},
}));
const updateNotifications = (ids, data, headers) => socket.patch(`/notifications/${ids.join(',')}`, data, headers);
/* Event handlers */
const makeHandleNotificationCreate = (next) => (body) => {
next({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
actions: body.included.actions.map(transformAction),
},
});
};
export default {
getNotifications,
updateNotifications,
makeHandleNotificationCreate,
};

View file

@ -0,0 +1,12 @@
import socket from './socket';
/* Actions */
const createProjectMembership = (projectId, data, headers) => socket.post(`/projects/${projectId}/memberships`, data, headers);
const deleteProjectMembership = (id, headers) => socket.delete(`/project-memberships/${id}`, undefined, headers);
export default {
createProjectMembership,
deleteProjectMembership,
};

18
client/src/api/projects.js Executable file
View file

@ -0,0 +1,18 @@
import socket from './socket';
/* Actions */
const getProjects = (headers) => socket.get('/projects', undefined, headers);
const createProject = (data, headers) => socket.post('/projects', data, headers);
const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers);
const deleteProject = (id, headers) => socket.delete(`/projects/${id}`, undefined, headers);
export default {
getProjects,
createProject,
updateProject,
deleteProject,
};

37
client/src/api/socket.js Executable file
View file

@ -0,0 +1,37 @@
import socketIOClient from 'socket.io-client';
import sailsIOClient from 'sails.io.js';
import Config from '../constants/Config';
const io = sailsIOClient(socketIOClient);
io.sails.url = Config.API_URL;
io.sails.autoConnect = false;
io.sails.reconnection = true;
io.sails.useCORSRouteToGetCookie = false;
const { socket } = io;
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {
socket[method.toLowerCase()] = (url, data, headers) => new Promise((resolve, reject) => {
socket.request(
{
method,
data,
headers,
url: `${Config.API_PATH}${url}`,
},
(_, { body, error }) => {
if (error) {
reject(body);
} else {
resolve(body);
}
},
);
});
});
export default socket;

15
client/src/api/tasks.js Executable file
View file

@ -0,0 +1,15 @@
import socket from './socket';
/* Actions */
const createTask = (cardId, data, headers) => socket.post(`/cards/${cardId}/tasks`, data, headers);
const updateTask = (id, data, headers) => socket.patch(`/tasks/${id}`, data, headers);
const deleteTask = (id, headers) => socket.delete(`/tasks/${id}`, undefined, headers);
export default {
createTask,
updateTask,
deleteTask,
};

31
client/src/api/users.js Executable file
View file

@ -0,0 +1,31 @@
import http from './http';
import socket from './socket';
/* Actions */
const getUsers = (headers) => socket.get('/users', undefined, headers);
const createUser = (data, headers) => socket.post('/users', data, headers);
const getCurrentUser = (headers) => socket.get('/users/me', undefined, headers);
const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers);
const uploadUserAvatar = (id, file, headers) => http.post(
`/users/${id}/upload-avatar`,
{
file,
},
headers,
);
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
export default {
getUsers,
createUser,
getCurrentUser,
updateUser,
uploadUserAvatar,
deleteUser,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 50 50">
<path d="M 25 2 C 12.309295 2 2 12.309295 2 25 C 2 37.690705 12.309295 48 25 48 C 37.690705 48 48 37.690705 48 25 C 48 12.309295 37.690705 2 25 2 z M 25 4 C 36.609824 4 46 13.390176 46 25 C 46 36.609824 36.609824 46 25 46 C 13.390176 46 4 36.609824 4 25 C 4 13.390176 13.390176 4 25 4 z M 24 13 L 24 24 L 13 24 L 13 26 L 24 26 L 24 37 L 26 37 L 26 26 L 37 26 L 37 24 L 26 24 L 26 13 L 24 13 z" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 50 50">
<path d="M 24 9 L 24 24 L 9 24 L 9 26 L 24 26 L 24 41 L 26 41 L 26 26 L 41 26 L 41 24 L 26 24 L 26 9 Z" />
</svg>

After

Width:  |  Height:  |  Size: 142 B

View file

@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Button, Form, Header, Modal,
} from 'semantic-ui-react';
import { Input } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import styles from './AddProjectModal.module.css';
const AddProjectModal = React.memo(({
defaultData, isSubmitting, onCreate, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
}, [onCreate, data]);
useEffect(() => {
nameField.current.select();
}, []);
return (
<Modal open basic closeIcon size="tiny" onClose={onClose}>
<Modal.Content>
<Header inverted size="huge">
{t('common.createProject', {
context: 'title',
})}
</Header>
<p>{t('common.enterProjectTitle')}</p>
<Form onSubmit={handleSubmit}>
<Input
fluid
inverted
ref={nameField}
name="name"
value={data.name}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
inverted
color="green"
icon="checkmark"
content={t('action.createProject')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Modal.Content>
</Modal>
);
});
AddProjectModal.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AddProjectModal;

View file

@ -0,0 +1,3 @@
.field {
margin-bottom: 20px;
}

View file

@ -0,0 +1,3 @@
import AddProjectModal from './AddProjectModal';
export default AddProjectModal;

View file

@ -0,0 +1,148 @@
import isEmail from 'validator/lib/isEmail';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import {
useDeepCompareCallback, useDeepCompareEffect, useForm, usePrevious,
} from '../../hooks';
import styles from './AddUserPopup.module.css';
const AddUserPopup = React.memo(
({
defaultData, isSubmitting, error, onCreate, onMessageDismiss, onClose,
}) => {
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
const [data, handleFieldChange] = useForm(() => ({
email: '',
password: '',
name: '',
...defaultData,
}));
const emailField = useRef(null);
const passwordField = useRef(null);
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
email: data.email.trim(),
name: data.name.trim(),
};
if (!isEmail(cleanData.email)) {
emailField.current.select();
return;
}
if (!cleanData.password) {
passwordField.current.focus();
return;
}
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
}, [onCreate, data]);
useEffect(() => {
emailField.current.select();
}, []);
useDeepCompareEffect(() => {
if (wasSubmitting && !isSubmitting) {
if (!error) {
onClose();
} else if (error.message === 'userIsAlreadyExist') {
emailField.current.select();
}
}
}, [isSubmitting, wasSubmitting, error, onClose]);
return (
<>
<Popup.Header>
{t('common.addUser', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{error && (
<Message
// eslint-disable-next-line react/jsx-props-no-spreading
{...{
[error.type || 'error']: true,
}}
visible
content={t(`common.${error.message}`)}
onDismiss={onMessageDismiss}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.email')}</div>
<Input
fluid
ref={emailField}
name="email"
value={data.email}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.password')}</div>
<Input
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.name')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
positive
content={t('action.addUser')}
loading={isSubmitting}
disabled={isSubmitting}
/>
</Form>
</Popup.Content>
</>
);
},
);
AddUserPopup.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
AddUserPopup.defaultProps = {
error: undefined,
};
export default withPopup(AddUserPopup);

View file

@ -0,0 +1,10 @@
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}

View file

@ -0,0 +1,3 @@
import AddUserPopup from './AddUserPopup';
export default AddUserPopup;

23
client/src/components/App.jsx Executable file
View file

@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import HeaderContainer from '../containers/HeaderContainer';
import ProjectsContainer from '../containers/ProjectsContainer';
import UsersModalContainer from '../containers/UsersModalContainer';
import AddProjectModalContainer from '../containers/AddProjectModalContainer';
const App = ({ isUsersModalOpened, isAddProjectModalOpened }) => (
<>
<HeaderContainer />
<ProjectsContainer />
{isUsersModalOpened && <UsersModalContainer />}
{isAddProjectModalOpened && <AddProjectModalContainer />}
</>
);
App.propTypes = {
isUsersModalOpened: PropTypes.bool.isRequired,
isAddProjectModalOpened: PropTypes.bool.isRequired,
};
export default App;

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import AppContainer from '../containers/AppContainer';
import SocketStatusContainer from '../containers/SocketStatusContainer';
const AppWrapper = React.memo(({ isInitializing }) => (
<>
{isInitializing ? <Loader active size="massive" /> : <AppContainer />}
<SocketStatusContainer />
</>
));
AppWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default AppWrapper;

View file

@ -0,0 +1,129 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import {
useClosableForm,
useDeepCompareCallback,
useDidUpdate,
useForm,
useToggle,
} from '../../hooks';
import styles from './AddList.module.css';
const DEFAULT_DATA = {
name: '',
};
const AddList = React.forwardRef(({ children, onCreate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectNameFieldState, selectNameField] = useToggle();
const nameField = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
}, []);
const close = useCallback(() => {
setIsOpened(false);
}, []);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleChildrenClick = useCallback(() => {
open();
}, [open]);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
close();
}
},
[close],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
selectNameField();
}, [onCreate, data, setData, selectNameField]);
useEffect(() => {
if (isOpened) {
nameField.current.select();
}
}, [isOpened]);
useDidUpdate(() => {
nameField.current.select();
}, [selectNameFieldState]);
if (!isOpened) {
return React.cloneElement(children, {
onClick: handleChildrenClick,
});
}
return (
<Form className={styles.wrapper} onSubmit={handleSubmit}>
<Input
ref={nameField}
name="name"
value={data.name}
placeholder={t('common.enterListTitle')}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.addList')}
className={styles.submitButton}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
AddList.propTypes = {
children: PropTypes.element.isRequired,
onCreate: PropTypes.func.isRequired,
};
export default React.memo(AddList);

View file

@ -0,0 +1,31 @@
.controls {
margin-top: 4px;
}
.field {
border: none;
border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important;
color: #333 !important;
outline: none !important;
overflow: hidden !important;
width: 100% !important;
}
.field:focus {
border-color: #298fca;
box-shadow: 0 0 2px #298fca;
}
.submitButton {
min-height: 30px;
vertical-align: top;
}
.wrapper {
background-color: #e2e4e6;
border-radius: 3px;
padding: 4px;
transition: opacity 40ms ease-in;
width: 272px;
}

View file

@ -0,0 +1,138 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { closePopup } from '../../lib/popup';
import DroppableTypes from '../../constants/DroppableTypes';
import ListContainer from '../../containers/ListContainer';
import CardModalContainer from '../../containers/CardModalContainer';
import AddList from './AddList';
import Filter from './Filter';
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
import styles from './Board.module.css';
const parseDNDId = (dndId) => parseInt(dndId.split(':').pop(), 10);
const Board = React.memo(
({
listIds,
filterUsers,
filterLabels,
allProjectMemberships,
allLabels,
isCardModalOpened,
onListCreate,
onListMove,
onCardMove,
onUserToFilterAdd,
onUserFromFilterRemove,
onLabelToFilterAdd,
onLabelFromFilterRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
closePopup();
}, []);
const handleDragEnd = useCallback(
({
draggableId, type, source, destination,
}) => {
if (
!destination
|| (source.droppableId === destination.droppableId && source.index === destination.index)
) {
return;
}
const id = parseDNDId(draggableId);
switch (type) {
case DroppableTypes.LIST:
onListMove(id, destination.index);
break;
case DroppableTypes.CARD:
onCardMove(id, parseDNDId(destination.droppableId), destination.index);
break;
default:
}
},
[onListMove, onCardMove],
);
return (
<>
<Filter
users={filterUsers}
labels={filterLabels}
allProjectMemberships={allProjectMemberships}
allLabels={allLabels}
onUserAdd={onUserToFilterAdd}
onUserRemove={onUserFromFilterRemove}
onLabelAdd={onLabelToFilterAdd}
onLabelRemove={onLabelFromFilterRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
/>
<div className={styles.wrapper}>
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="board" type={DroppableTypes.LIST} direction="horizontal">
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} data-drag-scroller ref={innerRef} className={styles.lists}>
{listIds.map((listId, index) => (
<ListContainer key={listId} id={listId} index={index} />
))}
{placeholder}
<div data-drag-scroller className={styles.list}>
<AddList onCreate={onListCreate}>
<button type="button" className={styles.addListButton}>
<PlusMathIcon className={styles.addListButtonIcon} />
<span className={styles.addListButtonText}>
{listIds.length > 0 ? t('action.addAnotherList') : t('action.addList')}
</span>
</button>
</AddList>
</div>
</div>
)}
</Droppable>
</DragDropContext>
</div>
{isCardModalOpened && <CardModalContainer />}
</>
);
},
);
Board.propTypes = {
/* eslint-disable react/forbid-prop-types */
listIds: PropTypes.array.isRequired,
filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
isCardModalOpened: PropTypes.bool.isRequired,
onListCreate: PropTypes.func.isRequired,
onListMove: PropTypes.func.isRequired,
onCardMove: PropTypes.func.isRequired,
onUserToFilterAdd: PropTypes.func.isRequired,
onUserFromFilterRemove: PropTypes.func.isRequired,
onLabelToFilterAdd: PropTypes.func.isRequired,
onLabelFromFilterRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
export default Board;

View file

@ -0,0 +1,52 @@
.addListButton {
background-color: rgba(0, 0, 0, 0.12);
border: none;
border-radius: 3px;
color: hsla(0, 0%, 100%, 0.7);
cursor: pointer;
display: block;
fill: hsla(0, 0%, 100%, 0.7);
font-weight: normal;
height: 42px;
padding: 11px;
text-align: left;
transition: background 85ms ease-in, opacity 40ms ease-in,
border-color 85ms ease-in;
width: 100%;
}
.addListButton:active {
outline: none;
}
.addListButton:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.addListButtonIcon {
height: 20px;
padding: 0.64px;
width: 20px;
}
.addListButtonText {
display: inline-block;
font-size: 14px;
line-height: 20px;
vertical-align: top;
}
.list {
margin: 0 20px 0 4px;
width: 272px;
}
.lists {
display: inline-flex;
height: 100%;
min-width: 100%;
}
.wrapper {
height: 100%;
}

View file

@ -0,0 +1,120 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import User from '../User';
import Label from '../Label';
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
import LabelsPopup from '../LabelsPopup';
import styles from './Filter.module.css';
const Filter = React.memo(
({
users,
labels,
allProjectMemberships,
allLabels,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const [t] = useTranslation();
const handleUserRemoveClick = useCallback(
(id) => {
onUserRemove(id);
},
[onUserRemove],
);
const handleLabelRemoveClick = useCallback(
(id) => {
onLabelRemove(id);
},
[onLabelRemove],
);
return (
<div className={styles.filters}>
<span className={styles.filter}>
<ProjectMembershipsPopup
items={allProjectMemberships}
currentUserIds={users.map((user) => user.id)}
title={t('common.filterByMembers', {
context: 'title',
})}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{users.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</ProjectMembershipsPopup>
{users.map((user) => (
<span key={user.id} className={styles.filterItem}>
<User
name={user.name}
avatar={user.avatar}
size="small"
onClick={() => handleUserRemoveClick(user.id)}
/>
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
items={allLabels}
currentIds={labels.map((label) => label.id)}
title={t('common.filterByLabels', {
context: 'title',
})}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labels.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</LabelsPopup>
{labels.map((label) => (
<span key={label.id} className={styles.filterItem}>
<Label
name={label.name}
color={label.color}
size="small"
onClick={() => handleLabelRemoveClick(label.id)}
/>
</span>
))}
</span>
</div>
);
},
);
Filter.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
export default Filter;

View file

@ -0,0 +1,51 @@
.filter {
display: inline-block;
line-height: 0 !important;
margin-right: 16px;
}
.filterButton {
background: transparent;
border: none;
cursor: pointer;
display: inline-block;
outline: none;
padding: 0;
}
.filterItem {
display: inline-block;
font-size: 0;
line-height: 0;
margin-right: 4px;
max-width: 190px;
vertical-align: top;
}
.filterLabel {
background: #2d3034;
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 8px;
}
.filterLabel:hover {
opacity: 0.75;
}
.filterTitle {
border-radius: 3px;
color: #fff;
display: inline-block;
font-size: 12px;
line-height: 20px;
padding: 2px 12px;
}
.filters {
line-height: 0 !important;
margin-bottom: 12px;
}

View file

@ -0,0 +1,3 @@
import Board from './Board';
export default Board;

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import BoardContainer from '../containers/BoardContainer';
const BoardWrapper = React.memo(({ isFetching }) => {
if (isFetching) {
return <Loader active />;
}
return <BoardContainer />;
});
BoardWrapper.propTypes = {
isFetching: PropTypes.bool.isRequired,
};
export default BoardWrapper;

View file

@ -0,0 +1,69 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm } from '../../hooks';
import styles from './AddPopup.module.css';
const AddStep = React.memo(({ onCreate, onClose }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm({
name: '',
});
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
onCreate(cleanData);
onClose();
}, [onCreate, onClose, data]);
useEffect(() => {
nameField.current.select();
}, []);
return (
<>
<Popup.Header>
{t('common.createBoard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.createBoard')} />
</Form>
</Popup.Content>
</>
);
});
AddStep.propTypes = {
onCreate: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(AddStep);

View file

@ -0,0 +1,3 @@
.field {
margin-bottom: 8px;
}

View file

@ -0,0 +1,183 @@
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { Link } from 'react-router-dom';
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';
import { closePopup } from '../../lib/popup';
import { DragScroller } from '../../lib/custom-ui';
import Paths from '../../constants/Paths';
import DroppableTypes from '../../constants/DroppableTypes';
import BoardWrapperContainer from '../../containers/BoardWrapperContainer';
import AddPopup from './AddPopup';
import EditPopup from './EditPopup';
import styles from './Boards.module.css';
const Boards = React.memo(
({
items, currentId, isEditable, onCreate, onUpdate, onMove, onDelete,
}) => {
const [t] = useTranslation();
const handleDragStart = useCallback(() => {
closePopup();
}, []);
const handleDragEnd = useCallback(
({ draggableId, source, destination }) => {
if (!destination || source.index === destination.index) {
return;
}
onMove(draggableId, destination.index);
},
[onMove],
);
const handleUpdate = useCallback(
(id, data) => {
onUpdate(id, data);
},
[onUpdate],
);
const handleDelete = useCallback(
(id) => {
onDelete(id);
},
[onDelete],
);
const renderItems = useCallback(
(safeItems) => safeItems.map((item) => (
<div key={item.id} className={styles.tabWrapper}>
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
{item.isPersisted ? (
<Link
to={Paths.BOARDS.replace(':id', item.id)}
title={item.name}
className={styles.link}
>
{item.name}
</Link>
) : (
<span className={styles.link}>{item.name}</span>
)}
</div>
</div>
)),
[currentId],
);
const renderEditableItems = useCallback(
(safeItems) => safeItems.map((item, index) => (
<Draggable
key={item.id}
draggableId={item.id}
index={index}
isDragDisabled={!item.isPersisted}
>
{({ innerRef, draggableProps, dragHandleProps }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} ref={innerRef} className={styles.tabWrapper}>
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
{item.isPersisted ? (
<Link
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
to={Paths.BOARDS.replace(':id', item.id)}
title={item.name}
className={styles.link}
>
{item.name}
</Link>
) : (
// eslint-disable-next-line react/jsx-props-no-spreading
<span {...dragHandleProps} className={styles.link}>
{item.name}
</span>
)}
{item.isPersisted && (
<EditPopup
defaultData={pick(item, 'name')}
onUpdate={(data) => handleUpdate(item.id, data)}
onDelete={() => handleDelete(item.id)}
>
<Button className={classNames(styles.editButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
</div>
)}
</Draggable>
)),
[currentId, handleUpdate, handleDelete],
);
return (
<div className={styles.wrapper}>
{isEditable ? (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="boards" type={DroppableTypes.BOARD} direction="horizontal">
{({ innerRef, droppableProps, placeholder }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...droppableProps} ref={innerRef} className={styles.tabs}>
{renderEditableItems(items)}
{placeholder}
<AddPopup onCreate={onCreate}>
<Button icon="plus" className={styles.addButton} />
</AddPopup>
</div>
)}
</Droppable>
</DragDropContext>
) : (
<div className={styles.tabs}>{renderItems(items)}</div>
)}
<DragScroller className={styles.board}>
{currentId ? (
<BoardWrapperContainer />
) : (
<div className={styles.message}>
<Icon
inverted
name="hand point up outline"
size="huge"
className={styles.messageIcon}
/>
<h1 className={styles.messageTitle}>
{t('common.openBoard', {
context: 'title',
})}
</h1>
<div className={styles.messageContent}>
<Trans i18nKey="common.createNewOneOrSelectExistingOne" />
</div>
</div>
)}
</DragScroller>
</div>
);
},
);
Boards.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
currentId: PropTypes.number,
isEditable: PropTypes.bool.isRequired,
onCreate: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
Boards.defaultProps = {
currentId: undefined,
};
export default Boards;

View file

@ -0,0 +1,115 @@
.addButton {
background: transparent !important;
color: #fff !important;
margin-right: 0 !important;
vertical-align: top !important;
}
.addButton:hover {
background: rgba(34, 36, 38, 0.3) !important;
}
.board {
display: flex;
flex: 1 1 auto;
flex-direction: column;
margin: 0 -20px 8px;
overflow: auto;
padding: 0 20px;
}
.editButton {
background: transparent !important;
color: #fff !important;
line-height: 32px !important;
margin-right: 0 !important;
opacity: 0;
padding: 0 !important;
position: absolute;
right: 2px;
top: 2px;
width: 32px;
}
.editButton:hover {
background: rgba(34, 36, 38, 0.3) !important;
}
.link {
color: #fff !important;
display: block;
line-height: 20px;
padding: 10px 34px 6px 14px;
text-overflow: ellipsis;
max-width: 400px;
overflow: hidden;
}
.message {
align-content: space-between;
align-items: center;
color: #fff;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
}
.messageIcon {
margin-top: -84px;
}
.messageTitle {
font-size: 32px;
margin: 24px 0 8px;
}
.messageContent {
font-size: 18px;
line-height: 1.4;
margin: 4px 0 0;
text-align: center;
}
.tab {
border-radius: 3px 3px 0 0;
min-width: 160px;
position: relative;
transition: all 0.1s ease;
}
.tab:hover {
background: #353a3f !important;
}
.tab:hover .target {
opacity: 1 !important;
}
.tabActive {
background: #2c3035 !important;
}
.tabActive:hover {
background: #353a3f !important;
}
.tabWrapper {
display: flex;
flex: 0 0 auto;
}
.tabs {
border-bottom: 2px solid #2c3035;
display: flex;
height: 38px;
flex: 0 0 auto;
margin-bottom: 16px;
white-space: nowrap;
}
.wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
}

View file

@ -0,0 +1,108 @@
import dequal from 'dequal';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Input, Popup } from '../../lib/custom-ui';
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
import DeleteStep from '../DeleteStep';
import styles from './EditPopup.module.css';
const StepTypes = {
DELETE: 'DELETE',
};
const EditStep = React.memo(({
defaultData, onUpdate, onDelete, onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...defaultData,
}));
const [step, openStep, handleBack] = useSteps();
const nameField = useRef(null);
const handleSubmit = useDeepCompareCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
};
if (!cleanData.name) {
nameField.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData);
}
onClose();
}, [defaultData, onUpdate, onClose, data]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
useEffect(() => {
nameField.current.select();
}, []);
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title={t('common.deleteBoard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisBoard')}
buttonContent={t('action.deleteBoard')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header>
{t('common.editBoard', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive content={t('action.save')} />
</Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content>
</>
);
});
EditStep.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(EditStep);

View file

@ -0,0 +1,10 @@
.deleteButton {
bottom: 12px;
box-shadow: 0 1px 0 #cbcccc;
position: absolute;
right: 9px;
}
.field {
margin-bottom: 8px;
}

View file

@ -0,0 +1,3 @@
import Boards from './Boards';
export default Boards;

View file

@ -0,0 +1,216 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { withPopup } from '../../lib/popup';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import ProjectMembershipsStep from '../ProjectMembershipsStep';
import LabelsStep from '../LabelsStep';
import EditDeadlineStep from '../EditDeadlineStep';
import EditTimerStep from '../EditTimerStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsPopup.module.css';
const StepTypes = {
USERS: 'USERS',
LABELS: 'LABELS',
EDIT_DEADLINE: 'EDIT_DEADLINE',
EDIT_TIMER: 'EDIT_TIMER',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(
({
card,
projectMemberships,
currentUserIds,
labels,
currentLabelIds,
onNameEdit,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
onClose,
}) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleNameEditClick = useCallback(() => {
onNameEdit();
onClose();
}, [onNameEdit, onClose]);
const handleUsersClick = useCallback(() => {
openStep(StepTypes.USERS);
}, [openStep]);
const handleLabelsClick = useCallback(() => {
openStep(StepTypes.LABELS);
}, [openStep]);
const handleEditDeadlineClick = useCallback(() => {
openStep(StepTypes.EDIT_DEADLINE);
}, [openStep]);
const handleEditTimerClick = useCallback(() => {
openStep(StepTypes.EDIT_TIMER);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleDeadlineUpdate = useCallback(
(deadline) => {
onUpdate({
deadline,
});
},
[onUpdate],
);
const handleTimerUpdate = useCallback(
(timer) => {
onUpdate({
timer,
});
},
[onUpdate],
);
if (step) {
switch (step.type) {
case StepTypes.USERS:
return (
<ProjectMembershipsStep
items={projectMemberships}
currentUserIds={currentUserIds}
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
onBack={handleBack}
/>
);
case StepTypes.LABELS:
return (
<LabelsStep
items={labels}
currentIds={currentLabelIds}
onSelect={onLabelAdd}
onDeselect={onLabelRemove}
onCreate={onLabelCreate}
onUpdate={onLabelUpdate}
onDelete={onLabelDelete}
onBack={handleBack}
/>
);
case StepTypes.EDIT_DEADLINE:
return (
<EditDeadlineStep
defaultValue={card.deadline}
onUpdate={handleDeadlineUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_TIMER:
return (
<EditTimerStep
defaultValue={card.timer}
onUpdate={handleTimerUpdate}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.DELETE:
return (
<DeleteStep
title={t('common.deleteCard', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisCard')}
buttonContent={t('action.deleteCard')}
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header>
{t('common.cardActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
{t('action.editTitle', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
{t('common.labels', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditDeadlineClick}>
{t('action.editDeadline', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditTimerClick}>
{t('action.editTimer', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteCard', {
context: 'title',
})}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
},
);
ActionsStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
card: PropTypes.object.isRequired,
projectMemberships: PropTypes.array.isRequired,
currentUserIds: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
currentLabelIds: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onNameEdit: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default withPopup(ActionsStep);

View file

@ -0,0 +1,9 @@
.menu {
margin: -7px -12px -5px !important;
width: calc(100% + 24px) !important;
}
.menuItem {
margin: 0 !important;
padding-left: 14px !important;
}

View file

@ -0,0 +1,201 @@
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button, Icon } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import Paths from '../../constants/Paths';
import Tasks from './Tasks';
import EditName from './EditName';
import ActionsPopup from './ActionsPopup';
import User from '../User';
import Label from '../Label';
import Deadline from '../Deadline';
import Timer from '../Timer';
import styles from './Card.module.css';
const Card = React.memo(
({
id,
index,
name,
deadline,
timer,
isPersisted,
notificationsTotal,
users,
labels,
tasks,
allProjectMemberships,
allLabels,
onUpdate,
onDelete,
onUserAdd,
onUserRemove,
onLabelAdd,
onLabelRemove,
onLabelCreate,
onLabelUpdate,
onLabelDelete,
}) => {
const editName = useRef(null);
const handleClick = useCallback(() => {
if (document.activeElement) {
document.activeElement.blur();
}
}, []);
const handleNameUpdate = useCallback(
(newName) => {
onUpdate({
name: newName,
});
},
[onUpdate],
);
const handleNameEdit = useCallback(() => {
editName.current.open();
}, []);
const contentNode = (
<>
{labels.length > 0 && (
<span className={styles.labels}>
{labels.map((label) => (
<span key={label.id} className={classNames(styles.attachment, styles.attachmentLeft)}>
<Label name={label.name} color={label.color} size="tiny" />
</span>
))}
</span>
)}
<div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />}
{(deadline || timer) && (
<span className={styles.attachments}>
{notificationsTotal > 0 && (
<span
className={classNames(
styles.attachment,
styles.attachmentLeft,
styles.notification,
)}
>
{notificationsTotal}
</span>
)}
{deadline && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Deadline value={deadline} size="tiny" />
</span>
)}
{timer && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Timer startedAt={timer.startedAt} total={timer.total} size="tiny" />
</span>
)}
</span>
)}
{users.length > 0 && (
<span className={classNames(styles.attachments, styles.attachmentsRight)}>
{users.map((user) => (
<span key={user.id} className={classNames(styles.attachment, styles.attachmentRight)}>
<User name={user.name} avatar={user.avatar} size="tiny" />
</span>
))}
</span>
)}
</>
);
return (
<Draggable draggableId={`card:${id}`} index={index} isDragDisabled={!isPersisted}>
{({ innerRef, draggableProps, dragHandleProps }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
<div className={styles.card}>
{isPersisted ? (
<>
<Link
to={isPersisted && Paths.CARDS.replace(':id', id)}
className={styles.content}
onClick={handleClick}
>
{contentNode}
</Link>
<ActionsPopup
card={{
id,
name,
deadline,
timer,
isPersisted,
}}
projectMemberships={allProjectMemberships}
currentUserIds={users.map((user) => user.id)}
labels={allLabels}
currentLabelIds={labels.map((label) => label.id)}
onNameEdit={handleNameEdit}
onUpdate={onUpdate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
onLabelAdd={onLabelAdd}
onLabelRemove={onLabelRemove}
onLabelCreate={onLabelCreate}
onLabelUpdate={onLabelUpdate}
onLabelDelete={onLabelDelete}
>
<Button className={classNames(styles.actionsButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
</>
) : (
<span className={styles.content}>{contentNode}</span>
)}
</div>
</EditName>
</div>
)}
</Draggable>
);
},
);
Card.propTypes = {
id: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
deadline: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isPersisted: PropTypes.bool.isRequired,
notificationsTotal: PropTypes.number.isRequired,
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
tasks: PropTypes.array.isRequired,
allProjectMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
onLabelAdd: PropTypes.func.isRequired,
onLabelRemove: PropTypes.func.isRequired,
onLabelCreate: PropTypes.func.isRequired,
onLabelUpdate: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
};
Card.defaultProps = {
deadline: undefined,
timer: undefined,
};
export default Card;

View file

@ -0,0 +1,112 @@
.actionsButton {
background: none !important;
box-shadow: none !important;
border-radius: 3px !important;
box-sizing: content-box;
color: #798d99 !important;
display: inline-block !important;
margin: 0 !important;
min-height: auto !important;
opacity: 0;
outline: none;
padding: 4px !important;
position: absolute;
right: 2px;
top: 2px;
transition: background 85ms ease !important;
width: 20px;
}
.actionsButton:hover {
background: #ebeef0 !important;
color: #516b7a !important;
}
.attachment {
display: inline-block;
line-height: 0;
margin: 0 0 6px 0;
max-width: 100%;
vertical-align: top;
}
.attachmentLeft {
margin-right: 4px;
}
.attachmentRight {
margin-left: 2px;
}
.attachments {
display: inline-block;
padding-bottom: 2px;
}
.attachmentsRight {
float: right;
line-height: 0;
}
.card {
background-color: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
position: relative;
cursor: pointer;
}
.card:hover {
background-color: #f5f6f7;
border-bottom-color: rgba(9, 45, 66, 0.25);
}
.card:hover .target {
opacity: 1 !important;
}
.content {
cursor: grab;
display: block;
padding: 6px 8px 0;
}
.content:after {
content: "";
display: table;
clear: both;
}
.labels {
display: block;
max-width: 100%;
overflow: hidden;
}
.name {
color: #17394d;
font-size: 14px;
line-height: 18px;
padding-bottom: 6px;
word-wrap: break-word;
}
.notification {
background: #eb5a46;
color: #fff;
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
border: none;
border-radius: 3px;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapper {
display: block;
margin-bottom: 8px;
}

View file

@ -0,0 +1,128 @@
import React, {
useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../hooks';
import styles from './EditName.module.css';
const EditName = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [value, handleFieldChange, setValue] = useField(defaultValue);
const field = useRef(null);
const open = useCallback(() => {
setIsOpened(true);
setValue(defaultValue);
}, [defaultValue, setValue]);
const close = useCallback(() => {
setIsOpened(false);
setValue(null);
}, [setValue]);
const submit = useCallback(() => {
const cleanValue = value.trim();
if (!cleanValue) {
field.current.ref.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue);
}
close();
}, [defaultValue, onUpdate, value, close]);
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
);
const handleFieldKeyDown = useCallback(
(event) => {
switch (event.key) {
case 'Enter':
event.preventDefault();
submit();
break;
case 'Escape':
close();
break;
default:
}
},
[close, submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
isOpened,
close,
);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useEffect(() => {
if (isOpened) {
field.current.ref.current.select();
}
}, [isOpened]);
if (!isOpened) {
return children;
}
return (
<Form onSubmit={handleSubmit}>
<div className={styles.fieldWrapper}>
<TextArea
ref={field}
as={TextareaAutosize}
value={value}
minRows={3}
maxRows={8}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
</div>
<div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button
positive
content={t('action.save')}
className={styles.submitButton}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
</Form>
);
});
EditName.propTypes = {
children: PropTypes.element.isRequired,
defaultValue: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
};
export default React.memo(EditName);

View file

@ -0,0 +1,24 @@
.field {
border: none !important;
margin-bottom: 4px !important;
outline: none !important;
overflow: hidden !important;
padding: 0 !important;
resize: none !important;
width: 100% !important;
word-wrap: break-word !important;
}
.fieldWrapper {
background-color: #fff !important;
border-radius: 3px !important;
box-shadow: 0 1px 0 #ccc !important;
margin-bottom: 8px !important;
min-height: 20px !important;
padding: 6px 8px 2px !important;
}
.submitButton {
margin-bottom: 8px;
vertical-align: top;
}

View file

@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../hooks';
import styles from './Tasks.module.css';
const Tasks = React.memo(({ items }) => {
const [isOpened, toggleOpened] = useToggle();
const handleToggleClick = useCallback(
(event) => {
event.preventDefault();
toggleOpened();
},
[toggleOpened],
);
const completedItems = items.filter((item) => item.isCompleted);
return (
<>
{/* eslint-disable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div className={styles.button} onClick={handleToggleClick}>
{/* eslint-enable jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedItems.length}
total={items.length}
color="blue"
size="tiny"
className={styles.progress}
/>
</span>
<span
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)}
>
{completedItems.length}
{'/'}
{items.length}
</span>
</div>
{isOpened && (
<ul className={styles.tasks}>
{items.map((item) => (
<li
key={item.id}
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
>
{item.name}
</li>
))}
</ul>
)}
</>
);
});
Tasks.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Tasks;

View file

@ -0,0 +1,83 @@
.button {
background: transparent;
border: none;
cursor: pointer;
line-height: 0;
margin: 0 -8px;
outline: none;
padding: 0px 8px 8px;
width: calc(100% + 16px);
}
.count {
color: #888;
cursor: pointer;
display: inline-block;
font-size: 12px;
line-height: 12px;
text-align: right;
vertical-align: top;
width: 50px;
}
.count:after {
content: "";
opacity: 0.4;
}
.count:hover {
opacity: 0.75;
}
.countOpened:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=")
no-repeat center right;
margin-left: 2px;
padding: 6px 6px 0px;
}
.countClosed:after {
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==")
no-repeat center right;
margin-left: 2px;
padding: 0 6px 6px;
}
.progress {
margin: 0 !important;
}
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.task {
display: block;
font-size: 12px;
line-height: 14px;
padding-bottom: 6px;
padding-left: 14px;
word-break: break-all;
}
.task:before {
content: "";
position: absolute;
left: 10px;
}
.taskCompleted {
color: #aaa;
text-decoration: line-through;
}
.tasks {
color: #333;
cursor: grab;
list-style: none;
margin: -2px 0 0;
padding-left: 0;
}

View file

@ -0,0 +1,3 @@
import Card from './Card';
export default Card;

View file

@ -0,0 +1,101 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
Comment, Icon, Loader, Visibility,
} from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import AddComment from './AddComment';
import Item from './Item';
import styles from './Actions.module.css';
const Actions = React.memo(
({
items,
isFetching,
isAllFetched,
isEditable,
onFetch,
onCommentCreate,
onCommentUpdate,
onCommentDelete,
}) => {
const [t] = useTranslation();
const handleCommentUpdate = useCallback(
(id, data) => {
onCommentUpdate(id, data);
},
[onCommentUpdate],
);
const handleCommentDelete = useCallback(
(id) => {
onCommentDelete(id);
},
[onCommentDelete],
);
return (
<>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="comment outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.addComment')}</div>
<AddComment onCreate={onCommentCreate} />
</div>
</div>
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="list ul" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.actions')}</div>
<div className={styles.wrapper}>
<Comment.Group>
{items.map((item) => (item.type === ActionTypes.COMMENT_CARD ? (
<Item.Comment
key={item.id}
data={item.data}
createdAt={item.createdAt}
isPersisted={item.isPersisted}
user={item.user}
isEditable={isEditable}
onUpdate={(data) => handleCommentUpdate(item.id, data)}
onDelete={() => handleCommentDelete(item.id)}
/>
) : (
<Item
key={item.id}
type={item.type}
data={item.data}
createdAt={item.createdAt}
user={item.user}
/>
)))}
</Comment.Group>
</div>
{isFetching ? (
<Loader active inverted inline="centered" size="small" className={styles.loader} />
) : (
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
)}
</div>
</div>
</>
);
},
);
Actions.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isFetching: PropTypes.bool.isRequired,
isAllFetched: PropTypes.bool.isRequired,
isEditable: PropTypes.bool.isRequired,
onFetch: PropTypes.func.isRequired,
onCommentCreate: PropTypes.func.isRequired,
onCommentUpdate: PropTypes.func.isRequired,
onCommentDelete: PropTypes.func.isRequired,
};
export default Actions;

View file

@ -0,0 +1,38 @@
.contentModule {
margin-bottom: 24px;
}
.loader {
margin-top: 10px !important;
}
.moduleHeader {
color: #17394d;
font-size: 16px;
font-weight: bold;
line-height: 20px;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
color: #17394d;
font-size: 17px !important;
height: 32px !important;
left: -40px;
line-height: 32px;
margin-right: 0 !important;
position: absolute;
top: 2px;
width: 32px !important;
}
.moduleWrapper {
margin: 0 0 0 40px;
position: relative;
}
.wrapper {
margin-left: -40px;
margin-top: 12px;
}

Some files were not shown because too many files have changed in this diff Show more