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