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
@@ -0,0 +1,192 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useSteps } from '../../../hooks';
import SelectPermissionsStep from './SelectPermissionsStep';
import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar';
import styles from './ActionsStep.module.scss';
const StepTypes = {
EDIT_PERMISSIONS: 'EDIT_PERMISSIONS',
DELETE: 'DELETE',
};
const ActionsStep = React.memo(({ boardMembershipId, title, onBack, onClose }) => {
const selectBoardMembershipById = useMemo(() => selectors.makeSelectBoardMembershipById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const boardMembership = useSelector((state) =>
selectBoardMembershipById(state, boardMembershipId),
);
const user = useSelector((state) => selectUserById(state, boardMembership.userId));
const isCurrentUser = useSelector(
(state) => boardMembership.userId === selectors.selectCurrentUserId(state),
);
const canEdit = useSelector(selectors.selectIsCurrentUserManagerForCurrentProject);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleRoleSelect = useCallback(
(data) => {
dispatch(entryActions.updateBoardMembership(boardMembershipId, data));
},
[boardMembershipId, dispatch],
);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteBoardMembership(boardMembershipId));
onClose();
}, [boardMembershipId, onClose, dispatch]);
const handleFilterClick = useCallback(() => {
dispatch(entryActions.addUserToFilterInCurrentBoard(boardMembership.userId, true));
onClose();
}, [onClose, boardMembership.userId, dispatch]);
const handleEditPermissionsClick = useCallback(() => {
openStep(StepTypes.EDIT_PERMISSIONS);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_PERMISSIONS:
return (
<SelectPermissionsStep
boardMembership={boardMembership}
title="common.editPermissions"
buttonContent="action.save"
onSelect={handleRoleSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.DELETE:
return (
<ConfirmationStep
title={isCurrentUser ? `common.leaveBoard` : 'common.removeMember'}
content={
isCurrentUser
? `common.areYouSureYouWantToLeaveBoard`
: `common.areYouSureYouWantToRemoveThisMemberFromBoard`
}
buttonContent={isCurrentUser ? `action.leaveBoard` : 'action.removeMember'}
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
openStep(null);
}
const contentNode = (
<>
<div className={styles.userWrapper}>
<span className={styles.user}>
<UserAvatar id={boardMembership.userId} size="large" />
</span>
<span className={styles.content}>
<div className={styles.name}>{user.name}</div>
{user.username && <div className={styles.username}>@{user.username}</div>}
</span>
</div>
{user.phone && (
<div className={styles.information}>
<Icon name="phone" className={styles.informationIcon} />
{user.phone}
</div>
)}
{user.organization && (
<div className={styles.information}>
<Icon name="briefcase" className={styles.informationIcon} />
{user.organization}
</div>
)}
<Button
basic
content={t('action.showCardsWithThisUser')}
icon="filter"
size="tiny"
onClick={handleFilterClick}
/>
{(isCurrentUser || canEdit) && (
<>
<hr className={styles.divider} />
{canEdit && (
<Button
fluid
content={t('action.editPermissions')}
className={styles.button}
onClick={handleEditPermissionsClick}
/>
)}
{isCurrentUser ? (
<Button
fluid
content={t(`action.leaveBoard`)}
className={styles.button}
onClick={handleDeleteClick}
/>
) : (
<Button
fluid
content={t(`action.removeFromBoard`)}
className={styles.button}
onClick={handleDeleteClick}
/>
)}
</>
)}
</>
);
return onBack ? (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>{contentNode}</Popup.Content>
</>
) : (
contentNode
);
});
ActionsStep.propTypes = {
boardMembershipId: PropTypes.string.isRequired,
title: PropTypes.string,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
ActionsStep.defaultProps = {
title: 'common.memberActions',
onBack: undefined,
};
export default ActionsStep;
@@ -0,0 +1,71 @@
/*!
* 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;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.content {
display: inline-block;
width: calc(100% - 44px);
}
.divider {
background: #eee;
border: 0;
height: 1px;
}
.information {
color: #888888;
margin-bottom: 8px;
}
.informationIcon {
margin-right: 6px;
opacity: 0.45;
}
.name {
color: #212121;
font-size: 16px;
font-weight: bold;
line-height: 1.2;
padding: 9px 28px 0 2px;
}
.userWrapper {
margin-bottom: 8px;
min-height: 49px;
}
.user {
display: inline-block;
padding-right: 8px;
padding-top: 10px;
vertical-align: top;
}
.username {
color: #888888;
font-size: 14px;
line-height: 1.2;
padding: 2px 0 2px 2px;
}
}
@@ -0,0 +1,140 @@
/*!
* 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, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Input, Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useField, useNestedRef, useSteps } from '../../../../hooks';
import { isUserAdminOrProjectOwner } from '../../../../utils/record-helpers';
import User from './User';
import SelectPermissionsStep from '../SelectPermissionsStep';
import styles from './AddStep.module.scss';
const StepTypes = {
SELECT_PERMISSIONS: 'SELECT_PERMISSIONS',
};
const AddStep = React.memo(({ onClose }) => {
const users = useSelector((state) => {
const user = selectors.selectCurrentUser(state);
if (!isUserAdminOrProjectOwner(user)) {
return [user];
}
return selectors.selectActiveUsers(state);
});
const currentUserIds = useSelector(selectors.selectMemberUserIdsForCurrentBoard);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredUsers = useMemo(
() =>
users.filter(
(user) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[users, cleanSearch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
const handleRoleSelect = useCallback(
(data) => {
dispatch(
entryActions.createMembershipInCurrentBoard({
...data,
userId: step.params.userId,
}),
);
onClose();
},
[onClose, dispatch, step],
);
const handleUserSelect = useCallback(
(userId) => {
openStep(StepTypes.SELECT_PERMISSIONS, {
userId,
});
},
[openStep],
);
useEffect(() => {
searchFieldRef.current.focus({
preventScroll: true,
});
}, [searchFieldRef]);
if (step && step.type === StepTypes.SELECT_PERMISSIONS) {
const currentUser = users.find((user) => user.id === step.params.userId);
if (currentUser) {
return (
<SelectPermissionsStep
buttonContent="action.addMember"
onSelect={handleRoleSelect}
onBack={handleBack}
onClose={onClose}
/>
);
}
openStep(null);
}
return (
<>
<Popup.Header>
{t('common.addMember', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchUsers')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredUsers.length > 0 && (
<div className={styles.users}>
{filteredUsers.map((user) => (
<User
key={user.id}
id={user.id}
isActive={currentUserIds.includes(user.id)}
onSelect={handleUserSelect}
/>
))}
</div>
)}
</Popup.Content>
</>
);
});
AddStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default AddStep;
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.users {
margin-top: 8px;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
}
}
}
@@ -0,0 +1,43 @@
/*!
* 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 { useSelector } from 'react-redux';
import selectors from '../../../../selectors';
import UserAvatar from '../../../users/UserAvatar';
import styles from './User.module.scss';
const User = React.memo(({ id, isActive, onSelect }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, id));
const handleClick = useCallback(() => {
onSelect(id);
}, [id, onSelect]);
return (
<button type="button" disabled={isActive} className={styles.menuItem} onClick={handleClick}>
<span className={styles.user}>
<UserAvatar id={id} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{user.name}
</div>
</button>
);
});
User.propTypes = {
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
};
export default User;
@@ -0,0 +1,55 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menuItem {
background: transparent;
border: none;
border-radius: 0.28571429rem;
display: block;
margin: 0;
outline: 0;
overflow: hidden;
padding: 4px;
text-align: left;
width: 100%;
&:enabled {
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
}
.menuItemText {
display: inline-block;
line-height: 32px;
position: relative;
width: calc(100% - 40px);
}
.menuItemTextActive:before {
bottom: 2px;
color: #798d99;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
.user {
display: inline-block;
line-height: 32px;
padding-right: 8px;
width: 40px;
}
}
@@ -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;
@@ -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 groupBy from 'lodash/groupBy';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Button } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import { isUserAdminOrProjectOwner } from '../../../utils/record-helpers';
import { BoardMembershipRoles } from '../../../constants/Enums';
import Group from './Group';
import AddStep from './AddStep';
import styles from './BoardMemberships.module.scss';
const BoardMemberships = React.memo(() => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const canAdd = useSelector((state) => {
const user = selectors.selectCurrentUser(state);
if (!isUserAdminOrProjectOwner(user)) {
return !selectors.selectCurrentUserMembershipForCurrentBoard(state);
}
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
});
const boardMembershipsByRole = useMemo(
() => groupBy(boardMemberships, 'role'),
[boardMemberships],
);
const AddPopup = usePopup(AddStep);
return (
<>
{boardMemberships.length > 0 && (
<div className={classNames(styles.segment, styles.groups)}>
{[BoardMembershipRoles.EDITOR, BoardMembershipRoles.VIEWER].map(
(role) =>
boardMembershipsByRole[role] && (
<Group
key={role}
items={boardMembershipsByRole[role]}
role={role}
groupsTotal={Object.keys(boardMembershipsByRole).length}
/>
),
)}
</div>
)}
{canAdd && (
<AddPopup>
<Button icon="add user" className={classNames(styles.segment, styles.addButton)} />
</AddPopup>
)}
</>
);
});
export default BoardMemberships;
@@ -0,0 +1,31 @@
/*!
* 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: rgba(0, 0, 0, 0.24);
border-radius: 50%;
box-shadow: none;
color: #fff;
line-height: 36px;
margin: 0;
padding: 0;
transition: all 0.1s ease 0s;
vertical-align: top;
width: 36px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.groups {
display: flex;
}
.segment:not(:last-child) {
margin-right: 10px;
}
}
@@ -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 from 'react';
import PropTypes from 'prop-types';
import { Icon, Button } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import { BoardMembershipRoleIcons } from '../../../constants/Icons';
import GroupItemsStep from './GroupItemsStep';
import ActionsStep from './ActionsStep';
import UserAvatar from '../../users/UserAvatar';
import styles from './Group.module.scss';
const MAX_MEMBERS = 6;
const Group = React.memo(({ items, role, groupsTotal }) => {
const GroupItemsPopup = usePopup(GroupItemsStep);
const ActionsPopup = usePopup(ActionsStep);
let visibleTotal = MAX_MEMBERS - groupsTotal;
let hiddenTotal = items.length - visibleTotal;
if (hiddenTotal === 1) {
visibleTotal += 1;
hiddenTotal -= 1;
}
return (
<div className={styles.wrapper}>
<Icon name={BoardMembershipRoleIcons[role]} className={styles.icon} />
{items.slice(0, visibleTotal).map((item) => (
<span key={item.id} className={styles.user}>
<ActionsPopup boardMembershipId={item.id}>
<UserAvatar id={item.user.id} size="large" isDisabled={!item.isPersisted} />
</ActionsPopup>
</span>
))}
{hiddenTotal > 0 && (
<GroupItemsPopup items={items} title={`common.${role}s`}>
<Button className={styles.othersButton}>+{hiddenTotal < 99 ? hiddenTotal : 99}</Button>
</GroupItemsPopup>
)}
</div>
);
});
Group.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
role: PropTypes.string.isRequired,
groupsTotal: PropTypes.number.isRequired,
};
export default Group;
@@ -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
*/
:global(#app) {
.icon {
color: rgba(255, 255, 255, 0.64);
line-height: 1;
}
.othersButton {
background: rgba(0, 0, 0, 0.24);
border-radius: 50%;
box-shadow: none;
color: #fff;
line-height: 36px;
margin: 0;
padding: 0;
transition: all 0.1s ease 0s;
vertical-align: top;
width: 36px;
&:hover {
background: rgba(0, 0, 0, 0.32);
}
}
.user {
line-height: 1;
margin: 0 -4px 0 0;
}
.wrapper {
align-items: center;
background: rgba(0, 0, 0, 0.08);
border-radius: 24px;
display: flex;
line-height: 34px;
padding: 5px 13px 5px 10px;
&:not(:last-child) {
margin-right: 10px;
}
}
}
@@ -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 { useSteps } from '../../../hooks';
import ActionsStep from './ActionsStep';
import PureBoardMembershipsStep from '../PureBoardMembershipsStep';
const StepTypes = {
SELECT: 'SELECT',
};
const GroupItemsStep = React.memo(({ items, title, onClose }) => {
const [step, openStep, handleBack] = useSteps();
const handleUserClick = useCallback(
(userId) => {
openStep(StepTypes.SELECT, {
userId,
});
},
[openStep],
);
if (step && step.type === StepTypes.SELECT) {
const currentItem = items.find((item) => item.userId === step.params.userId);
if (currentItem) {
return (
<ActionsStep boardMembershipId={currentItem.id} onBack={handleBack} onClose={onClose} />
);
}
openStep(null);
}
return <PureBoardMembershipsStep items={items} title={title} onUserSelect={handleUserClick} />;
});
GroupItemsStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default GroupItemsStep;
@@ -0,0 +1,122 @@
/*!
* 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 omit from 'lodash/omit';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Icon, Menu, Radio, Segment } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { useForm } from '../../../hooks';
import { BoardMembershipRoles } from '../../../constants/Enums';
import { BoardMembershipRoleIcons } from '../../../constants/Icons';
import styles from './SelectPermissionsStep.module.scss';
const DESCRIPTION_BY_ROLE = {
[BoardMembershipRoles.EDITOR]: 'common.canEditBoardLayoutAndAssignMembersToCards',
[BoardMembershipRoles.VIEWER]: 'common.canOnlyViewBoard',
};
const SelectPermissionsStep = React.memo(
({ boardMembership, title, buttonContent, onSelect, onBack, onClose }) => {
const [t] = useTranslation();
const defaultData = useMemo(
() =>
boardMembership && {
role: boardMembership.role,
canComment: boardMembership.canComment,
},
[boardMembership],
);
const [data, handleFieldChange, setData] = useForm(() => ({
role: BoardMembershipRoles.EDITOR,
canComment: null,
...defaultData,
}));
const handleSubmit = useCallback(() => {
if (!dequal(data, defaultData)) {
onSelect(data.role === BoardMembershipRoles.EDITOR ? omit(data, 'canComment') : data);
}
onClose();
}, [defaultData, onSelect, onClose, data]);
const handleSelectRoleClick = useCallback(
(_, { value: role }) => {
setData((prevData) => ({
...prevData,
role,
canComment: role === BoardMembershipRoles.EDITOR ? null : !!prevData.canComment,
}));
},
[setData],
);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Menu secondary vertical className={styles.menu}>
{[BoardMembershipRoles.EDITOR, BoardMembershipRoles.VIEWER].map((role) => (
<Menu.Item
key={role}
value={role}
active={role === data.role}
className={styles.menuItem}
onClick={handleSelectRoleClick}
>
<Icon name={BoardMembershipRoleIcons[role]} className={styles.menuItemIcon} />
<div className={styles.menuItemTitle}>{t(`common.${role}`)}</div>
<p className={styles.menuItemDescription}>{t(DESCRIPTION_BY_ROLE[role])}</p>
</Menu.Item>
))}
</Menu>
{data.role !== BoardMembershipRoles.EDITOR && (
<Segment basic className={styles.settings}>
<Radio
toggle
name="canComment"
checked={data.canComment}
label={t('common.canComment')}
className={styles.fieldRadio}
onChange={handleFieldChange}
/>
</Segment>
)}
<Button positive content={t(buttonContent)} />
</Form>
</Popup.Content>
</>
);
},
);
SelectPermissionsStep.propTypes = {
boardMembership: PropTypes.object, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
buttonContent: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
SelectPermissionsStep.defaultProps = {
boardMembership: undefined,
title: 'common.selectPermissions',
buttonContent: 'action.selectPermissions',
};
export default SelectPermissionsStep;
@@ -0,0 +1,41 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.fieldRadio {
margin-bottom: 16px;
width: 100%;
&:last-child {
margin-bottom: 0;
}
}
.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;
}
.settings {
margin: 0 0 8px;
}
}
@@ -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 BoardMemberships from './BoardMemberships';
export default BoardMemberships;
@@ -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 from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import selectors from '../../selectors';
import PureBoardMembershipsStep from './PureBoardMembershipsStep';
const BoardMembershipsStep = React.memo(
({
currentUserIds,
title,
clearButtonContent,
onUserSelect,
onUserDeselect,
onClear,
onBack,
}) => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
return (
<PureBoardMembershipsStep
items={boardMemberships}
currentUserIds={currentUserIds}
title={title}
clearButtonContent={clearButtonContent}
onUserSelect={onUserSelect}
onUserDeselect={onUserDeselect}
onClear={onClear}
onBack={onBack}
/>
);
},
);
BoardMembershipsStep.propTypes = {
currentUserIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
clearButtonContent: PropTypes.string,
onUserSelect: PropTypes.func.isRequired,
onUserDeselect: PropTypes.func.isRequired,
onClear: PropTypes.func,
onBack: PropTypes.func,
};
BoardMembershipsStep.defaultProps = {
title: undefined,
clearButtonContent: undefined,
onClear: undefined,
onBack: undefined,
};
export default BoardMembershipsStep;
@@ -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
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Menu } from 'semantic-ui-react';
import selectors from '../../../selectors';
import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss';
const Item = React.memo(({ id, isActive, onUserSelect, onUserDeselect }) => {
const selectBoardMembershipById = useMemo(() => selectors.makeSelectBoardMembershipById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const boardMembership = useSelector((state) => selectBoardMembershipById(state, id));
const user = useSelector((state) => selectUserById(state, boardMembership.userId));
const handleToggleClick = useCallback(() => {
if (isActive) {
if (onUserDeselect) {
onUserDeselect(boardMembership.userId);
}
} else {
onUserSelect(boardMembership.userId);
}
}, [isActive, onUserSelect, onUserDeselect, boardMembership.userId]);
return (
<Menu.Item
active={isActive}
disabled={!boardMembership.isPersisted}
className={classNames(styles.menuItem, isActive && styles.menuItemActive)}
onClick={handleToggleClick}
>
<span className={styles.user}>
<UserAvatar id={boardMembership.userId} />
</span>
<div className={classNames(styles.menuItemText, isActive && styles.menuItemTextActive)}>
{user.name}
</div>
</Menu.Item>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onUserSelect: PropTypes.func.isRequired,
onUserDeselect: PropTypes.func,
};
Item.defaultProps = {
onUserDeselect: undefined,
};
export default Item;
@@ -0,0 +1,48 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menuItem {
display: block;
margin: 0;
overflow: hidden;
padding: 4px;
}
.menuItemActive {
background: transparent;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
.menuItemText {
display: inline-block;
line-height: 32px;
width: calc(100% - 40px);
}
.menuItemTextActive:before {
bottom: 2px;
color: #798d99;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
.user {
display: inline-block;
line-height: 32px;
padding-right: 8px;
width: 40px;
}
}
@@ -0,0 +1,116 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Menu } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import { useField, useNestedRef } from '../../../hooks';
import Item from './Item';
import styles from './PureBoardMembershipsStep.module.scss';
const PureBoardMembershipsStep = React.memo(
({
items,
currentUserIds,
title,
clearButtonContent,
onUserSelect,
onUserDeselect,
onClear,
onBack,
}) => {
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredItems = useMemo(
() =>
items.filter(
({ user }) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[items, cleanSearch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
useEffect(() => {
searchFieldRef.current.focus({
preventScroll: true,
});
}, [searchFieldRef]);
return (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Input
fluid
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchMembers')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredItems.length > 0 && (
<Menu secondary vertical className={styles.menu}>
{filteredItems.map((boardMembership) => (
<Item
key={boardMembership.id}
id={boardMembership.id}
isActive={currentUserIds.includes(boardMembership.user.id)}
onUserSelect={onUserSelect}
onUserDeselect={onUserDeselect}
/>
))}
</Menu>
)}
{currentUserIds.length > 0 && onClear && (
<Button
fluid
content={t(clearButtonContent)}
className={styles.clearButton}
onClick={onClear}
/>
)}
</Popup.Content>
</>
);
},
);
PureBoardMembershipsStep.propTypes = {
/* eslint-disable react/forbid-prop-types */
items: PropTypes.array.isRequired,
currentUserIds: PropTypes.array,
/* eslint-enable react/forbid-prop-types */
title: PropTypes.string,
clearButtonContent: PropTypes.string,
onUserSelect: PropTypes.func.isRequired,
onUserDeselect: PropTypes.func,
onClear: PropTypes.func,
onBack: PropTypes.func,
};
PureBoardMembershipsStep.defaultProps = {
currentUserIds: [],
title: 'common.members',
clearButtonContent: 'action.clear',
onUserDeselect: undefined,
onClear: undefined,
onBack: undefined,
};
export default PureBoardMembershipsStep;
@@ -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
*/
:global(#app) {
.clearButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: background 0.3s ease;
&:hover {
background: #e9e9e9;
}
}
.menu {
margin: 8px auto 0;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
@supports (-moz-appearance: none) {
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
scrollbar-width: thin;
}
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-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 PureBoardMembershipsStep from './PureBoardMembershipsStep';
export default PureBoardMembershipsStep;