feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev
2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions

View 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;

View 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;

View 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;

View 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;

View 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);
}
}
}

View 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;

View 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) {
.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;
}
}

View 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;

View File

@@ -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;
}
}

View 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;

View 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;

View 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);
}
}
}

View 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;

View 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;

View 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
*/
: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;
}
}
}

View 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;

View File

@@ -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;
}
}
}
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}
}
}

View 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 RightSide from './RightSide';
export default RightSide;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View 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 GeneralPane from './GeneralPane';
export default GeneralPane;

View File

@@ -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;

View File

@@ -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;
}
}

View 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
*/
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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View 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, 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;

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View 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 PreferencesPane from './PreferencesPane';
export default PreferencesPane;

View 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;

View 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;

View File

@@ -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;
}
}

View 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;

View File

@@ -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;
}
}
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;