Compare commits
20
Commits
fe00e81a81
...
f6d7d90bde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d7d90bde | ||
|
|
627701dda3 | ||
|
|
b581a97c2c | ||
|
|
ca035d670e | ||
|
|
f1e8c59d6e | ||
|
|
3edce1cc04 | ||
|
|
67d3ccf425 | ||
|
|
1c683604ea | ||
|
|
75fcddcd0b | ||
|
|
bb2da7f36a | ||
|
|
f348583977 | ||
|
|
401c0412cb | ||
|
|
4bc12dabbc | ||
|
|
2413ddd1cd | ||
|
|
f23ce6e025 | ||
|
|
0fd2bd3bc5 | ||
|
|
ae0c23eada | ||
|
|
334f7b2028 | ||
|
|
e1efe663a0 | ||
|
|
ffb9693184 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
createList,
|
||||
handleListCreate,
|
||||
@@ -212,4 +228,6 @@ export default {
|
||||
handleListClear,
|
||||
deleteList,
|
||||
handleListDelete,
|
||||
addListToBoardFilter,
|
||||
removeListFromBoardFilter,
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ const FiniteContent = React.memo(() => {
|
||||
return (
|
||||
<View
|
||||
cardIds={cardIds}
|
||||
isReorderingEnabled={board.view === BoardViews.LIST}
|
||||
onCardCreate={canAddCard ? handleCardCreate : undefined}
|
||||
onCardPaste={canAddCard ? handleCardPaste : undefined}
|
||||
/>
|
||||
|
||||
@@ -6,22 +6,39 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { Button, Icon, Loader } from 'semantic-ui-react';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { Tooltip } from '../../../lib/custom-ui';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
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 DraggableCard from '../../cards/DraggableCard';
|
||||
import AddCard from '../../cards/AddCard';
|
||||
import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
const ListView = React.memo(
|
||||
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => {
|
||||
({
|
||||
cardIds,
|
||||
isCardsFetching,
|
||||
isAllCardsFetched,
|
||||
isReorderingEnabled,
|
||||
onCardsFetch,
|
||||
onCardCreate,
|
||||
onCardPaste,
|
||||
}) => {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
|
||||
const { canAddCard, canPasteCard } = useSelector((state) => {
|
||||
@@ -54,6 +71,94 @@ const ListView = React.memo(
|
||||
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 (
|
||||
<div className={styles.wrapper}>
|
||||
{canAddCard &&
|
||||
@@ -88,15 +193,7 @@ const ListView = React.memo(
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{cardIds.length > 0 && (
|
||||
<div className={classNames(styles.segment, styles.cards)}>
|
||||
{cardIds.map((cardId, cardIndex) => (
|
||||
<div key={cardId} className={styles.card}>
|
||||
<Card isInline id={cardId} index={cardIndex} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderCardsContent()}
|
||||
{isCardsFetching !== undefined && isAllCardsFetched !== undefined && (
|
||||
<div className={styles.loaderWrapper}>
|
||||
{isCardsFetching ? (
|
||||
@@ -115,6 +212,7 @@ ListView.propTypes = {
|
||||
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
isCardsFetching: PropTypes.bool,
|
||||
isAllCardsFetched: PropTypes.bool,
|
||||
isReorderingEnabled: PropTypes.bool,
|
||||
onCardsFetch: PropTypes.func,
|
||||
onCardCreate: PropTypes.func,
|
||||
onCardPaste: PropTypes.func,
|
||||
@@ -123,6 +221,7 @@ ListView.propTypes = {
|
||||
ListView.defaultProps = {
|
||||
isCardsFetching: undefined,
|
||||
isAllCardsFetched: undefined,
|
||||
isReorderingEnabled: false,
|
||||
onCardsFetch: undefined,
|
||||
onCardCreate: undefined,
|
||||
onCardPaste: undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -16,19 +17,59 @@ import { Input, Tooltip } from '../../../lib/custom-ui';
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useNestedRef } from '../../../hooks';
|
||||
import { BoardViews, LabelFilterModes } from '../../../constants/Enums';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
|
||||
import LabelChip from '../../labels/LabelChip';
|
||||
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';
|
||||
|
||||
// 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 board = useSelector(selectors.selectCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
|
||||
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
|
||||
const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
|
||||
const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard);
|
||||
const currentUserId = useSelector(selectors.selectCurrentUserId);
|
||||
|
||||
const withCurrentUserSelector = useSelector(
|
||||
@@ -39,6 +80,11 @@ const Filters = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
const [search, setSearch] = useState(board.search);
|
||||
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(
|
||||
() =>
|
||||
@@ -70,12 +116,73 @@ const Filters = React.memo(() => {
|
||||
[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(() => {
|
||||
debouncedSearch.cancel();
|
||||
setSearch('');
|
||||
dispatch(entryActions.searchInCurrentBoard(''));
|
||||
invalidateLastFilter();
|
||||
searchFieldRef.current.blur();
|
||||
}, [dispatch, debouncedSearch, searchFieldRef]);
|
||||
}, [dispatch, debouncedSearch, invalidateLastFilter, searchFieldRef]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
@@ -91,8 +198,9 @@ const Filters = React.memo(() => {
|
||||
const handleUserDeselect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleUserClick = useCallback(
|
||||
@@ -102,10 +210,26 @@ const Filters = React.memo(() => {
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
(listId) => {
|
||||
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleListDeselect = useCallback(
|
||||
(listId) => {
|
||||
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
({
|
||||
currentTarget: {
|
||||
@@ -113,15 +237,22 @@ const Filters = React.memo(() => {
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleLabelModeChange = useCallback(
|
||||
(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(
|
||||
@@ -153,14 +284,145 @@ const Filters = React.memo(() => {
|
||||
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(() => {
|
||||
setSearch(board.search);
|
||||
}, [board.search]);
|
||||
|
||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopup(LabelsStep);
|
||||
const ListsFilterPopup = usePopup(ListsFilterStep);
|
||||
const SaveFilterPopup = usePopup(SaveFilterStep);
|
||||
const SavedFiltersPopup = usePopup(SavedFiltersStep);
|
||||
|
||||
const isSearchActive = search || isSearchFocused;
|
||||
const isListView = board.view === BoardViews.LIST;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -176,6 +438,13 @@ const Filters = React.memo(() => {
|
||||
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
|
||||
</button>
|
||||
</BoardMembershipsPopup>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.filterButton, styles.filterLabel)}
|
||||
onClick={handleNoMemberClick}
|
||||
>
|
||||
{t('common.noMember')}
|
||||
</button>
|
||||
{userIds.length === 0 && withCurrentUserSelector && (
|
||||
<Tooltip content={t('action.filterByCurrentUser')}>
|
||||
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
|
||||
@@ -217,6 +486,26 @@ const Filters = React.memo(() => {
|
||||
</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}>
|
||||
<Input
|
||||
ref={handleSearchFieldRef}
|
||||
@@ -237,6 +526,64 @@ const Filters = React.memo(() => {
|
||||
onBlur={handleSearchBlur}
|
||||
/>
|
||||
</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}
|
||||
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
|
||||
toggle
|
||||
name="displayCardAges"
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
*/
|
||||
|
||||
: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 {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 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 classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
@@ -16,6 +17,7 @@ import entryActions from '../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../hooks';
|
||||
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
|
||||
import { isUsableMarkdownElement } from '../../../utils/element-helpers';
|
||||
import { mentionTextToMarkup } from '../../../utils/mentions';
|
||||
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
|
||||
import { CardTypeIcons } from '../../../constants/Icons';
|
||||
import { ClosableContext } from '../../../contexts';
|
||||
@@ -50,6 +52,7 @@ const ProjectContent = React.memo(() => {
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
|
||||
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
|
||||
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
|
||||
@@ -142,6 +145,15 @@ const ProjectContent = React.memo(() => {
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const userByUsername = useMemo(
|
||||
() =>
|
||||
keyBy(
|
||||
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
|
||||
({ username }) => username.toLowerCase(),
|
||||
),
|
||||
[boardMemberships],
|
||||
);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(null);
|
||||
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
|
||||
@@ -169,11 +181,11 @@ const ProjectContent = React.memo(() => {
|
||||
(description) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
description,
|
||||
description: description && mentionTextToMarkup(description, userByUsername),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, userByUsername],
|
||||
);
|
||||
|
||||
const handleDueCompletionChange = useCallback(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 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 classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
@@ -16,6 +17,7 @@ import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { usePopupInClosableContext } from '../../../hooks';
|
||||
import { isUsableMarkdownElement } from '../../../utils/element-helpers';
|
||||
import { mentionTextToMarkup } from '../../../utils/mentions';
|
||||
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
|
||||
import { CardTypeIcons } from '../../../constants/Icons';
|
||||
import { ClosableContext } from '../../../contexts';
|
||||
@@ -46,6 +48,7 @@ const StoryContent = React.memo(() => {
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
const userIds = useSelector(selectors.selectUserIdsForCurrentCard);
|
||||
const labelIds = useSelector(selectors.selectLabelIdsForCurrentCard);
|
||||
const attachmentIds = useSelector(selectors.selectAttachmentIdsForCurrentCard);
|
||||
@@ -137,6 +140,15 @@ const StoryContent = React.memo(() => {
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const userByUsername = useMemo(
|
||||
() =>
|
||||
keyBy(
|
||||
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
|
||||
({ username }) => username.toLowerCase(),
|
||||
),
|
||||
[boardMemberships],
|
||||
);
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(null);
|
||||
const [isEditDescriptionOpened, setIsEditDescriptionOpened] = useState(false);
|
||||
@@ -164,11 +176,11 @@ const StoryContent = React.memo(() => {
|
||||
(description) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
description,
|
||||
description: description && mentionTextToMarkup(description, userByUsername),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, userByUsername],
|
||||
);
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import keyBy from 'lodash/keyBy';
|
||||
import React, { useCallback, useState, useRef, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Mention, MentionsInput } from 'react-mentions';
|
||||
@@ -24,7 +25,7 @@ const DEFAULT_DATA = {
|
||||
text: '',
|
||||
};
|
||||
|
||||
const Add = React.memo(() => {
|
||||
const Add = React.memo(({ initialText, onInitialTextConsumed }) => {
|
||||
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@@ -138,6 +139,19 @@ const Add = React.memo(() => {
|
||||
textInputRef.current.focus();
|
||||
}, [selectTextFieldState]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (!initialText) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData({
|
||||
text: initialText,
|
||||
});
|
||||
setIsOpened(true);
|
||||
selectTextField();
|
||||
onInitialTextConsumed();
|
||||
}, [initialText, onInitialTextConsumed, selectTextField, setData]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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 { useInView } from 'react-intersection-observer';
|
||||
import { Comment, Loader } from 'semantic-ui-react';
|
||||
@@ -45,6 +45,15 @@ const Comments = React.memo(() => {
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [replyText, setReplyText] = useState();
|
||||
|
||||
const handleReply = useCallback((username) => {
|
||||
setReplyText(`@${username} `);
|
||||
}, []);
|
||||
|
||||
const handleReplyTextConsumed = useCallback(() => {
|
||||
setReplyText(undefined);
|
||||
}, []);
|
||||
|
||||
const [inViewRef] = useInView({
|
||||
threshold: 1,
|
||||
@@ -57,11 +66,11 @@ const Comments = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{cadAdd && <Add />}
|
||||
{cadAdd && <Add initialText={replyText} onInitialTextConsumed={handleReplyTextConsumed} />}
|
||||
<div className={styles.itemsWrapper}>
|
||||
<Comment.Group className={styles.items}>
|
||||
{commentIds.map((commentId) => (
|
||||
<Item key={commentId} id={commentId} />
|
||||
<Item key={commentId} id={commentId} canReply={cadAdd} onReply={handleReply} />
|
||||
))}
|
||||
</Comment.Group>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ import UserAvatar from '../../users/UserAvatar';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
||||
const Item = React.memo(({ id }) => {
|
||||
const Item = React.memo(({ id, canReply, onReply }) => {
|
||||
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
|
||||
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
@@ -83,6 +83,12 @@ const Item = React.memo(({ id }) => {
|
||||
setIsEditOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleReplyClick = useCallback(() => {
|
||||
if (user.username) {
|
||||
onReply(user.username);
|
||||
}
|
||||
}, [onReply, user.username]);
|
||||
|
||||
const handleEditClose = useCallback(() => {
|
||||
setIsEditOpened(false);
|
||||
}, []);
|
||||
@@ -117,8 +123,18 @@ const Item = React.memo(({ id }) => {
|
||||
<span className={styles.date}>
|
||||
<TimeAgo date={comment.createdAt} />
|
||||
</span>
|
||||
{(canEdit || canDelete) && (
|
||||
{(canReply || canEdit || canDelete) && (
|
||||
<span className={styles.actions}>
|
||||
{canReply && user.username && (
|
||||
<Comment.Action
|
||||
as="button"
|
||||
content={t('action.reply', {
|
||||
defaultValue: 'Reply',
|
||||
})}
|
||||
disabled={!comment.isPersisted}
|
||||
onClick={handleReplyClick}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Comment.Action
|
||||
as="button"
|
||||
@@ -153,6 +169,13 @@ const Item = React.memo(({ id }) => {
|
||||
|
||||
Item.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
canReply: PropTypes.bool,
|
||||
onReply: PropTypes.func,
|
||||
};
|
||||
|
||||
Item.defaultProps = {
|
||||
canReply: false,
|
||||
onReply: () => {},
|
||||
};
|
||||
|
||||
export default Item;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* 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 keyBy from 'lodash/keyBy';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
@@ -13,6 +14,7 @@ import { useClickAwayListener } from '../../../lib/hooks';
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useNestedRef } from '../../../hooks';
|
||||
import { mentionMarkupToText, mentionTextToMarkup } from '../../../utils/mentions';
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
|
||||
import styles from './EditMarkdown.module.scss';
|
||||
@@ -21,10 +23,20 @@ const MAX_LENGTH = 1048576;
|
||||
|
||||
const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }) => {
|
||||
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 [t] = useTranslation();
|
||||
const [value, setValue] = useState(() => draftValue || defaultValue || '');
|
||||
const [value, setValue] = useState(() => mentionMarkupToText(draftValue || defaultValue || ''));
|
||||
|
||||
const fieldRef = useRef(null);
|
||||
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
|
||||
@@ -44,14 +56,15 @@ const EditMarkdown = React.memo(({ defaultValue, draftValue, onUpdate, onClose }
|
||||
const isExceeded = value.length > MAX_LENGTH;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const cleanValue = value.trim() || null;
|
||||
const valueWithMarkup = mentionTextToMarkup(value, userByUsername);
|
||||
const cleanValue = valueWithMarkup.trim() || null;
|
||||
|
||||
if (!isExceeded && cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
onClose(isExceeded ? cleanValue : null);
|
||||
}, [onUpdate, onClose, defaultValue, value, isExceeded]);
|
||||
}, [onUpdate, onClose, defaultValue, value, isExceeded, userByUsername]);
|
||||
|
||||
const handleChange = useCallback((nextValue) => {
|
||||
setValue(nextValue);
|
||||
|
||||
@@ -49,12 +49,24 @@ const List = React.memo(({ id, index }) => {
|
||||
[],
|
||||
);
|
||||
|
||||
const selectCardCountByListId = useMemo(() => selectors.makeSelectCardCountByListId(), []);
|
||||
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
|
||||
|
||||
const list = useSelector((state) => selectListById(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(
|
||||
(state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
@@ -148,6 +160,10 @@ const List = React.memo(({ id, index }) => {
|
||||
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
|
||||
}, [scrollBottomState]);
|
||||
|
||||
const cardCountNode = showCardCounter && cardCount !== null && (
|
||||
<span className={styles.addCardButtonCount}>{cardCount}</span>
|
||||
);
|
||||
|
||||
const ActionsPopup = usePopup(ActionsStep);
|
||||
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
|
||||
|
||||
@@ -274,6 +290,7 @@ const List = React.memo(({ id, index }) => {
|
||||
)}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
{cardCountNode}
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{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 {
|
||||
height: 20px;
|
||||
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;
|
||||
@@ -273,6 +273,9 @@ export default {
|
||||
LABEL_FROM_BOARD_FILTER_REMOVE: 'LABEL_FROM_BOARD_FILTER_REMOVE',
|
||||
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 */
|
||||
|
||||
LIST_CREATE: 'LIST_CREATE',
|
||||
@@ -297,6 +300,8 @@ export default {
|
||||
LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS',
|
||||
LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE',
|
||||
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 */
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ export default {
|
||||
LIST_CLEAR_HANDLE: `${PREFIX}/LIST_CLEAR_HANDLE`,
|
||||
LIST_DELETE: `${PREFIX}/LIST_DELETE`,
|
||||
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 */
|
||||
|
||||
@@ -321,4 +323,9 @@ export default {
|
||||
NOTIFICATION_SERVICE_TEST: `${PREFIX}/NOTIFICATION_SERVICE_TEST`,
|
||||
NOTIFICATION_SERVICE_DELETE: `${PREFIX}/NOTIFICATION_SERVICE_DELETE`,
|
||||
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`,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
createBoardInCurrentProject,
|
||||
handleBoardCreate,
|
||||
@@ -106,4 +114,6 @@ export default {
|
||||
searchInCurrentBoard,
|
||||
deleteBoard,
|
||||
handleBoardDelete,
|
||||
setNoMemberToFilterInCurrentBoard,
|
||||
removeNoMemberFromFilterInCurrentBoard,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
createListInCurrentBoard,
|
||||
handleListCreate,
|
||||
@@ -108,4 +122,6 @@ export default {
|
||||
handleListClear,
|
||||
deleteList,
|
||||
handleListDelete,
|
||||
addListToFilterInCurrentBoard,
|
||||
removeListFromFilterInCurrentBoard,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
alwaysDisplayCardCreator: 'Kartenersteller immer anzeigen',
|
||||
apiKeyCreated_title: 'API-Schlüssel erstellt',
|
||||
apiKey_title: 'API-Schlüssel',
|
||||
applyFilter: 'Filter anwenden',
|
||||
archive: 'Archiv',
|
||||
archiveCard_title: 'Karte archivieren',
|
||||
archiveCards_title: 'Karten archivieren',
|
||||
@@ -74,6 +75,8 @@ export default {
|
||||
'Sind Sie sicher, dass Sie dieses Datenfeld löschen möchten?',
|
||||
areYouSureYouWantToDeleteThisCustomFieldGroup:
|
||||
'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?',
|
||||
areYouSureYouWantToDeleteThisList:
|
||||
'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:
|
||||
'Karten in dieser Liste sind abgeschlossen und können archiviert werden.',
|
||||
cardsOnThisListAreReadyToBeWorkedOn: 'Karten in dieser Liste sind bereit zur Bearbeitung.',
|
||||
clearFilter: 'Filter zurücksetzen',
|
||||
clickHereOrRefreshPageToUpdate:
|
||||
'<0>Hier klicken</0> oder Seite aktualisieren, um zu aktualisieren.',
|
||||
clientHostnameInEhlo: 'Client-Hostname in EHLO',
|
||||
@@ -226,6 +230,7 @@ export default {
|
||||
unknownDevice: 'Unbekanntes Gerät',
|
||||
upgradeTeamToPro_title: 'Team auf Pro upgraden',
|
||||
date: 'Datum',
|
||||
deactivateFilter: 'Filter deaktivieren',
|
||||
deactivateUser_title: 'Benutzer deaktivieren',
|
||||
defaultCardType_title: 'Standard-Kartentyp',
|
||||
defaultFrom: 'Standard "Von"',
|
||||
@@ -241,6 +246,7 @@ export default {
|
||||
deleteComment_title: 'Kommentar löschen',
|
||||
deleteCustomFieldGroup_title: 'Feldgruppe löschen',
|
||||
deleteCustomField_title: 'Datenfeld löschen',
|
||||
deleteFilter_title: 'Filter löschen',
|
||||
deleteLabel_title: 'Label löschen',
|
||||
deleteList_title: 'Liste löschen',
|
||||
deleteNotificationService_title: 'Benachrichtigungsdienst löschen',
|
||||
@@ -284,7 +290,9 @@ export default {
|
||||
excludedEvents: 'Ausgeschlossene Ereignisse',
|
||||
expandTaskListsByDefault: 'Aufgabenlisten standardmäßig erweitern',
|
||||
filterByLabels_title: 'Nach Label filtern',
|
||||
filterByLists_title: 'Nach Listen filtern',
|
||||
filterByMembers_title: 'Nach Mitgliedern filtern',
|
||||
filterName: 'Filtername',
|
||||
forPersonalProjects: 'Für persönliche Projekte.',
|
||||
forTeamBasedProjects: 'Für teambasierte Projekte.',
|
||||
fromComputer_title: 'Vom Computer',
|
||||
@@ -334,7 +342,9 @@ export default {
|
||||
noCardsFound: 'Keine Karten gefunden.',
|
||||
noConnectionToServer: 'Keine Verbindung zum Server',
|
||||
noLists: 'Keine Listen',
|
||||
noMember: 'Ohne Mitglied',
|
||||
noProjects: 'Keine Projekte',
|
||||
noSavedFilters: 'Keine gespeicherten Filter',
|
||||
noUnreadNotifications: 'Keine ungelesenen Benachrichtigungen.',
|
||||
notifications: 'Benachrichtigungen',
|
||||
oldestFirst: 'Älteste zuerst',
|
||||
@@ -371,9 +381,12 @@ export default {
|
||||
rejectUnauthorizedTlsCertificates: 'Nicht autorisierte TLS-Zertifikate ablehnen',
|
||||
removeManager_title: 'Projektleiter entfernen',
|
||||
removeMember_title: 'Mitglied entfernen',
|
||||
restoreFilter: 'Filter wiederherstellen',
|
||||
role: 'Rolle',
|
||||
saveFilter_title: 'Filter speichern',
|
||||
saveThisKeyItWillNotBeShownAgain:
|
||||
'Speichern Sie diesen Schlüssel — er wird nicht erneut angezeigt!',
|
||||
savedFilters_title: 'Gespeicherte Filter',
|
||||
searchCards: 'Karte suchen...',
|
||||
searchCustomFieldGroups: 'Benutzerdefinierte Feldgruppen suchen...',
|
||||
searchCustomFields: 'In Feldgruppen suchen...',
|
||||
@@ -396,6 +409,9 @@ export default {
|
||||
settings: 'Einstellungen',
|
||||
shared: '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',
|
||||
smtp: 'SMTP',
|
||||
sortList_title: 'Liste sortieren',
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
alwaysDisplayCardCreator: 'Always display card creator',
|
||||
apiKeyCreated_title: 'API Key Created',
|
||||
apiKey_title: 'API Key',
|
||||
applyFilter: 'Apply filter',
|
||||
archive: 'Archive',
|
||||
archiveCard_title: 'Archive Card',
|
||||
archiveCards_title: 'Archive Cards',
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
'Are you sure you want to delete this custom field?',
|
||||
areYouSureYouWantToDeleteThisCustomFieldGroup:
|
||||
'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?',
|
||||
areYouSureYouWantToDeleteThisList:
|
||||
'Are you sure you want to delete this list? All cards will be moved to trash.',
|
||||
@@ -125,6 +127,7 @@ export default {
|
||||
cardsOnThisListAreCompleteAndReadyToBeArchived:
|
||||
'Cards on this list are complete and ready to be archived.',
|
||||
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.',
|
||||
clientHostnameInEhlo: 'Client hostname in EHLO',
|
||||
closed: 'Closed',
|
||||
@@ -204,6 +207,7 @@ export default {
|
||||
unknownDevice: 'Unknown device',
|
||||
upgradeTeamToPro_title: 'Upgrade Team to Pro',
|
||||
date: 'Date',
|
||||
deactivateFilter: 'Deactivate filter',
|
||||
deactivateUser_title: 'Deactivate User',
|
||||
defaultCardType_title: 'Default Card Type',
|
||||
defaultFrom: 'Default "from"',
|
||||
@@ -219,6 +223,7 @@ export default {
|
||||
deleteComment_title: 'Delete Comment',
|
||||
deleteCustomFieldGroup_title: 'Delete Custom Field Group',
|
||||
deleteCustomField_title: 'Delete Custom Field',
|
||||
deleteFilter_title: 'Delete Filter',
|
||||
deleteLabel_title: 'Delete Label',
|
||||
deleteList_title: 'Delete List',
|
||||
deleteNotificationService_title: 'Delete Notification Service',
|
||||
@@ -262,7 +267,9 @@ export default {
|
||||
excludedEvents: 'Excluded events',
|
||||
expandTaskListsByDefault: 'Expand task lists by default',
|
||||
filterByLabels_title: 'Filter By Labels',
|
||||
filterByLists_title: 'Filter By Lists',
|
||||
filterByMembers_title: 'Filter By Members',
|
||||
filterName: 'Filter name',
|
||||
forPersonalProjects: 'For personal projects.',
|
||||
forTeamBasedProjects: 'For team-based projects.',
|
||||
fromComputer_title: 'From Computer',
|
||||
@@ -312,7 +319,9 @@ export default {
|
||||
noCardsFound: 'No cards found.',
|
||||
noConnectionToServer: 'No connection to server',
|
||||
noLists: 'No lists',
|
||||
noMember: 'No member',
|
||||
noProjects: 'No projects',
|
||||
noSavedFilters: 'No saved filters',
|
||||
noUnreadNotifications: 'No unread notifications.',
|
||||
notifications: 'Notifications',
|
||||
oldestFirst: 'Oldest first',
|
||||
@@ -349,8 +358,11 @@ export default {
|
||||
rejectUnauthorizedTlsCertificates: 'Reject unauthorized TLS certificates',
|
||||
removeManager_title: 'Remove Manager',
|
||||
removeMember_title: 'Remove Member',
|
||||
restoreFilter: 'Restore filter',
|
||||
role: 'Role',
|
||||
saveFilter_title: 'Save Filter',
|
||||
saveThisKeyItWillNotBeShownAgain: 'Save this key — it will not be shown again!',
|
||||
savedFilters_title: 'Saved Filters',
|
||||
searchCards: 'Search cards...',
|
||||
searchCustomFieldGroups: 'Search custom field groups...',
|
||||
searchCustomFields: 'Search custom fields...',
|
||||
@@ -373,6 +385,9 @@ export default {
|
||||
settings: 'Settings',
|
||||
shared: 'Shared',
|
||||
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',
|
||||
smtp: 'SMTP',
|
||||
sortList_title: 'Sort List',
|
||||
|
||||
@@ -36,6 +36,7 @@ export default class extends BaseModel {
|
||||
defaultCardType: attr(),
|
||||
limitCardTypesToDefaultOne: attr(),
|
||||
alwaysDisplayCardCreator: attr(),
|
||||
showCardCounter: attr(),
|
||||
displayCardAges: attr(),
|
||||
expandTaskListsByDefault: attr(),
|
||||
context: attr(),
|
||||
@@ -69,6 +70,10 @@ export default class extends BaseModel {
|
||||
filterUsers: many('User', 'filterBoards'),
|
||||
filterLabels: many('Label', 'filterBoards'),
|
||||
filterExcludedLabels: many('Label', 'filterExcludedBoards'),
|
||||
filterNoMember: attr({
|
||||
getDefault: () => false,
|
||||
}),
|
||||
filterLists: many('List', 'filterBoards'),
|
||||
};
|
||||
|
||||
static reducer({ type, payload }, Board) {
|
||||
@@ -283,6 +288,14 @@ export default class extends BaseModel {
|
||||
|
||||
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:
|
||||
Board.withId(payload.boardId).update({
|
||||
isActivitiesFetching: true,
|
||||
@@ -299,6 +312,17 @@ export default class extends BaseModel {
|
||||
});
|
||||
|
||||
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:
|
||||
}
|
||||
}
|
||||
@@ -416,6 +440,19 @@ export default class extends BaseModel {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -472,6 +509,7 @@ export default class extends BaseModel {
|
||||
this.filterUsers.clear();
|
||||
this.filterLabels.clear();
|
||||
this.filterExcludedLabels.clear();
|
||||
this.filterLists.clear();
|
||||
}
|
||||
|
||||
deleteRelated(exceptMemberUserId, soft) {
|
||||
|
||||
@@ -389,6 +389,11 @@ export default class extends BaseModel {
|
||||
cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
|
||||
}
|
||||
|
||||
if (this.board.filterNoMember) {
|
||||
cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
|
||||
return cardModels;
|
||||
}
|
||||
|
||||
return cardModels;
|
||||
}
|
||||
|
||||
@@ -445,6 +450,13 @@ export default class extends BaseModel {
|
||||
|
||||
deleteWithRelated(soft) {
|
||||
this.deleteRelated(soft);
|
||||
|
||||
try {
|
||||
this.board.filterLists.remove(this.id);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
this.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
createBoard,
|
||||
createBoardInCurrentProject,
|
||||
@@ -271,4 +287,6 @@ export default {
|
||||
searchInCurrentBoard,
|
||||
deleteBoard,
|
||||
handleBoardDelete,
|
||||
setNoMemberToFilterInCurrentBoard,
|
||||
removeNoMemberFromFilterInCurrentBoard,
|
||||
};
|
||||
|
||||
@@ -261,6 +261,26 @@ export function* 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 {
|
||||
createList,
|
||||
createListInCurrentBoard,
|
||||
@@ -275,4 +295,8 @@ export default {
|
||||
handleListClear,
|
||||
deleteList,
|
||||
handleListDelete,
|
||||
addListToBoardFilter,
|
||||
addListToFilterInCurrentBoard,
|
||||
removeListFromBoardFilter,
|
||||
removeListFromFilterInCurrentBoard,
|
||||
};
|
||||
|
||||
@@ -44,5 +44,13 @@ export default function* boardsWatchers() {
|
||||
takeEvery(EntryActionTypes.BOARD_DELETE_HANDLE, ({ payload: { 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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,5 +44,11 @@ export default function* listsWatchers() {
|
||||
takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { 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),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
@@ -513,5 +531,6 @@ export default {
|
||||
selectFilterUserIdsForCurrentBoard,
|
||||
selectFilterLabelIdsForCurrentBoard,
|
||||
selectFilterExcludedLabelIdsForCurrentBoard,
|
||||
selectFilterListIdsForCurrentBoard,
|
||||
selectIsBoardWithIdExists,
|
||||
};
|
||||
|
||||
@@ -65,6 +65,27 @@ export const 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(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
@@ -171,6 +192,8 @@ export default {
|
||||
selectCardIdsByListId,
|
||||
makeSelectFilteredCardIdsByListId,
|
||||
selectFilteredCardIdsByListId,
|
||||
makeSelectCardCountByListId,
|
||||
selectCardCountByListId,
|
||||
selectIsListWithIdAvailableForCurrentUser,
|
||||
selectCurrentListId,
|
||||
selectCurrentList,
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
{"mcpServers":{"gitea_repo":{"type":"http","url":"http://192.168.1.122:18100/mcp"}}}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "planka",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planka",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
* type: boolean
|
||||
* description: Whether to always display card creators
|
||||
* example: false
|
||||
* showCardCounter:
|
||||
* type: boolean
|
||||
* description: Whether a list's add-card button shows how many cards it holds
|
||||
* example: false
|
||||
* displayCardAges:
|
||||
* type: boolean
|
||||
* description: Whether to display card ages
|
||||
@@ -124,6 +128,9 @@ module.exports = {
|
||||
alwaysDisplayCardCreator: {
|
||||
type: 'boolean',
|
||||
},
|
||||
showCardCounter: {
|
||||
type: 'boolean',
|
||||
},
|
||||
displayCardAges: {
|
||||
type: 'boolean',
|
||||
},
|
||||
@@ -167,6 +174,7 @@ module.exports = {
|
||||
'defaultCardType',
|
||||
'limitCardTypesToDefaultOne',
|
||||
'alwaysDisplayCardCreator',
|
||||
'showCardCounter',
|
||||
'displayCardAges',
|
||||
'expandTaskListsByDefault',
|
||||
);
|
||||
@@ -186,6 +194,7 @@ module.exports = {
|
||||
'defaultCardType',
|
||||
'limitCardTypesToDefaultOne',
|
||||
'alwaysDisplayCardCreator',
|
||||
'showCardCounter',
|
||||
'displayCardAges',
|
||||
'expandTaskListsByDefault',
|
||||
'isSubscribed',
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* - defaultCardType
|
||||
* - limitCardTypesToDefaultOne
|
||||
* - alwaysDisplayCardCreator
|
||||
* - showCardCounter
|
||||
* - displayCardAges
|
||||
* - expandTaskListsByDefault
|
||||
* - createdAt
|
||||
@@ -68,6 +69,11 @@
|
||||
* default: false
|
||||
* description: Whether to always display the card creator
|
||||
* example: false
|
||||
* showCardCounter:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: Whether a list's add-card button shows how many cards it holds
|
||||
* example: false
|
||||
* displayCardAges:
|
||||
* type: boolean
|
||||
* default: false
|
||||
@@ -143,6 +149,12 @@ module.exports = {
|
||||
defaultsTo: false,
|
||||
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: {
|
||||
type: 'boolean',
|
||||
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');
|
||||
});
|
||||
};
|
||||
Generated
-48
@@ -756,9 +756,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -775,9 +772,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -794,9 +788,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -813,9 +804,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -832,9 +820,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -851,9 +836,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -870,9 +852,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -889,9 +868,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -908,9 +884,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -933,9 +906,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -958,9 +928,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +950,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1008,9 +972,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1033,9 +994,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1058,9 +1016,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1083,9 +1038,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
Reference in New Issue
Block a user