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