mirror of
https://github.com/plankanban/planka.git
synced 2025-12-28 01:11:54 +03:00
Initial commit
This commit is contained in:
129
client/src/components/Board/AddList.jsx
Executable file
129
client/src/components/Board/AddList.jsx
Executable 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);
|
||||
31
client/src/components/Board/AddList.module.css
Normal file
31
client/src/components/Board/AddList.module.css
Normal 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;
|
||||
}
|
||||
138
client/src/components/Board/Board.jsx
Executable file
138
client/src/components/Board/Board.jsx
Executable 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;
|
||||
52
client/src/components/Board/Board.module.css
Normal file
52
client/src/components/Board/Board.module.css
Normal 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%;
|
||||
}
|
||||
120
client/src/components/Board/Filter.jsx
Normal file
120
client/src/components/Board/Filter.jsx
Normal 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;
|
||||
51
client/src/components/Board/Filter.module.css
Normal file
51
client/src/components/Board/Filter.module.css
Normal 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;
|
||||
}
|
||||
3
client/src/components/Board/index.js
Executable file
3
client/src/components/Board/index.js
Executable file
@@ -0,0 +1,3 @@
|
||||
import Board from './Board';
|
||||
|
||||
export default Board;
|
||||
Reference in New Issue
Block a user