diff --git a/client/src/components/boards/BoardActions/Filters.jsx b/client/src/components/boards/BoardActions/Filters.jsx index 528150ad..8d15e161 100644 --- a/client/src/components/boards/BoardActions/Filters.jsx +++ b/client/src/components/boards/BoardActions/Filters.jsx @@ -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 ( <> @@ -316,6 +526,64 @@ const Filters = React.memo(() => { onBlur={handleSearchBlur} /> + + {hasFilter && ( + + + + )} + {/* Takes the place of the clear button once the filter is gone, and + only while there is something to put back. */} + {!hasFilter && !!lastFilter && ( + + + + )} + {/* 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 && ( + + + + )} + + + + ); }); diff --git a/client/src/components/boards/BoardActions/SaveFilterStep.jsx b/client/src/components/boards/BoardActions/SaveFilterStep.jsx new file mode 100644 index 00000000..bd15dd8b --- /dev/null +++ b/client/src/components/boards/BoardActions/SaveFilterStep.jsx @@ -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 ( + <> + + {t('common.saveFilter', { + context: 'title', + })} + + +
+ +
+ + + ); + })} + + )} +
+ + ); +}); + +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; diff --git a/client/src/components/boards/BoardActions/SavedFiltersStep.module.scss b/client/src/components/boards/BoardActions/SavedFiltersStep.module.scss new file mode 100644 index 00000000..b0530194 --- /dev/null +++ b/client/src/components/boards/BoardActions/SavedFiltersStep.module.scss @@ -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; + } + } +} diff --git a/client/src/components/boards/BoardActions/useFilterStorage.js b/client/src/components/boards/BoardActions/useFilterStorage.js new file mode 100644 index 00000000..7b3096c3 --- /dev/null +++ b/client/src/components/boards/BoardActions/useFilterStorage.js @@ -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), + ); +}; diff --git a/client/src/locales/de-DE/core.js b/client/src/locales/de-DE/core.js index a92b8e08..1bc6b169 100644 --- a/client/src/locales/de-DE/core.js +++ b/client/src/locales/de-DE/core.js @@ -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 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...', diff --git a/client/src/locales/en-US/core.js b/client/src/locales/en-US/core.js index 709ba1ce..97cd9e80 100644 --- a/client/src/locales/en-US/core.js +++ b/client/src/locales/en-US/core.js @@ -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 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...',