diff --git a/client/src/components/Board/ListAdd.jsx b/client/src/components/Board/ListAdd.jsx index 7fc1e49..7160b7d 100755 --- a/client/src/components/Board/ListAdd.jsx +++ b/client/src/components/Board/ListAdd.jsx @@ -10,6 +10,7 @@ import styles from './ListAdd.module.scss'; const DEFAULT_DATA = { name: '', + isCollapsed: false, }; const ListAdd = React.memo(({ onCreate, onClose }) => { diff --git a/client/src/components/BoardActions/BoardActions.jsx b/client/src/components/BoardActions/BoardActions.jsx index 0f1d552..5aa1db7 100644 --- a/client/src/components/BoardActions/BoardActions.jsx +++ b/client/src/components/BoardActions/BoardActions.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; import Filters from './Filters'; import Memberships from '../Memberships'; import BoardMembershipPermissionsSelectStep from '../BoardMembershipPermissionsSelectStep'; @@ -9,6 +11,7 @@ import styles from './BoardActions.module.scss'; const BoardActions = React.memo( ({ + cardCount, memberships, labels, filterUsers, @@ -28,9 +31,14 @@ const BoardActions = React.memo( onLabelMove, onLabelDelete, }) => { + const [t] = useTranslation(); + return (
+
+ {cardCount} {[cardCount !== 1 ? t('common.cards') : t('common.card')]} +
{ + // eslint-disable-next-line prettier/prettier + ({ id, index, name, isPersisted, isCollapsed, cardIds, isFiltered, filteredCardIds, canEdit, onUpdate, onDelete, onCardCreate }) => { const [t] = useTranslation(); const [isAddCardOpened, setIsAddCardOpened] = useState(false); const nameEdit = useRef(null); const listWrapper = useRef(null); - const handleHeaderClick = useCallback(() => { + const handleToggleCollapseClick = useCallback(() => { + if (isPersisted && canEdit) { + onUpdate({ + isCollapsed: !isCollapsed, + }); + } + }, [isPersisted, canEdit, onUpdate, isCollapsed]); + + const handleHeaderNameClick = useCallback(() => { if (isPersisted && canEdit) { nameEdit.current.open(); } @@ -58,7 +67,7 @@ const List = React.memo( if (isAddCardOpened) { listWrapper.current.scrollTop = listWrapper.current.scrollHeight; } - }, [cardIds, isAddCardOpened]); + }, [filteredCardIds, isAddCardOpened]); const ActionsPopup = usePopup(ActionsStep); @@ -72,7 +81,7 @@ const List = React.memo( // eslint-disable-next-line react/jsx-props-no-spreading
- {cardIds.map((cardId, cardIndex) => ( + {filteredCardIds.map((cardId, cardIndex) => ( ))} {placeholder} @@ -89,6 +98,55 @@ const List = React.memo( ); + const cardsCountText = () => { + return ( + [ + isFiltered + ? `${filteredCardIds.length} ${t('common.of')} ${cardIds.length} ` + : `${cardIds.length} `, + ] + [cardIds.length !== 1 ? t('common.cards') : t('common.card')] + ); + }; + + if (isCollapsed) { + return ( + + {({ innerRef, draggableProps, dragHandleProps }) => ( +
+
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} +
+ +
+
{name}
+
{cardsCountText()}
+
+
+
+ )} +
+ ); + } return ( {({ innerRef, draggableProps, dragHandleProps }) => ( @@ -99,15 +157,30 @@ const List = React.memo( className={styles.innerWrapper} >
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, - jsx-a11y/no-static-element-interactions */}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} +
+ +
-
{name}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, + jsx-a11y/no-static-element-interactions */} +
+ {name} +
{isPersisted && canEdit && ( )} +
{cardsCountText()}
- {cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')} + {filteredCardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')} )} @@ -155,8 +229,11 @@ List.propTypes = { id: PropTypes.string.isRequired, index: PropTypes.number.isRequired, name: PropTypes.string.isRequired, + isCollapsed: PropTypes.bool.isRequired, isPersisted: PropTypes.bool.isRequired, cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + isFiltered: PropTypes.bool.isRequired, + filteredCardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types canEdit: PropTypes.bool.isRequired, onUpdate: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, diff --git a/client/src/components/List/List.module.scss b/client/src/components/List/List.module.scss index b1a7096..0a78913 100644 --- a/client/src/components/List/List.module.scss +++ b/client/src/components/List/List.module.scss @@ -74,7 +74,7 @@ .header { outline: none; - padding: 6px 36px 4px 8px; + padding: 4px 36px 4px 10px; position: relative; &:hover .target { @@ -82,6 +82,15 @@ } } + .headerCollapsed { + outline: none; + height: calc(100vh - 182px); + + &:hover .target { + opacity: 1; + } + } + .headerEditable { cursor: pointer; } @@ -109,12 +118,53 @@ color: #17394d; font-weight: bold; line-height: 20px; - max-height: 256px; + max-height: 82px; outline: none; overflow: hidden; overflow-wrap: break-word; - padding: 4px 8px; word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + } + + .headerNameCollapsed { + color: #17394d; + font-weight: bold; + outline: none; + overflow: hidden; + writing-mode: vertical-rl; + padding: 0px 5px 10px 5px; + text-overflow: ellipsis; + white-space: nowrap; + max-height: 85%; + } + + .headerCollapseButton { + width: 10px; + height: 30px; + padding: 2px 0px 0px 2px; + position: absolute; + left: 2px; + top: 2px; + } + + .headerCollapseButtonCollapsed { + height: 30px; + text-align: center; + } + + .headerCardsCount { + color: #798d99; + font-size: 12px; + } + + .headerCardsCountCollapsed { + color: #798d99; + font-size: 12px; + max-height: 10%; + writing-mode: vertical-rl; + padding: 0px 5px 10px 5px; } .innerWrapper { @@ -122,6 +172,11 @@ width: 272px; } + .innerWrapperCollapsed { + margin-right: 8px; + width: 30px; + } + .outerWrapper { background: #dfe3e6; border-radius: 3px; diff --git a/client/src/containers/BoardActionsContainer.js b/client/src/containers/BoardActionsContainer.js index 40170cb..b7d568e 100644 --- a/client/src/containers/BoardActionsContainer.js +++ b/client/src/containers/BoardActionsContainer.js @@ -7,6 +7,9 @@ import { BoardMembershipRoles } from '../constants/Enums'; import BoardActions from '../components/BoardActions'; const mapStateToProps = (state) => { + const listIds = selectors.selectListIdsForCurrentBoard(state); + const listCardsCount = listIds.map((list) => selectors.selectCardIdsByListId(state, list).length); + const cardCount = listCardsCount.reduce((sum, count) => sum + count, 0); const allUsers = selectors.selectUsers(state); const isCurrentUserManager = selectors.selectIsCurrentUserManagerForCurrentProject(state); const memberships = selectors.selectMembershipsForCurrentBoard(state); @@ -19,6 +22,7 @@ const mapStateToProps = (state) => { !!currentUserMembership && currentUserMembership.role === BoardMembershipRoles.EDITOR; return { + cardCount, memberships, labels, filterUsers, diff --git a/client/src/containers/ListContainer.js b/client/src/containers/ListContainer.js index cb2f6a0..498fe2b 100755 --- a/client/src/containers/ListContainer.js +++ b/client/src/containers/ListContainer.js @@ -9,10 +9,14 @@ import List from '../components/List'; const makeMapStateToProps = () => { const selectListById = selectors.makeSelectListById(); const selectCardIdsByListId = selectors.makeSelectCardIdsByListId(); + const selectIsFilteredByListId = selectors.makeSelectIsFilteredByListId(); + const selectFilteredCardIdsByListId = selectors.makeSelectFilteredCardIdsByListId(); return (state, { id, index }) => { - const { name, isPersisted } = selectListById(state, id); + const { name, isPersisted, isCollapsed } = selectListById(state, id); const cardIds = selectCardIdsByListId(state, id); + const isFiltered = selectIsFilteredByListId(state, id); + const filteredCardIds = selectFilteredCardIdsByListId(state, id); const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); const isCurrentUserEditor = @@ -22,8 +26,11 @@ const makeMapStateToProps = () => { id, index, name, + isCollapsed, isPersisted, cardIds, + isFiltered, + filteredCardIds, canEdit: isCurrentUserEditor, }; }; diff --git a/client/src/locales/en/core.js b/client/src/locales/en/core.js index 0eb0ae3..b2f32f1 100644 --- a/client/src/locales/en/core.js +++ b/client/src/locales/en/core.js @@ -162,6 +162,9 @@ export default { version: 'Version', viewer: 'Viewer', writeComment: 'Write a comment...', + card: 'card', + cards: 'cards', + of: 'of', }, action: { diff --git a/client/src/models/List.js b/client/src/models/List.js index b634a66..b9973f1 100755 --- a/client/src/models/List.js +++ b/client/src/models/List.js @@ -10,6 +10,7 @@ export default class extends BaseModel { id: attr(), position: attr(), name: attr(), + isCollapsed: attr(), boardId: fk({ to: 'Board', as: 'board', @@ -84,6 +85,16 @@ export default class extends BaseModel { return this.cards.orderBy('position'); } + getOrderedCardsModelArray() { + return this.getOrderedCardsQuerySet().toModelArray(); + } + + getIsFiltered() { + const filterUserIds = this.board.filterUsers.toRefArray().map((user) => user.id); + const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id); + return filterUserIds.length > 0 || filterLabelIds.length > 0; + } + getFilteredOrderedCardsModelArray() { let cardModels = this.getOrderedCardsQuerySet().toModelArray(); @@ -93,7 +104,6 @@ export default class extends BaseModel { if (filterUserIds.length > 0) { cardModels = cardModels.filter((cardModel) => { const users = cardModel.users.toRefArray(); - return users.some((user) => filterUserIds.includes(user.id)); }); } @@ -101,7 +111,6 @@ export default class extends BaseModel { if (filterLabelIds.length > 0) { cardModels = cardModels.filter((cardModel) => { const labels = cardModel.labels.toRefArray(); - return labels.some((label) => filterLabelIds.includes(label.id)); }); } diff --git a/client/src/selectors/core.js b/client/src/selectors/core.js index df7d42d..6d8cdc8 100755 --- a/client/src/selectors/core.js +++ b/client/src/selectors/core.js @@ -91,6 +91,7 @@ export const selectNextCardPosition = createSelector( return listModel; } + // eslint-disable-next-line prettier/prettier return nextPosition(listModel.getFilteredOrderedCardsModelArray(), index, excludedId); }, ); diff --git a/client/src/selectors/lists.js b/client/src/selectors/lists.js index 1eddbd9..e918241 100644 --- a/client/src/selectors/lists.js +++ b/client/src/selectors/lists.js @@ -34,15 +34,53 @@ export const makeSelectCardIdsByListId = () => return listModel; } - return listModel.getFilteredOrderedCardsModelArray().map((cardModel) => cardModel.id); + return listModel.getOrderedCardsModelArray().map((cardModel) => cardModel.id); }, ); export const selectCardIdsByListId = makeSelectCardIdsByListId(); +export const makeSelectIsFilteredByListId = () => + createSelector( + orm, + (_, id) => id, + ({ List }, id) => { + const listModel = List.withId(id); + + if (!listModel) { + return listModel; + } + + return listModel.getIsFiltered(); + }, + ); + +export const selectIsFilteredByListId = makeSelectIsFilteredByListId(); + +export const makeSelectFilteredCardIdsByListId = () => + createSelector( + orm, + (_, id) => id, + ({ List }, id) => { + const listModel = List.withId(id); + + if (!listModel) { + return listModel; + } + + return listModel.getFilteredOrderedCardsModelArray().map((cardModel) => cardModel.id); + }, + ); + +export const selectFilteredCardIdsByListId = makeSelectFilteredCardIdsByListId(); + export default { makeSelectListById, selectListById, makeSelectCardIdsByListId, selectCardIdsByListId, + makeSelectIsFilteredByListId, + selectIsFilteredByListId, + makeSelectFilteredCardIdsByListId, + selectFilteredCardIdsByListId, }; diff --git a/server/api/controllers/lists/create.js b/server/api/controllers/lists/create.js index 9383f5b..49a9bce 100755 --- a/server/api/controllers/lists/create.js +++ b/server/api/controllers/lists/create.js @@ -22,6 +22,10 @@ module.exports = { type: 'string', required: true, }, + isCollapsed: { + type: 'boolean', + required: true, + }, }, exits: { @@ -53,7 +57,7 @@ module.exports = { throw Errors.NOT_ENOUGH_RIGHTS; } - const values = _.pick(inputs, ['position', 'name']); + const values = _.pick(inputs, ['position', 'name', 'isCollapsed']); const list = await sails.helpers.lists.createOne.with({ values: { diff --git a/server/api/controllers/lists/update.js b/server/api/controllers/lists/update.js index 2cb34e2..b6e1493 100755 --- a/server/api/controllers/lists/update.js +++ b/server/api/controllers/lists/update.js @@ -21,6 +21,9 @@ module.exports = { type: 'string', isNotEmptyString: true, }, + isCollapsed: { + type: 'boolean', + }, }, exits: { @@ -52,7 +55,7 @@ module.exports = { throw Errors.NOT_ENOUGH_RIGHTS; } - const values = _.pick(inputs, ['position', 'name']); + const values = _.pick(inputs, ['position', 'name', 'isCollapsed']); list = await sails.helpers.lists.updateOne.with({ values, diff --git a/server/api/models/List.js b/server/api/models/List.js index ce785b0..e2ee0ec 100755 --- a/server/api/models/List.js +++ b/server/api/models/List.js @@ -19,6 +19,11 @@ module.exports = { type: 'string', required: true, }, + isCollapsed: { + type: 'boolean', + required: true, + columnName: 'is_collapsed', + }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/server/db/migrations/20230112022500_add_list_isCollapsed.js b/server/db/migrations/20230112022500_add_list_isCollapsed.js new file mode 100644 index 0000000..00b0a25 --- /dev/null +++ b/server/db/migrations/20230112022500_add_list_isCollapsed.js @@ -0,0 +1,9 @@ +module.exports.up = (knex) => + knex.schema.alterTable('list', (table) => { + table.boolean('is_collapsed').notNullable().defaultTo(false); + }); + +module.exports.down = (knex) => + knex.schema.alterTable('list', (table) => { + table.dropColumn('is_collapsed'); + });