Compare commits

..
20 Commits
Author SHA1 Message Date
Ahmed Tawfik f6d7d90bde Initial commit: PLANKA project
Build and Test / build-and-test (push) Failing after 6m57s
Build and Push Docker Nightly Image / build-and-push-docker-nightly-image (push) Canceled after 0s
2026-09-22 02:20:23 -04:00
Daniel Hiller 627701dda3 feat: Let a board filter be put down and picked up again
Two things, and neither of them happens on its own.

Clearing the filter row now keeps a snapshot, and the same spot turns into an
undo arrow that puts it back. Taking the filter apart chip by chip does not
leave one, because that is someone changing their mind rather than setting
something aside.

A filter can also be saved under a name and picked again later, per person and
per board.

Nothing is restored automatically when a board opens, which is the whole
reason this may live in localStorage at all. Two windows on the same board
would otherwise pull one stored filter back and forth. The storage is read in
three places and only one of them dispatches, inside a click handler.

The keys and the payload are Pro's, so saved filters carry over to an upgrade
instead of being left behind. Community's own filter dimensions ride along as
extra fields that Pro reads as absent.
2026-09-17 03:51:52 +02:00
Daniel Hiller b581a97c2c feat: Show how many cards a list holds
A board level switch, off by default. With it on, each list puts its card
count on the add-card button. Useful to a board that is being kept to a size
and noise to one that is not, which is why it is a choice rather than always
on.

The count is every card in the list, not the ones a filter leaves visible.
Those answer different questions: how full is this list, versus how many match
what I am looking at right now. The first is the one a counter is for, and it
is also what the same switch counts in Pro, so a board keeps its meaning
across an upgrade.

The column matches Pro's name, type and default, so the setting survives that
upgrade rather than being dropped on the way.
2026-09-17 03:51:40 +02:00
Daniel Hiller ca035d670e fix: Restore two files committed empty by the list filter merge
75fcddcd wrote `client/src/models/Board.js` and the board filter bar out as
zero bytes. `BoardActions` imports `Filters` from that module, so the client
could not render a board at all.

The conflict resolution there was scripted, and the script opened each file
for writing before reading it, which truncates it first and then reads back
nothing. ESLint accepts an empty module, so lint stayed green and the commit
looked fine.

Both files are rebuilt from the two merge parents. All six conflicts resolve
to keeping both sides, which is what the merge intended: excluded labels,
cards without a member and lists are three independent filters.

One behaviour fix rides along, because the union merge would otherwise have
carried it in. The no-member branch ended its block with `return cardModels`,
which was correct while it was the last filter and wrong once the list filter
followed it: combining the two would have dropped the list filter without a
word. The three now compose.
2026-09-17 03:50:09 +02:00
Daniel Hiller f1e8c59d6e Merge pull request #1571 from lustsazeus-lab/fix/issue-165-feature-bounty
feat: improve card communication with reply and mentions
2026-09-17 03:27:21 +02:00
Daniel Hiller 3edce1cc04 fix: Date the webhook scope migration after the one before it
The file came in named 20260502000000, which sorts ahead of the TOTP attempt
limit from August. Knex records migrations in the order it runs them, so the
last row in the table is not the same on every instance: a fresh install runs
both in name order and ends on the August one, while an instance that already
had it runs only this one and ends here.

Pro reads that last row to recognise which Community version it is upgrading
from, and it can only carry one name per version. Renaming to a timestamp
after the August migration makes the last row the same everywhere.

The rename is safe while this is only a few commits old on master and in no
tag. Anyone who pulled in that window and already ran it would see knex offer
it a second time.
2026-09-17 02:38:56 +02:00
Daniel Hiller 67d3ccf425 chore: Add the German strings for the two new filters 2026-09-17 02:05:06 +02:00
Daniel Hiller 1c683604ea Merge pull request #1649 from symonbaikov/feat/list-view-reorder
feat: allow re-ordering cards in list view

Conflicted only on the import block, where the icon button tooltips had added
a line. Both imports stay.
2026-09-17 02:03:25 +02:00
Daniel Hiller 75fcddcd0b Merge pull request #1648 from symonbaikov/feat/list-filter
feat: add filter by list (status) in list view

Three filters landed in the same places while this was open: excluded labels,
cards without a member, and now lists. They are independent of each other, so
every conflict resolves to keeping both sides.

The selector was the one exception worth naming. Both branches had written the
same body under a different name, so the merge put one header on one body; it
is now two complete selectors.
2026-09-17 02:03:04 +02:00
Daniel Hiller bb2da7f36a Merge pull request #1236 from symonbaikov/feat/no-member-filter
Add filter for cards without members ("No member" filter)

Conflicted with the negative label filter, which had added its own field to
the board model next to this one. Both stay; they filter on different things.
2026-09-17 02:01:58 +02:00
Symon f348583977 feat: allow re-ordering cards in list view 2026-05-02 09:45:07 +03:00
Symon 401c0412cb feat: add filter by list (status) in list view
Adds a per-list filter to the board action bar, visible only in
List View. Mirrors the existing transient label/member filter
pattern: filterLists is a many-relation on the Board ORM model,
toggled via entry actions and reset on board navigation. Empty
selection keeps the current behavior; non-empty selection shows
only cards whose listId is in the chosen set. Filter UI is hidden
in Kanban/Grid views where columns already convey the same info.

Closes #1524
2026-05-02 09:24:48 +03:00
lustsazeus-lab 4bc12dabbc chore: retrigger CLA check 2026-03-06 11:10:58 +08:00
lustsazeus-lab 2413ddd1cd feat: map @username mentions when saving card descriptions 2026-03-06 09:48:20 +08:00
lustsazeus-lab f23ce6e025 feat: add comment reply shortcut and description mentions 2026-03-06 09:48:20 +08:00
symonbaikov 0fd2bd3bc5 Restore i18n.js to remove deletion from PR 2025-07-05 23:25:18 +03:00
symonbaikov ae0c23eada chore: remove unrelated config and locale files from feature branch 2025-07-05 23:09:01 +03:00
symonbaikov 334f7b2028 feat: add 'No member' filter for board cards 2025-07-05 23:07:22 +03:00
symonbaikov e1efe663a0 Add German (de-DE) translation and enable it in i18n config 2025-07-05 22:46:32 +03:00
symonbaikov ffb9693184 feat(api): allow setting custom fields when creating a card via API (closes #1155) 2025-07-04 21:37:11 +03:00
48 changed files with 1720 additions and 80 deletions
+18
View File
@@ -0,0 +1,18 @@
# This is an example configuration file
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
name: Local Ollama Config
version: 1.0.0
schema: v1
models:
- name: qwen3.5
provider: anthropic
model: qwen3.5
apiBase: http://192.168.1.122:30068/v1
apiKey: ollama
- name: qwen2.5
provider: anthropic
model: qwen2.5
apiBase: http://192.168.1.122:30068/v1
apiKey: ollama
+7
View File
@@ -0,0 +1,7 @@
name: MCP server
version: 0.0.1
schema: v1
mcpServers:
- name: Gitea MCP server
type: streamable-http
url: http://192.168.1.122:18100/mcp
+18
View File
@@ -201,6 +201,22 @@ const handleListDelete = (list, cards) => ({
}, },
}); });
const addListToBoardFilter = (id, boardId) => ({
type: ActionTypes.LIST_TO_BOARD_FILTER_ADD,
payload: {
id,
boardId,
},
});
const removeListFromBoardFilter = (id, boardId) => ({
type: ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE,
payload: {
id,
boardId,
},
});
export default { export default {
createList, createList,
handleListCreate, handleListCreate,
@@ -212,4 +228,6 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToBoardFilter,
removeListFromBoardFilter,
}; };
@@ -46,6 +46,7 @@ const FiniteContent = React.memo(() => {
return ( return (
<View <View
cardIds={cardIds} cardIds={cardIds}
isReorderingEnabled={board.view === BoardViews.LIST}
onCardCreate={canAddCard ? handleCardCreate : undefined} onCardCreate={canAddCard ? handleCardCreate : undefined}
onCardPaste={canAddCard ? handleCardPaste : undefined} onCardPaste={canAddCard ? handleCardPaste : undefined}
/> />
+110 -11
View File
@@ -6,22 +6,39 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Icon, Loader } from 'semantic-ui-react'; import { Button, Icon, Loader } from 'semantic-ui-react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { Tooltip } from '../../../lib/custom-ui'; import { Tooltip } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { BoardMembershipRoles } from '../../../constants/Enums'; import { BoardMembershipRoles } from '../../../constants/Enums';
import DroppableTypes from '../../../constants/DroppableTypes';
import parseDndId from '../../../utils/parse-dnd-id';
import { closePopup } from '../../../lib/popup';
import globalStyles from '../../../styles.module.scss';
import Card from '../../cards/Card'; import Card from '../../cards/Card';
import DraggableCard from '../../cards/DraggableCard';
import AddCard from '../../cards/AddCard'; import AddCard from '../../cards/AddCard';
import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react'; import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
import styles from './ListView.module.scss'; import styles from './ListView.module.scss';
const ListView = React.memo( const ListView = React.memo(
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => { ({
cardIds,
isCardsFetching,
isAllCardsFetched,
isReorderingEnabled,
onCardsFetch,
onCardCreate,
onCardPaste,
}) => {
const store = useStore();
const dispatch = useDispatch();
const clipboard = useSelector(selectors.selectClipboard); const clipboard = useSelector(selectors.selectClipboard);
const { canAddCard, canPasteCard } = useSelector((state) => { const { canAddCard, canPasteCard } = useSelector((state) => {
@@ -54,6 +71,94 @@ const ListView = React.memo(
setIsAddCardOpened(false); setIsAddCardOpened(false);
}, []); }, []);
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 || type !== DroppableTypes.CARD) {
return;
}
if (source.index === destination.index) {
return;
}
const cardId = parseDndId(draggableId);
const state = store.getState();
const getListId = (id) => {
const card = id ? selectors.selectCardById(state, id) : null;
return card ? card.listId : null;
};
const newOrder = cardIds.filter((id) => id !== cardId);
const prevId = destination.index > 0 ? newOrder[destination.index - 1] : null;
const nextId = destination.index < newOrder.length ? newOrder[destination.index] : null;
const targetListId = getListId(prevId) || getListId(nextId);
if (!targetListId) {
return;
}
let localIndex = 0;
for (let i = 0; i < destination.index; i += 1) {
if (getListId(newOrder[i]) === targetListId) {
localIndex += 1;
}
}
dispatch(entryActions.moveCard(cardId, targetListId, localIndex));
},
[cardIds, dispatch, store],
);
const renderCardsContent = () => {
if (cardIds.length === 0) {
return null;
}
if (!isReorderingEnabled) {
return (
<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>
);
}
return (
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="list-view" type={DroppableTypes.CARD}>
{({ innerRef, droppableProps, placeholder }) => (
<div
{...droppableProps} // eslint-disable-line react/jsx-props-no-spreading
ref={innerRef}
className={classNames(styles.segment, styles.cards)}
>
{cardIds.map((cardId, cardIndex) => (
<DraggableCard
key={cardId}
isInline
id={cardId}
index={cardIndex}
className={styles.card}
/>
))}
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
};
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{canAddCard && {canAddCard &&
@@ -88,15 +193,7 @@ const ListView = React.memo(
)} )}
</div> </div>
))} ))}
{cardIds.length > 0 && ( {renderCardsContent()}
<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 && ( {isCardsFetching !== undefined && isAllCardsFetched !== undefined && (
<div className={styles.loaderWrapper}> <div className={styles.loaderWrapper}>
{isCardsFetching ? ( {isCardsFetching ? (
@@ -115,6 +212,7 @@ ListView.propTypes = {
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isCardsFetching: PropTypes.bool, isCardsFetching: PropTypes.bool,
isAllCardsFetched: PropTypes.bool, isAllCardsFetched: PropTypes.bool,
isReorderingEnabled: PropTypes.bool,
onCardsFetch: PropTypes.func, onCardsFetch: PropTypes.func,
onCardCreate: PropTypes.func, onCardCreate: PropTypes.func,
onCardPaste: PropTypes.func, onCardPaste: PropTypes.func,
@@ -123,6 +221,7 @@ ListView.propTypes = {
ListView.defaultProps = { ListView.defaultProps = {
isCardsFetching: undefined, isCardsFetching: undefined,
isAllCardsFetched: undefined, isAllCardsFetched: undefined,
isReorderingEnabled: false,
onCardsFetch: undefined, onCardsFetch: undefined,
onCardCreate: undefined, onCardCreate: undefined,
onCardPaste: undefined, onCardPaste: undefined,
@@ -5,6 +5,7 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -16,19 +17,59 @@ import { Input, Tooltip } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks'; import { useNestedRef } from '../../../hooks';
import { BoardViews, LabelFilterModes } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar'; import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep'; import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip'; import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep'; import LabelsStep from '../../labels/LabelsStep';
import { LabelFilterModes } from '../../../constants/Enums'; import ListsFilterStep from '../../lists/ListsFilterStep';
import SaveFilterStep from './SaveFilterStep';
import SavedFiltersStep from './SavedFiltersStep';
import {
addSavedFilter,
clearLastFilter,
readLastFilter,
readSavedFilters,
removeSavedFilter,
writeLastFilter,
} from './useFilterStorage';
import styles from './Filters.module.scss'; import styles from './Filters.module.scss';
// Membership comparison — order carries no meaning for a filter selection.
const sameIds = (a, b) => a.length === b.length && a.every((id) => b.includes(id));
const FilterListChip = React.memo(({ id, onClick }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleClick = useCallback(() => {
onClick(id);
}, [id, onClick]);
if (!list) {
return null;
}
return (
<button type="button" className={styles.filterButton} onClick={handleClick}>
<span className={styles.filterLabel}>{list.name || t(`common.${list.type}`)}</span>
</button>
);
});
FilterListChip.propTypes = {
id: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const Filters = React.memo(() => { const Filters = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard); const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard); const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard); const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId); const currentUserId = useSelector(selectors.selectCurrentUserId);
const withCurrentUserSelector = useSelector( const withCurrentUserSelector = useSelector(
@@ -39,6 +80,11 @@ const Filters = React.memo(() => {
const [t] = useTranslation(); const [t] = useTranslation();
const [search, setSearch] = useState(board.search); const [search, setSearch] = useState(board.search);
const [isSearchFocused, setIsSearchFocused] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false);
// Cache-buster for the two localStorage buckets below. Bumping it after a
// write re-runs the memos that read them, which is all the syncing they
// need — nothing here watches storage, so another window's snapshot never
// arrives uninvited.
const [storageRev, setStorageRev] = useState(0);
const debouncedSearch = useMemo( const debouncedSearch = useMemo(
() => () =>
@@ -70,12 +116,73 @@ const Filters = React.memo(() => {
[labelIds, excludedLabelIds], [labelIds, excludedLabelIds],
); );
// Everything the board is currently filtered by, in the shape the storage
// helpers read and write.
const currentFilter = useMemo(
() => ({
userIds,
labelIds,
search: board.search || '',
excludedLabelIds,
listIds,
noMember: !!board.filterNoMember,
}),
[board.filterNoMember, board.search, excludedLabelIds, labelIds, listIds, userIds],
);
const hasFilter =
userIds.length > 0 ||
labelIds.length > 0 ||
excludedLabelIds.length > 0 ||
listIds.length > 0 ||
!!board.filterNoMember ||
currentFilter.search.length > 0;
const savedFilters = useMemo(
() => readSavedFilters(currentUserId, board.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[board.id, currentUserId, storageRev],
);
// Only looked up while the board is unfiltered, and only to decide whether
// the restore button is worth showing. Reading it never applies it.
const lastFilter = useMemo(
() => (hasFilter ? null : readLastFilter(currentUserId, board.id)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[board.id, currentUserId, hasFilter, storageRev],
);
// Which saved filter, if any, matches the board exactly. Drives both the
// highlight in the list and the second-click-to-deactivate behaviour.
const activeSavedFilterId = useMemo(() => {
const match = savedFilters.find(
(item) =>
sameIds(item.userIds || [], currentFilter.userIds) &&
sameIds(item.labelIds || [], currentFilter.labelIds) &&
sameIds(item.excludedLabelIds || [], currentFilter.excludedLabelIds) &&
sameIds(item.listIds || [], currentFilter.listIds) &&
!!item.noMember === currentFilter.noMember &&
(item.search || '') === currentFilter.search,
);
return match && match.id;
}, [currentFilter, savedFilters]);
// Taking a filter apart chip by chip says "these values are not what I
// want", so every such deselect drops the recall snapshot. Only the clear
// button, which sets the snapshot on its way out, preserves it.
const invalidateLastFilter = useCallback(() => {
clearLastFilter(currentUserId, board.id);
setStorageRev((prevStorageRev) => prevStorageRev + 1);
}, [board.id, currentUserId]);
const cancelSearch = useCallback(() => { const cancelSearch = useCallback(() => {
debouncedSearch.cancel(); debouncedSearch.cancel();
setSearch(''); setSearch('');
dispatch(entryActions.searchInCurrentBoard('')); dispatch(entryActions.searchInCurrentBoard(''));
invalidateLastFilter();
searchFieldRef.current.blur(); searchFieldRef.current.blur();
}, [dispatch, debouncedSearch, searchFieldRef]); }, [dispatch, debouncedSearch, invalidateLastFilter, searchFieldRef]);
const handleUserSelect = useCallback( const handleUserSelect = useCallback(
(userId) => { (userId) => {
@@ -91,8 +198,9 @@ const Filters = React.memo(() => {
const handleUserDeselect = useCallback( const handleUserDeselect = useCallback(
(userId) => { (userId) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId)); dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
invalidateLastFilter();
}, },
[dispatch], [dispatch, invalidateLastFilter],
); );
const handleUserClick = useCallback( const handleUserClick = useCallback(
@@ -102,10 +210,26 @@ const Filters = React.memo(() => {
}, },
}) => { }) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId)); dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
invalidateLastFilter();
},
[dispatch, invalidateLastFilter],
);
const handleListSelect = useCallback(
(listId) => {
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
}, },
[dispatch], [dispatch],
); );
const handleListDeselect = useCallback(
(listId) => {
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
invalidateLastFilter();
},
[dispatch, invalidateLastFilter],
);
const handleLabelClick = useCallback( const handleLabelClick = useCallback(
({ ({
currentTarget: { currentTarget: {
@@ -113,15 +237,22 @@ const Filters = React.memo(() => {
}, },
}) => { }) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE)); dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
invalidateLastFilter();
}, },
[dispatch], [dispatch, invalidateLastFilter],
); );
const handleLabelModeChange = useCallback( const handleLabelModeChange = useCallback(
(labelId, mode) => { (labelId, mode) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode)); dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode));
// Switching a label between include and exclude is still filtering;
// only dropping it altogether counts as a deselect.
if (mode === LabelFilterModes.NONE) {
invalidateLastFilter();
}
}, },
[dispatch], [dispatch, invalidateLastFilter],
); );
const handleSearchChange = useCallback( const handleSearchChange = useCallback(
@@ -153,14 +284,145 @@ const Filters = React.memo(() => {
cancelSearch(); cancelSearch();
}, [cancelSearch]); }, [cancelSearch]);
const handleNoMemberClick = useCallback(() => {
if (board.filterNoMember) {
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
invalidateLastFilter();
} else {
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
}
}, [board.filterNoMember, dispatch, invalidateLastFilter]);
// Take the board back to unfiltered, one existing entry action per
// dimension — the same path the controls above use, so other sessions see
// it exactly as they always have.
const clearCurrentFilter = useCallback(() => {
userIds.forEach((userId) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
});
[...labelIds, ...excludedLabelIds].forEach((labelId) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
});
listIds.forEach((listId) => {
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
});
if (board.filterNoMember) {
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
}
debouncedSearch.cancel();
setSearch('');
if (board.search) {
dispatch(entryActions.searchInCurrentBoard(''));
}
}, [
board.filterNoMember,
board.search,
debouncedSearch,
dispatch,
excludedLabelIds,
labelIds,
listIds,
userIds,
]);
// The mirror image, used by both the restore button and the saved-filter
// list. "No member" goes first because setting it clears the member
// selection, which would otherwise undo the users we just added.
const applyFilter = useCallback(
(filter) => {
if (filter.noMember) {
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
}
(filter.userIds || []).forEach((userId) => {
dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
});
(filter.labelIds || []).forEach((labelId) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.INCLUDE));
});
(filter.excludedLabelIds || []).forEach((labelId) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.EXCLUDE));
});
(filter.listIds || []).forEach((listId) => {
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
});
if (filter.search) {
debouncedSearch.cancel();
setSearch(filter.search);
dispatch(entryActions.searchInCurrentBoard(filter.search));
}
},
[debouncedSearch, dispatch],
);
// Clearing everything at once leaves the snapshot behind, so the very next
// click of the restore button can put it back.
const handleClearClick = useCallback(() => {
writeLastFilter(currentUserId, board.id, currentFilter);
clearCurrentFilter();
setStorageRev((prevStorageRev) => prevStorageRev + 1);
}, [board.id, clearCurrentFilter, currentFilter, currentUserId]);
const handleRestoreClick = useCallback(() => {
if (lastFilter) {
applyFilter(lastFilter);
}
}, [applyFilter, lastFilter]);
// Saved filters replace rather than merge. Picking the one already on the
// board turns it off instead, so the same row toggles both ways.
const handleSavedFilterApply = useCallback(
(item) => {
clearCurrentFilter();
if (item.id !== activeSavedFilterId) {
applyFilter(item);
}
},
[activeSavedFilterId, applyFilter, clearCurrentFilter],
);
const handleSavedFilterRemove = useCallback(
(id) => {
removeSavedFilter(currentUserId, board.id, id);
setStorageRev((prevStorageRev) => prevStorageRev + 1);
},
[board.id, currentUserId],
);
const handleSaveFilter = useCallback(
(name) => {
addSavedFilter(currentUserId, board.id, {
name,
...currentFilter,
});
setStorageRev((prevStorageRev) => prevStorageRev + 1);
},
[board.id, currentFilter, currentUserId],
);
useDidUpdate(() => { useDidUpdate(() => {
setSearch(board.search); setSearch(board.search);
}, [board.search]); }, [board.search]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep); const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep); const LabelsPopup = usePopup(LabelsStep);
const ListsFilterPopup = usePopup(ListsFilterStep);
const SaveFilterPopup = usePopup(SaveFilterStep);
const SavedFiltersPopup = usePopup(SavedFiltersStep);
const isSearchActive = search || isSearchFocused; const isSearchActive = search || isSearchFocused;
const isListView = board.view === BoardViews.LIST;
return ( return (
<> <>
@@ -176,6 +438,13 @@ const Filters = React.memo(() => {
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>} {userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button> </button>
</BoardMembershipsPopup> </BoardMembershipsPopup>
<button
type="button"
className={classNames(styles.filterButton, styles.filterLabel)}
onClick={handleNoMemberClick}
>
{t('common.noMember')}
</button>
{userIds.length === 0 && withCurrentUserSelector && ( {userIds.length === 0 && withCurrentUserSelector && (
<Tooltip content={t('action.filterByCurrentUser')}> <Tooltip content={t('action.filterByCurrentUser')}>
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}> <button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
@@ -217,6 +486,26 @@ const Filters = React.memo(() => {
</span> </span>
))} ))}
</span> </span>
{isListView && (
<span className={styles.filter}>
<ListsFilterPopup
currentIds={listIds}
title="common.filterByLists"
onSelect={handleListSelect}
onDeselect={handleListDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.lists')}:`}</span>
{listIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</ListsFilterPopup>
{listIds.map((listId) => (
<FilterListChip key={listId} id={listId} onClick={handleListDeselect} />
))}
</span>
)}
<span className={styles.filter}> <span className={styles.filter}>
<Input <Input
ref={handleSearchFieldRef} ref={handleSearchFieldRef}
@@ -237,6 +526,64 @@ const Filters = React.memo(() => {
onBlur={handleSearchBlur} onBlur={handleSearchBlur}
/> />
</span> </span>
<span className={styles.filter}>
{hasFilter && (
<Tooltip content={t('common.clearFilter')}>
<button type="button" className={styles.filterButton} onClick={handleClearClick}>
<span className={styles.filterLabel}>
<Icon fitted name="close" className={styles.filterLabelIcon} />
</span>
</button>
</Tooltip>
)}
{/* Takes the place of the clear button once the filter is gone, and
only while there is something to put back. */}
{!hasFilter && !!lastFilter && (
<Tooltip content={t('common.restoreFilter')}>
<button type="button" className={styles.filterButton} onClick={handleRestoreClick}>
<span className={styles.filterLabel}>
<Icon fitted name="undo" className={styles.filterLabelIcon} />
</span>
</button>
</Tooltip>
)}
{/* No Tooltip around either popup trigger: usePopup clones its
immediate child to attach the click handler, so anything wrapped
around the button swallows the open. */}
{hasFilter && (
<SaveFilterPopup onSave={handleSaveFilter}>
<button
type="button"
title={t('common.saveFilter', {
context: 'title',
})}
className={styles.filterButton}
>
<span className={styles.filterLabel}>
<Icon fitted name="bookmark outline" className={styles.filterLabelIcon} />
</span>
</button>
</SaveFilterPopup>
)}
<SavedFiltersPopup
items={savedFilters}
activeId={activeSavedFilterId}
onApply={handleSavedFilterApply}
onRemove={handleSavedFilterRemove}
>
<button
type="button"
title={t('common.savedFilters', {
context: 'title',
})}
className={styles.filterButton}
>
<span className={styles.filterLabel}>
<Icon fitted name="bookmark" className={styles.filterLabelIcon} />
</span>
</button>
</SavedFiltersPopup>
</span>
</> </>
); );
}); });
@@ -0,0 +1,86 @@
/*!
* 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, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react';
import { Input, Popup } from '../../../lib/custom-ui';
import { useNestedRef } from '../../../hooks';
import styles from './SaveFilterStep.module.scss';
// Small naming popup. The filter being saved is whatever is active on
// the board right now, so this step only hands the name back through
// `onSave` — Filters does the writing, which also lets it kick the
// re-render that exposes the new entry in the saved-filters list.
const SaveFilterStep = React.memo(({ defaultName, onSave, onClose }) => {
const [t] = useTranslation();
const [name, setName] = useState(defaultName);
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
useEffect(() => {
nameFieldRef.current.focus({
preventScroll: true,
});
nameFieldRef.current.select();
}, [nameFieldRef]);
const handleChange = useCallback((_, { value }) => {
setName(value);
}, []);
const handleSubmit = useCallback(() => {
const cleanName = name.trim();
if (!cleanName) {
nameFieldRef.current.select();
return;
}
onSave(cleanName);
onClose();
}, [name, nameFieldRef, onClose, onSave]);
return (
<>
<Popup.Header>
{t('common.saveFilter', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Form onSubmit={handleSubmit}>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={name}
placeholder={t('common.filterName')}
maxLength={64}
className={styles.field}
onChange={handleChange}
/>
<Button positive content={t('action.save')} />
</Form>
</Popup.Content>
</>
);
});
SaveFilterStep.propTypes = {
defaultName: PropTypes.string,
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
SaveFilterStep.defaultProps = {
defaultName: '',
};
export default SaveFilterStep;
@@ -0,0 +1,10 @@
/*!
* 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;
}
}
@@ -0,0 +1,136 @@
/*!
* 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 classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import { useSteps } from '../../../hooks';
import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './SavedFiltersStep.module.scss';
const StepTypes = {
DELETE: 'DELETE',
};
// The saved-filter picker. Each row is a name (apply, or deactivate when it
// is the one already matching the board) plus an X that removes it after a
// confirmation, the same way every other destructive row in the app behaves.
//
// Opening this popup reads localStorage but applies nothing; a saved filter
// only reaches the board when a row is clicked.
const SavedFiltersStep = React.memo(({ items, activeId, onApply, onRemove, onBack, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleApplyClick = useCallback(
({
currentTarget: {
dataset: { id },
},
}) => {
onApply(items.find((item) => item.id === id));
onClose();
},
[items, onApply, onClose],
);
const handleRemoveClick = useCallback(
({
currentTarget: {
dataset: { id },
},
}) => {
openStep(StepTypes.DELETE, {
id,
});
},
[openStep],
);
const handleRemoveConfirm = useCallback(() => {
onRemove(step.params.id);
handleBack();
}, [handleBack, onRemove, step]);
if (step && step.type === StepTypes.DELETE) {
return (
<ConfirmationStep
title="common.deleteFilter"
content="common.areYouSureYouWantToDeleteThisFilter"
buttonContent="action.delete"
onConfirm={handleRemoveConfirm}
onBack={handleBack}
/>
);
}
return (
<>
<Popup.Header onBack={onBack}>
{t('common.savedFilters', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{items.length === 0 ? (
<div className={styles.empty}>{t('common.noSavedFilters')}</div>
) : (
<div className={styles.items}>
{items.map((item) => {
const isActive = item.id === activeId;
return (
<div key={item.id} className={styles.item}>
<button
type="button"
data-id={item.id}
title={isActive ? t('common.deactivateFilter') : t('common.applyFilter')}
className={classNames(styles.itemButton, isActive && styles.itemButtonActive)}
onClick={handleApplyClick}
>
<Icon fitted name={isActive ? 'check' : 'filter'} className={styles.itemIcon} />
<span className={styles.itemName}>{item.name}</span>
</button>
<button
type="button"
data-id={item.id}
title={t('common.deleteFilter', {
context: 'title',
})}
className={styles.itemRemoveButton}
onClick={handleRemoveClick}
>
<Icon fitted name="trash alternate outline" />
</button>
</div>
);
})}
</div>
)}
</Popup.Content>
</>
);
});
SavedFiltersStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
activeId: PropTypes.string,
onApply: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
SavedFiltersStep.defaultProps = {
activeId: undefined,
onBack: undefined,
};
export default SavedFiltersStep;
@@ -0,0 +1,70 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.empty {
color: #6b808c;
font-size: 13px;
font-style: italic;
padding: 4px 0;
}
.item {
display: flex;
margin-bottom: 4px;
max-width: 280px;
white-space: nowrap;
}
.itemButton {
background: rgba(9, 30, 66, 0.04);
border: none;
border-radius: 3px;
color: #17394d;
cursor: pointer;
flex: 1 1 auto;
font-size: 14px;
overflow: hidden;
padding: 8px 10px;
text-align: left;
text-overflow: ellipsis;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
}
.itemButtonActive {
background: rgba(9, 30, 66, 0.13);
}
.itemIcon {
color: rgba(9, 30, 66, 0.24);
font-size: 12px;
margin: 0 8px 0 0;
width: 14px;
}
.itemName {
overflow: hidden;
text-overflow: ellipsis;
}
.itemRemoveButton {
background: transparent;
border: none;
color: #6b808c;
cursor: pointer;
flex: 0 0 auto;
font-size: 12px;
margin-left: 4px;
outline: none;
padding: 8px;
&:hover {
color: #17394d;
}
}
}
@@ -0,0 +1,225 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
// LocalStorage helpers behind the two filter-recall behaviours:
//
// • "last filter" — clearing the whole filter via the X leaves a
// snapshot here, so the next click of the restore button can put
// it back.
// • "saved filters" — named filter recipes, per user and per board.
//
// NOTHING IN HERE IS EVER APPLIED ON ITS OWN. There is no effect that
// reads a stored filter when a board opens; every read below happens
// inside a click handler, or to decide whether a button is worth
// rendering. That restraint is what makes localStorage the right home
// for this: two windows on the same board hold their own live filter
// (which still lives on the Board model in redux-orm and still travels
// over the existing entry actions) and neither one is ever overwritten
// by what the other stashed away.
//
// The keys and the `{ userIds, labelIds, search }` core of the payload
// are deliberately identical to PLANKA Pro's, so a user's saved filters
// survive an upgrade: same origin, same database, same user ids.
// Community's own filter dimensions are stored alongside as extra
// fields, which Pro simply reads as absent.
const LAST_KEY_PREFIX = 'planka:filter:last';
const SAVED_KEY_PREFIX = 'planka:filter:saved';
const buildKey = (prefix, userId, boardId) => {
if (!userId || !boardId) {
return null;
}
return `${prefix}:${userId}:${boardId}`;
};
// Stable-ish id for a saved filter. It doesn't need to be globally
// unique — just unique inside one (user, board) bucket. Time plus a
// short random suffix is plenty, and reads cleanly when inspecting
// localStorage by hand.
const createSavedFilterId = () => {
const time = Date.now().toString(36);
const random = Math.floor(Math.random() * 1e6)
.toString(36)
.padStart(4, '0');
return `f-${time}-${random}`;
};
// A filter worth remembering. Every dimension Community can filter on
// counts, including the ones Pro has no equivalent for — otherwise a
// "cards without a member" filter could never be saved.
const isFilterMeaningful = (filter) =>
!!filter &&
((filter.userIds && filter.userIds.length > 0) ||
(filter.labelIds && filter.labelIds.length > 0) ||
(filter.excludedLabelIds && filter.excludedLabelIds.length > 0) ||
(filter.listIds && filter.listIds.length > 0) ||
!!filter.noMember ||
(filter.search && filter.search.length > 0));
// One shape for both buckets. `userIds` / `labelIds` / `search` are the
// fields Pro reads; the rest are Community-only and additive.
const normalizeFilter = (filter) => ({
userIds: filter.userIds || [],
labelIds: filter.labelIds || [],
search: filter.search || '',
excludedLabelIds: filter.excludedLabelIds || [],
listIds: filter.listIds || [],
noMember: !!filter.noMember,
});
export const readLastFilter = (userId, boardId) => {
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
if (!key) {
return null;
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
return isFilterMeaningful(parsed) ? parsed : null;
} catch {
return null;
}
};
export const writeLastFilter = (userId, boardId, filter) => {
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
if (!key) {
return;
}
try {
if (isFilterMeaningful(filter)) {
window.localStorage.setItem(key, JSON.stringify(normalizeFilter(filter)));
} else {
window.localStorage.removeItem(key);
}
} catch {
// Storage may be full, blocked (private mode), or otherwise
// unavailable. The feature degrades silently — worst case the user
// just doesn't get the recall behaviour.
}
};
export const clearLastFilter = (userId, boardId) => {
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
if (!key) {
return;
}
try {
window.localStorage.removeItem(key);
} catch {
// See writeLastFilter.
}
};
// A list of { id, name, ...filter } per (user, board). The id is local
// to this bucket and lets us address one entry for removal. Order:
// most recently saved first, so the user's last save is easy to spot.
export const readSavedFilters = (userId, boardId) => {
const key = buildKey(SAVED_KEY_PREFIX, userId, boardId);
if (!key) {
return [];
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(entry) =>
entry &&
typeof entry.id === 'string' &&
typeof entry.name === 'string' &&
isFilterMeaningful(entry),
);
} catch {
return [];
}
};
const writeSavedFilters = (userId, boardId, entries) => {
const key = buildKey(SAVED_KEY_PREFIX, userId, boardId);
if (!key) {
return;
}
try {
if (entries.length > 0) {
window.localStorage.setItem(key, JSON.stringify(entries));
} else {
window.localStorage.removeItem(key);
}
} catch {
// See writeLastFilter.
}
};
export const addSavedFilter = (userId, boardId, { name, ...filter }) => {
if (!userId || !boardId) {
return null;
}
const cleanName = name.trim();
if (!cleanName) {
return null;
}
const cleanFilter = normalizeFilter(filter);
if (!isFilterMeaningful(cleanFilter)) {
return null;
}
const entry = {
id: createSavedFilterId(),
name: cleanName,
...cleanFilter,
};
const current = readSavedFilters(userId, boardId);
// Newest first, and re-saving under an existing name overwrites that
// entry rather than adding a duplicate.
writeSavedFilters(userId, boardId, [entry, ...current.filter((item) => item.name !== cleanName)]);
return entry;
};
export const removeSavedFilter = (userId, boardId, id) => {
const current = readSavedFilters(userId, boardId);
writeSavedFilters(
userId,
boardId,
current.filter((entry) => entry.id !== id),
);
};
@@ -43,6 +43,15 @@ const Others = React.memo(() => {
className={styles.radio} className={styles.radio}
onChange={handleChange} onChange={handleChange}
/> />
<Radio
toggle
name="showCardCounter"
checked={board.showCardCounter}
label={t('common.showCardCounter')}
className={styles.radio}
onChange={handleChange}
/>
<p className={styles.hint}>{t('common.showCardCounterHint')}</p>
<Radio <Radio
toggle toggle
name="displayCardAges" name="displayCardAges"
@@ -4,6 +4,15 @@
*/ */
:global(#app) { :global(#app) {
// Sits under the toggle it belongs to: pulled up out of that toggle's 16px
// gap so only a few pixels separate the two, and carrying the full 16px on
// its own underside so the next toggle keeps its distance.
.hint {
color: #666666;
margin-bottom: 16px;
margin-top: -12px;
}
.radio { .radio {
margin-bottom: 16px; margin-bottom: 16px;
width: 100%; width: 100%;
@@ -3,6 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import keyBy from 'lodash/keyBy';
import React, { useCallback, useContext, useMemo, useState } from 'react'; import React, { useCallback, useContext, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -16,6 +17,7 @@ import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks'; import { usePopupInClosableContext } from '../../../hooks';
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch'; import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
import { isUsableMarkdownElement } from '../../../utils/element-helpers'; import { isUsableMarkdownElement } from '../../../utils/element-helpers';
import { mentionTextToMarkup } from '../../../utils/mentions';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
import { CardTypeIcons } from '../../../constants/Icons'; import { CardTypeIcons } from '../../../constants/Icons';
import { ClosableContext } from '../../../contexts'; import { ClosableContext } from '../../../contexts';
@@ -50,6 +52,7 @@ const ProjectContent = React.memo(() => {
const card = useSelector(selectors.selectCurrentCard); const card = useSelector(selectors.selectCurrentCard);
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const userIds = useSelector(selectors.selectUserIdsForCurrentCard); const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard); const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard); const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
@@ -142,6 +145,15 @@ const ProjectContent = React.memo(() => {
}, shallowEqual); }, shallowEqual);
const dispatch = useDispatch(); const dispatch = useDispatch();
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
({ username }) => username.toLowerCase(),
),
[boardMemberships],
);
const [t] = useTranslation(); const [t] = useTranslation();
const [descriptionDraft, setDescriptionDraft] = useState(null); const [descriptionDraft, setDescriptionDraft] = useState(null);
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false); const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
@@ -169,11 +181,11 @@ const ProjectContent = React.memo(() => {
(description) => { (description) => {
dispatch( dispatch(
entryActions.updateCurrentCard({ entryActions.updateCurrentCard({
description, description: description && mentionTextToMarkup(description, userByUsername),
}), }),
); );
}, },
[dispatch], [dispatch, userByUsername],
); );
const handleDueCompletionChange = useCallback(() => { const handleDueCompletionChange = useCallback(() => {
@@ -3,6 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import keyBy from 'lodash/keyBy';
import React, { useCallback, useContext, useMemo, useState } from 'react'; import React, { useCallback, useContext, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -16,6 +17,7 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks'; import { usePopupInClosableContext } from '../../../hooks';
import { isUsableMarkdownElement } from '../../../utils/element-helpers'; import { isUsableMarkdownElement } from '../../../utils/element-helpers';
import { mentionTextToMarkup } from '../../../utils/mentions';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
import { CardTypeIcons } from '../../../constants/Icons'; import { CardTypeIcons } from '../../../constants/Icons';
import { ClosableContext } from '../../../contexts'; import { ClosableContext } from '../../../contexts';
@@ -46,6 +48,7 @@ const StoryContent = React.memo(() => {
const card = useSelector(selectors.selectCurrentCard); const card = useSelector(selectors.selectCurrentCard);
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const userIds = useSelector(selectors.selectUserIdsForCurrentCard); const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard); const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard); const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
@@ -137,6 +140,15 @@ const StoryContent = React.memo(() => {
}, shallowEqual); }, shallowEqual);
const dispatch = useDispatch(); const dispatch = useDispatch();
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
({ username }) => username.toLowerCase(),
),
[boardMemberships],
);
const [t] = useTranslation(); const [t] = useTranslation();
const [descriptionDraft, setDescriptionDraft] = useState(null); const [descriptionDraft, setDescriptionDraft] = useState(null);
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false); const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
@@ -164,11 +176,11 @@ const StoryContent = React.memo(() => {
(description) => { (description) => {
dispatch( dispatch(
entryActions.updateCurrentCard({ entryActions.updateCurrentCard({
description, description: description && mentionTextToMarkup(description, userByUsername),
}), }),
); );
}, },
[dispatch], [dispatch, userByUsername],
); );
const handleRestoreClick = useCallback(() => { const handleRestoreClick = useCallback(() => {
@@ -5,6 +5,7 @@
import keyBy from 'lodash/keyBy'; import keyBy from 'lodash/keyBy';
import React, { useCallback, useState, useRef, useMemo } from 'react'; import React, { useCallback, useState, useRef, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Mention, MentionsInput } from 'react-mentions'; import { Mention, MentionsInput } from 'react-mentions';
@@ -24,7 +25,7 @@ const DEFAULT_DATA = {
text: '', text: '',
}; };
const Add = React.memo(() => { const Add = React.memo(({ initialText, onInitialTextConsumed }) => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard); const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -138,6 +139,19 @@ const Add = React.memo(() => {
textInputRef.current.focus(); textInputRef.current.focus();
}, [selectTextFieldState]); }, [selectTextFieldState]);
useDidUpdate(() => {
if (!initialText) {
return;
}
setData({
text: initialText,
});
setIsOpened(true);
selectTextField();
onInitialTextConsumed();
}, [initialText, onInitialTextConsumed, selectTextField, setData]);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<div ref={textFieldRef} className={styles.field}> <div ref={textFieldRef} className={styles.field}>
@@ -188,4 +202,14 @@ const Add = React.memo(() => {
); );
}); });
Add.propTypes = {
initialText: PropTypes.string,
onInitialTextConsumed: PropTypes.func,
};
Add.defaultProps = {
initialText: undefined,
onInitialTextConsumed: () => {},
};
export default Add; export default Add;
@@ -3,7 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useMemo } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { Comment, Loader } from 'semantic-ui-react'; import { Comment, Loader } from 'semantic-ui-react';
@@ -45,6 +45,15 @@ const Comments = React.memo(() => {
}); });
const dispatch = useDispatch(); const dispatch = useDispatch();
const [replyText, setReplyText] = useState();
const handleReply = useCallback((username) => {
setReplyText(`@${username} `);
}, []);
const handleReplyTextConsumed = useCallback(() => {
setReplyText(undefined);
}, []);
const [inViewRef] = useInView({ const [inViewRef] = useInView({
threshold: 1, threshold: 1,
@@ -57,11 +66,11 @@ const Comments = React.memo(() => {
return ( return (
<> <>
{cadAdd && <Add />} {cadAdd && <Add initialText={replyText} onInitialTextConsumed={handleReplyTextConsumed} />}
<div className={styles.itemsWrapper}> <div className={styles.itemsWrapper}>
<Comment.Group className={styles.items}> <Comment.Group className={styles.items}>
{commentIds.map((commentId) => ( {commentIds.map((commentId) => (
<Item key={commentId} id={commentId} /> <Item key={commentId} id={commentId} canReply={cadAdd} onReply={handleReply} />
))} ))}
</Comment.Group> </Comment.Group>
</div> </div>
@@ -25,7 +25,7 @@ import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
const Item = React.memo(({ id }) => { const Item = React.memo(({ id, canReply, onReply }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []); const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []); const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -83,6 +83,12 @@ const Item = React.memo(({ id }) => {
setIsEditOpened(true); setIsEditOpened(true);
}, []); }, []);
const handleReplyClick = useCallback(() => {
if (user.username) {
onReply(user.username);
}
}, [onReply, user.username]);
const handleEditClose = useCallback(() => { const handleEditClose = useCallback(() => {
setIsEditOpened(false); setIsEditOpened(false);
}, []); }, []);
@@ -117,8 +123,18 @@ const Item = React.memo(({ id }) => {
<span className={styles.date}> <span className={styles.date}>
<TimeAgo date={comment.createdAt} /> <TimeAgo date={comment.createdAt} />
</span> </span>
{(canEdit || canDelete) && ( {(canReply || canEdit || canDelete) && (
<span className={styles.actions}> <span className={styles.actions}>
{canReply && user.username && (
<Comment.Action
as="button"
content={t('action.reply', {
defaultValue: 'Reply',
})}
disabled={!comment.isPersisted}
onClick={handleReplyClick}
/>
)}
{canEdit && ( {canEdit && (
<Comment.Action <Comment.Action
as="button" as="button"
@@ -153,6 +169,13 @@ const Item = React.memo(({ id }) => {
Item.propTypes = { Item.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
canReply: PropTypes.bool,
onReply: PropTypes.func,
};
Item.defaultProps = {
canReply: false,
onReply: () => {},
}; };
export default Item; export default Item;
@@ -3,8 +3,9 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import keyBy from 'lodash/keyBy';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
@@ -13,6 +14,7 @@ import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks'; import { useNestedRef } from '../../../hooks';
import { mentionMarkupToText, mentionTextToMarkup } from '../../../utils/mentions';
import MarkdownEditor from '../MarkdownEditor'; import MarkdownEditor from '../MarkdownEditor';
import styles from './EditMarkdown.module.scss'; import styles from './EditMarkdown.module.scss';
@@ -21,10 +23,20 @@ const MAX_LENGTH = 1048576;
const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => { const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => {
const defaultMode = useSelector((state) => selectors.selectCurrentUser(state).defaultEditorMode); const defaultMode = useSelector((state) => selectors.selectCurrentUser(state).defaultEditorMode);
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.map(({ user }) => user),
(user) => user.username.toLowerCase(),
),
[boardMemberships],
);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [value, setValue] = useState(() => draftValue || defaultValue || ''); const [value, setValue] = useState(() => mentionMarkupToText(draftValue || defaultValue || ''));
const fieldRef = useRef(null); const fieldRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef(); const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
@@ -44,14 +56,15 @@ const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }
const isExceeded = value.length > MAX_LENGTH; const isExceeded = value.length > MAX_LENGTH;
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanValue = value.trim() || null; const valueWithMarkup = mentionTextToMarkup(value, userByUsername);
const cleanValue = valueWithMarkup.trim() || null;
if (!isExceeded && cleanValue !== defaultValue) { if (!isExceeded && cleanValue !== defaultValue) {
onUpdate(cleanValue); onUpdate(cleanValue);
} }
onClose(isExceeded ? cleanValue : null); onClose(isExceeded ? cleanValue : null);
}, [onUpdate, onClose, defaultValue, value, isExceeded]); }, [onUpdate, onClose, defaultValue, value, isExceeded, userByUsername]);
const handleChange = useCallback((nextValue) => { const handleChange = useCallback((nextValue) => {
setValue(nextValue); setValue(nextValue);
+17
View File
@@ -49,12 +49,24 @@ const List = React.memo(({ id, index }) => {
[], [],
); );
const selectCardCountByListId = useMemo(() => selectors.makeSelectCardCountByListId(), []);
const clipboard = useSelector(selectors.selectClipboard); const clipboard = useSelector(selectors.selectClipboard);
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser); const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
const list = useSelector((state) => selectListById(state, id)); const list = useSelector((state) => selectListById(state, id));
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id)); const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id));
// Shown on the add-card button when the board asks for it. Counted over every
// card the list holds, not the ones the filter leaves standing: a filter
// hides cards, it does not remove them, and a number that fell as the filter
// narrowed would say the list had emptied.
const showCardCounter = useSelector(
(state) => selectors.selectCurrentBoard(state).showCardCounter,
);
const cardCount = useSelector((state) => selectCardCountByListId(state, id));
const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector( const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
(state) => { (state) => {
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out? const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
@@ -148,6 +160,10 @@ const List = React.memo(({ id, index }) => {
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight; cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
}, [scrollBottomState]); }, [scrollBottomState]);
const cardCountNode = showCardCounter && cardCount !== null && (
<span className={styles.addCardButtonCount}>{cardCount}</span>
);
const ActionsPopup = usePopup(ActionsStep); const ActionsPopup = usePopup(ActionsStep);
const ArchiveCardsPopup = usePopup(ArchiveCardsStep); const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
@@ -274,6 +290,7 @@ const List = React.memo(({ id, index }) => {
)} )}
onClick={handleAddCardClick} onClick={handleAddCardClick}
> >
{cardCountNode}
<PlusMathIcon className={styles.addCardButtonIcon} /> <PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}> <span className={styles.addCardButtonText}>
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')} {cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
@@ -33,6 +33,24 @@
} }
} }
.addCardButtonCount {
background: #17394d;
border-radius: 10px;
color: #dfe3e6;
display: inline-block;
font-size: 11px;
font-weight: 700;
// Same 20px line box as the label beside it, so the two are centred on
// each other by construction rather than by a nudge that only holds at one
// font size.
line-height: 20px;
margin-right: 6px;
min-width: 20px;
padding: 0 6px;
text-align: center;
vertical-align: top;
}
.addCardButtonIcon { .addCardButtonIcon {
height: 20px; height: 20px;
padding: 0.64px; padding: 0.64px;
@@ -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 { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { ListTypes } from '../../../constants/Enums';
import { ListTypeIcons } from '../../../constants/Icons';
import styles from './Item.module.scss';
const Item = React.memo(({ id, isActive, onSelect, onDeselect }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleToggleClick = useCallback(() => {
if (!list.isPersisted) {
return;
}
if (isActive) {
onDeselect(id);
} else {
onSelect(id);
}
}, [id, isActive, onSelect, onDeselect, list.isPersisted]);
return (
<div className={styles.wrapper}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<span
className={classNames(styles.name, isActive && styles.nameActive)}
onClick={handleToggleClick}
>
{list.type !== ListTypes.ACTIVE && (
<Icon name={ListTypeIcons[list.type]} className={styles.nameIcon} />
)}
{list.name || t(`common.${list.type}`)}
</span>
</div>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
};
export default Item;
@@ -0,0 +1,54 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.name {
background: rgba(9, 30, 66, 0.04);
border-radius: 3px;
color: #17394d;
cursor: pointer;
flex: 1 1 auto;
font-size: 14px;
overflow: hidden;
padding: 8px 32px 8px 10px;
position: relative;
text-overflow: ellipsis;
&:hover {
background: rgba(9, 30, 66, 0.08);
}
}
.nameActive {
opacity: 0.45;
&:before {
bottom: 1px;
content: "Г";
font-size: 18px;
font-weight: normal;
line-height: 36px;
position: absolute;
right: 2px;
text-align: center;
transform: rotate(-135deg);
width: 36px;
}
}
.nameIcon {
color: rgba(9, 30, 66, 0.24);
font-size: 12px;
margin: 0 8px 0 0;
width: 14px;
}
.wrapper {
display: flex;
margin-bottom: 4px;
max-width: 280px;
white-space: nowrap;
}
}
@@ -0,0 +1,89 @@
/*!
* 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 { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Input, Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import { useField, useNestedRef } from '../../../hooks';
import Item from './Item';
import styles from './ListsFilterStep.module.scss';
const ListsFilterStep = React.memo(({ currentIds, title, onSelect, onDeselect, onBack }) => {
const lists = useSelector(selectors.selectAvailableListsForCurrentBoard);
const [t] = useTranslation();
const [search, handleSearchChange] = useField('');
const cleanSearch = useMemo(() => search.trim().toLowerCase(), [search]);
const filteredLists = useMemo(
() =>
lists.filter((list) =>
(list.name ? list.name.toLowerCase() : list.type).includes(cleanSearch),
),
[lists, 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.searchLists')}
maxLength={128}
icon="search"
onChange={handleSearchChange}
/>
{filteredLists.length > 0 && (
<div className={styles.items}>
{filteredLists.map((list) => (
<Item
key={list.id}
id={list.id}
isActive={currentIds.includes(list.id)}
onSelect={onSelect}
onDeselect={onDeselect}
/>
))}
</div>
)}
</Popup.Content>
</>
);
});
ListsFilterStep.propTypes = {
currentIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListsFilterStep.defaultProps = {
title: 'common.filterByLists',
onBack: undefined,
};
export default ListsFilterStep;
@@ -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) {
.items {
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,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ListsFilterStep from './ListsFilterStep';
export default ListsFilterStep;
+5
View File
@@ -273,6 +273,9 @@ export default {
LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE', LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE',
LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE', LABEL_FILTER_IN_BOARD_UPDATE: 'LABEL_FILTER_IN_BOARD_UPDATE',
NO_MEMBER_TO_BOARD_FILTER_SET: 'NO_MEMBER_TO_BOARD_FILTER_SET',
NO_MEMBER_FROM_BOARD_FILTER_REMOVE: 'NO_MEMBER_FROM_BOARD_FILTER_REMOVE',
/* Lists */ /* Lists */
LIST_CREATE: 'LIST_CREATE', LIST_CREATE: 'LIST_CREATE',
@@ -297,6 +300,8 @@ export default {
LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS', LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS',
LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE', LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE',
LIST_DELETE_HANDLE: 'LIST_DELETE_HANDLE', LIST_DELETE_HANDLE: 'LIST_DELETE_HANDLE',
LIST_TO_BOARD_FILTER_ADD: 'LIST_TO_BOARD_FILTER_ADD',
LIST_FROM_BOARD_FILTER_REMOVE: 'LIST_FROM_BOARD_FILTER_REMOVE',
/* Cards */ /* Cards */
+7
View File
@@ -198,6 +198,8 @@ export default {
LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`, LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`,
LIST_DELETE: `${PREFIX}/LIST_DELETE`, LIST_DELETE: `${PREFIX}/LIST_DELETE`,
LIST_DELETE_HANDLE: `${PREFIX}/LIST_DELETE_HANDLE`, LIST_DELETE_HANDLE: `${PREFIX}/LIST_DELETE_HANDLE`,
LIST_TO_FILTER_IN_CURRENT_BOARD_ADD: `${PREFIX}/LIST_TO_FILTER_IN_CURRENT_BOARD_ADD`,
LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`,
/* Cards */ /* Cards */
@@ -321,4 +323,9 @@ export default {
NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`, NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`,
NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`, NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`,
NOTIFICATION_SERVICE_DELETE_HANDLE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE_HANDLE`, NOTIFICATION_SERVICE_DELETE_HANDLE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE_HANDLE`,
/* New member types */
NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET: `${PREFIX}/NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET`,
NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE: `${PREFIX}/NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE`,
}; };
+10
View File
@@ -93,6 +93,14 @@ const handleBoardDelete = (board) => ({
}, },
}); });
const setNoMemberToFilterInCurrentBoard = () => ({
type: EntryActionTypes.NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET,
});
const removeNoMemberFromFilterInCurrentBoard = () => ({
type: EntryActionTypes.NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
});
export default { export default {
createBoardInCurrentProject, createBoardInCurrentProject,
handleBoardCreate, handleBoardCreate,
@@ -106,4 +114,6 @@ export default {
searchInCurrentBoard, searchInCurrentBoard,
deleteBoard, deleteBoard,
handleBoardDelete, handleBoardDelete,
setNoMemberToFilterInCurrentBoard,
removeNoMemberFromFilterInCurrentBoard,
}; };
+16
View File
@@ -95,6 +95,20 @@ const handleListDelete = (list, cards) => ({
}, },
}); });
const addListToFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LIST_TO_FILTER_IN_CURRENT_BOARD_ADD,
payload: {
id,
},
});
const removeListFromFilterInCurrentBoard = (id) => ({
type: EntryActionTypes.LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
payload: {
id,
},
});
export default { export default {
createListInCurrentBoard, createListInCurrentBoard,
handleListCreate, handleListCreate,
@@ -108,4 +122,6 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToFilterInCurrentBoard,
removeListFromFilterInCurrentBoard,
}; };
+16
View File
@@ -44,6 +44,7 @@ export default {
alwaysDisplayCardCreator: 'Kartenersteller immer anzeigen', alwaysDisplayCardCreator: 'Kartenersteller immer anzeigen',
apiKeyCreated_title: 'API-Schlüssel erstellt', apiKeyCreated_title: 'API-Schlüssel erstellt',
apiKey_title: 'API-Schlüssel', apiKey_title: 'API-Schlüssel',
applyFilter: 'Filter anwenden',
archive: 'Archiv', archive: 'Archiv',
archiveCard_title: 'Karte archivieren', archiveCard_title: 'Karte archivieren',
archiveCards_title: 'Karten archivieren', archiveCards_title: 'Karten archivieren',
@@ -74,6 +75,8 @@ export default {
'Sind Sie sicher, dass Sie dieses Datenfeld löschen möchten?', 'Sind Sie sicher, dass Sie dieses Datenfeld löschen möchten?',
areYouSureYouWantToDeleteThisCustomFieldGroup: areYouSureYouWantToDeleteThisCustomFieldGroup:
'Sind Sie sicher, dass Sie diese Feldgruppe löschen möchten?', 'Sind Sie sicher, dass Sie diese Feldgruppe löschen möchten?',
areYouSureYouWantToDeleteThisFilter:
'Sind Sie sicher, dass Sie diesen Filter löschen möchten?',
areYouSureYouWantToDeleteThisLabel: 'Sind Sie sicher, dass Sie dieses Label löschen möchten?', areYouSureYouWantToDeleteThisLabel: 'Sind Sie sicher, dass Sie dieses Label löschen möchten?',
areYouSureYouWantToDeleteThisList: areYouSureYouWantToDeleteThisList:
'Sind Sie sicher, dass Sie diese Liste löschen möchten? Alle Karten werden in den Papierkorb verschoben.', 'Sind Sie sicher, dass Sie diese Liste löschen möchten? Alle Karten werden in den Papierkorb verschoben.',
@@ -143,6 +146,7 @@ export default {
cardsOnThisListAreCompleteAndReadyToBeArchived: cardsOnThisListAreCompleteAndReadyToBeArchived:
'Karten in dieser Liste sind abgeschlossen und können archiviert werden.', 'Karten in dieser Liste sind abgeschlossen und können archiviert werden.',
cardsOnThisListAreReadyToBeWorkedOn: 'Karten in dieser Liste sind bereit zur Bearbeitung.', cardsOnThisListAreReadyToBeWorkedOn: 'Karten in dieser Liste sind bereit zur Bearbeitung.',
clearFilter: 'Filter zurücksetzen',
clickHereOrRefreshPageToUpdate: clickHereOrRefreshPageToUpdate:
'<0>Hier klicken</0> oder Seite aktualisieren, um zu aktualisieren.', '<0>Hier klicken</0> oder Seite aktualisieren, um zu aktualisieren.',
clientHostnameInEhlo: 'Client-Hostname in EHLO', clientHostnameInEhlo: 'Client-Hostname in EHLO',
@@ -226,6 +230,7 @@ export default {
unknownDevice: 'Unbekanntes Gerät', unknownDevice: 'Unbekanntes Gerät',
upgradeTeamToPro_title: 'Team auf Pro upgraden', upgradeTeamToPro_title: 'Team auf Pro upgraden',
date: 'Datum', date: 'Datum',
deactivateFilter: 'Filter deaktivieren',
deactivateUser_title: 'Benutzer deaktivieren', deactivateUser_title: 'Benutzer deaktivieren',
defaultCardType_title: 'Standard-Kartentyp', defaultCardType_title: 'Standard-Kartentyp',
defaultFrom: 'Standard "Von"', defaultFrom: 'Standard "Von"',
@@ -241,6 +246,7 @@ export default {
deleteComment_title: 'Kommentar löschen', deleteComment_title: 'Kommentar löschen',
deleteCustomFieldGroup_title: 'Feldgruppe löschen', deleteCustomFieldGroup_title: 'Feldgruppe löschen',
deleteCustomField_title: 'Datenfeld löschen', deleteCustomField_title: 'Datenfeld löschen',
deleteFilter_title: 'Filter löschen',
deleteLabel_title: 'Label löschen', deleteLabel_title: 'Label löschen',
deleteList_title: 'Liste löschen', deleteList_title: 'Liste löschen',
deleteNotificationService_title: 'Benachrichtigungsdienst löschen', deleteNotificationService_title: 'Benachrichtigungsdienst löschen',
@@ -284,7 +290,9 @@ export default {
excludedEvents: 'Ausgeschlossene Ereignisse', excludedEvents: 'Ausgeschlossene Ereignisse',
expandTaskListsByDefault: 'Aufgabenlisten standardmäßig erweitern', expandTaskListsByDefault: 'Aufgabenlisten standardmäßig erweitern',
filterByLabels_title: 'Nach Label filtern', filterByLabels_title: 'Nach Label filtern',
filterByLists_title: 'Nach Listen filtern',
filterByMembers_title: 'Nach Mitgliedern filtern', filterByMembers_title: 'Nach Mitgliedern filtern',
filterName: 'Filtername',
forPersonalProjects: 'Für persönliche Projekte.', forPersonalProjects: 'Für persönliche Projekte.',
forTeamBasedProjects: 'Für teambasierte Projekte.', forTeamBasedProjects: 'Für teambasierte Projekte.',
fromComputer_title: 'Vom Computer', fromComputer_title: 'Vom Computer',
@@ -334,7 +342,9 @@ export default {
noCardsFound: 'Keine Karten gefunden.', noCardsFound: 'Keine Karten gefunden.',
noConnectionToServer: 'Keine Verbindung zum Server', noConnectionToServer: 'Keine Verbindung zum Server',
noLists: 'Keine Listen', noLists: 'Keine Listen',
noMember: 'Ohne Mitglied',
noProjects: 'Keine Projekte', noProjects: 'Keine Projekte',
noSavedFilters: 'Keine gespeicherten Filter',
noUnreadNotifications: 'Keine ungelesenen Benachrichtigungen.', noUnreadNotifications: 'Keine ungelesenen Benachrichtigungen.',
notifications: 'Benachrichtigungen', notifications: 'Benachrichtigungen',
oldestFirst: 'Älteste zuerst', oldestFirst: 'Älteste zuerst',
@@ -371,9 +381,12 @@ export default {
rejectUnauthorizedTlsCertificates: 'Nicht autorisierte TLS-Zertifikate ablehnen', rejectUnauthorizedTlsCertificates: 'Nicht autorisierte TLS-Zertifikate ablehnen',
removeManager_title: 'Projektleiter entfernen', removeManager_title: 'Projektleiter entfernen',
removeMember_title: 'Mitglied entfernen', removeMember_title: 'Mitglied entfernen',
restoreFilter: 'Filter wiederherstellen',
role: 'Rolle', role: 'Rolle',
saveFilter_title: 'Filter speichern',
saveThisKeyItWillNotBeShownAgain: saveThisKeyItWillNotBeShownAgain:
'Speichern Sie diesen Schlüssel — er wird nicht erneut angezeigt!', 'Speichern Sie diesen Schlüssel — er wird nicht erneut angezeigt!',
savedFilters_title: 'Gespeicherte Filter',
searchCards: 'Karte suchen...', searchCards: 'Karte suchen...',
searchCustomFieldGroups: 'Benutzerdefinierte Feldgruppen suchen...', searchCustomFieldGroups: 'Benutzerdefinierte Feldgruppen suchen...',
searchCustomFields: 'In Feldgruppen suchen...', searchCustomFields: 'In Feldgruppen suchen...',
@@ -396,6 +409,9 @@ export default {
settings: 'Einstellungen', settings: 'Einstellungen',
shared: 'Geteilt', shared: 'Geteilt',
sharedWithMe_title: 'Mit mir geteilt', sharedWithMe_title: 'Mit mir geteilt',
showCardCounter: 'Kartenzähler in Listen anzeigen',
showCardCounterHint:
'Zeigt die Anzahl der Karten einer Liste auf ihrer Schaltfläche zum Hinzufügen einer Karte an. Gezählt werden alle Karten der Liste, nicht nur die von einem Filter übrig gelassenen.',
showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen', showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen',
smtp: 'SMTP', smtp: 'SMTP',
sortList_title: 'Liste sortieren', sortList_title: 'Liste sortieren',
+15
View File
@@ -39,6 +39,7 @@ export default {
alwaysDisplayCardCreator: 'Always display card creator', alwaysDisplayCardCreator: 'Always display card creator',
apiKeyCreated_title: 'API Key Created', apiKeyCreated_title: 'API Key Created',
apiKey_title: 'API Key', apiKey_title: 'API Key',
applyFilter: 'Apply filter',
archive: 'Archive', archive: 'Archive',
archiveCard_title: 'Archive Card', archiveCard_title: 'Archive Card',
archiveCards_title: 'Archive Cards', archiveCards_title: 'Archive Cards',
@@ -61,6 +62,7 @@ export default {
'Are you sure you want to delete this custom field?', 'Are you sure you want to delete this custom field?',
areYouSureYouWantToDeleteThisCustomFieldGroup: areYouSureYouWantToDeleteThisCustomFieldGroup:
'Are you sure you want to delete this custom field group?', 'Are you sure you want to delete this custom field group?',
areYouSureYouWantToDeleteThisFilter: 'Are you sure you want to delete this filter?',
areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?', areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?',
areYouSureYouWantToDeleteThisList: areYouSureYouWantToDeleteThisList:
'Are you sure you want to delete this list? All cards will be moved to trash.', 'Are you sure you want to delete this list? All cards will be moved to trash.',
@@ -125,6 +127,7 @@ export default {
cardsOnThisListAreCompleteAndReadyToBeArchived: cardsOnThisListAreCompleteAndReadyToBeArchived:
'Cards on this list are complete and ready to be archived.', 'Cards on this list are complete and ready to be archived.',
cardsOnThisListAreReadyToBeWorkedOn: 'Cards on this list are ready to be worked on.', cardsOnThisListAreReadyToBeWorkedOn: 'Cards on this list are ready to be worked on.',
clearFilter: 'Clear filter',
clickHereOrRefreshPageToUpdate: '<0>Click here</0> or refresh the page to update.', clickHereOrRefreshPageToUpdate: '<0>Click here</0> or refresh the page to update.',
clientHostnameInEhlo: 'Client hostname in EHLO', clientHostnameInEhlo: 'Client hostname in EHLO',
closed: 'Closed', closed: 'Closed',
@@ -204,6 +207,7 @@ export default {
unknownDevice: 'Unknown device', unknownDevice: 'Unknown device',
upgradeTeamToPro_title: 'Upgrade Team to Pro', upgradeTeamToPro_title: 'Upgrade Team to Pro',
date: 'Date', date: 'Date',
deactivateFilter: 'Deactivate filter',
deactivateUser_title: 'Deactivate User', deactivateUser_title: 'Deactivate User',
defaultCardType_title: 'Default Card Type', defaultCardType_title: 'Default Card Type',
defaultFrom: 'Default "from"', defaultFrom: 'Default "from"',
@@ -219,6 +223,7 @@ export default {
deleteComment_title: 'Delete Comment', deleteComment_title: 'Delete Comment',
deleteCustomFieldGroup_title: 'Delete Custom Field Group', deleteCustomFieldGroup_title: 'Delete Custom Field Group',
deleteCustomField_title: 'Delete Custom Field', deleteCustomField_title: 'Delete Custom Field',
deleteFilter_title: 'Delete Filter',
deleteLabel_title: 'Delete Label', deleteLabel_title: 'Delete Label',
deleteList_title: 'Delete List', deleteList_title: 'Delete List',
deleteNotificationService_title: 'Delete Notification Service', deleteNotificationService_title: 'Delete Notification Service',
@@ -262,7 +267,9 @@ export default {
excludedEvents: 'Excluded events', excludedEvents: 'Excluded events',
expandTaskListsByDefault: 'Expand task lists by default', expandTaskListsByDefault: 'Expand task lists by default',
filterByLabels_title: 'Filter By Labels', filterByLabels_title: 'Filter By Labels',
filterByLists_title: 'Filter By Lists',
filterByMembers_title: 'Filter By Members', filterByMembers_title: 'Filter By Members',
filterName: 'Filter name',
forPersonalProjects: 'For personal projects.', forPersonalProjects: 'For personal projects.',
forTeamBasedProjects: 'For team-based projects.', forTeamBasedProjects: 'For team-based projects.',
fromComputer_title: 'From Computer', fromComputer_title: 'From Computer',
@@ -312,7 +319,9 @@ export default {
noCardsFound: 'No cards found.', noCardsFound: 'No cards found.',
noConnectionToServer: 'No connection to server', noConnectionToServer: 'No connection to server',
noLists: 'No lists', noLists: 'No lists',
noMember: 'No member',
noProjects: 'No projects', noProjects: 'No projects',
noSavedFilters: 'No saved filters',
noUnreadNotifications: 'No unread notifications.', noUnreadNotifications: 'No unread notifications.',
notifications: 'Notifications', notifications: 'Notifications',
oldestFirst: 'Oldest first', oldestFirst: 'Oldest first',
@@ -349,8 +358,11 @@ export default {
rejectUnauthorizedTlsCertificates: 'Reject unauthorized TLS certificates', rejectUnauthorizedTlsCertificates: 'Reject unauthorized TLS certificates',
removeManager_title: 'Remove Manager', removeManager_title: 'Remove Manager',
removeMember_title: 'Remove Member', removeMember_title: 'Remove Member',
restoreFilter: 'Restore filter',
role: 'Role', role: 'Role',
saveFilter_title: 'Save Filter',
saveThisKeyItWillNotBeShownAgain: 'Save this key — it will not be shown again!', saveThisKeyItWillNotBeShownAgain: 'Save this key — it will not be shown again!',
savedFilters_title: 'Saved Filters',
searchCards: 'Search cards...', searchCards: 'Search cards...',
searchCustomFieldGroups: 'Search custom field groups...', searchCustomFieldGroups: 'Search custom field groups...',
searchCustomFields: 'Search custom fields...', searchCustomFields: 'Search custom fields...',
@@ -373,6 +385,9 @@ export default {
settings: 'Settings', settings: 'Settings',
shared: 'Shared', shared: 'Shared',
sharedWithMe_title: 'Shared With Me', sharedWithMe_title: 'Shared With Me',
showCardCounter: 'Show card counter in lists',
showCardCounterHint:
'Adds the number of cards a list holds to its add-card button. The count covers every card in the list, not only the ones a filter leaves visible.',
showOnFrontOfCard: 'Show on front of card', showOnFrontOfCard: 'Show on front of card',
smtp: 'SMTP', smtp: 'SMTP',
sortList_title: 'Sort List', sortList_title: 'Sort List',
+38
View File
@@ -36,6 +36,7 @@ export default class extends BaseModel {
defaultCardType: attr(), defaultCardType: attr(),
limitCardTypesToDefaultOne: attr(), limitCardTypesToDefaultOne: attr(),
alwaysDisplayCardCreator: attr(), alwaysDisplayCardCreator: attr(),
showCardCounter: attr(),
displayCardAges: attr(), displayCardAges: attr(),
expandTaskListsByDefault: attr(), expandTaskListsByDefault: attr(),
context: attr(), context: attr(),
@@ -69,6 +70,10 @@ export default class extends BaseModel {
filterUsers: many('User', 'filterBoards'), filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'), filterLabels: many('Label', 'filterBoards'),
filterExcludedLabels: many('Label', 'filterExcludedBoards'), filterExcludedLabels: many('Label', 'filterExcludedBoards'),
filterNoMember: attr({
getDefault: () => false,
}),
filterLists: many('List', 'filterBoards'),
}; };
static reducer({ type, payload }, Board) { static reducer({ type, payload }, Board) {
@@ -283,6 +288,14 @@ export default class extends BaseModel {
break; break;
} }
case ActionTypes.LIST_TO_BOARD_FILTER_ADD:
Board.withId(payload.boardId).filterLists.add(payload.id);
break;
case ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLists.remove(payload.id);
break;
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH: case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
Board.withId(payload.boardId).update({ Board.withId(payload.boardId).update({
isActivitiesFetching: true, isActivitiesFetching: true,
@@ -299,6 +312,17 @@ export default class extends BaseModel {
}); });
break; break;
case ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET: {
const boardModel = Board.withId(payload.boardId);
boardModel.filterUsers.clear();
boardModel.update({ filterNoMember: true });
break;
}
case ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE: {
const boardModel = Board.withId(payload.boardId);
boardModel.update({ filterNoMember: false });
break;
}
default: default:
} }
} }
@@ -416,6 +440,19 @@ export default class extends BaseModel {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds); cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
} }
// The three filters below are independent of one another and compose: a
// board can be narrowed to unassigned cards AND to a set of lists at the
// same time, so neither one may return early.
if (this.filterNoMember) {
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
}
const filterListIds = this.filterLists.toRefArray().map((list) => list.id);
if (filterListIds.length > 0) {
cardModels = cardModels.filter((cardModel) => filterListIds.includes(cardModel.listId));
}
return cardModels; return cardModels;
} }
@@ -472,6 +509,7 @@ export default class extends BaseModel {
this.filterUsers.clear(); this.filterUsers.clear();
this.filterLabels.clear(); this.filterLabels.clear();
this.filterExcludedLabels.clear(); this.filterExcludedLabels.clear();
this.filterLists.clear();
} }
deleteRelated(exceptMemberUserId, soft) { deleteRelated(exceptMemberUserId, soft) {
+12
View File
@@ -389,6 +389,11 @@ export default class extends BaseModel {
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds); cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
} }
if (this.board.filterNoMember) {
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
return cardModels;
}
return cardModels; return cardModels;
} }
@@ -445,6 +450,13 @@ export default class extends BaseModel {
deleteWithRelated(soft) { deleteWithRelated(soft) {
this.deleteRelated(soft); this.deleteRelated(soft);
try {
this.board.filterLists.remove(this.id);
} catch {
/* empty */
}
this.delete(); this.delete();
} }
} }
+18
View File
@@ -255,6 +255,22 @@ export function* handleBoardDelete(board) {
} }
} }
export function* setNoMemberToFilterInCurrentBoard() {
const { boardId } = yield select(selectors.selectPath);
yield put({
type: ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET,
payload: { boardId },
});
}
export function* removeNoMemberFromFilterInCurrentBoard() {
const { boardId } = yield select(selectors.selectPath);
yield put({
type: ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE,
payload: { boardId },
});
}
export default { export default {
createBoard, createBoard,
createBoardInCurrentProject, createBoardInCurrentProject,
@@ -271,4 +287,6 @@ export default {
searchInCurrentBoard, searchInCurrentBoard,
deleteBoard, deleteBoard,
handleBoardDelete, handleBoardDelete,
setNoMemberToFilterInCurrentBoard,
removeNoMemberFromFilterInCurrentBoard,
}; };
+24
View File
@@ -261,6 +261,26 @@ export function* handleListDelete(list, cards) {
yield put(actions.handleListDelete(list, cards)); yield put(actions.handleListDelete(list, cards));
} }
export function* addListToBoardFilter(id, boardId) {
yield put(actions.addListToBoardFilter(id, boardId));
}
export function* addListToFilterInCurrentBoard(id) {
const { boardId } = yield select(selectors.selectPath);
yield call(addListToBoardFilter, id, boardId);
}
export function* removeListFromBoardFilter(id, boardId) {
yield put(actions.removeListFromBoardFilter(id, boardId));
}
export function* removeListFromFilterInCurrentBoard(id) {
const { boardId } = yield select(selectors.selectPath);
yield call(removeListFromBoardFilter, id, boardId);
}
export default { export default {
createList, createList,
createListInCurrentBoard, createListInCurrentBoard,
@@ -275,4 +295,8 @@ export default {
handleListClear, handleListClear,
deleteList, deleteList,
handleListDelete, handleListDelete,
addListToBoardFilter,
addListToFilterInCurrentBoard,
removeListFromBoardFilter,
removeListFromFilterInCurrentBoard,
}; };
+8
View File
@@ -44,5 +44,13 @@ export default function* boardsWatchers() {
takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { board } }) => takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { board } }) =>
services.handleBoardDelete(board), services.handleBoardDelete(board),
), ),
takeEvery(
EntryActionTypes.NO_MEMBER_TO_FILTER_IN_CURRENT_BOARD_SET,
services.setNoMemberToFilterInCurrentBoard,
),
takeEvery(
EntryActionTypes.NO_MEMBER_FROM_FILTER_IN_CURRENT_BOARD_REMOVE,
services.removeNoMemberFromFilterInCurrentBoard,
),
]); ]);
} }
+6
View File
@@ -44,5 +44,11 @@ export default function* listsWatchers() {
takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) => takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list, cards } }) =>
services.handleListDelete(list, cards), services.handleListDelete(list, cards),
), ),
takeEvery(EntryActionTypes.LIST_TO_FILTER_IN_CURRENT_BOARD_ADD, ({ payload: { id } }) =>
services.addListToFilterInCurrentBoard(id),
),
takeEvery(EntryActionTypes.LIST_FROM_FILTER_IN_CURRENT_BOARD_REMOVE, ({ payload: { id } }) =>
services.removeListFromFilterInCurrentBoard(id),
),
]); ]);
} }
+19
View File
@@ -479,6 +479,24 @@ export const selectFilterExcludedLabelIdsForCurrentBoard = createSelector(
}, },
); );
export const selectFilterListIdsForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.filterLists.toRefArray().map((list) => list.id);
},
);
export const selectIsBoardWithIdExists = createSelector( export const selectIsBoardWithIdExists = createSelector(
orm, orm,
(_, id) => id, (_, id) => id,
@@ -513,5 +531,6 @@ export default {
selectFilterUserIdsForCurrentBoard, selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard, selectFilterLabelIdsForCurrentBoard,
selectFilterExcludedLabelIdsForCurrentBoard, selectFilterExcludedLabelIdsForCurrentBoard,
selectFilterListIdsForCurrentBoard,
selectIsBoardWithIdExists, selectIsBoardWithIdExists,
}; };
+23
View File
@@ -65,6 +65,27 @@ export const makeSelectFilteredCardIdsByListId = () =>
export const selectFilteredCardIdsByListId = makeSelectFilteredCardIdsByListId(); export const selectFilteredCardIdsByListId = makeSelectFilteredCardIdsByListId();
// How many cards the list holds.
//
// Unfiltered on purpose: a filter hides cards, it does not remove them, and a
// count that fell as the filter narrowed would say the list had emptied.
export const makeSelectCardCountByListId = () =>
createSelector(
orm,
(_, id) => id,
({ List }, id) => {
const listModel = List.withId(id);
if (!listModel) {
return null;
}
return listModel.getCardsModelArray().length;
},
);
export const selectCardCountByListId = makeSelectCardCountByListId();
export const selectIsListWithIdAvailableForCurrentUser = createSelector( export const selectIsListWithIdAvailableForCurrentUser = createSelector(
orm, orm,
(_, id) => id, (_, id) => id,
@@ -171,6 +192,8 @@ export default {
selectCardIdsByListId, selectCardIdsByListId,
makeSelectFilteredCardIdsByListId, makeSelectFilteredCardIdsByListId,
selectFilteredCardIdsByListId, selectFilteredCardIdsByListId,
makeSelectCardCountByListId,
selectCardCountByListId,
selectIsListWithIdAvailableForCurrentUser, selectIsListWithIdAvailableForCurrentUser,
selectCurrentListId, selectCurrentListId,
selectCurrentList, selectCurrentList,
+22
View File
@@ -0,0 +1,22 @@
# Gitea MCP Memory - Key Lessons
## NEVER DO AGAIN:
- ❌ Use fetch_url_content() for MCP endpoints (always returns 405)
- ❌ Add readme to create_repo() requests (causes MaxSize error)
- ❌ Expect terminal output capture from git commands in remote env
- ❌ Assume HTTP GET works on /mcp path (MCP requires protocol connections)
## ALWAYS DO:
- ✅ Use MCP tools directly (list_my_repos, create_repo, etc.)
- ✅ Use wiki_write for documentation instead of repo readme
- ✅ Restart IDE after mcpServers/mcp.json updates
- ✅ Verify with netstat before assuming connectivity
## Known URLs:
- API: http://192.168.1.122/api/v1/*
- Web UI: http://192.168.1.122
- MCP endpoint: http://192.168.1.122:30008/mcp
## Proof of Working Connection:
- Repo created: AhmedTawfik/gitea-temp-repo-dummy
- MCP tools operational after IDE restart
+1
View File
@@ -0,0 +1 @@
{"mcpServers":{"gitea_repo":{"type":"http","url":"http://192.168.1.122:18100/mcp"}}}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planka", "name": "planka",
"version": "2.2.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planka", "name": "planka",
"version": "2.2.0", "version": "2.2.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"concurrently": "^10.0.4", "concurrently": "^10.0.4",
+9
View File
@@ -55,6 +55,10 @@
* type: boolean * type: boolean
* description: Whether to always display card creators * description: Whether to always display card creators
* example: false * example: false
* showCardCounter:
* type: boolean
* description: Whether a list's add-card button shows how many cards it holds
* example: false
* displayCardAges: * displayCardAges:
* type: boolean * type: boolean
* description: Whether to display card ages * description: Whether to display card ages
@@ -124,6 +128,9 @@ module.exports = {
alwaysDisplayCardCreator: { alwaysDisplayCardCreator: {
type: 'boolean', type: 'boolean',
}, },
showCardCounter: {
type: 'boolean',
},
displayCardAges: { displayCardAges: {
type: 'boolean', type: 'boolean',
}, },
@@ -167,6 +174,7 @@ module.exports = {
'defaultCardType', 'defaultCardType',
'limitCardTypesToDefaultOne', 'limitCardTypesToDefaultOne',
'alwaysDisplayCardCreator', 'alwaysDisplayCardCreator',
'showCardCounter',
'displayCardAges', 'displayCardAges',
'expandTaskListsByDefault', 'expandTaskListsByDefault',
); );
@@ -186,6 +194,7 @@ module.exports = {
'defaultCardType', 'defaultCardType',
'limitCardTypesToDefaultOne', 'limitCardTypesToDefaultOne',
'alwaysDisplayCardCreator', 'alwaysDisplayCardCreator',
'showCardCounter',
'displayCardAges', 'displayCardAges',
'expandTaskListsByDefault', 'expandTaskListsByDefault',
'isSubscribed', 'isSubscribed',
+12
View File
@@ -25,6 +25,7 @@
* - defaultCardType * - defaultCardType
* - limitCardTypesToDefaultOne * - limitCardTypesToDefaultOne
* - alwaysDisplayCardCreator * - alwaysDisplayCardCreator
* - showCardCounter
* - displayCardAges * - displayCardAges
* - expandTaskListsByDefault * - expandTaskListsByDefault
* - createdAt * - createdAt
@@ -68,6 +69,11 @@
* default: false * default: false
* description: Whether to always display the card creator * description: Whether to always display the card creator
* example: false * example: false
* showCardCounter:
* type: boolean
* default: false
* description: Whether a list's add-card button shows how many cards it holds
* example: false
* displayCardAges: * displayCardAges:
* type: boolean * type: boolean
* default: false * default: false
@@ -143,6 +149,12 @@ module.exports = {
defaultsTo: false, defaultsTo: false,
columnName: 'always_display_card_creator', columnName: 'always_display_card_creator',
}, },
// Whether a list's add-card button carries the number of cards it holds.
showCardCounter: {
type: 'boolean',
defaultsTo: false,
columnName: 'show_card_counter',
},
displayCardAges: { displayCardAges: {
type: 'boolean', type: 'boolean',
defaultsTo: false, defaultsTo: false,
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2026 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
// Whether a list says how many cards it holds, on its add-card button. Off by
// default: a count is useful to a board that is being kept to a size and noise
// to one that is not.
module.exports.up = async (knex) => {
await knex.schema.alterTable('board', (table) => {
table.boolean('show_card_counter').notNullable().defaultTo(false);
});
};
module.exports.down = async (knex) => {
await knex.schema.alterTable('board', (table) => {
table.dropColumn('show_card_counter');
});
};
-48
View File
@@ -756,9 +756,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -775,9 +772,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -794,9 +788,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -813,9 +804,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -832,9 +820,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -851,9 +836,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -870,9 +852,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -889,9 +868,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -908,9 +884,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -933,9 +906,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -958,9 +928,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -983,9 +950,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1008,9 +972,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1033,9 +994,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1058,9 +1016,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1083,9 +1038,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [