mirror of
https://github.com/plankanban/planka.git
synced 2025-07-27 17:19:43 +02:00
Initial commit
This commit is contained in:
commit
5ffef61fe7
613 changed files with 91659 additions and 0 deletions
85
client/src/components/AddProjectModal/AddProjectModal.jsx
Executable file
85
client/src/components/AddProjectModal/AddProjectModal.jsx
Executable 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;
|
|
@ -0,0 +1,3 @@
|
|||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
3
client/src/components/AddProjectModal/index.js
Normal file
3
client/src/components/AddProjectModal/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import AddProjectModal from './AddProjectModal';
|
||||
|
||||
export default AddProjectModal;
|
148
client/src/components/AddUserPopup/AddUserPopup.jsx
Executable file
148
client/src/components/AddUserPopup/AddUserPopup.jsx
Executable 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);
|
10
client/src/components/AddUserPopup/AddUserPopup.module.css
Normal file
10
client/src/components/AddUserPopup/AddUserPopup.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
3
client/src/components/AddUserPopup/index.js
Normal file
3
client/src/components/AddUserPopup/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import AddUserPopup from './AddUserPopup';
|
||||
|
||||
export default AddUserPopup;
|
23
client/src/components/App.jsx
Executable file
23
client/src/components/App.jsx
Executable 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;
|
19
client/src/components/AppWrapper.jsx
Executable file
19
client/src/components/AppWrapper.jsx
Executable 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;
|
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;
|
19
client/src/components/BoardWrapper.jsx
Executable file
19
client/src/components/BoardWrapper.jsx
Executable 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;
|
69
client/src/components/Boards/AddPopup.jsx
Executable file
69
client/src/components/Boards/AddPopup.jsx
Executable 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);
|
3
client/src/components/Boards/AddPopup.module.css
Normal file
3
client/src/components/Boards/AddPopup.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
183
client/src/components/Boards/Boards.jsx
Executable file
183
client/src/components/Boards/Boards.jsx
Executable 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;
|
115
client/src/components/Boards/Boards.module.css
Normal file
115
client/src/components/Boards/Boards.module.css
Normal 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;
|
||||
}
|
108
client/src/components/Boards/EditPopup.jsx
Executable file
108
client/src/components/Boards/EditPopup.jsx
Executable 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);
|
10
client/src/components/Boards/EditPopup.module.css
Normal file
10
client/src/components/Boards/EditPopup.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
3
client/src/components/Boards/index.js
Executable file
3
client/src/components/Boards/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Boards from './Boards';
|
||||
|
||||
export default Boards;
|
216
client/src/components/Card/ActionsPopup.jsx
Normal file
216
client/src/components/Card/ActionsPopup.jsx
Normal 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);
|
9
client/src/components/Card/ActionsPopup.module.css
Normal file
9
client/src/components/Card/ActionsPopup.module.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.menu {
|
||||
margin: -7px -12px -5px !important;
|
||||
width: calc(100% + 24px) !important;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0 !important;
|
||||
padding-left: 14px !important;
|
||||
}
|
201
client/src/components/Card/Card.jsx
Executable file
201
client/src/components/Card/Card.jsx
Executable 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;
|
112
client/src/components/Card/Card.module.css
Normal file
112
client/src/components/Card/Card.module.css
Normal 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;
|
||||
}
|
128
client/src/components/Card/EditName.jsx
Normal file
128
client/src/components/Card/EditName.jsx
Normal 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);
|
24
client/src/components/Card/EditName.module.css
Normal file
24
client/src/components/Card/EditName.module.css
Normal 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;
|
||||
}
|
69
client/src/components/Card/Tasks.jsx
Normal file
69
client/src/components/Card/Tasks.jsx
Normal 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;
|
83
client/src/components/Card/Tasks.module.css
Normal file
83
client/src/components/Card/Tasks.module.css
Normal 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;
|
||||
}
|
3
client/src/components/Card/index.js
Normal file
3
client/src/components/Card/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Card from './Card';
|
||||
|
||||
export default Card;
|
101
client/src/components/CardModal/Actions/Actions.jsx
Executable file
101
client/src/components/CardModal/Actions/Actions.jsx
Executable 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;
|
38
client/src/components/CardModal/Actions/Actions.module.css
Normal file
38
client/src/components/CardModal/Actions/Actions.module.css
Normal 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;
|
||||
}
|
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable file
75
client/src/components/CardModal/Actions/AddComment.jsx
Executable file
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback, useRef } 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 { useDeepCompareCallback, useForm } from '../../../hooks';
|
||||
|
||||
import styles from './AddComment.module.css';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
text: '',
|
||||
};
|
||||
|
||||
const AddComment = React.memo(({ onCreate }) => {
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
|
||||
const textField = useRef(null);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
text: data.text.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData);
|
||||
|
||||
setData(DEFAULT_DATA);
|
||||
}, [onCreate, data, setData]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={textField}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
placeholder={t('common.writeComment')}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button positive content={t('action.addComment')} disabled={!data.text} />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
AddComment.propTypes = {
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddComment;
|
|
@ -0,0 +1,23 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 0 !important;
|
||||
box-sizing: border-box;
|
||||
color: #333 !important;
|
||||
display: block;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 6px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable file
122
client/src/components/CardModal/Actions/EditComment.jsx
Executable file
|
@ -0,0 +1,122 @@
|
|||
import dequal from 'dequal';
|
||||
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, useDeepCompareCallback, useForm } from '../../../hooks';
|
||||
|
||||
import styles from './EditComment.module.css';
|
||||
|
||||
const EditComment = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [data, handleFieldChange, setData] = useForm(null);
|
||||
|
||||
const textField = useRef(null);
|
||||
|
||||
const open = useDeepCompareCallback(() => {
|
||||
setIsOpened(true);
|
||||
setData({
|
||||
text: '',
|
||||
...defaultData,
|
||||
});
|
||||
}, [defaultData, setData]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
setData(null);
|
||||
}, [setData]);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
text: data.text.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
onUpdate(cleanData);
|
||||
}
|
||||
|
||||
close();
|
||||
}, [defaultData, onUpdate, data, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
textField.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
if (!isOpened) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={textField}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
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.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditComment.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(EditComment);
|
|
@ -0,0 +1,24 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box;
|
||||
color: #333 !important;
|
||||
display: block;
|
||||
line-height: 1.4 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
91
client/src/components/CardModal/Actions/Item.jsx
Executable file
91
client/src/components/CardModal/Actions/Item.jsx
Executable file
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import { ActionTypes } from '../../../constants/Enums';
|
||||
import ItemComment from './ItemComment';
|
||||
import User from '../../User';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(({
|
||||
type, data, createdAt, user,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
let contentNode;
|
||||
|
||||
switch (type) {
|
||||
case ActionTypes.CREATE_CARD:
|
||||
contentNode = (
|
||||
<Trans
|
||||
i18nKey="common.userAddedThisCardToList"
|
||||
values={{
|
||||
user: user.name,
|
||||
list: data.list.name,
|
||||
}}
|
||||
>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.text}>
|
||||
{' added this card to '}
|
||||
{data.list.name}
|
||||
</span>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
break;
|
||||
case ActionTypes.MOVE_CARD:
|
||||
contentNode = (
|
||||
<Trans
|
||||
i18nKey="common.userMovedThisCardFromListToList"
|
||||
values={{
|
||||
user: user.name,
|
||||
fromList: data.fromList.name,
|
||||
toList: data.toList.name,
|
||||
}}
|
||||
>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.text}>
|
||||
{' moved this card from '}
|
||||
{data.fromList.name}
|
||||
{' to '}
|
||||
{data.toList.name}
|
||||
</span>
|
||||
</Trans>
|
||||
);
|
||||
|
||||
break;
|
||||
default:
|
||||
contentNode = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div>{contentNode}</div>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Comment>
|
||||
);
|
||||
});
|
||||
|
||||
Item.Comment = ItemComment;
|
||||
|
||||
Item.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
createdAt: PropTypes.instanceOf(Date).isRequired,
|
||||
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
|
||||
export default Item;
|
31
client/src/components/CardModal/Actions/Item.module.css
Normal file
31
client/src/components/CardModal/Actions/Item.module.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.author {
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-bottom: 1px solid #092d4221;
|
||||
display: inline-block;
|
||||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #6b808c;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-block;
|
||||
padding: 4px 8px 0 0;
|
||||
vertical-align: top;
|
||||
}
|
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable file
87
client/src/components/CardModal/Actions/ItemComment.jsx
Executable file
|
@ -0,0 +1,87 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import EditComment from './EditComment';
|
||||
import User from '../../User';
|
||||
import DeletePopup from '../../DeletePopup';
|
||||
|
||||
import styles from './ItemComment.module.css';
|
||||
|
||||
const ItemComment = React.memo(
|
||||
({
|
||||
data, createdAt, isPersisted, user, isEditable, onUpdate, onDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const editComment = useRef(null);
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
editComment.current.open();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Comment>
|
||||
<span className={styles.user}>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</span>
|
||||
<div className={classNames(styles.content)}>
|
||||
<div className={styles.title}>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<EditComment ref={editComment} defaultData={data} onUpdate={onUpdate}>
|
||||
<>
|
||||
<p className={styles.text}>{data.text}</p>
|
||||
<Comment.Actions>
|
||||
{user.isCurrent && (
|
||||
<Comment.Action
|
||||
as="button"
|
||||
content={t('action.edit')}
|
||||
disabled={!isPersisted}
|
||||
onClick={handleEditClick}
|
||||
/>
|
||||
)}
|
||||
{(user.isCurrent || isEditable) && (
|
||||
<DeletePopup
|
||||
title={t('common.deleteComment', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisComment')}
|
||||
buttonContent={t('action.deleteComment')}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<Comment.Action
|
||||
as="button"
|
||||
content={t('action.delete')}
|
||||
disabled={!isPersisted}
|
||||
/>
|
||||
</DeletePopup>
|
||||
)}
|
||||
</Comment.Actions>
|
||||
</>
|
||||
</EditComment>
|
||||
</div>
|
||||
</Comment>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ItemComment.propTypes = {
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
createdAt: PropTypes.instanceOf(Date).isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
user: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ItemComment;
|
|
@ -0,0 +1,49 @@
|
|||
.author {
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
border-bottom: 1px solid #092d4221;
|
||||
display: inline-block;
|
||||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #6b808c;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
background-color: #fff;
|
||||
border-radius: 0px 8px 8px;
|
||||
box-shadow: 0 1px 2px -1px rgba(9, 45, 66, 0.25),
|
||||
0 0 0 1px rgba(9, 45, 66, 0.08);
|
||||
box-sizing: border-box;
|
||||
color: #17394d;
|
||||
display: inline-block;
|
||||
margin: 1px 2px 4px 1px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-line;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-block;
|
||||
padding: 4px 8px 0 0;
|
||||
vertical-align: top;
|
||||
}
|
3
client/src/components/CardModal/Actions/index.js
Executable file
3
client/src/components/CardModal/Actions/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Actions from './Actions';
|
||||
|
||||
export default Actions;
|
388
client/src/components/CardModal/CardModal.jsx
Executable file
388
client/src/components/CardModal/CardModal.jsx
Executable file
|
@ -0,0 +1,388 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button, Grid, Icon, Modal,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
import NameField from './NameField';
|
||||
import EditDescription from './EditDescription';
|
||||
import Tasks from './Tasks';
|
||||
import Actions from './Actions';
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
import Deadline from '../Deadline';
|
||||
import Timer from '../Timer';
|
||||
import ProjectMembershipsPopup from '../ProjectMembershipsPopup';
|
||||
import LabelsPopup from '../LabelsPopup';
|
||||
import EditDeadlinePopup from '../EditDeadlinePopup';
|
||||
import EditTimerPopup from '../EditTimerPopup';
|
||||
import DeletePopup from '../DeletePopup';
|
||||
|
||||
import styles from './CardModal.module.css';
|
||||
|
||||
const CardModal = React.memo(
|
||||
({
|
||||
name,
|
||||
description,
|
||||
deadline,
|
||||
timer,
|
||||
isSubscribed,
|
||||
isActionsFetching,
|
||||
isAllActionsFetched,
|
||||
users,
|
||||
labels,
|
||||
tasks,
|
||||
actions,
|
||||
allProjectMemberships,
|
||||
allLabels,
|
||||
isEditable,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onUserAdd,
|
||||
onUserRemove,
|
||||
onLabelAdd,
|
||||
onLabelRemove,
|
||||
onLabelCreate,
|
||||
onLabelUpdate,
|
||||
onLabelDelete,
|
||||
onTaskCreate,
|
||||
onTaskUpdate,
|
||||
onTaskDelete,
|
||||
onActionsFetch,
|
||||
onCommentActionCreate,
|
||||
onCommentActionUpdate,
|
||||
onCommentActionDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
onUpdate({
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDescriptionUpdate = useCallback(
|
||||
(newDescription) => {
|
||||
onUpdate({
|
||||
description: newDescription,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDeadlineUpdate = useCallback(
|
||||
(newDeadline) => {
|
||||
onUpdate({
|
||||
deadline: newDeadline,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleTimerUpdate = useCallback(
|
||||
(newTimer) => {
|
||||
onUpdate({
|
||||
timer: newTimer,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleToggleSubscribeClick = useCallback(() => {
|
||||
onUpdate({
|
||||
isSubscribed: !isSubscribed,
|
||||
});
|
||||
}, [isSubscribed, onUpdate]);
|
||||
|
||||
const userIds = users.map((user) => user.id);
|
||||
const labelIds = labels.map((label) => label.id);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
closeIcon
|
||||
size="small"
|
||||
centered={false}
|
||||
className={styles.wrapper}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Grid className={styles.grid}>
|
||||
<Grid.Row className={styles.headerPadding}>
|
||||
<Grid.Column width={16} className={styles.headerPadding}>
|
||||
<div className={styles.headerWrapper}>
|
||||
<Icon name="list alternate outline" className={styles.moduleIcon} />
|
||||
<div className={styles.headerTitle}>
|
||||
<NameField defaultValue={name} onUpdate={handleNameUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
<Grid.Row className={styles.modalPadding}>
|
||||
<Grid.Column width={12} className={styles.contentPadding}>
|
||||
{(users.length > 0 || labels.length > 0 || deadline || timer) && (
|
||||
<div className={styles.moduleWrapper}>
|
||||
{users.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{users.map((user) => (
|
||||
<span key={user.id} className={styles.attachment}>
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<User name={user.name} avatar={user.avatar} />
|
||||
</ProjectMembershipsPopup>
|
||||
</span>
|
||||
))}
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.deadline)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</ProjectMembershipsPopup>
|
||||
</div>
|
||||
)}
|
||||
{labels.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.labels', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
{labels.map((label) => (
|
||||
<span key={label.id} className={styles.attachment}>
|
||||
<LabelsPopup
|
||||
key={label.id}
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Label name={label.name} color={label.color} />
|
||||
</LabelsPopup>
|
||||
</span>
|
||||
))}
|
||||
<LabelsPopup
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.deadline)}
|
||||
>
|
||||
<Icon name="add" size="small" className={styles.addAttachment} />
|
||||
</button>
|
||||
</LabelsPopup>
|
||||
</div>
|
||||
)}
|
||||
{deadline && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.deadline', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
|
||||
<Deadline value={deadline} />
|
||||
</EditDeadlinePopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{timer && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.timer', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<Timer startedAt={timer.startedAt} total={timer.total} />
|
||||
</EditTimerPopup>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="align justify" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.description')}</div>
|
||||
<EditDescription defaultValue={description} onUpdate={handleDescriptionUpdate}>
|
||||
{description ? (
|
||||
<button type="button" className={styles.descriptionText}>
|
||||
{description}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={styles.descriptionButton}>
|
||||
<span className={styles.descriptionButtonText}>
|
||||
{t('action.addMoreDetailedDescription')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</EditDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.contentModule}>
|
||||
<div className={styles.moduleWrapper}>
|
||||
<Icon name="check square outline" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.tasks')}</div>
|
||||
<Tasks
|
||||
items={tasks}
|
||||
onCreate={onTaskCreate}
|
||||
onUpdate={onTaskUpdate}
|
||||
onDelete={onTaskDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Actions
|
||||
items={actions}
|
||||
isFetching={isActionsFetching}
|
||||
isAllFetched={isAllActionsFetched}
|
||||
isEditable={isEditable}
|
||||
onFetch={onActionsFetch}
|
||||
onCommentCreate={onCommentActionCreate}
|
||||
onCommentUpdate={onCommentActionUpdate}
|
||||
onCommentDelete={onCommentActionDelete}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={4} className={styles.sidebarPadding}>
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('action.addToCard')}</span>
|
||||
<ProjectMembershipsPopup
|
||||
items={allProjectMemberships}
|
||||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="user outline" className={styles.actionIcon} />
|
||||
{t('common.members')}
|
||||
</Button>
|
||||
</ProjectMembershipsPopup>
|
||||
<LabelsPopup
|
||||
items={allLabels}
|
||||
currentIds={labelIds}
|
||||
onSelect={onLabelAdd}
|
||||
onDeselect={onLabelRemove}
|
||||
onCreate={onLabelCreate}
|
||||
onUpdate={onLabelUpdate}
|
||||
onDelete={onLabelDelete}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="bookmark outline" className={styles.actionIcon} />
|
||||
{t('common.labels')}
|
||||
</Button>
|
||||
</LabelsPopup>
|
||||
<EditDeadlinePopup defaultValue={deadline} onUpdate={handleDeadlineUpdate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="calendar check outline" className={styles.actionIcon} />
|
||||
{t('common.deadline')}
|
||||
</Button>
|
||||
</EditDeadlinePopup>
|
||||
<EditTimerPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="clock outline" className={styles.actionIcon} />
|
||||
{t('common.timer')}
|
||||
</Button>
|
||||
</EditTimerPopup>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.actionsTitle}>{t('common.actions')}</span>
|
||||
<Button fluid className={styles.actionButton} onClick={handleToggleSubscribeClick}>
|
||||
<Icon name="paper plane outline" className={styles.actionIcon} />
|
||||
{isSubscribed ? t('action.unsubscribe') : t('action.subscribe')}
|
||||
</Button>
|
||||
<DeletePopup
|
||||
title={t('common.deleteCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisCard')}
|
||||
buttonContent={t('action.deleteCard')}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="trash alternate outline" className={styles.actionIcon} />
|
||||
{t('action.delete')}
|
||||
</Button>
|
||||
</DeletePopup>
|
||||
</div>
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardModal.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
deadline: PropTypes.instanceOf(Date),
|
||||
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
isSubscribed: PropTypes.bool.isRequired,
|
||||
isActionsFetching: PropTypes.bool.isRequired,
|
||||
isAllActionsFetched: PropTypes.bool.isRequired,
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
users: PropTypes.array.isRequired,
|
||||
labels: PropTypes.array.isRequired,
|
||||
tasks: PropTypes.array.isRequired,
|
||||
actions: PropTypes.array.isRequired,
|
||||
allProjectMemberships: PropTypes.array.isRequired,
|
||||
allLabels: PropTypes.array.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
isEditable: PropTypes.bool.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,
|
||||
onTaskCreate: PropTypes.func.isRequired,
|
||||
onTaskUpdate: PropTypes.func.isRequired,
|
||||
onTaskDelete: PropTypes.func.isRequired,
|
||||
onActionsFetch: PropTypes.func.isRequired,
|
||||
onCommentActionCreate: PropTypes.func.isRequired,
|
||||
onCommentActionUpdate: PropTypes.func.isRequired,
|
||||
onCommentActionDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CardModal.defaultProps = {
|
||||
description: undefined,
|
||||
deadline: undefined,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
export default CardModal;
|
211
client/src/components/CardModal/CardModal.module.css
Normal file
211
client/src/components/CardModal/CardModal.module.css
Normal file
|
@ -0,0 +1,211 @@
|
|||
.actionButton {
|
||||
background: #ebeef0 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.13) !important;
|
||||
color: #444 !important;
|
||||
margin-top: 8px !important;
|
||||
padding: 6px 8px 6px 18px !important;
|
||||
text-align: left !important;
|
||||
transition: background 85ms ease !important;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
background: #dfe3e6 !important;
|
||||
box-shadow: 0 1px 0 0 rgba(9, 45, 66, 0.25) !important;
|
||||
color: #4c4c4c !important;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: #17394d !important;
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actionsTitle {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
|
||||
.addAttachment {
|
||||
margin: 0 -4.3px !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: inline-block;
|
||||
margin: 0 8px 8px 0;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.contentModule {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.contentPadding {
|
||||
padding: 8px 8px 0 16px !important;
|
||||
}
|
||||
|
||||
.deadline {
|
||||
background: #dce0e4;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 6px 14px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.deadline:hover {
|
||||
background: #d2d8dc;
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.descriptionButton {
|
||||
background: rgba(9, 45, 66, 0.08);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
min-height: 54px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.descriptionButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
.descriptionButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #17394d;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
background: #f5f6f7;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.headerPadding {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 4px 0;
|
||||
padding: 6px 0 0;
|
||||
}
|
||||
|
||||
.headerWrapper {
|
||||
margin: 12px 48px 12px 56px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.labels {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
margin: 0 4px 4px 0;
|
||||
max-width: 100%;
|
||||
min-width: 40px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modalPadding {
|
||||
padding: 0px !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;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #6b808c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 20px;
|
||||
margin: 0 8px 4px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebarPadding {
|
||||
padding: 8px 16px 0 8px !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 768px !important;
|
||||
}
|
122
client/src/components/CardModal/EditDescription.jsx
Executable file
122
client/src/components/CardModal/EditDescription.jsx
Executable file
|
@ -0,0 +1,122 @@
|
|||
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 './EditDescription.module.css';
|
||||
|
||||
const EditDescription = React.forwardRef(({ children, defaultValue, onUpdate }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [value, handleFieldChange, setValue] = useField(null);
|
||||
|
||||
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() || null;
|
||||
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
close();
|
||||
}, [defaultValue, onUpdate, value, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleChildrenClick = useCallback(() => {
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[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 React.cloneElement(children, {
|
||||
onClick: handleChildrenClick,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={field}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
placeholder={t('common.enterDescription')}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
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.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
EditDescription.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
defaultValue: PropTypes.string,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditDescription.defaultProps = {
|
||||
defaultValue: undefined,
|
||||
};
|
||||
|
||||
export default React.memo(EditDescription);
|
22
client/src/components/CardModal/EditDescription.module.css
Normal file
22
client/src/components/CardModal/EditDescription.module.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.controls {
|
||||
clear: both !important;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none !important;
|
||||
}
|
67
client/src/components/CardModal/NameField.jsx
Executable file
67
client/src/components/CardModal/NameField.jsx
Executable file
|
@ -0,0 +1,67 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useDidUpdate, useField, usePrevious } from '../../hooks';
|
||||
|
||||
import styles from './NameField.module.css';
|
||||
|
||||
const NameField = React.memo(({ defaultValue, onUpdate }) => {
|
||||
const prevDefaultValue = usePrevious(defaultValue);
|
||||
const [value, handleChange, setValue] = useField(defaultValue);
|
||||
|
||||
const isFocused = useRef(false);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocused.current = true;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
event.target.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
isFocused.current = false;
|
||||
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (cleanValue) {
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
} else {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, onUpdate, value, setValue]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!isFocused.current && defaultValue !== prevDefaultValue) {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, prevDefaultValue, setValue]);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NameField.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default NameField;
|
22
client/src/components/CardModal/NameField.module.css
Normal file
22
client/src/components/CardModal/NameField.module.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.field {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
box-shadow: none;
|
||||
color: #17394d;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
background: #fff;
|
||||
border-color: #5ba4cf;
|
||||
box-shadow: 0 0 2px 0 #5ba4cf;
|
||||
outline: 0;
|
||||
}
|
75
client/src/components/CardModal/Tasks/ActionsPopup.jsx
Executable file
75
client/src/components/CardModal/Tasks/ActionsPopup.jsx
Executable file
|
@ -0,0 +1,75 @@
|
|||
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 DeleteStep from '../../DeleteStep';
|
||||
|
||||
import styles from './ActionsPopup.module.css';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ onNameEdit, onDelete, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleNameEditClick = useCallback(() => {
|
||||
onNameEdit();
|
||||
onClose();
|
||||
}, [onNameEdit, onClose]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<DeleteStep
|
||||
title={t('common.deleteTask', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisTask')}
|
||||
buttonContent={t('action.deleteTask')}
|
||||
onConfirm={onDelete}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.taskActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
|
||||
{t('action.editDescription', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
{t('action.deleteTask', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ActionsStep.propTypes = {
|
||||
onNameEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withPopup(ActionsStep);
|
|
@ -0,0 +1,9 @@
|
|||
.menu {
|
||||
margin: -7px -12px -5px !important;
|
||||
width: calc(100% + 24px) !important;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0 !important;
|
||||
padding-left: 14px !important;
|
||||
}
|
138
client/src/components/CardModal/Tasks/Add.jsx
Executable file
138
client/src/components/CardModal/Tasks/Add.jsx
Executable file
|
@ -0,0 +1,138 @@
|
|||
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,
|
||||
useDeepCompareCallback,
|
||||
useDidUpdate,
|
||||
useForm,
|
||||
useToggle,
|
||||
} from '../../../hooks';
|
||||
|
||||
import styles from './Add.module.css';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
const Add = 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);
|
||||
}, []);
|
||||
|
||||
const submit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData);
|
||||
|
||||
setData(DEFAULT_DATA);
|
||||
selectNameField();
|
||||
}, [onCreate, data, setData, selectNameField]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
const handleChildrenClick = useCallback(() => {
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
isOpened,
|
||||
close,
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
nameField.current.ref.current.select();
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameField.current.ref.current.select();
|
||||
}, [selectNameFieldState]);
|
||||
|
||||
if (!isOpened) {
|
||||
return React.cloneElement(children, {
|
||||
onClick: handleChildrenClick,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className={styles.wrapper} onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
ref={nameField}
|
||||
as={TextareaAutosize}
|
||||
name="name"
|
||||
value={data.name}
|
||||
placeholder={t('common.enterTaskDescription')}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
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.addTask')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
Add.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(Add);
|
23
client/src/components/CardModal/Tasks/Add.module.css
Normal file
23
client/src/components/CardModal/Tasks/Add.module.css
Normal file
|
@ -0,0 +1,23 @@
|
|||
.controls {
|
||||
clear: both !important;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 4px !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: 6px !important;
|
||||
padding-bottom: 8px !important;
|
||||
}
|
116
client/src/components/CardModal/Tasks/EditName.jsx
Executable file
116
client/src/components/CardModal/Tasks/EditName.jsx
Executable file
|
@ -0,0 +1,116 @@
|
|||
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(null);
|
||||
|
||||
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) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[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} className={styles.wrapper}>
|
||||
<TextArea
|
||||
ref={field}
|
||||
as={TextareaAutosize}
|
||||
value={value}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
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.save')}
|
||||
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);
|
26
client/src/components/CardModal/Tasks/EditName.module.css
Normal file
26
client/src/components/CardModal/Tasks/EditName.module.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.controls {
|
||||
clear: both;
|
||||
margin-top: 6px !important;
|
||||
}
|
||||
|
||||
.field {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 45, 66, 0.13) !important;
|
||||
border-radius: 3px !important;
|
||||
box-sizing: border-box !important;
|
||||
color: #17394d !important;
|
||||
display: block !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
overflow: hidden !important;
|
||||
padding: 8px 12px !important;
|
||||
resize: none !important;
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 9px 32px 16px 40px;
|
||||
}
|
83
client/src/components/CardModal/Tasks/Item.jsx
Executable file
83
client/src/components/CardModal/Tasks/Item.jsx
Executable file
|
@ -0,0 +1,83 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
||||
|
||||
import EditName from './EditName';
|
||||
import ActionsPopup from './ActionsPopup';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(({
|
||||
name, isCompleted, isPersisted, onUpdate, onDelete,
|
||||
}) => {
|
||||
const editName = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isPersisted) {
|
||||
editName.current.open();
|
||||
}
|
||||
}, [isPersisted]);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
onUpdate({
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleToggleChange = useCallback(() => {
|
||||
onUpdate({
|
||||
isCompleted: !isCompleted,
|
||||
});
|
||||
}, [isCompleted, onUpdate]);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
editName.current.open();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={isCompleted}
|
||||
disabled={!isPersisted}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</span>
|
||||
<EditName ref={editName} defaultValue={name} onUpdate={handleNameUpdate}>
|
||||
<div className={styles.content}>
|
||||
{/* eslint-disable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={styles.text} onClick={handleClick}>
|
||||
{/* eslint-enable jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
{isPersisted && (
|
||||
<ActionsPopup onNameEdit={handleNameEdit} onDelete={onDelete}>
|
||||
<Button className={classNames(styles.button, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
)}
|
||||
</div>
|
||||
</EditName>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
73
client/src/components/CardModal/Tasks/Item.module.css
Normal file
73
client/src/components/CardModal/Tasks/Item.module.css
Normal file
|
@ -0,0 +1,73 @@
|
|||
.button {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
line-height: 28px !important;
|
||||
margin: 0 !important;
|
||||
min-height: auto !important;
|
||||
opacity: 0;
|
||||
padding: 0 !important;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13) !important;
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
display: inline-block;
|
||||
padding: 10px 15px 0px 8px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: 0;
|
||||
left: 0;
|
||||
vertical-align: top;
|
||||
z-index: 2000;
|
||||
line-height: 1;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.content:hover {
|
||||
background-color: rgba(9, 45, 66, 0.08);
|
||||
}
|
||||
|
||||
.content:hover .target {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.task {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 8px 0;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskCompleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
color: #17394d;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 32px;
|
||||
padding: 0 32px 0 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
margin-left: -40px;
|
||||
min-height: 32px;
|
||||
position: relative;
|
||||
transition: all 0.14s ease-in;
|
||||
width: calc(100% + 40px);
|
||||
}
|
72
client/src/components/CardModal/Tasks/Tasks.jsx
Executable file
72
client/src/components/CardModal/Tasks/Tasks.jsx
Executable file
|
@ -0,0 +1,72 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress } from 'semantic-ui-react';
|
||||
|
||||
import Item from './Item';
|
||||
import Add from './Add';
|
||||
|
||||
import styles from './Tasks.module.css';
|
||||
|
||||
const Tasks = React.memo(({
|
||||
items, onCreate, onUpdate, onDelete,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onUpdate(id, data);
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
const completedItems = items.filter((item) => item.isCompleted);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.length > 0 && (
|
||||
<Progress
|
||||
autoSuccess
|
||||
value={completedItems.length}
|
||||
total={items.length}
|
||||
color="blue"
|
||||
size="tiny"
|
||||
className={styles.progress}
|
||||
/>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Item
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
isCompleted={item.isCompleted}
|
||||
isPersisted={item.isPersisted}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
<Add onCreate={onCreate}>
|
||||
<button type="button" className={styles.taskButton}>
|
||||
<span className={styles.taskButtonText}>
|
||||
{items.length > 0 ? t('action.addAnotherTask') : t('action.addTask')}
|
||||
</span>
|
||||
</button>
|
||||
</Add>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Tasks.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Tasks;
|
30
client/src/components/CardModal/Tasks/Tasks.module.css
Normal file
30
client/src/components/CardModal/Tasks/Tasks.module.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
.progress {
|
||||
margin: 0 0 16px !important;
|
||||
}
|
||||
|
||||
.taskButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
min-height: 54px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskButton:hover {
|
||||
background-color: rgba(9, 45, 66, 0.13);
|
||||
color: #092d42;
|
||||
}
|
||||
|
||||
.taskButtonText {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
}
|
3
client/src/components/CardModal/Tasks/index.js
Executable file
3
client/src/components/CardModal/Tasks/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Task from './Tasks';
|
||||
|
||||
export default Task;
|
3
client/src/components/CardModal/index.js
Executable file
3
client/src/components/CardModal/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import CardModal from './CardModal';
|
||||
|
||||
export default CardModal;
|
78
client/src/components/Deadline/Deadline.jsx
Normal file
78
client/src/components/Deadline/Deadline.jsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './Deadline.module.css';
|
||||
|
||||
const SIZES = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
// TODO: move to styles
|
||||
const STYLES = {
|
||||
tiny: {
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
padding: '0px 6px',
|
||||
},
|
||||
small: {
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
padding: '2px 6px',
|
||||
},
|
||||
medium: {
|
||||
lineHeight: '20px',
|
||||
padding: '6px 12px',
|
||||
},
|
||||
};
|
||||
|
||||
const FORMATS = {
|
||||
tiny: 'longDate',
|
||||
small: 'longDate',
|
||||
medium: 'longDateTime',
|
||||
};
|
||||
|
||||
const Deadline = React.memo(({
|
||||
value, size, isDisabled, onClick,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const style = {
|
||||
...STYLES[size],
|
||||
};
|
||||
|
||||
const contentNode = (
|
||||
<span className={classNames(styles.wrapper, onClick && styles.hoverable)} style={style}>
|
||||
{t(`format:${FORMATS[size]}`, {
|
||||
value,
|
||||
postProcess: 'formatDate',
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
|
||||
{contentNode}
|
||||
</button>
|
||||
) : (
|
||||
contentNode
|
||||
);
|
||||
});
|
||||
|
||||
Deadline.propTypes = {
|
||||
value: PropTypes.instanceOf(Date).isRequired,
|
||||
size: PropTypes.oneOf(Object.values(SIZES)),
|
||||
isDisabled: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Deadline.defaultProps = {
|
||||
size: SIZES.MEDIUM,
|
||||
isDisabled: false,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
export default Deadline;
|
25
client/src/components/Deadline/Deadline.module.css
Normal file
25
client/src/components/Deadline/Deadline.module.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
.button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hoverable:hover {
|
||||
background: #d2d8dc;
|
||||
color: #17394d;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #dce0e4;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #6a808b;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: top;
|
||||
}
|
3
client/src/components/Deadline/index.js
Normal file
3
client/src/components/Deadline/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Deadline from './Deadline';
|
||||
|
||||
export default Deadline;
|
5
client/src/components/DeletePopup.jsx
Normal file
5
client/src/components/DeletePopup.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { withPopup } from '../lib/popup';
|
||||
|
||||
import DeleteStep from './DeleteStep';
|
||||
|
||||
export default withPopup(DeleteStep);
|
32
client/src/components/DeleteStep/DeleteStep.jsx
Normal file
32
client/src/components/DeleteStep/DeleteStep.jsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import styles from './DeleteStep.module.css';
|
||||
|
||||
const DeleteStep = React.memo(({
|
||||
title, content, buttonContent, onConfirm, onBack,
|
||||
}) => (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>{title}</Popup.Header>
|
||||
<Popup.Content>
|
||||
<div className={styles.content}>{content}</div>
|
||||
<Button fluid negative content={buttonContent} onClick={onConfirm} />
|
||||
</Popup.Content>
|
||||
</>
|
||||
));
|
||||
|
||||
DeleteStep.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
content: PropTypes.string.isRequired,
|
||||
buttonContent: PropTypes.string.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
};
|
||||
|
||||
DeleteStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default DeleteStep;
|
5
client/src/components/DeleteStep/DeleteStep.module.css
Normal file
5
client/src/components/DeleteStep/DeleteStep.module.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.content {
|
||||
color: #212121;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 2px;
|
||||
}
|
3
client/src/components/DeleteStep/index.js
Normal file
3
client/src/components/DeleteStep/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import DeleteStep from './DeleteStep';
|
||||
|
||||
export default DeleteStep;
|
5
client/src/components/EditDeadlinePopup.jsx
Normal file
5
client/src/components/EditDeadlinePopup.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { withPopup } from '../lib/popup';
|
||||
|
||||
import EditDeadlineStep from './EditDeadlineStep';
|
||||
|
||||
export default withPopup(EditDeadlineStep);
|
152
client/src/components/EditDeadlineStep/EditDeadlineStep.jsx
Executable file
152
client/src/components/EditDeadlineStep/EditDeadlineStep.jsx
Executable file
|
@ -0,0 +1,152 @@
|
|||
import React, {
|
||||
useCallback, useEffect, useMemo, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Input, Popup } from '../../lib/custom-ui';
|
||||
|
||||
import {
|
||||
useDeepCompareCallback, useDidUpdate, useForm, useToggle,
|
||||
} from '../../hooks';
|
||||
|
||||
import styles from './EditDeadlineStep.module.css';
|
||||
|
||||
const EditDeadlineStep = React.memo(({
|
||||
defaultValue, onUpdate, onBack, onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm(() => {
|
||||
const date = defaultValue || new Date().setHours(12, 0, 0, 0);
|
||||
|
||||
return {
|
||||
date: t('format:date', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
time: t('format:time', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const [selectTimeFieldState, selectTimeField] = useToggle();
|
||||
|
||||
const dateField = useRef(null);
|
||||
const timeField = useRef(null);
|
||||
|
||||
const nullableDate = useMemo(() => {
|
||||
const date = t('format:date', {
|
||||
postProcess: 'parseDate',
|
||||
value: data.date,
|
||||
});
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}, [data.date, t]);
|
||||
|
||||
const handleDatePickerChange = useCallback(
|
||||
(date) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
date: t('format:date', {
|
||||
postProcess: 'formatDate',
|
||||
value: date,
|
||||
}),
|
||||
}));
|
||||
selectTimeField();
|
||||
},
|
||||
[setData, selectTimeField, t],
|
||||
);
|
||||
|
||||
const handleSubmit = useDeepCompareCallback(() => {
|
||||
if (!nullableDate) {
|
||||
dateField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = t('format:dateTime', {
|
||||
postProcess: 'parseDate',
|
||||
value: `${data.date} ${data.time}`,
|
||||
});
|
||||
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
timeField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultValue || value.getTime() !== defaultValue.getTime()) {
|
||||
onUpdate(value);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose, data, nullableDate]);
|
||||
|
||||
const handleClearClick = useDeepCompareCallback(() => {
|
||||
if (defaultValue) {
|
||||
onUpdate(null);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
dateField.current.select();
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
timeField.current.select();
|
||||
}, [selectTimeFieldState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editDeadline', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.date')}</div>
|
||||
<Input ref={dateField} name="date" value={data.date} onChange={handleFieldChange} />
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.time')}</div>
|
||||
<Input ref={timeField} name="time" value={data.time} onChange={handleFieldChange} />
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker inline selected={nullableDate} onChange={handleDatePickerChange} />
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
<Button
|
||||
negative
|
||||
content={t('action.remove')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleClearClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditDeadlineStep.propTypes = {
|
||||
defaultValue: PropTypes.instanceOf(Date),
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditDeadlineStep.defaultProps = {
|
||||
defaultValue: undefined,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditDeadlineStep;
|
|
@ -0,0 +1,24 @@
|
|||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.fieldBox {
|
||||
display: inline-block;
|
||||
margin: 0 4px 12px;
|
||||
width: calc(50% - 8px);
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
3
client/src/components/EditDeadlineStep/index.js
Normal file
3
client/src/components/EditDeadlineStep/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import EditDeadlineStep from './EditDeadlineStep';
|
||||
|
||||
export default EditDeadlineStep;
|
5
client/src/components/EditTimerPopup.jsx
Normal file
5
client/src/components/EditTimerPopup.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { withPopup } from '../lib/popup';
|
||||
|
||||
import EditTimerStep from './EditTimerStep';
|
||||
|
||||
export default withPopup(EditTimerStep);
|
189
client/src/components/EditTimerStep/EditTimerStep.jsx
Normal file
189
client/src/components/EditTimerStep/EditTimerStep.jsx
Normal file
|
@ -0,0 +1,189 @@
|
|||
import dequal from 'dequal';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Input, Popup } from '../../lib/custom-ui';
|
||||
|
||||
import { useDeepCompareCallback, useForm, useToggle } from '../../hooks';
|
||||
import {
|
||||
createTimer, getTimerParts, startTimer, stopTimer, updateTimer,
|
||||
} from '../../utils/timer';
|
||||
|
||||
import styles from './EditTimerStep.module.css';
|
||||
|
||||
const createData = (timer) => {
|
||||
if (!timer) {
|
||||
return {
|
||||
hours: '0',
|
||||
minutes: '0',
|
||||
seconds: '0',
|
||||
};
|
||||
}
|
||||
|
||||
const { hours, minutes, seconds } = getTimerParts(timer);
|
||||
|
||||
return {
|
||||
hours: `${hours}`,
|
||||
minutes: `${minutes}`,
|
||||
seconds: `${seconds}`,
|
||||
};
|
||||
};
|
||||
|
||||
const EditTimerStep = React.memo(({
|
||||
defaultValue, onUpdate, onBack, onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(() => createData(defaultValue));
|
||||
const [isEditing, toggleEdit] = useToggle();
|
||||
|
||||
const hoursField = useRef(null);
|
||||
const minutesField = useRef(null);
|
||||
const secondsField = useRef(null);
|
||||
|
||||
const handleStartClick = useDeepCompareCallback(() => {
|
||||
onUpdate(startTimer(defaultValue));
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose]);
|
||||
|
||||
const handleStopClick = useDeepCompareCallback(() => {
|
||||
onUpdate(stopTimer(defaultValue));
|
||||
}, [defaultValue, onUpdate]);
|
||||
|
||||
const handleClearClick = useDeepCompareCallback(() => {
|
||||
if (defaultValue) {
|
||||
onUpdate(null);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose]);
|
||||
|
||||
const handleToggleEditClick = useDeepCompareCallback(() => {
|
||||
setData(createData(defaultValue));
|
||||
toggleEdit();
|
||||
}, [defaultValue, setData, toggleEdit]);
|
||||
|
||||
const handleSubmit = useDeepCompareCallback(() => {
|
||||
const parts = {
|
||||
hours: parseInt(data.hours, 10),
|
||||
minutes: parseInt(data.minutes, 10),
|
||||
seconds: parseInt(data.seconds, 10),
|
||||
};
|
||||
|
||||
if (Number.isNaN(parts.hours)) {
|
||||
hoursField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parts.minutes) || parts.minutes > 60) {
|
||||
minutesField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(parts.seconds) || parts.seconds > 60) {
|
||||
secondsField.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultValue) {
|
||||
if (!dequal(parts, getTimerParts(defaultValue))) {
|
||||
onUpdate(updateTimer(defaultValue, parts));
|
||||
}
|
||||
} else {
|
||||
onUpdate(createTimer(parts));
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
hoursField.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editTimer', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.fieldWrapper}>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.hours')}</div>
|
||||
<Input
|
||||
ref={hoursField}
|
||||
name="hours"
|
||||
value={data.hours}
|
||||
mask="9999"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.minutes')}</div>
|
||||
<Input
|
||||
ref={minutesField}
|
||||
name="minutes"
|
||||
value={data.minutes}
|
||||
mask="99"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldBox}>
|
||||
<div className={styles.text}>{t('common.seconds')}</div>
|
||||
<Input
|
||||
ref={secondsField}
|
||||
name="seconds"
|
||||
value={data.seconds}
|
||||
mask="99"
|
||||
maskChar={null}
|
||||
disabled={!isEditing}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
icon={isEditing ? 'close' : 'edit'}
|
||||
className={styles.iconButton}
|
||||
onClick={handleToggleEditClick}
|
||||
/>
|
||||
</div>
|
||||
{isEditing && <Button positive content={t('action.save')} />}
|
||||
</Form>
|
||||
{!isEditing
|
||||
&& (defaultValue && defaultValue.startedAt ? (
|
||||
<Button positive content={t('action.stop')} icon="pause" onClick={handleStopClick} />
|
||||
) : (
|
||||
<Button positive content={t('action.start')} icon="play" onClick={handleStartClick} />
|
||||
))}
|
||||
<Button
|
||||
negative
|
||||
content={t('action.remove')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleClearClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditTimerStep.propTypes = {
|
||||
defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditTimerStep.defaultProps = {
|
||||
defaultValue: undefined,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default EditTimerStep;
|
35
client/src/components/EditTimerStep/EditTimerStep.module.css
Normal file
35
client/src/components/EditTimerStep/EditTimerStep.module.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.fieldBox {
|
||||
display: inline-block;
|
||||
margin: 0 4px 12px;
|
||||
width: calc(33.3333% - 22px);
|
||||
}
|
||||
|
||||
.fieldWrapper {
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
margin: 0 4px 0 1px !important;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
3
client/src/components/EditTimerStep/index.js
Normal file
3
client/src/components/EditTimerStep/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import EditTimerStep from './EditTimerStep';
|
||||
|
||||
export default EditTimerStep;
|
78
client/src/components/Header/Header.jsx
Executable file
78
client/src/components/Header/Header.jsx
Executable file
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
|
||||
import Paths from '../../constants/Paths';
|
||||
import NotificationsPopup from './NotificationsPopup';
|
||||
import UserPopup from './UserPopup';
|
||||
|
||||
import styles from './Header.module.css';
|
||||
|
||||
const Header = React.memo(
|
||||
({
|
||||
user,
|
||||
notifications,
|
||||
isEditable,
|
||||
onUserUpdate,
|
||||
onUserAvatarUpload,
|
||||
onNotificationDelete,
|
||||
onUsers,
|
||||
onLogout,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Link to={Paths.ROOT} className={styles.logo}>
|
||||
Planka
|
||||
</Link>
|
||||
<Menu inverted size="large" className={styles.menu}>
|
||||
{isEditable && (
|
||||
<Menu.Item className={styles.item} onClick={onUsers}>
|
||||
{t('common.users', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Menu position="right">
|
||||
<NotificationsPopup items={notifications} onDelete={onNotificationDelete}>
|
||||
<Menu.Item className={styles.item}>
|
||||
<Icon name="bell" className={styles.icon} />
|
||||
{notifications.length > 0 && (
|
||||
<span className={styles.notification}>{notifications.length}</span>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</NotificationsPopup>
|
||||
<UserPopup
|
||||
name={user.name}
|
||||
avatar={user.avatar}
|
||||
isAvatarUploading={user.isAvatarUploading}
|
||||
onUpdate={onUserUpdate}
|
||||
onAvatarUpload={onUserAvatarUpload}
|
||||
onLogout={onLogout}
|
||||
>
|
||||
<Menu.Item className={styles.item}>{user.name}</Menu.Item>
|
||||
</UserPopup>
|
||||
</Menu.Menu>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Header.propTypes = {
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
user: PropTypes.object.isRequired,
|
||||
notifications: PropTypes.array.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
isEditable: PropTypes.bool.isRequired,
|
||||
onUserUpdate: PropTypes.func.isRequired,
|
||||
onUserAvatarUpload: PropTypes.func.isRequired,
|
||||
onNotificationDelete: PropTypes.func.isRequired,
|
||||
onUsers: PropTypes.func.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Header;
|
58
client/src/components/Header/Header.module.css
Normal file
58
client/src/components/Header/Header.module.css
Normal file
|
@ -0,0 +1,58 @@
|
|||
.icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: #2d3035;
|
||||
color: #fff !important;
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3.5px;
|
||||
line-height: 50px;
|
||||
padding: 0 16px;
|
||||
text-transform: uppercase;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.logo:before {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background: #2d3035 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
color: #fff !important;
|
||||
flex: 1 1 auto;
|
||||
height: 50px;
|
||||
margin: 0 !important;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background: #eb5a46;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
text-align: center;
|
||||
top: 8px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
114
client/src/components/Header/NotificationsPopup.jsx
Executable file
114
client/src/components/Header/NotificationsPopup.jsx
Executable file
|
@ -0,0 +1,114 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { withPopup } from '../../lib/popup';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import Paths from '../../constants/Paths';
|
||||
import { ActionTypes } from '../../constants/Enums';
|
||||
import User from '../User';
|
||||
|
||||
import styles from './NotificationsPopup.module.css';
|
||||
|
||||
const NotificationsStep = React.memo(({ items, onDelete, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
({ action, card }) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.MOVE_CARD:
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="common.userMovedCardFromListToList"
|
||||
values={{
|
||||
user: action.user.name,
|
||||
card: card.name,
|
||||
fromList: action.data.fromList.name,
|
||||
toList: action.data.toList.name,
|
||||
}}
|
||||
>
|
||||
{action.user.name}
|
||||
{' moved '}
|
||||
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
|
||||
{card.name}
|
||||
</Link>
|
||||
{' from '}
|
||||
{action.data.fromList.name}
|
||||
{' to '}
|
||||
{action.data.toList.name}
|
||||
</Trans>
|
||||
);
|
||||
case ActionTypes.COMMENT_CARD:
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="common.userLeftNewCommentToCard"
|
||||
values={{
|
||||
user: action.user.name,
|
||||
comment: action.data.text,
|
||||
card: card.name,
|
||||
}}
|
||||
>
|
||||
{action.user.name}
|
||||
{` left a new comment «${action.data.text}» to `}
|
||||
<Link to={Paths.CARDS.replace(':id', card.id)} onClick={onClose}>
|
||||
{card.name}
|
||||
</Link>
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>{t('common.notifications')}</Popup.Header>
|
||||
<Popup.Content>
|
||||
{items.length > 0
|
||||
? items.map((item) => (
|
||||
<div key={item.id} className={styles.wrapper}>
|
||||
{item.card && item.action ? (
|
||||
<>
|
||||
<User
|
||||
name={item.action.user.name}
|
||||
avatar={item.action.user.avatar}
|
||||
size="large"
|
||||
/>
|
||||
<span className={styles.content}>{renderItemContent(item)}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.deletedContent}>{t('common.cardOrActionAreDeleted')}</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
icon="close"
|
||||
className={styles.button}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: t('common.noUnreadNotifications')}
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
NotificationsStep.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withPopup(NotificationsStep);
|
45
client/src/components/Header/NotificationsPopup.module.css
Normal file
45
client/src/components/Header/NotificationsPopup.module.css
Normal file
|
@ -0,0 +1,45 @@
|
|||
.button {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
float: right !important;
|
||||
height: 20px !important;
|
||||
line-height: 20px !important;
|
||||
margin: 0 !important;
|
||||
min-height: auto !important;
|
||||
padding: 5px 0 !important;
|
||||
transition: background 0.3s ease !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: #e9e9e9 !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block !important;
|
||||
font-size: 12px;
|
||||
min-height: 36px !important;
|
||||
overflow: hidden;
|
||||
padding: 0 4px 0 8px !important;
|
||||
vertical-align: top !important;
|
||||
width: calc(100% - 56px) !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.deletedContent {
|
||||
display: inline-block !important;
|
||||
line-height: 20px !important;
|
||||
min-height: 20px !important;
|
||||
padding: 0 4px 0 8px !important;
|
||||
vertical-align: top !important;
|
||||
width: calc(100% - 20px) !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin: 0 -12px !important;
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
.wrapper:hover {
|
||||
background: #f0f0f0 !important;
|
||||
}
|
74
client/src/components/Header/UserPopup/EditAvatarStep.jsx
Executable file
74
client/src/components/Header/UserPopup/EditAvatarStep.jsx
Executable file
|
@ -0,0 +1,74 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import User from '../../User';
|
||||
|
||||
import styles from './EditAvatarStep.module.css';
|
||||
|
||||
const EditAvatarStep = React.memo(
|
||||
({
|
||||
defaultValue, name, isUploading, onUpload, onClear, onBack,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const field = useRef(null);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
({ target }) => {
|
||||
if (target.files[0]) {
|
||||
onUpload(target.files[0]);
|
||||
|
||||
target.value = null; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
},
|
||||
[onUpload],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
field.current.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editAvatar', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<User name={name} avatar={defaultValue} size="large" />
|
||||
<div className={styles.input}>
|
||||
<Button content={t('action.uploadNewAvatar')} className={styles.customButton} />
|
||||
<input
|
||||
ref={field}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={isUploading}
|
||||
className={styles.file}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
{defaultValue && <Button negative content={t('action.deleteAvatar')} onClick={onClear} />}
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EditAvatarStep.propTypes = {
|
||||
defaultValue: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
isUploading: PropTypes.bool.isRequired,
|
||||
onUpload: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
EditAvatarStep.defaultProps = {
|
||||
defaultValue: undefined,
|
||||
};
|
||||
|
||||
export default EditAvatarStep;
|
|
@ -0,0 +1,35 @@
|
|||
.customButton {
|
||||
background: transparent !important;
|
||||
color: #6b808c !important;
|
||||
font-weight: normal !important;
|
||||
height: 36px;
|
||||
line-height: 24px !important;
|
||||
padding: 6px 12px !important;
|
||||
text-align: left !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.file {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: none;
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
transition: background 0.3s ease;
|
||||
width: calc(100% - 44px);
|
||||
}
|
||||
|
||||
.input:hover {
|
||||
background: #e9e9e9 !important;
|
||||
}
|
68
client/src/components/Header/UserPopup/EditNameStep.jsx
Executable file
68
client/src/components/Header/UserPopup/EditNameStep.jsx
Executable file
|
@ -0,0 +1,68 @@
|
|||
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 { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import { useField } from '../../../hooks';
|
||||
|
||||
import styles from './EditNameStep.module.css';
|
||||
|
||||
const EditNameStep = React.memo(({
|
||||
defaultValue, onUpdate, onBack, onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [value, handleFieldChange] = useField(defaultValue);
|
||||
|
||||
const field = useRef(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (!cleanValue) {
|
||||
field.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [defaultValue, onUpdate, onClose, value]);
|
||||
|
||||
useEffect(() => {
|
||||
field.current.select();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editName', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
fluid
|
||||
ref={field}
|
||||
value={value}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EditNameStep.propTypes = {
|
||||
defaultValue: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditNameStep;
|
|
@ -0,0 +1,3 @@
|
|||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
116
client/src/components/Header/UserPopup/UserPopup.jsx
Executable file
116
client/src/components/Header/UserPopup/UserPopup.jsx
Executable file
|
@ -0,0 +1,116 @@
|
|||
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 EditNameStep from './EditNameStep';
|
||||
import EditAvatarStep from './EditAvatarStep';
|
||||
|
||||
import styles from './UserPopup.module.css';
|
||||
|
||||
const StepTypes = {
|
||||
EDIT_NAME: 'EDIT_NAME',
|
||||
EDIT_AVATAR: 'EDIT_AVATAR',
|
||||
};
|
||||
|
||||
const UserStep = React.memo(
|
||||
({
|
||||
name, avatar, isAvatarUploading, onUpdate, onAvatarUpload, onLogout, onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleNameEditClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_NAME);
|
||||
}, [openStep]);
|
||||
|
||||
const handleAvatarEditClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_AVATAR);
|
||||
}, [openStep]);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
onUpdate({
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleAvatarClear = useCallback(() => {
|
||||
onUpdate({
|
||||
avatar: null,
|
||||
});
|
||||
}, [onUpdate]);
|
||||
|
||||
if (step) {
|
||||
switch (step.type) {
|
||||
case StepTypes.EDIT_NAME:
|
||||
return (
|
||||
<EditNameStep
|
||||
defaultValue={name}
|
||||
onUpdate={handleNameUpdate}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_AVATAR:
|
||||
return (
|
||||
<EditAvatarStep
|
||||
defaultValue={avatar}
|
||||
name={name}
|
||||
isUploading={isAvatarUploading}
|
||||
onUpload={onAvatarUpload}
|
||||
onClear={handleAvatarClear}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>{name}</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleNameEditClick}>
|
||||
{t('action.editName', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleAvatarEditClick}>
|
||||
{t('action.editAvatar', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={onLogout}>
|
||||
{t('action.logOut', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserStep.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
avatar: PropTypes.string,
|
||||
isAvatarUploading: PropTypes.bool.isRequired,
|
||||
onUpdate: PropTypes.func.isRequired,
|
||||
onAvatarUpload: PropTypes.func.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UserStep.defaultProps = {
|
||||
avatar: undefined,
|
||||
};
|
||||
|
||||
export default withPopup(UserStep);
|
|
@ -0,0 +1,9 @@
|
|||
.menu {
|
||||
margin: -7px -12px -5px !important;
|
||||
width: calc(100% + 24px) !important;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0 !important;
|
||||
padding-left: 14px !important;
|
||||
}
|
3
client/src/components/Header/UserPopup/index.js
Executable file
3
client/src/components/Header/UserPopup/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import UserPopup from './UserPopup';
|
||||
|
||||
export default UserPopup;
|
3
client/src/components/Header/index.js
Executable file
3
client/src/components/Header/index.js
Executable file
|
@ -0,0 +1,3 @@
|
|||
import Header from './Header';
|
||||
|
||||
export default Header;
|
78
client/src/components/Label/Label.jsx
Normal file
78
client/src/components/Label/Label.jsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import LabelColors from '../../constants/LabelColors';
|
||||
|
||||
import styles from './Label.module.css';
|
||||
|
||||
const SIZES = {
|
||||
TINY: 'tiny',
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
// TODO: move to styles
|
||||
const STYLES = {
|
||||
tiny: {
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
maxWidth: '176px',
|
||||
padding: '0px 6px',
|
||||
},
|
||||
small: {
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
maxWidth: '176px',
|
||||
padding: '2px 8px',
|
||||
},
|
||||
medium: {
|
||||
fontSize: '14px',
|
||||
lineHeight: '32px',
|
||||
maxWidth: '230px',
|
||||
padding: '0 12px',
|
||||
},
|
||||
};
|
||||
|
||||
const Label = React.memo(({
|
||||
name, color, size, isDisabled, onClick,
|
||||
}) => {
|
||||
const style = {
|
||||
...STYLES[size],
|
||||
background: LabelColors.MAP[color],
|
||||
};
|
||||
|
||||
const contentNode = (
|
||||
<div
|
||||
title={name}
|
||||
className={classNames(styles.wrapper, onClick && styles.hoverable)}
|
||||
style={style}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
<button type="button" disabled={isDisabled} className={styles.button} onClick={onClick}>
|
||||
{contentNode}
|
||||
</button>
|
||||
) : (
|
||||
contentNode
|
||||
);
|
||||
});
|
||||
|
||||
Label.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.oneOf(LabelColors.KEYS).isRequired, // TODO: without color
|
||||
size: PropTypes.oneOf(Object.values(SIZES)),
|
||||
isDisabled: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
size: SIZES.MEDIUM,
|
||||
isDisabled: false,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
export default Label;
|
27
client/src/components/Label/Label.module.css
Normal file
27
client/src/components/Label/Label.module.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
.button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hoverable:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
min-width: 40px;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: rgba(0, 0, 0, 0.2) 1px 1px 0;
|
||||
white-space: nowrap;
|
||||
}
|
3
client/src/components/Label/index.js
Normal file
3
client/src/components/Label/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Label from './Label';
|
||||
|
||||
export default Label;
|
5
client/src/components/LabelsPopup.jsx
Normal file
5
client/src/components/LabelsPopup.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { withPopup } from '../lib/popup';
|
||||
|
||||
import LabelsStep from './LabelsStep';
|
||||
|
||||
export default withPopup(LabelsStep);
|
60
client/src/components/LabelsStep/AddStep.jsx
Executable file
60
client/src/components/LabelsStep/AddStep.jsx
Executable file
|
@ -0,0 +1,60 @@
|
|||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import { useDeepCompareCallback, useForm } from '../../hooks';
|
||||
import LabelColors from '../../constants/LabelColors';
|
||||
import Editor from './Editor';
|
||||
|
||||
import styles from './AddStep.module.css';
|
||||
|
||||
const AddStep = React.memo(({ onCreate, onBack }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
color: LabelColors.KEYS[0],
|
||||
}));
|
||||
|
||||
const editor = useRef(null);
|
||||
|
||||
const handleSubmit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
editor.current.selectNameField();
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(cleanData);
|
||||
onBack();
|
||||
}, [data, onCreate, onBack]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.createLabel', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Editor ref={editor} data={data} onFieldChange={handleFieldChange} />
|
||||
<Button positive content={t('action.createLabel')} className={styles.submitButton} />
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AddStep.propTypes = {
|
||||
onCreate: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddStep;
|
3
client/src/components/LabelsStep/AddStep.module.css
Normal file
3
client/src/components/LabelsStep/AddStep.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.submitButton {
|
||||
margin-top: 12px;
|
||||
}
|
99
client/src/components/LabelsStep/EditStep.jsx
Executable file
99
client/src/components/LabelsStep/EditStep.jsx
Executable file
|
@ -0,0 +1,99 @@
|
|||
import dequal from 'dequal';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import { useDeepCompareCallback, useForm, useSteps } from '../../hooks';
|
||||
import LabelColors from '../../constants/LabelColors';
|
||||
import Editor from './Editor';
|
||||
import DeleteStep from '../DeleteStep';
|
||||
|
||||
import styles from './EditStep.module.css';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const EditStep = React.memo(({
|
||||
defaultData, onUpdate, onDelete, onBack,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
color: LabelColors.KEYS[0],
|
||||
...defaultData,
|
||||
}));
|
||||
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const editor = useRef(null);
|
||||
|
||||
const handleSubmit = useDeepCompareCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
editor.current.selectNameField();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
onUpdate(cleanData);
|
||||
}
|
||||
|
||||
onBack();
|
||||
}, [defaultData, data, onUpdate, onBack]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<DeleteStep
|
||||
title={t('common.deleteLabel', {
|
||||
context: 'title',
|
||||
})}
|
||||
content={t('common.areYouSureYouWantToDeleteThisLabel')}
|
||||
buttonContent={t('action.deleteLabel')}
|
||||
onConfirm={onDelete}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.editLabel', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Editor ref={editor} data={data} onFieldChange={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,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default EditStep;
|
6
client/src/components/LabelsStep/EditStep.module.css
Normal file
6
client/src/components/LabelsStep/EditStep.module.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
}
|
74
client/src/components/LabelsStep/Editor.jsx
Executable file
74
client/src/components/LabelsStep/Editor.jsx
Executable file
|
@ -0,0 +1,74 @@
|
|||
import React, {
|
||||
useCallback, useEffect, useImperativeHandle, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { Input } from '../../lib/custom-ui';
|
||||
|
||||
import LabelColors from '../../constants/LabelColors';
|
||||
|
||||
import styles from './Editor.module.css';
|
||||
|
||||
const Editor = React.forwardRef(({ data, onFieldChange }, ref) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const nameField = useRef(null);
|
||||
|
||||
const selectNameField = useCallback(() => {
|
||||
nameField.current.select();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectNameField,
|
||||
}),
|
||||
[selectNameField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectNameField();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={nameField}
|
||||
name="name"
|
||||
value={data.name}
|
||||
className={styles.field}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
<div className={styles.text}>{t('common.color')}</div>
|
||||
<div className={styles.colorButtons}>
|
||||
{LabelColors.PAIRS.map(([name, hex]) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
name="color"
|
||||
value={name}
|
||||
className={classNames(
|
||||
styles.colorButton,
|
||||
name === data.color && styles.colorButtonActive,
|
||||
)}
|
||||
style={{
|
||||
background: hex,
|
||||
}}
|
||||
onClick={onFieldChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Editor.propTypes = {
|
||||
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
onFieldChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(Editor);
|
49
client/src/components/LabelsStep/Editor.module.css
Normal file
49
client/src/components/LabelsStep/Editor.module.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
.colorButton {
|
||||
float: left;
|
||||
height: 40px;
|
||||
margin: 4px !important;
|
||||
padding: 0 !important;
|
||||
position: relative;
|
||||
width: 49.6px;
|
||||
}
|
||||
|
||||
.colorButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.colorButtonActive:before {
|
||||
bottom: 3px;
|
||||
color: #ffffff;
|
||||
content: "Г";
|
||||
font-size: 18px;
|
||||
line-height: 36px;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
text-align: center;
|
||||
text-shadow: -1px 1px 0 rgba(0, 0, 0, 0.2);
|
||||
top: 0;
|
||||
transform: rotate(-135deg);
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.colorButtons {
|
||||
margin: -4px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.colorButtons:after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
55
client/src/components/LabelsStep/Item.jsx
Executable file
55
client/src/components/LabelsStep/Item.jsx
Executable file
|
@ -0,0 +1,55 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
|
||||
import LabelColors from '../../constants/LabelColors';
|
||||
|
||||
import styles from './Item.module.css';
|
||||
|
||||
const Item = React.memo(({
|
||||
name, color, isPersisted, isActive, onSelect, onDeselect, onEdit,
|
||||
}) => {
|
||||
const handleToggleClick = useCallback(() => {
|
||||
if (isActive) {
|
||||
onDeselect();
|
||||
} else {
|
||||
onSelect();
|
||||
}
|
||||
}, [isActive, onSelect, onDeselect]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Button
|
||||
fluid
|
||||
content={name}
|
||||
active={isActive}
|
||||
disabled={!isPersisted}
|
||||
className={classNames(styles.labelButton, isActive && styles.labelButtonActive)}
|
||||
style={{
|
||||
background: LabelColors.MAP[color],
|
||||
}}
|
||||
onClick={handleToggleClick}
|
||||
/>
|
||||
<Button
|
||||
icon="pencil"
|
||||
size="small"
|
||||
disabled={!isPersisted}
|
||||
className={styles.editButton}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
color: PropTypes.string.isRequired,
|
||||
isPersisted: PropTypes.bool.isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onDeselect: PropTypes.func.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
51
client/src/components/LabelsStep/Item.module.css
Normal file
51
client/src/components/LabelsStep/Item.module.css
Normal file
|
@ -0,0 +1,51 @@
|
|||
.editButton {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
flex: 0 0 auto;
|
||||
font-weight: normal !important;
|
||||
margin: 0 !important;
|
||||
padding: 8px 10px !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.editButton:hover {
|
||||
background: #e9e9e9 !important;
|
||||
}
|
||||
|
||||
.labelButton {
|
||||
color: #fff !important;
|
||||
flex: 1 1 auto;
|
||||
font-size: 14px !important;
|
||||
font-weight: bold !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 32px 8px 10px !important;
|
||||
position: relative;
|
||||
text-align: left !important;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.labelButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.labelButtonActive:before {
|
||||
bottom: 1px;
|
||||
content: "Г";
|
||||
font-size: 18px;
|
||||
font-weight: normal;
|
||||
line-height: 36px;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
text-align: center;
|
||||
text-shadow: -1px 1px 0 rgba(0, 0, 0, 0.2);
|
||||
transform: rotate(-135deg);
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin-bottom: 4px;
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue