feat: Let a board filter be put down and picked up again
Two things, and neither of them happens on its own. Clearing the filter row now keeps a snapshot, and the same spot turns into an undo arrow that puts it back. Taking the filter apart chip by chip does not leave one, because that is someone changing their mind rather than setting something aside. A filter can also be saved under a name and picked again later, per person and per board. Nothing is restored automatically when a board opens, which is the whole reason this may live in localStorage at all. Two windows on the same board would otherwise pull one stored filter back and forth. The storage is read in three places and only one of them dispatches, inside a click handler. The keys and the payload are Pro's, so saved filters carry over to an upgrade instead of being left behind. Community's own filter dimensions ride along as extra fields that Pro reads as absent.
This commit is contained in:
@@ -23,9 +23,22 @@ import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
|
||||
import LabelChip from '../../labels/LabelChip';
|
||||
import LabelsStep from '../../labels/LabelsStep';
|
||||
import ListsFilterStep from '../../lists/ListsFilterStep';
|
||||
import SaveFilterStep from './SaveFilterStep';
|
||||
import SavedFiltersStep from './SavedFiltersStep';
|
||||
import {
|
||||
addSavedFilter,
|
||||
clearLastFilter,
|
||||
readLastFilter,
|
||||
readSavedFilters,
|
||||
removeSavedFilter,
|
||||
writeLastFilter,
|
||||
} from './useFilterStorage';
|
||||
|
||||
import styles from './Filters.module.scss';
|
||||
|
||||
// Membership comparison — order carries no meaning for a filter selection.
|
||||
const sameIds = (a, b) => a.length === b.length && a.every((id) => b.includes(id));
|
||||
|
||||
const FilterListChip = React.memo(({ id, onClick }) => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
const list = useSelector((state) => selectListById(state, id));
|
||||
@@ -67,6 +80,11 @@ const Filters = React.memo(() => {
|
||||
const [t] = useTranslation();
|
||||
const [search, setSearch] = useState(board.search);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
// Cache-buster for the two localStorage buckets below. Bumping it after a
|
||||
// write re-runs the memos that read them, which is all the syncing they
|
||||
// need — nothing here watches storage, so another window's snapshot never
|
||||
// arrives uninvited.
|
||||
const [storageRev, setStorageRev] = useState(0);
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
() =>
|
||||
@@ -98,12 +116,73 @@ const Filters = React.memo(() => {
|
||||
[labelIds, excludedLabelIds],
|
||||
);
|
||||
|
||||
// Everything the board is currently filtered by, in the shape the storage
|
||||
// helpers read and write.
|
||||
const currentFilter = useMemo(
|
||||
() => ({
|
||||
userIds,
|
||||
labelIds,
|
||||
search: board.search || '',
|
||||
excludedLabelIds,
|
||||
listIds,
|
||||
noMember: !!board.filterNoMember,
|
||||
}),
|
||||
[board.filterNoMember, board.search, excludedLabelIds, labelIds, listIds, userIds],
|
||||
);
|
||||
|
||||
const hasFilter =
|
||||
userIds.length > 0 ||
|
||||
labelIds.length > 0 ||
|
||||
excludedLabelIds.length > 0 ||
|
||||
listIds.length > 0 ||
|
||||
!!board.filterNoMember ||
|
||||
currentFilter.search.length > 0;
|
||||
|
||||
const savedFilters = useMemo(
|
||||
() => readSavedFilters(currentUserId, board.id),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[board.id, currentUserId, storageRev],
|
||||
);
|
||||
|
||||
// Only looked up while the board is unfiltered, and only to decide whether
|
||||
// the restore button is worth showing. Reading it never applies it.
|
||||
const lastFilter = useMemo(
|
||||
() => (hasFilter ? null : readLastFilter(currentUserId, board.id)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[board.id, currentUserId, hasFilter, storageRev],
|
||||
);
|
||||
|
||||
// Which saved filter, if any, matches the board exactly. Drives both the
|
||||
// highlight in the list and the second-click-to-deactivate behaviour.
|
||||
const activeSavedFilterId = useMemo(() => {
|
||||
const match = savedFilters.find(
|
||||
(item) =>
|
||||
sameIds(item.userIds || [], currentFilter.userIds) &&
|
||||
sameIds(item.labelIds || [], currentFilter.labelIds) &&
|
||||
sameIds(item.excludedLabelIds || [], currentFilter.excludedLabelIds) &&
|
||||
sameIds(item.listIds || [], currentFilter.listIds) &&
|
||||
!!item.noMember === currentFilter.noMember &&
|
||||
(item.search || '') === currentFilter.search,
|
||||
);
|
||||
|
||||
return match && match.id;
|
||||
}, [currentFilter, savedFilters]);
|
||||
|
||||
// Taking a filter apart chip by chip says "these values are not what I
|
||||
// want", so every such deselect drops the recall snapshot. Only the clear
|
||||
// button, which sets the snapshot on its way out, preserves it.
|
||||
const invalidateLastFilter = useCallback(() => {
|
||||
clearLastFilter(currentUserId, board.id);
|
||||
setStorageRev((prevStorageRev) => prevStorageRev + 1);
|
||||
}, [board.id, currentUserId]);
|
||||
|
||||
const cancelSearch = useCallback(() => {
|
||||
debouncedSearch.cancel();
|
||||
setSearch('');
|
||||
dispatch(entryActions.searchInCurrentBoard(''));
|
||||
invalidateLastFilter();
|
||||
searchFieldRef.current.blur();
|
||||
}, [dispatch, debouncedSearch, searchFieldRef]);
|
||||
}, [dispatch, debouncedSearch, invalidateLastFilter, searchFieldRef]);
|
||||
|
||||
const handleUserSelect = useCallback(
|
||||
(userId) => {
|
||||
@@ -119,8 +198,9 @@ const Filters = React.memo(() => {
|
||||
const handleUserDeselect = useCallback(
|
||||
(userId) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleUserClick = useCallback(
|
||||
@@ -130,8 +210,9 @@ const Filters = React.memo(() => {
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
@@ -144,8 +225,9 @@ const Filters = React.memo(() => {
|
||||
const handleListDeselect = useCallback(
|
||||
(listId) => {
|
||||
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleLabelClick = useCallback(
|
||||
@@ -155,15 +237,22 @@ const Filters = React.memo(() => {
|
||||
},
|
||||
}) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
|
||||
invalidateLastFilter();
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleLabelModeChange = useCallback(
|
||||
(labelId, mode) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode));
|
||||
|
||||
// Switching a label between include and exclude is still filtering;
|
||||
// only dropping it altogether counts as a deselect.
|
||||
if (mode === LabelFilterModes.NONE) {
|
||||
invalidateLastFilter();
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, invalidateLastFilter],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
@@ -195,6 +284,133 @@ const Filters = React.memo(() => {
|
||||
cancelSearch();
|
||||
}, [cancelSearch]);
|
||||
|
||||
const handleNoMemberClick = useCallback(() => {
|
||||
if (board.filterNoMember) {
|
||||
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
|
||||
invalidateLastFilter();
|
||||
} else {
|
||||
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
|
||||
}
|
||||
}, [board.filterNoMember, dispatch, invalidateLastFilter]);
|
||||
|
||||
// Take the board back to unfiltered, one existing entry action per
|
||||
// dimension — the same path the controls above use, so other sessions see
|
||||
// it exactly as they always have.
|
||||
const clearCurrentFilter = useCallback(() => {
|
||||
userIds.forEach((userId) => {
|
||||
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
|
||||
});
|
||||
|
||||
[...labelIds, ...excludedLabelIds].forEach((labelId) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
|
||||
});
|
||||
|
||||
listIds.forEach((listId) => {
|
||||
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
|
||||
});
|
||||
|
||||
if (board.filterNoMember) {
|
||||
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
|
||||
}
|
||||
|
||||
debouncedSearch.cancel();
|
||||
setSearch('');
|
||||
|
||||
if (board.search) {
|
||||
dispatch(entryActions.searchInCurrentBoard(''));
|
||||
}
|
||||
}, [
|
||||
board.filterNoMember,
|
||||
board.search,
|
||||
debouncedSearch,
|
||||
dispatch,
|
||||
excludedLabelIds,
|
||||
labelIds,
|
||||
listIds,
|
||||
userIds,
|
||||
]);
|
||||
|
||||
// The mirror image, used by both the restore button and the saved-filter
|
||||
// list. "No member" goes first because setting it clears the member
|
||||
// selection, which would otherwise undo the users we just added.
|
||||
const applyFilter = useCallback(
|
||||
(filter) => {
|
||||
if (filter.noMember) {
|
||||
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
|
||||
}
|
||||
|
||||
(filter.userIds || []).forEach((userId) => {
|
||||
dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
|
||||
});
|
||||
|
||||
(filter.labelIds || []).forEach((labelId) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.INCLUDE));
|
||||
});
|
||||
|
||||
(filter.excludedLabelIds || []).forEach((labelId) => {
|
||||
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.EXCLUDE));
|
||||
});
|
||||
|
||||
(filter.listIds || []).forEach((listId) => {
|
||||
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
|
||||
});
|
||||
|
||||
if (filter.search) {
|
||||
debouncedSearch.cancel();
|
||||
setSearch(filter.search);
|
||||
dispatch(entryActions.searchInCurrentBoard(filter.search));
|
||||
}
|
||||
},
|
||||
[debouncedSearch, dispatch],
|
||||
);
|
||||
|
||||
// Clearing everything at once leaves the snapshot behind, so the very next
|
||||
// click of the restore button can put it back.
|
||||
const handleClearClick = useCallback(() => {
|
||||
writeLastFilter(currentUserId, board.id, currentFilter);
|
||||
clearCurrentFilter();
|
||||
setStorageRev((prevStorageRev) => prevStorageRev + 1);
|
||||
}, [board.id, clearCurrentFilter, currentFilter, currentUserId]);
|
||||
|
||||
const handleRestoreClick = useCallback(() => {
|
||||
if (lastFilter) {
|
||||
applyFilter(lastFilter);
|
||||
}
|
||||
}, [applyFilter, lastFilter]);
|
||||
|
||||
// Saved filters replace rather than merge. Picking the one already on the
|
||||
// board turns it off instead, so the same row toggles both ways.
|
||||
const handleSavedFilterApply = useCallback(
|
||||
(item) => {
|
||||
clearCurrentFilter();
|
||||
|
||||
if (item.id !== activeSavedFilterId) {
|
||||
applyFilter(item);
|
||||
}
|
||||
},
|
||||
[activeSavedFilterId, applyFilter, clearCurrentFilter],
|
||||
);
|
||||
|
||||
const handleSavedFilterRemove = useCallback(
|
||||
(id) => {
|
||||
removeSavedFilter(currentUserId, board.id, id);
|
||||
setStorageRev((prevStorageRev) => prevStorageRev + 1);
|
||||
},
|
||||
[board.id, currentUserId],
|
||||
);
|
||||
|
||||
const handleSaveFilter = useCallback(
|
||||
(name) => {
|
||||
addSavedFilter(currentUserId, board.id, {
|
||||
name,
|
||||
...currentFilter,
|
||||
});
|
||||
|
||||
setStorageRev((prevStorageRev) => prevStorageRev + 1);
|
||||
},
|
||||
[board.id, currentFilter, currentUserId],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setSearch(board.search);
|
||||
}, [board.search]);
|
||||
@@ -202,18 +418,12 @@ const Filters = React.memo(() => {
|
||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopup(LabelsStep);
|
||||
const ListsFilterPopup = usePopup(ListsFilterStep);
|
||||
const SaveFilterPopup = usePopup(SaveFilterStep);
|
||||
const SavedFiltersPopup = usePopup(SavedFiltersStep);
|
||||
|
||||
const isSearchActive = search || isSearchFocused;
|
||||
const isListView = board.view === BoardViews.LIST;
|
||||
|
||||
const handleNoMemberClick = useCallback(() => {
|
||||
if (board.filterNoMember) {
|
||||
dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
|
||||
} else {
|
||||
dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
|
||||
}
|
||||
}, [dispatch, board.filterNoMember]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.filter}>
|
||||
@@ -316,6 +526,64 @@ const Filters = React.memo(() => {
|
||||
onBlur={handleSearchBlur}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.filter}>
|
||||
{hasFilter && (
|
||||
<Tooltip content={t('common.clearFilter')}>
|
||||
<button type="button" className={styles.filterButton} onClick={handleClearClick}>
|
||||
<span className={styles.filterLabel}>
|
||||
<Icon fitted name="close" className={styles.filterLabelIcon} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Takes the place of the clear button once the filter is gone, and
|
||||
only while there is something to put back. */}
|
||||
{!hasFilter && !!lastFilter && (
|
||||
<Tooltip content={t('common.restoreFilter')}>
|
||||
<button type="button" className={styles.filterButton} onClick={handleRestoreClick}>
|
||||
<span className={styles.filterLabel}>
|
||||
<Icon fitted name="undo" className={styles.filterLabelIcon} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* No Tooltip around either popup trigger: usePopup clones its
|
||||
immediate child to attach the click handler, so anything wrapped
|
||||
around the button swallows the open. */}
|
||||
{hasFilter && (
|
||||
<SaveFilterPopup onSave={handleSaveFilter}>
|
||||
<button
|
||||
type="button"
|
||||
title={t('common.saveFilter', {
|
||||
context: 'title',
|
||||
})}
|
||||
className={styles.filterButton}
|
||||
>
|
||||
<span className={styles.filterLabel}>
|
||||
<Icon fitted name="bookmark outline" className={styles.filterLabelIcon} />
|
||||
</span>
|
||||
</button>
|
||||
</SaveFilterPopup>
|
||||
)}
|
||||
<SavedFiltersPopup
|
||||
items={savedFilters}
|
||||
activeId={activeSavedFilterId}
|
||||
onApply={handleSavedFilterApply}
|
||||
onRemove={handleSavedFilterRemove}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('common.savedFilters', {
|
||||
context: 'title',
|
||||
})}
|
||||
className={styles.filterButton}
|
||||
>
|
||||
<span className={styles.filterLabel}>
|
||||
<Icon fitted name="bookmark" className={styles.filterLabelIcon} />
|
||||
</span>
|
||||
</button>
|
||||
</SavedFiltersPopup>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import { useNestedRef } from '../../../hooks';
|
||||
|
||||
import styles from './SaveFilterStep.module.scss';
|
||||
|
||||
// Small naming popup. The filter being saved is whatever is active on
|
||||
// the board right now, so this step only hands the name back through
|
||||
// `onSave` — Filters does the writing, which also lets it kick the
|
||||
// re-render that exposes the new entry in the saved-filters list.
|
||||
const SaveFilterStep = React.memo(({ defaultName, onSave, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
const [name, setName] = useState(defaultName);
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
|
||||
|
||||
useEffect(() => {
|
||||
nameFieldRef.current.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
nameFieldRef.current.select();
|
||||
}, [nameFieldRef]);
|
||||
|
||||
const handleChange = useCallback((_, { value }) => {
|
||||
setName(value);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const cleanName = name.trim();
|
||||
|
||||
if (!cleanName) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(cleanName);
|
||||
onClose();
|
||||
}, [name, nameFieldRef, onClose, onSave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.saveFilter', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
fluid
|
||||
ref={handleNameFieldRef}
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder={t('common.filterName')}
|
||||
maxLength={64}
|
||||
className={styles.field}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SaveFilterStep.propTypes = {
|
||||
defaultName: PropTypes.string,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SaveFilterStep.defaultProps = {
|
||||
defaultName: '',
|
||||
};
|
||||
|
||||
export default SaveFilterStep;
|
||||
@@ -0,0 +1,10 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import { useSteps } from '../../../hooks';
|
||||
import ConfirmationStep from '../../common/ConfirmationStep';
|
||||
|
||||
import styles from './SavedFiltersStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
// The saved-filter picker. Each row is a name (apply, or deactivate when it
|
||||
// is the one already matching the board) plus an X that removes it after a
|
||||
// confirmation, the same way every other destructive row in the app behaves.
|
||||
//
|
||||
// Opening this popup reads localStorage but applies nothing; a saved filter
|
||||
// only reaches the board when a row is clicked.
|
||||
const SavedFiltersStep = React.memo(({ items, activeId, onApply, onRemove, onBack, onClose }) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleApplyClick = useCallback(
|
||||
({
|
||||
currentTarget: {
|
||||
dataset: { id },
|
||||
},
|
||||
}) => {
|
||||
onApply(items.find((item) => item.id === id));
|
||||
onClose();
|
||||
},
|
||||
[items, onApply, onClose],
|
||||
);
|
||||
|
||||
const handleRemoveClick = useCallback(
|
||||
({
|
||||
currentTarget: {
|
||||
dataset: { id },
|
||||
},
|
||||
}) => {
|
||||
openStep(StepTypes.DELETE, {
|
||||
id,
|
||||
});
|
||||
},
|
||||
[openStep],
|
||||
);
|
||||
|
||||
const handleRemoveConfirm = useCallback(() => {
|
||||
onRemove(step.params.id);
|
||||
handleBack();
|
||||
}, [handleBack, onRemove, step]);
|
||||
|
||||
if (step && step.type === StepTypes.DELETE) {
|
||||
return (
|
||||
<ConfirmationStep
|
||||
title="common.deleteFilter"
|
||||
content="common.areYouSureYouWantToDeleteThisFilter"
|
||||
buttonContent="action.delete"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('common.savedFilters', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>{t('common.noSavedFilters')}</div>
|
||||
) : (
|
||||
<div className={styles.items}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
|
||||
return (
|
||||
<div key={item.id} className={styles.item}>
|
||||
<button
|
||||
type="button"
|
||||
data-id={item.id}
|
||||
title={isActive ? t('common.deactivateFilter') : t('common.applyFilter')}
|
||||
className={classNames(styles.itemButton, isActive && styles.itemButtonActive)}
|
||||
onClick={handleApplyClick}
|
||||
>
|
||||
<Icon fitted name={isActive ? 'check' : 'filter'} className={styles.itemIcon} />
|
||||
<span className={styles.itemName}>{item.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-id={item.id}
|
||||
title={t('common.deleteFilter', {
|
||||
context: 'title',
|
||||
})}
|
||||
className={styles.itemRemoveButton}
|
||||
onClick={handleRemoveClick}
|
||||
>
|
||||
<Icon fitted name="trash alternate outline" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SavedFiltersStep.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
activeId: PropTypes.string,
|
||||
onApply: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SavedFiltersStep.defaultProps = {
|
||||
activeId: undefined,
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default SavedFiltersStep;
|
||||
@@ -0,0 +1,70 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.empty {
|
||||
color: #6b808c;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
margin-bottom: 4px;
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
background: rgba(9, 30, 66, 0.04);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #17394d;
|
||||
cursor: pointer;
|
||||
flex: 1 1 auto;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
background: rgba(9, 30, 66, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.itemButtonActive {
|
||||
background: rgba(9, 30, 66, 0.13);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
color: rgba(9, 30, 66, 0.24);
|
||||
font-size: 12px;
|
||||
margin: 0 8px 0 0;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemRemoveButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b808c;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
|
||||
&:hover {
|
||||
color: #17394d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
// LocalStorage helpers behind the two filter-recall behaviours:
|
||||
//
|
||||
// • "last filter" — clearing the whole filter via the X leaves a
|
||||
// snapshot here, so the next click of the restore button can put
|
||||
// it back.
|
||||
// • "saved filters" — named filter recipes, per user and per board.
|
||||
//
|
||||
// NOTHING IN HERE IS EVER APPLIED ON ITS OWN. There is no effect that
|
||||
// reads a stored filter when a board opens; every read below happens
|
||||
// inside a click handler, or to decide whether a button is worth
|
||||
// rendering. That restraint is what makes localStorage the right home
|
||||
// for this: two windows on the same board hold their own live filter
|
||||
// (which still lives on the Board model in redux-orm and still travels
|
||||
// over the existing entry actions) and neither one is ever overwritten
|
||||
// by what the other stashed away.
|
||||
//
|
||||
// The keys and the `{ userIds, labelIds, search }` core of the payload
|
||||
// are deliberately identical to PLANKA Pro's, so a user's saved filters
|
||||
// survive an upgrade: same origin, same database, same user ids.
|
||||
// Community's own filter dimensions are stored alongside as extra
|
||||
// fields, which Pro simply reads as absent.
|
||||
|
||||
const LAST_KEY_PREFIX = 'planka:filter:last';
|
||||
const SAVED_KEY_PREFIX = 'planka:filter:saved';
|
||||
|
||||
const buildKey = (prefix, userId, boardId) => {
|
||||
if (!userId || !boardId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${prefix}:${userId}:${boardId}`;
|
||||
};
|
||||
|
||||
// Stable-ish id for a saved filter. It doesn't need to be globally
|
||||
// unique — just unique inside one (user, board) bucket. Time plus a
|
||||
// short random suffix is plenty, and reads cleanly when inspecting
|
||||
// localStorage by hand.
|
||||
const createSavedFilterId = () => {
|
||||
const time = Date.now().toString(36);
|
||||
|
||||
const random = Math.floor(Math.random() * 1e6)
|
||||
.toString(36)
|
||||
.padStart(4, '0');
|
||||
|
||||
return `f-${time}-${random}`;
|
||||
};
|
||||
|
||||
// A filter worth remembering. Every dimension Community can filter on
|
||||
// counts, including the ones Pro has no equivalent for — otherwise a
|
||||
// "cards without a member" filter could never be saved.
|
||||
const isFilterMeaningful = (filter) =>
|
||||
!!filter &&
|
||||
((filter.userIds && filter.userIds.length > 0) ||
|
||||
(filter.labelIds && filter.labelIds.length > 0) ||
|
||||
(filter.excludedLabelIds && filter.excludedLabelIds.length > 0) ||
|
||||
(filter.listIds && filter.listIds.length > 0) ||
|
||||
!!filter.noMember ||
|
||||
(filter.search && filter.search.length > 0));
|
||||
|
||||
// One shape for both buckets. `userIds` / `labelIds` / `search` are the
|
||||
// fields Pro reads; the rest are Community-only and additive.
|
||||
const normalizeFilter = (filter) => ({
|
||||
userIds: filter.userIds || [],
|
||||
labelIds: filter.labelIds || [],
|
||||
search: filter.search || '',
|
||||
excludedLabelIds: filter.excludedLabelIds || [],
|
||||
listIds: filter.listIds || [],
|
||||
noMember: !!filter.noMember,
|
||||
});
|
||||
|
||||
export const readLastFilter = (userId, boardId) => {
|
||||
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
return isFilterMeaningful(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeLastFilter = (userId, boardId, filter) => {
|
||||
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isFilterMeaningful(filter)) {
|
||||
window.localStorage.setItem(key, JSON.stringify(normalizeFilter(filter)));
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Storage may be full, blocked (private mode), or otherwise
|
||||
// unavailable. The feature degrades silently — worst case the user
|
||||
// just doesn't get the recall behaviour.
|
||||
}
|
||||
};
|
||||
|
||||
export const clearLastFilter = (userId, boardId) => {
|
||||
const key = buildKey(LAST_KEY_PREFIX, userId, boardId);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// See writeLastFilter.
|
||||
}
|
||||
};
|
||||
|
||||
// A list of { id, name, ...filter } per (user, board). The id is local
|
||||
// to this bucket and lets us address one entry for removal. Order:
|
||||
// most recently saved first, so the user's last save is easy to spot.
|
||||
export const readSavedFilters = (userId, boardId) => {
|
||||
const key = buildKey(SAVED_KEY_PREFIX, userId, boardId);
|
||||
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry.id === 'string' &&
|
||||
typeof entry.name === 'string' &&
|
||||
isFilterMeaningful(entry),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeSavedFilters = (userId, boardId, entries) => {
|
||||
const key = buildKey(SAVED_KEY_PREFIX, userId, boardId);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (entries.length > 0) {
|
||||
window.localStorage.setItem(key, JSON.stringify(entries));
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// See writeLastFilter.
|
||||
}
|
||||
};
|
||||
|
||||
export const addSavedFilter = (userId, boardId, { name, ...filter }) => {
|
||||
if (!userId || !boardId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanName = name.trim();
|
||||
|
||||
if (!cleanName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanFilter = normalizeFilter(filter);
|
||||
|
||||
if (!isFilterMeaningful(cleanFilter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: createSavedFilterId(),
|
||||
name: cleanName,
|
||||
...cleanFilter,
|
||||
};
|
||||
|
||||
const current = readSavedFilters(userId, boardId);
|
||||
|
||||
// Newest first, and re-saving under an existing name overwrites that
|
||||
// entry rather than adding a duplicate.
|
||||
writeSavedFilters(userId, boardId, [entry, ...current.filter((item) => item.name !== cleanName)]);
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const removeSavedFilter = (userId, boardId, id) => {
|
||||
const current = readSavedFilters(userId, boardId);
|
||||
|
||||
writeSavedFilters(
|
||||
userId,
|
||||
boardId,
|
||||
current.filter((entry) => entry.id !== id),
|
||||
);
|
||||
};
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
alwaysDisplayCardCreator: 'Kartenersteller immer anzeigen',
|
||||
apiKeyCreated_title: 'API-Schlüssel erstellt',
|
||||
apiKey_title: 'API-Schlüssel',
|
||||
applyFilter: 'Filter anwenden',
|
||||
archive: 'Archiv',
|
||||
archiveCard_title: 'Karte archivieren',
|
||||
archiveCards_title: 'Karten archivieren',
|
||||
@@ -74,6 +75,8 @@ export default {
|
||||
'Sind Sie sicher, dass Sie dieses Datenfeld löschen möchten?',
|
||||
areYouSureYouWantToDeleteThisCustomFieldGroup:
|
||||
'Sind Sie sicher, dass Sie diese Feldgruppe löschen möchten?',
|
||||
areYouSureYouWantToDeleteThisFilter:
|
||||
'Sind Sie sicher, dass Sie diesen Filter löschen möchten?',
|
||||
areYouSureYouWantToDeleteThisLabel: 'Sind Sie sicher, dass Sie dieses Label löschen möchten?',
|
||||
areYouSureYouWantToDeleteThisList:
|
||||
'Sind Sie sicher, dass Sie diese Liste löschen möchten? Alle Karten werden in den Papierkorb verschoben.',
|
||||
@@ -143,6 +146,7 @@ export default {
|
||||
cardsOnThisListAreCompleteAndReadyToBeArchived:
|
||||
'Karten in dieser Liste sind abgeschlossen und können archiviert werden.',
|
||||
cardsOnThisListAreReadyToBeWorkedOn: 'Karten in dieser Liste sind bereit zur Bearbeitung.',
|
||||
clearFilter: 'Filter zurücksetzen',
|
||||
clickHereOrRefreshPageToUpdate:
|
||||
'<0>Hier klicken</0> oder Seite aktualisieren, um zu aktualisieren.',
|
||||
clientHostnameInEhlo: 'Client-Hostname in EHLO',
|
||||
@@ -226,6 +230,7 @@ export default {
|
||||
unknownDevice: 'Unbekanntes Gerät',
|
||||
upgradeTeamToPro_title: 'Team auf Pro upgraden',
|
||||
date: 'Datum',
|
||||
deactivateFilter: 'Filter deaktivieren',
|
||||
deactivateUser_title: 'Benutzer deaktivieren',
|
||||
defaultCardType_title: 'Standard-Kartentyp',
|
||||
defaultFrom: 'Standard "Von"',
|
||||
@@ -241,6 +246,7 @@ export default {
|
||||
deleteComment_title: 'Kommentar löschen',
|
||||
deleteCustomFieldGroup_title: 'Feldgruppe löschen',
|
||||
deleteCustomField_title: 'Datenfeld löschen',
|
||||
deleteFilter_title: 'Filter löschen',
|
||||
deleteLabel_title: 'Label löschen',
|
||||
deleteList_title: 'Liste löschen',
|
||||
deleteNotificationService_title: 'Benachrichtigungsdienst löschen',
|
||||
@@ -286,6 +292,7 @@ export default {
|
||||
filterByLabels_title: 'Nach Label filtern',
|
||||
filterByLists_title: 'Nach Listen filtern',
|
||||
filterByMembers_title: 'Nach Mitgliedern filtern',
|
||||
filterName: 'Filtername',
|
||||
forPersonalProjects: 'Für persönliche Projekte.',
|
||||
forTeamBasedProjects: 'Für teambasierte Projekte.',
|
||||
fromComputer_title: 'Vom Computer',
|
||||
@@ -337,6 +344,7 @@ export default {
|
||||
noLists: 'Keine Listen',
|
||||
noMember: 'Ohne Mitglied',
|
||||
noProjects: 'Keine Projekte',
|
||||
noSavedFilters: 'Keine gespeicherten Filter',
|
||||
noUnreadNotifications: 'Keine ungelesenen Benachrichtigungen.',
|
||||
notifications: 'Benachrichtigungen',
|
||||
oldestFirst: 'Älteste zuerst',
|
||||
@@ -373,9 +381,12 @@ export default {
|
||||
rejectUnauthorizedTlsCertificates: 'Nicht autorisierte TLS-Zertifikate ablehnen',
|
||||
removeManager_title: 'Projektleiter entfernen',
|
||||
removeMember_title: 'Mitglied entfernen',
|
||||
restoreFilter: 'Filter wiederherstellen',
|
||||
role: 'Rolle',
|
||||
saveFilter_title: 'Filter speichern',
|
||||
saveThisKeyItWillNotBeShownAgain:
|
||||
'Speichern Sie diesen Schlüssel — er wird nicht erneut angezeigt!',
|
||||
savedFilters_title: 'Gespeicherte Filter',
|
||||
searchCards: 'Karte suchen...',
|
||||
searchCustomFieldGroups: 'Benutzerdefinierte Feldgruppen suchen...',
|
||||
searchCustomFields: 'In Feldgruppen suchen...',
|
||||
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
alwaysDisplayCardCreator: 'Always display card creator',
|
||||
apiKeyCreated_title: 'API Key Created',
|
||||
apiKey_title: 'API Key',
|
||||
applyFilter: 'Apply filter',
|
||||
archive: 'Archive',
|
||||
archiveCard_title: 'Archive Card',
|
||||
archiveCards_title: 'Archive Cards',
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
'Are you sure you want to delete this custom field?',
|
||||
areYouSureYouWantToDeleteThisCustomFieldGroup:
|
||||
'Are you sure you want to delete this custom field group?',
|
||||
areYouSureYouWantToDeleteThisFilter: 'Are you sure you want to delete this filter?',
|
||||
areYouSureYouWantToDeleteThisLabel: 'Are you sure you want to delete this label?',
|
||||
areYouSureYouWantToDeleteThisList:
|
||||
'Are you sure you want to delete this list? All cards will be moved to trash.',
|
||||
@@ -125,6 +127,7 @@ export default {
|
||||
cardsOnThisListAreCompleteAndReadyToBeArchived:
|
||||
'Cards on this list are complete and ready to be archived.',
|
||||
cardsOnThisListAreReadyToBeWorkedOn: 'Cards on this list are ready to be worked on.',
|
||||
clearFilter: 'Clear filter',
|
||||
clickHereOrRefreshPageToUpdate: '<0>Click here</0> or refresh the page to update.',
|
||||
clientHostnameInEhlo: 'Client hostname in EHLO',
|
||||
closed: 'Closed',
|
||||
@@ -204,6 +207,7 @@ export default {
|
||||
unknownDevice: 'Unknown device',
|
||||
upgradeTeamToPro_title: 'Upgrade Team to Pro',
|
||||
date: 'Date',
|
||||
deactivateFilter: 'Deactivate filter',
|
||||
deactivateUser_title: 'Deactivate User',
|
||||
defaultCardType_title: 'Default Card Type',
|
||||
defaultFrom: 'Default "from"',
|
||||
@@ -219,6 +223,7 @@ export default {
|
||||
deleteComment_title: 'Delete Comment',
|
||||
deleteCustomFieldGroup_title: 'Delete Custom Field Group',
|
||||
deleteCustomField_title: 'Delete Custom Field',
|
||||
deleteFilter_title: 'Delete Filter',
|
||||
deleteLabel_title: 'Delete Label',
|
||||
deleteList_title: 'Delete List',
|
||||
deleteNotificationService_title: 'Delete Notification Service',
|
||||
@@ -264,6 +269,7 @@ export default {
|
||||
filterByLabels_title: 'Filter By Labels',
|
||||
filterByLists_title: 'Filter By Lists',
|
||||
filterByMembers_title: 'Filter By Members',
|
||||
filterName: 'Filter name',
|
||||
forPersonalProjects: 'For personal projects.',
|
||||
forTeamBasedProjects: 'For team-based projects.',
|
||||
fromComputer_title: 'From Computer',
|
||||
@@ -315,6 +321,7 @@ export default {
|
||||
noLists: 'No lists',
|
||||
noMember: 'No member',
|
||||
noProjects: 'No projects',
|
||||
noSavedFilters: 'No saved filters',
|
||||
noUnreadNotifications: 'No unread notifications.',
|
||||
notifications: 'Notifications',
|
||||
oldestFirst: 'Oldest first',
|
||||
@@ -351,8 +358,11 @@ export default {
|
||||
rejectUnauthorizedTlsCertificates: 'Reject unauthorized TLS certificates',
|
||||
removeManager_title: 'Remove Manager',
|
||||
removeMember_title: 'Remove Member',
|
||||
restoreFilter: 'Restore filter',
|
||||
role: 'Role',
|
||||
saveFilter_title: 'Save Filter',
|
||||
saveThisKeyItWillNotBeShownAgain: 'Save this key — it will not be shown again!',
|
||||
savedFilters_title: 'Saved Filters',
|
||||
searchCards: 'Search cards...',
|
||||
searchCustomFieldGroups: 'Search custom field groups...',
|
||||
searchCustomFields: 'Search custom fields...',
|
||||
|
||||
Reference in New Issue
Block a user