mirror of
https://github.com/plankanban/planka.git
synced 2025-12-25 17:25:01 +03:00
46
client/src/components/boards/Board/Board.jsx
Normal file
46
client/src/components/boards/Board/Board.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardContexts, BoardViews } from '../../../constants/Enums';
|
||||
import KanbanContent from './KanbanContent';
|
||||
import FiniteContent from './FiniteContent';
|
||||
import EndlessContent from './EndlessContent';
|
||||
import CardModal from '../../cards/CardModal';
|
||||
|
||||
const Board = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const isCardModalOpened = useSelector((state) => !!selectors.selectPath(state).cardId);
|
||||
|
||||
let Content;
|
||||
if (board.view === BoardViews.KANBAN) {
|
||||
Content = KanbanContent;
|
||||
} else {
|
||||
switch (board.context) {
|
||||
case BoardContexts.BOARD:
|
||||
Content = FiniteContent;
|
||||
|
||||
break;
|
||||
case BoardContexts.ARCHIVE:
|
||||
case BoardContexts.TRASH:
|
||||
Content = EndlessContent;
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Content />
|
||||
{isCardModalOpened && <CardModal />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Board;
|
||||
57
client/src/components/boards/Board/EndlessContent.jsx
Normal file
57
client/src/components/boards/Board/EndlessContent.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { BoardViews } from '../../../constants/Enums';
|
||||
import GridView from './GridView';
|
||||
import ListView from './ListView';
|
||||
|
||||
const EndlessContent = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const { isCardsFetching, isAllCardsFetched } = useSelector(selectors.selectCurrentList);
|
||||
const cardIds = useSelector(selectors.selectFilteredCardIdsForCurrentList);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCardsFetch = useCallback(() => {
|
||||
dispatch(entryActions.fetchCardsInCurrentList());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleCardCreate = useCallback(
|
||||
(data, autoOpen) => {
|
||||
dispatch(entryActions.createCardInCurrentList(data, autoOpen));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const viewProps = {
|
||||
cardIds,
|
||||
isCardsFetching,
|
||||
isAllCardsFetched,
|
||||
onCardsFetch: handleCardsFetch,
|
||||
onCardCreate: handleCardCreate,
|
||||
};
|
||||
|
||||
let View;
|
||||
switch (board.view) {
|
||||
case BoardViews.GRID:
|
||||
View = GridView;
|
||||
|
||||
break;
|
||||
case BoardViews.LIST:
|
||||
View = ListView;
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return <View {...viewProps} />; // eslint-disable-line react/jsx-props-no-spreading
|
||||
});
|
||||
|
||||
export default EndlessContent;
|
||||
45
client/src/components/boards/Board/FiniteContent.jsx
Normal file
45
client/src/components/boards/Board/FiniteContent.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { BoardViews } from '../../../constants/Enums';
|
||||
import GridView from './GridView';
|
||||
import ListView from './ListView';
|
||||
|
||||
const FiniteContent = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const cardIds = useSelector(selectors.selectFilteredCardIdsForCurrentBoard);
|
||||
const hasAnyFiniteList = useSelector((state) => !!selectors.selectFirstFiniteListId(state));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCardCreate = useCallback(
|
||||
(data, autoOpen) => {
|
||||
dispatch(entryActions.createCardInFirstFiniteList(data, autoOpen));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
let View;
|
||||
switch (board.view) {
|
||||
case BoardViews.GRID:
|
||||
View = GridView;
|
||||
|
||||
break;
|
||||
case BoardViews.LIST:
|
||||
View = ListView;
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return <View cardIds={cardIds} onCardCreate={hasAnyFiniteList ? handleCardCreate : undefined} />;
|
||||
});
|
||||
|
||||
export default FiniteContent;
|
||||
109
client/src/components/boards/Board/GridView.jsx
Executable file
109
client/src/components/boards/Board/GridView.jsx
Executable file
@@ -0,0 +1,109 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Loader } from 'semantic-ui-react';
|
||||
import { useWindowWidth } from '../../../lib/hooks';
|
||||
import { Masonry } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
||||
import Card from '../../cards/Card';
|
||||
import AddCard from '../../cards/AddCard';
|
||||
import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
||||
|
||||
import styles from './GridView.module.scss';
|
||||
|
||||
const GridView = React.memo(
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
|
||||
const canAddCard = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
|
||||
const windowWidth = useWindowWidth();
|
||||
|
||||
const [inViewRef] = useInView({
|
||||
threshold: 1,
|
||||
onChange: (inView) => {
|
||||
if (inView && onCardsFetch) {
|
||||
onCardsFetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddCardClick = useCallback(() => {
|
||||
setIsAddCardOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleAddCardClose = useCallback(() => {
|
||||
setIsAddCardOpened(false);
|
||||
}, []);
|
||||
|
||||
const columns = Math.floor(windowWidth / 300); // TODO: move to constant?
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Masonry columns={columns} spacing={20}>
|
||||
{canAddCard &&
|
||||
(onCardCreate && isAddCardOpened ? (
|
||||
<div className={styles.card}>
|
||||
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{cardIds.map((cardId) => (
|
||||
<div key={cardId} className={styles.card}>
|
||||
<Card id={cardId} />
|
||||
</div>
|
||||
))}
|
||||
</Masonry>
|
||||
{isCardsFetching !== undefined && isAllCardsFetched !== undefined && (
|
||||
<div className={styles.loaderWrapper}>
|
||||
{isCardsFetching ? (
|
||||
<Loader active inverted inline="centered" size="small" />
|
||||
) : (
|
||||
!isAllCardsFetched && <div ref={inViewRef} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GridView.propTypes = {
|
||||
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isCardsFetching: PropTypes.bool,
|
||||
isAllCardsFetched: PropTypes.bool,
|
||||
onCardsFetch: PropTypes.func,
|
||||
onCardCreate: PropTypes.func,
|
||||
};
|
||||
|
||||
GridView.defaultProps = {
|
||||
isCardsFetching: undefined,
|
||||
isAllCardsFetched: undefined,
|
||||
onCardsFetch: undefined,
|
||||
onCardCreate: undefined,
|
||||
};
|
||||
|
||||
export default GridView;
|
||||
65
client/src/components/boards/Board/GridView.module.scss
Normal file
65
client/src/components/boards/Board/GridView.module.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.addCardButton {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: rgba(255, 255, 255, 0.72);
|
||||
font-weight: normal;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in,
|
||||
border-color 85ms ease-in;
|
||||
width: 100%;
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
height: 20px;
|
||||
padding: 0.64px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.addCardButtonText {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(223, 227, 230, 0.8);
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.loaderWrapper {
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-y: scroll;
|
||||
padding-left: 20px;
|
||||
width: 100%;
|
||||
|
||||
@supports (-moz-appearance: none) {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.32) rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
151
client/src/components/boards/Board/KanbanContent/AddList.jsx
Executable file
151
client/src/components/boards/Board/KanbanContent/AddList.jsx
Executable file
@@ -0,0 +1,151 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Icon, Input } from 'semantic-ui-react';
|
||||
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../../lib/hooks';
|
||||
import { usePopup } from '../../../../lib/popup';
|
||||
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useClosable, useForm, useNestedRef } from '../../../../hooks';
|
||||
import { ListTypes } from '../../../../constants/Enums';
|
||||
import { ListTypeIcons } from '../../../../constants/Icons';
|
||||
import SelectListTypeStep from '../../../lists/SelectListTypeStep';
|
||||
|
||||
import styles from './AddList.module.scss';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
name: '',
|
||||
type: ListTypes.ACTIVE,
|
||||
};
|
||||
|
||||
const AddList = React.memo(({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
const [focusNameFieldState, focusNameField] = useToggle();
|
||||
const [isClosableActiveRef, activateClosable, deactivateClosable] = useClosable();
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
|
||||
const [selectTypeButtonRef, handleSelectTypeButtonRef] = useNestedRef();
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.createListInCurrentBoard(cleanData));
|
||||
setData(DEFAULT_DATA);
|
||||
focusNameField();
|
||||
}, [dispatch, data, setData, focusNameField, nameFieldRef]);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
type,
|
||||
}));
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleSelectTypeClose = useCallback(() => {
|
||||
deactivateClosable();
|
||||
nameFieldRef.current.focus();
|
||||
}, [deactivateClosable, nameFieldRef]);
|
||||
|
||||
const handleAwayClick = useCallback(() => {
|
||||
if (isClosableActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [onClose, isClosableActiveRef]);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
[nameFieldRef, submitButtonRef, selectTypeButtonRef],
|
||||
handleAwayClick,
|
||||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [focusNameFieldState]);
|
||||
|
||||
const SelectListTypePopup = usePopup(SelectListTypeStep, {
|
||||
onOpen: activateClosable,
|
||||
onClose: handleSelectTypeClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form className={styles.wrapper} onSubmit={handleSubmit}>
|
||||
<Input
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
placeholder={t('common.enterListTitle')}
|
||||
maxLength={128}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
positive
|
||||
ref={handleSubmitButtonRef}
|
||||
content={t('action.addList')}
|
||||
className={styles.button}
|
||||
/>
|
||||
<SelectListTypePopup defaultValue={data.type} onSelect={handleTypeSelect}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleSelectTypeButtonRef}
|
||||
type="button"
|
||||
className={classNames(styles.button, styles.selectTypeButton)}
|
||||
>
|
||||
<Icon name={ListTypeIcons[data.type]} className={styles.selectTypeButtonIcon} />
|
||||
{t(`common.${data.type}`)}
|
||||
</Button>
|
||||
</SelectListTypePopup>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
AddList.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddList;
|
||||
@@ -0,0 +1,62 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
min-height: 30px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 0 #ccc;
|
||||
color: #333;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
border-color: #298fca;
|
||||
box-shadow: 0 0 2px #298fca;
|
||||
}
|
||||
}
|
||||
|
||||
.selectTypeButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-overflow: ellipsis;
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
|
||||
.selectTypeButtonIcon {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background: #e2e4e6;
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
transition: opacity 40ms ease-in;
|
||||
width: 272px;
|
||||
}
|
||||
}
|
||||
195
client/src/components/boards/Board/KanbanContent/KanbanContent.jsx
Executable file
195
client/src/components/boards/Board/KanbanContent/KanbanContent.jsx
Executable file
@@ -0,0 +1,195 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { useDidUpdate } from '../../../../lib/hooks';
|
||||
import { closePopup } from '../../../../lib/popup';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import parseDndId from '../../../../utils/parse-dnd-id';
|
||||
import DroppableTypes from '../../../../constants/DroppableTypes';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import AddList from './AddList';
|
||||
import List from '../../../lists/List';
|
||||
import PlusMathIcon from '../../../../assets/images/plus-math-icon.svg?react';
|
||||
|
||||
import styles from './KanbanContent.module.scss';
|
||||
import globalStyles from '../../../../styles.module.scss';
|
||||
|
||||
const KanbanContent = React.memo(() => {
|
||||
const listIds = useSelector(selectors.selectFiniteListIdsForCurrentBoard);
|
||||
|
||||
const canAddList = useSelector((state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
|
||||
if (!isEditModeEnabled) {
|
||||
return isEditModeEnabled;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [isAddListOpened, setIsAddListOpened] = useState(false);
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const prevPositionRef = useRef(null);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
document.body.classList.add(globalStyles.dragging);
|
||||
closePopup();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, type, source, destination }) => {
|
||||
document.body.classList.remove(globalStyles.dragging);
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseDndId(draggableId);
|
||||
|
||||
switch (type) {
|
||||
case DroppableTypes.LIST:
|
||||
dispatch(entryActions.moveList(id, destination.index));
|
||||
|
||||
break;
|
||||
case DroppableTypes.CARD:
|
||||
dispatch(
|
||||
entryActions.moveCard(id, parseDndId(destination.droppableId), destination.index),
|
||||
);
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleAddListClick = useCallback(() => {
|
||||
setIsAddListOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleAddListClose = useCallback(() => {
|
||||
setIsAddListOpened(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((event) => {
|
||||
// If button is defined and not equal to 0 (left click)
|
||||
if (event.button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target !== wrapperRef.current && !event.target.dataset.dragScroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevPositionRef.current = event.clientX;
|
||||
|
||||
window.getSelection().removeAllRanges();
|
||||
document.body.classList.add(globalStyles.dragScrolling);
|
||||
}, []);
|
||||
|
||||
const handleWindowMouseMove = useCallback((event) => {
|
||||
if (prevPositionRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
window.scrollBy({
|
||||
left: prevPositionRef.current - event.clientX,
|
||||
});
|
||||
|
||||
prevPositionRef.current = event.clientX;
|
||||
}, []);
|
||||
|
||||
const handleWindowMouseRelease = useCallback(() => {
|
||||
if (prevPositionRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevPositionRef.current = null;
|
||||
document.body.classList.remove(globalStyles.dragScrolling);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', handleWindowMouseMove);
|
||||
|
||||
window.addEventListener('mouseup', handleWindowMouseRelease);
|
||||
window.addEventListener('blur', handleWindowMouseRelease);
|
||||
window.addEventListener('contextmenu', handleWindowMouseRelease);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleWindowMouseMove);
|
||||
|
||||
window.removeEventListener('mouseup', handleWindowMouseRelease);
|
||||
window.removeEventListener('blur', handleWindowMouseRelease);
|
||||
window.removeEventListener('contextmenu', handleWindowMouseRelease);
|
||||
};
|
||||
}, [handleWindowMouseMove, handleWindowMouseRelease]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (isAddListOpened) {
|
||||
window.scroll(document.body.scrollWidth, 0);
|
||||
}
|
||||
}, [listIds, isAddListOpened]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div ref={wrapperRef} className={styles.wrapper} onMouseDown={handleMouseDown}>
|
||||
<div>
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="board" type={DroppableTypes.LIST} direction="horizontal">
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
<div
|
||||
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
data-drag-scroller
|
||||
ref={innerRef}
|
||||
className={styles.lists}
|
||||
>
|
||||
{listIds.map((listId, index) => (
|
||||
<List key={listId} id={listId} index={index} />
|
||||
))}
|
||||
{placeholder}
|
||||
{canAddList && (
|
||||
<div data-drag-scroller className={styles.list}>
|
||||
{isAddListOpened ? (
|
||||
<AddList onClose={handleAddListClose} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addListButton}
|
||||
onClick={handleAddListClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addListButtonIcon} />
|
||||
<span className={styles.addListButtonText}>
|
||||
{listIds.length > 0 ? t('action.addAnotherList') : t('action.addList')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default KanbanContent;
|
||||
@@ -0,0 +1,59 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.addListButton {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: rgba(255, 255, 255, 0.72);
|
||||
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%;
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.addListButtonIcon {
|
||||
height: 20px;
|
||||
padding: 0.64px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.addListButtonText {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-right: 8px;
|
||||
width: 272px;
|
||||
}
|
||||
|
||||
.lists {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 0 12px 0 20px;
|
||||
}
|
||||
}
|
||||
8
client/src/components/boards/Board/KanbanContent/index.js
Executable file
8
client/src/components/boards/Board/KanbanContent/index.js
Executable file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import KanbanContent from './KanbanContent';
|
||||
|
||||
export default KanbanContent;
|
||||
107
client/src/components/boards/Board/ListView.jsx
Executable file
107
client/src/components/boards/Board/ListView.jsx
Executable file
@@ -0,0 +1,107 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Loader } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
||||
import Card from '../../cards/Card';
|
||||
import AddCard from '../../cards/AddCard';
|
||||
import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
const ListView = React.memo(
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
|
||||
const canAddCard = useSelector((state) => {
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
});
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
|
||||
|
||||
const [inViewRef] = useInView({
|
||||
threshold: 1,
|
||||
onChange: (inView) => {
|
||||
if (inView && onCardsFetch) {
|
||||
onCardsFetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddCardClick = useCallback(() => {
|
||||
setIsAddCardOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleAddCardClose = useCallback(() => {
|
||||
setIsAddCardOpened(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{canAddCard &&
|
||||
(onCardCreate && isAddCardOpened ? (
|
||||
<div className={styles.segment}>
|
||||
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!onCardCreate}
|
||||
className={styles.addCardButton}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{cardIds.length > 0 && (
|
||||
<div className={classNames(styles.segment, styles.cards)}>
|
||||
{cardIds.map((cardId, cardIndex) => (
|
||||
<div key={cardId} className={styles.card}>
|
||||
<Card isInline id={cardId} index={cardIndex} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isCardsFetching !== undefined && isAllCardsFetched !== undefined && (
|
||||
<div className={styles.loaderWrapper}>
|
||||
{isCardsFetching ? (
|
||||
<Loader active inverted inline="centered" size="small" />
|
||||
) : (
|
||||
!isAllCardsFetched && <div ref={inViewRef} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ListView.propTypes = {
|
||||
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isCardsFetching: PropTypes.bool,
|
||||
isAllCardsFetched: PropTypes.bool,
|
||||
onCardsFetch: PropTypes.func,
|
||||
onCardCreate: PropTypes.func,
|
||||
};
|
||||
|
||||
ListView.defaultProps = {
|
||||
isCardsFetching: undefined,
|
||||
isAllCardsFetched: undefined,
|
||||
onCardsFetch: undefined,
|
||||
onCardCreate: undefined,
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
76
client/src/components/boards/Board/ListView.module.scss
Normal file
76
client/src/components/boards/Board/ListView.module.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.addCardButton {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
fill: rgba(255, 255, 255, 0.72);
|
||||
font-weight: normal;
|
||||
height: 42px;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px;
|
||||
text-align: left;
|
||||
transition: background 85ms ease-in, opacity 40ms ease-in,
|
||||
border-color 85ms ease-in;
|
||||
width: 100%;
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
height: 20px;
|
||||
padding: 0.64px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.addCardButtonText {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0 !important; // TODO: hack?
|
||||
}
|
||||
|
||||
.loaderWrapper {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.segment {
|
||||
background: rgba(223, 227, 230, 0.8);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
|
||||
@supports (-moz-appearance: none) {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.32) rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
client/src/components/boards/Board/index.js
Normal file
8
client/src/components/boards/Board/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Board from './Board';
|
||||
|
||||
export default Board;
|
||||
47
client/src/components/boards/BoardActions/BoardActions.jsx
Normal file
47
client/src/components/boards/BoardActions/BoardActions.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import Filters from './Filters';
|
||||
import RightSide from './RightSide';
|
||||
import BoardMemberships from '../../board-memberships/BoardMemberships';
|
||||
|
||||
import styles from './BoardActions.module.scss';
|
||||
|
||||
const BoardActions = React.memo(() => {
|
||||
const withMemberships = useSelector((state) => {
|
||||
const boardMemberships = selectors.selectMembershipsForCurrentBoard(state);
|
||||
|
||||
if (boardMemberships.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.actions}>
|
||||
{withMemberships && (
|
||||
<div className={styles.action}>
|
||||
<BoardMemberships />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.action}>
|
||||
<Filters />
|
||||
</div>
|
||||
<div className={classNames(styles.action, styles.actionRightSide)}>
|
||||
<RightSide />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BoardActions;
|
||||
@@ -0,0 +1,45 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.action {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionRightSide {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-ms-overflow-style: none;
|
||||
padding: 15px 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
client/src/components/boards/BoardActions/Filters.jsx
Normal file
219
client/src/components/boards/BoardActions/Filters.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate } from '../../../lib/hooks';
|
||||
import { usePopup } from '../../../lib/popup';
|
||||
import { Input } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useNestedRef } from '../../../hooks';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
|
||||
import LabelChip from '../../labels/LabelChip';
|
||||
import LabelsStep from '../../labels/LabelsStep';
|
||||
|
||||
import styles from './Filters.module.scss';
|
||||
|
||||
const Filters = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
|
||||
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
|
||||
const currentUserId = useSelector(selectors.selectCurrentUserId);
|
||||
|
||||
const withCurrentUserSelector = useSelector(
|
||||
(state) => !!selectors.selectCurrentUserMembershipForCurrentBoard(state),
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [search, setSearch] = useState(board.search);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
debounce((nextSearch) => {
|
||||
dispatch(entryActions.searchInCurrentBoard(nextSearch));
|
||||
}, 400),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const cancelSearch = useCallback(() => {
|
||||
debouncedSearch.cancel();
|
||||
setSearch('');
|
||||
dispatch(entryActions.searchInCurrentBoard(''));
|
||||
searchFieldRef.current.blur();
|
||||
}, [dispatch, debouncedSearch, searchFieldRef]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleCurrentUserSelect = useCallback(() => {
|
||||
dispatch(entryActions.addUserToFilterInCurrentBoard(currentUserId));
|
||||
}, [currentUserId, dispatch]);
|
||||
|
||||
const handleUserDeselect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleUserClick = useCallback(
|
||||
({
|
||||
currentTarget: {
|
||||
dataset: { id: userId },
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelSelect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.addLabelToFilterInCurrentBoard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelDeselect = useCallback(
|
||||
(labelId) => {
|
||||
dispatch(entryActions.removeLabelFromFilterInCurrentBoard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
({
|
||||
currentTarget: {
|
||||
dataset: { id: labelId },
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.removeLabelFromFilterInCurrentBoard(labelId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(_, { value }) => {
|
||||
setSearch(value);
|
||||
debouncedSearch(value);
|
||||
},
|
||||
[debouncedSearch],
|
||||
);
|
||||
|
||||
const handleSearchFocus = useCallback(() => {
|
||||
setIsSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
cancelSearch();
|
||||
}
|
||||
},
|
||||
[cancelSearch],
|
||||
);
|
||||
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
setIsSearchFocused(false);
|
||||
}, []);
|
||||
|
||||
const handleCancelSearchClick = useCallback(() => {
|
||||
cancelSearch();
|
||||
}, [cancelSearch]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setSearch(board.search);
|
||||
}, [board.search]);
|
||||
|
||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopup(LabelsStep);
|
||||
|
||||
const isSearchActive = search || isSearchFocused;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.filter}>
|
||||
<BoardMembershipsPopup
|
||||
currentUserIds={userIds}
|
||||
title="common.filterByMembers"
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
<button type="button" className={styles.filterButton}>
|
||||
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
|
||||
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
|
||||
</button>
|
||||
</BoardMembershipsPopup>
|
||||
{userIds.length === 0 && withCurrentUserSelector && (
|
||||
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
|
||||
<span className={styles.filterLabel}>
|
||||
<Icon fitted name="target" className={styles.filterLabelIcon} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{userIds.map((userId) => (
|
||||
<span key={userId} className={styles.filterItem}>
|
||||
<UserAvatar id={userId} size="tiny" onClick={handleUserClick} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.filter}>
|
||||
<LabelsPopup
|
||||
currentIds={labelIds}
|
||||
title="common.filterByLabels"
|
||||
onSelect={handleLabelSelect}
|
||||
onDeselect={handleLabelDeselect}
|
||||
>
|
||||
<button type="button" className={styles.filterButton}>
|
||||
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
|
||||
{labelIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
|
||||
</button>
|
||||
</LabelsPopup>
|
||||
{labelIds.map((labelId) => (
|
||||
<span key={labelId} className={styles.filterItem}>
|
||||
<LabelChip id={labelId} size="small" onClick={handleLabelClick} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.filter}>
|
||||
<Input
|
||||
ref={handleSearchFieldRef}
|
||||
value={search}
|
||||
placeholder={t('common.searchCards')}
|
||||
maxLength={128}
|
||||
icon={
|
||||
isSearchActive ? (
|
||||
<Icon link name="cancel" onClick={handleCancelSearchClick} />
|
||||
) : (
|
||||
'search'
|
||||
)
|
||||
}
|
||||
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
|
||||
onFocus={handleSearchFocus}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
onChange={handleSearchChange}
|
||||
onBlur={handleSearchBlur}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Filters;
|
||||
@@ -0,0 +1,91 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.filter {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.filterItem {
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
margin-right: 4px;
|
||||
max-width: 190px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 2px 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.filterLabelIcon {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filterTitle {
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
height: 30px;
|
||||
margin: 0 12px;
|
||||
transition: width 0.2s ease;
|
||||
width: 280px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInactive {
|
||||
color: #fff;
|
||||
height: 24px;
|
||||
width: 220px;
|
||||
|
||||
input {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
color: #fff !important;
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
client/src/components/boards/BoardActions/RightSide/ActionsStep.jsx
Executable file
167
client/src/components/boards/BoardActions/RightSide/ActionsStep.jsx
Executable file
@@ -0,0 +1,167 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useSteps } from '../../../../hooks';
|
||||
import { BoardContexts, BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import { BoardContextIcons } from '../../../../constants/Icons';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
import CustomFieldGroupsStep from '../../../custom-field-groups/CustomFieldGroupsStep';
|
||||
|
||||
import styles from './ActionsStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
EMPTY_TRASH: 'EMPTY_TRASH',
|
||||
CUSTOM_FIELD_GROUPS: 'CUSTOM_FIELD_GROUPS',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ onClose }) => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
|
||||
const { withSubscribe, withTrashEmptier, withCustomFieldGroups } = useSelector((state) => {
|
||||
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
|
||||
let isMember = false;
|
||||
let isEditor = false;
|
||||
|
||||
if (boardMembership) {
|
||||
isMember = true;
|
||||
isEditor = boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
}
|
||||
|
||||
return {
|
||||
withSubscribe: isMember, // TODO: rename?
|
||||
withTrashEmptier: board.context === BoardContexts.TRASH && (isManager || isEditor),
|
||||
withCustomFieldGroups: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleToggleSubscriptionClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentBoard({
|
||||
isSubscribed: !board.isSubscribed,
|
||||
}),
|
||||
);
|
||||
|
||||
onClose();
|
||||
}, [onClose, board.isSubscribed, dispatch]);
|
||||
|
||||
const handleSelectContextClick = useCallback(
|
||||
(_, { value: context }) => {
|
||||
dispatch(entryActions.updateContextInCurrentBoard(context));
|
||||
onClose();
|
||||
},
|
||||
[onClose, dispatch],
|
||||
);
|
||||
|
||||
const handleEmptyTrashConfirm = useCallback(() => {
|
||||
dispatch(entryActions.clearTrashListInCurrentBoard());
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
const handleEmptyTrashClick = useCallback(() => {
|
||||
openStep(StepTypes.EMPTY_TRASH);
|
||||
}, [openStep]);
|
||||
|
||||
const handleCustomFieldsClick = useCallback(() => {
|
||||
openStep(StepTypes.CUSTOM_FIELD_GROUPS);
|
||||
}, [openStep]);
|
||||
|
||||
if (step) {
|
||||
switch (step.type) {
|
||||
case StepTypes.EMPTY_TRASH:
|
||||
return (
|
||||
<ConfirmationStep
|
||||
title="common.emptyTrash"
|
||||
content="common.areYouSureYouWantToEmptyTrash"
|
||||
buttonContent="action.emptyTrash"
|
||||
onConfirm={handleEmptyTrashConfirm}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
case StepTypes.CUSTOM_FIELD_GROUPS:
|
||||
return <CustomFieldGroupsStep onBack={handleBack} onClose={onClose} />;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.boardActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
{withSubscribe && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleToggleSubscriptionClick}>
|
||||
<Icon
|
||||
name={board.isSubscribed ? 'bell slash outline' : 'bell outline'}
|
||||
className={styles.menuItemIcon}
|
||||
/>
|
||||
{t(board.isSubscribed ? 'action.unsubscribe' : 'action.subscribe', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{withCustomFieldGroups && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleCustomFieldsClick}>
|
||||
<Icon name="sticky note outline" className={styles.menuItemIcon} />
|
||||
{t('common.customFields', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{withTrashEmptier && (
|
||||
<>
|
||||
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />}
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.emptyTrash', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
{(withSubscribe || withTrashEmptier) && <hr className={styles.divider} />}
|
||||
{[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => (
|
||||
<Menu.Item
|
||||
key={context}
|
||||
value={context}
|
||||
active={context === board.context}
|
||||
className={styles.menuItem}
|
||||
onClick={handleSelectContextClick}
|
||||
>
|
||||
<Icon name={BoardContextIcons[context]} className={styles.menuItemIcon} />
|
||||
{t(`common.${context}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ActionsStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ActionsStep;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.divider {
|
||||
background: #eee;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { usePopup } from '../../../../lib/popup';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { BoardContexts, BoardViews } from '../../../../constants/Enums';
|
||||
import { BoardContextIcons, BoardViewIcons } from '../../../../constants/Icons';
|
||||
import ActionsStep from './ActionsStep';
|
||||
|
||||
import styles from './RightSide.module.scss';
|
||||
|
||||
const RightSide = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSelectViewClick = useCallback(
|
||||
({ currentTarget: { value: view } }) => {
|
||||
dispatch(entryActions.updateViewInCurrentBoard(view));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const ActionsPopup = usePopup(ActionsStep);
|
||||
|
||||
const views = [BoardViews.GRID, BoardViews.LIST];
|
||||
if (board.context === BoardContexts.BOARD) {
|
||||
views.unshift(BoardViews.KANBAN);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.action}>
|
||||
<div className={styles.buttonGroup}>
|
||||
{views.map((view) => (
|
||||
<button
|
||||
key={view}
|
||||
type="button"
|
||||
value={view}
|
||||
disabled={view === board.view}
|
||||
className={styles.button}
|
||||
onClick={handleSelectViewClick}
|
||||
>
|
||||
<Icon fitted name={BoardViewIcons[view]} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<ActionsPopup>
|
||||
<button type="button" className={styles.button}>
|
||||
<Icon fitted name={BoardContextIcons[board.context]} />
|
||||
</button>
|
||||
</ActionsPopup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default RightSide;
|
||||
@@ -0,0 +1,53 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.action:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
padding: 5px 12px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
.button {
|
||||
border-radius: 0;
|
||||
|
||||
&:enabled {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import RightSide from './RightSide';
|
||||
|
||||
export default RightSide;
|
||||
8
client/src/components/boards/BoardActions/index.js
Normal file
8
client/src/components/boards/BoardActions/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import BoardActions from './BoardActions';
|
||||
|
||||
export default BoardActions;
|
||||
@@ -0,0 +1,69 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tab } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useClosableModal } from '../../../hooks';
|
||||
import GeneralPane from './GeneralPane';
|
||||
import PreferencesPane from './PreferencesPane';
|
||||
import NotificationsPane from './NotificationsPane';
|
||||
|
||||
const BoardSettingsModal = React.memo(() => {
|
||||
const openPreferences = useSelector(
|
||||
(state) => selectors.selectCurrentModal(state).params.openPreferences,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(entryActions.closeModal());
|
||||
}, [dispatch]);
|
||||
|
||||
const [ClosableModal] = useClosableModal();
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: t('common.general', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <GeneralPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.preferences', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <PreferencesPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.notifications', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <NotificationsPane />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ClosableModal closeIcon size="small" centered={false} onClose={handleClose}>
|
||||
<ClosableModal.Content>
|
||||
<Tab
|
||||
menu={{
|
||||
secondary: true,
|
||||
pointing: true,
|
||||
}}
|
||||
panes={panes}
|
||||
defaultActiveIndex={openPreferences ? 1 : undefined}
|
||||
/>
|
||||
</ClosableModal.Content>
|
||||
</ClosableModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default BoardSettingsModal;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import { dequal } from 'dequal';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../../hooks';
|
||||
|
||||
import styles from './EditInformation.module.scss';
|
||||
|
||||
const EditInformation = React.memo(() => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
const board = useSelector((state) => selectBoardById(state, boardId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const defaultData = useMemo(
|
||||
() => ({
|
||||
name: board.name,
|
||||
}),
|
||||
[board.name],
|
||||
);
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
name: '',
|
||||
...defaultData,
|
||||
}));
|
||||
|
||||
const cleanData = useMemo(
|
||||
() => ({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.updateBoard(boardId, cleanData));
|
||||
}, [boardId, dispatch, cleanData, nameFieldRef]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={128}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<Button positive disabled={dequal(cleanData, defaultData)} content={t('action.save')} />
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditInformation;
|
||||
@@ -0,0 +1,17 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Header, Tab } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../../hooks';
|
||||
import EditInformation from './EditInformation';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
|
||||
import styles from './GeneralPane.module.scss';
|
||||
|
||||
const GeneralPane = React.memo(() => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
const board = useSelector((state) => selectBoardById(state, boardId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
dispatch(entryActions.deleteBoard(boardId));
|
||||
}, [boardId, dispatch]);
|
||||
|
||||
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<EditInformation />
|
||||
<Divider horizontal section>
|
||||
<Header as="h4">
|
||||
{t('common.dangerZone', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<div className={styles.action}>
|
||||
<ConfirmationPopup
|
||||
title="common.deleteBoard"
|
||||
content="common.areYouSureYouWantToDeleteThisBoard"
|
||||
buttonContent="action.deleteBoard"
|
||||
typeValue={board.name}
|
||||
typeContent="common.typeTitleToConfirm"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
>
|
||||
<Button className={styles.actionButton}>
|
||||
{t(`action.deleteBoard`, {
|
||||
context: 'title',
|
||||
})}
|
||||
</Button>
|
||||
</ConfirmationPopup>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default GeneralPane;
|
||||
@@ -0,0 +1,38 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.action {
|
||||
border: none;
|
||||
border-radius: 0.28571429rem;
|
||||
display: inline-block;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background 0.3s ease;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
background: transparent;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
height: 36px;
|
||||
line-height: 24px;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import GeneralPane from './GeneralPane';
|
||||
|
||||
export default GeneralPane;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Tab } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import NotificationServices from '../../notification-services/NotificationServices';
|
||||
|
||||
import styles from './NotificationsPane.module.scss';
|
||||
|
||||
const NotificationsPane = React.memo(() => {
|
||||
const selectNotificationServiceIdsByBoardId = useMemo(
|
||||
() => selectors.makeSelectNotificationServiceIdsByBoardId(),
|
||||
[],
|
||||
);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
|
||||
const notificationServiceIds = useSelector((state) =>
|
||||
selectNotificationServiceIdsByBoardId(state, boardId),
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCreate = useCallback(
|
||||
(data) => {
|
||||
dispatch(entryActions.createNotificationServiceInBoard(boardId, data));
|
||||
},
|
||||
[boardId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<NotificationServices ids={notificationServiceIds} onCreate={handleCreate} />
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default NotificationsPane;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Radio, Segment } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import SelectCardType from '../../../cards/SelectCardType';
|
||||
|
||||
import styles from './DefaultCardType.module.scss';
|
||||
|
||||
const DefaultCardType = React.memo(() => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
const board = useSelector((state) => selectBoardById(state, boardId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(defaultCardType) => {
|
||||
dispatch(
|
||||
entryActions.updateBoard(boardId, {
|
||||
defaultCardType,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[boardId, dispatch],
|
||||
);
|
||||
|
||||
const handleToggleChange = useCallback(
|
||||
(_, { name: fieldName, checked }) => {
|
||||
dispatch(
|
||||
entryActions.updateBoard(boardId, {
|
||||
[fieldName]: checked,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[boardId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectCardType value={board.defaultCardType} onSelect={handleSelect} />
|
||||
<Segment basic className={styles.settings}>
|
||||
<Radio
|
||||
toggle
|
||||
name="limitCardTypesToDefaultOne"
|
||||
checked={board.limitCardTypesToDefaultOne}
|
||||
label={t('common.limitCardTypesToDefaultOne')}
|
||||
className={styles.radio}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default DefaultCardType;
|
||||
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.radio {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { BoardViews } from '../../../../constants/Enums';
|
||||
import { BoardViewIcons } from '../../../../constants/Icons';
|
||||
|
||||
import styles from './DefaultView.module.scss';
|
||||
|
||||
const DESCRIPTION_BY_VIEW = {
|
||||
[BoardViews.KANBAN]: 'common.visualTaskManagementWithLists',
|
||||
[BoardViews.GRID]: 'common.dynamicAndUnevenlySpacedLayout',
|
||||
[BoardViews.LIST]: 'common.sequentialDisplayOfCards',
|
||||
};
|
||||
|
||||
const DefaultView = React.memo(() => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
const board = useSelector((state) => selectBoardById(state, boardId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleSelectClick = useCallback(
|
||||
(_, { value: defaultView }) => {
|
||||
dispatch(
|
||||
entryActions.updateBoard(boardId, {
|
||||
defaultView,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[boardId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
{[BoardViews.KANBAN, BoardViews.GRID, BoardViews.LIST].map((view) => (
|
||||
<Menu.Item
|
||||
key={view}
|
||||
value={view}
|
||||
active={view === board.defaultView}
|
||||
className={styles.menuItem}
|
||||
onClick={handleSelectClick}
|
||||
>
|
||||
<Icon name={BoardViewIcons[view]} className={styles.menuItemIcon} />
|
||||
<div className={styles.menuItemTitle}>{t(`common.${view}`)}</div>
|
||||
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_VIEW[view])}</p>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export default DefaultView;
|
||||
@@ -0,0 +1,28 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: 0 auto 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menuItem:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menuItemDescription {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.35714286em 0 0;
|
||||
}
|
||||
|
||||
.menuItemTitle {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Radio, Segment } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
|
||||
import styles from './Others.module.scss';
|
||||
|
||||
const Others = React.memo(() => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const boardId = useSelector((state) => selectors.selectCurrentModal(state).params.id);
|
||||
const board = useSelector((state) => selectBoardById(state, boardId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(_, { name: fieldName, checked }) => {
|
||||
dispatch(
|
||||
entryActions.updateBoard(boardId, {
|
||||
[fieldName]: checked,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[boardId, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Segment basic>
|
||||
<Radio
|
||||
toggle
|
||||
name="alwaysDisplayCardCreator"
|
||||
checked={board.alwaysDisplayCardCreator}
|
||||
label={t('common.alwaysDisplayCardCreator')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Segment>
|
||||
);
|
||||
});
|
||||
|
||||
export default Others;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.radio {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Divider, Header, Tab } from 'semantic-ui-react';
|
||||
|
||||
import DefaultView from './DefaultView';
|
||||
import DefaultCardType from './DefaultCardType';
|
||||
import Others from './Others';
|
||||
|
||||
import styles from './PreferencesPane.module.scss';
|
||||
|
||||
const PreferencesPane = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<Tab.Pane attached={false} className={styles.wrapper}>
|
||||
<Divider horizontal className={styles.firstDivider}>
|
||||
<Header as="h4">
|
||||
{t('common.defaultView', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<DefaultView />
|
||||
<Divider horizontal>
|
||||
<Header as="h4">
|
||||
{t('common.defaultCardType', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<DefaultCardType />
|
||||
<Divider horizontal>
|
||||
<Header as="h4">
|
||||
{t('common.others', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Header>
|
||||
</Divider>
|
||||
<Others />
|
||||
</Tab.Pane>
|
||||
);
|
||||
});
|
||||
|
||||
export default PreferencesPane;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.firstDivider {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import PreferencesPane from './PreferencesPane';
|
||||
|
||||
export default PreferencesPane;
|
||||
8
client/src/components/boards/BoardSettingsModal/index.js
Normal file
8
client/src/components/boards/BoardSettingsModal/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import BoardSettingsModal from './BoardSettingsModal';
|
||||
|
||||
export default BoardSettingsModal;
|
||||
130
client/src/components/boards/Boards/AddStep/AddStep.jsx
Executable file
130
client/src/components/boards/Boards/AddStep/AddStep.jsx
Executable file
@@ -0,0 +1,130 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate, useToggle } from '../../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useForm, useNestedRef, useSteps } from '../../../../hooks';
|
||||
import ImportStep from './ImportStep';
|
||||
|
||||
import styles from './AddStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
IMPORT: 'IMPORT',
|
||||
};
|
||||
|
||||
const AddStep = React.memo(({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [data, handleFieldChange, setData] = useForm({
|
||||
name: '',
|
||||
import: null,
|
||||
});
|
||||
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
const [focusNameFieldState, focusNameField] = useToggle();
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanData = {
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.createBoardInCurrentProject(cleanData));
|
||||
onClose();
|
||||
}, [onClose, dispatch, data, nameFieldRef]);
|
||||
|
||||
const handleImportSelect = useCallback(
|
||||
(nextImport) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
import: nextImport,
|
||||
}));
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const handleImportBack = useCallback(() => {
|
||||
handleBack();
|
||||
focusNameField();
|
||||
}, [handleBack, focusNameField]);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
openStep(StepTypes.IMPORT);
|
||||
}, [openStep]);
|
||||
|
||||
useEffect(() => {
|
||||
nameFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}, [nameFieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [focusNameFieldState]);
|
||||
|
||||
if (step && step.type === StepTypes.IMPORT) {
|
||||
return <ImportStep onSelect={handleImportSelect} onBack={handleImportBack} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.createBoard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.title')}</div>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={data.name}
|
||||
maxLength={128}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
<Button positive content={t('action.createBoard')} className={styles.button} />
|
||||
<Button
|
||||
type="button"
|
||||
className={classNames(styles.button, styles.importButton)}
|
||||
onClick={handleImportClick}
|
||||
>
|
||||
<Icon
|
||||
name={data.import ? data.import.type : 'arrow down'}
|
||||
className={styles.importButtonIcon}
|
||||
/>
|
||||
{data.import ? data.import.file.name : t('action.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AddStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddStep;
|
||||
@@ -0,0 +1,53 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
max-width: 280px;
|
||||
|
||||
@media only screen and (width < 768px) {
|
||||
max-width: 226px;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.importButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-overflow: ellipsis;
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
|
||||
.importButtonIcon {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
50
client/src/components/boards/Boards/AddStep/ImportStep.jsx
Normal file
50
client/src/components/boards/Boards/AddStep/ImportStep.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { FilePicker, Popup } from '../../../../lib/custom-ui';
|
||||
|
||||
import styles from './ImportStep.module.scss';
|
||||
|
||||
const ImportStep = React.memo(({ onSelect, onBack }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(type, file) => {
|
||||
onSelect({
|
||||
type,
|
||||
file,
|
||||
});
|
||||
|
||||
onBack();
|
||||
},
|
||||
[onSelect, onBack],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.importBoard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<FilePicker accept=".json" onSelect={(file) => handleFileSelect('trello', file)}>
|
||||
<Button fluid content={t('common.fromTrello')} icon="trello" className={styles.button} />
|
||||
</FilePicker>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ImportStep.propTypes = {
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ImportStep;
|
||||
@@ -0,0 +1,22 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #6b808c;
|
||||
font-weight: normal;
|
||||
margin-top: 8px;
|
||||
padding: 6px 11px;
|
||||
text-align: left;
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
color: #092d42;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
client/src/components/boards/Boards/AddStep/index.js
Normal file
8
client/src/components/boards/Boards/AddStep/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import AddStep from './AddStep';
|
||||
|
||||
export default AddStep;
|
||||
90
client/src/components/boards/Boards/Boards.jsx
Executable file
90
client/src/components/boards/Boards/Boards.jsx
Executable file
@@ -0,0 +1,90 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { closePopup, usePopup } from '../../../lib/popup';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import DroppableTypes from '../../../constants/DroppableTypes';
|
||||
import Item from './Item';
|
||||
import AddStep from './AddStep';
|
||||
|
||||
import styles from './Boards.module.scss';
|
||||
import globalStyles from '../../../styles.module.scss';
|
||||
|
||||
const Boards = React.memo(() => {
|
||||
const boardIds = useSelector(selectors.selectBoardIdsForCurrentProject);
|
||||
|
||||
const canAdd = useSelector((state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
|
||||
if (!isEditModeEnabled) {
|
||||
return isEditModeEnabled;
|
||||
}
|
||||
|
||||
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const tabsWrapperRef = useRef(null);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
document.body.classList.add(globalStyles.dragging);
|
||||
closePopup();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, source, destination }) => {
|
||||
document.body.classList.remove(globalStyles.dragging);
|
||||
|
||||
if (!destination || source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.moveBoard(draggableId, destination.index));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(({ deltaY }) => {
|
||||
tabsWrapperRef.current.scrollBy({
|
||||
left: deltaY,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const AddPopup = usePopup(AddStep);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} onWheel={handleWheel}>
|
||||
<div ref={tabsWrapperRef} className={styles.tabsWrapper}>
|
||||
<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}>
|
||||
{boardIds.map((boardId, index) => (
|
||||
<Item key={boardId} id={boardId} index={index} />
|
||||
))}
|
||||
{placeholder}
|
||||
{canAdd && (
|
||||
<AddPopup>
|
||||
<Button icon="plus" className={styles.addButton} />
|
||||
</AddPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Boards;
|
||||
62
client/src/components/boards/Boards/Boards.module.scss
Normal file
62
client/src/components/boards/Boards/Boards.module.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.addButton {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
margin-right: 0;
|
||||
vertical-align: top;
|
||||
|
||||
&:hover {
|
||||
background: rgba(34, 36, 38, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabsWrapper {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: 56px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
@supports (-moz-appearance: none) {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.24);
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
height: 38px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
87
client/src/components/boards/Boards/Item.jsx
Normal file
87
client/src/components/boards/Boards/Item.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import Paths from '../../../constants/Paths';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
||||
const Item = React.memo(({ id, index }) => {
|
||||
const selectBoardById = useMemo(() => selectors.makeSelectBoardById(), []);
|
||||
|
||||
const selectNotificationsTotalByBoardId = useMemo(
|
||||
() => selectors.makeSelectNotificationsTotalByBoardId(),
|
||||
[],
|
||||
);
|
||||
|
||||
const board = useSelector((state) => selectBoardById(state, id));
|
||||
const notificationsTotal = useSelector((state) => selectNotificationsTotalByBoardId(state, id));
|
||||
const isActive = useSelector((state) => id === selectors.selectPath(state).boardId);
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
|
||||
if (!isEditModeEnabled) {
|
||||
return isEditModeEnabled;
|
||||
}
|
||||
|
||||
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleEditClick = useCallback(() => {
|
||||
dispatch(entryActions.openBoardSettingsModal(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
return (
|
||||
<Draggable draggableId={id} index={index} isDragDisabled={!board.isPersisted || !canEdit}>
|
||||
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...draggableProps} {...dragHandleProps} ref={innerRef} className={styles.wrapper}>
|
||||
<div className={classNames(styles.tab, isActive && styles.tabActive)}>
|
||||
{board.isPersisted ? (
|
||||
<>
|
||||
<Link
|
||||
to={Paths.BOARDS.replace(':id', id)}
|
||||
title={board.name}
|
||||
className={styles.link}
|
||||
>
|
||||
{notificationsTotal > 0 && (
|
||||
<span className={styles.notifications}>{notificationsTotal}</span>
|
||||
)}
|
||||
<span className={styles.name}>{board.name}</span>
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<Button className={styles.editButton} onClick={handleEditClick}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className={classNames(styles.name, styles.link)}>{board.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default Item;
|
||||
90
client/src/components/boards/Boards/Item.module.scss
Normal file
90
client/src/components/boards/Boards/Item.module.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
line-height: 32px;
|
||||
margin-right: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
width: 32px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
padding: 10px 34px 6px 14px;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.notifications {
|
||||
background: #eb5a46;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
margin-right: 6px;
|
||||
outline: none;
|
||||
padding: 0px 6px;
|
||||
transition: background 0.3s ease;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-width: 100px;
|
||||
position: relative;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
|
||||
.name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
8
client/src/components/boards/Boards/index.js
Executable file
8
client/src/components/boards/Boards/index.js
Executable file
@@ -0,0 +1,8 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import Boards from './Boards';
|
||||
|
||||
export default Boards;
|
||||
Reference in New Issue
Block a user