fix: Restore two files committed empty by the list filter merge

75fcddcd wrote `client/src/models/Board.js` and the board filter bar out as
zero bytes. `BoardActions` imports `Filters` from that module, so the client
could not render a board at all.

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

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

One behaviour fix rides along, because the union merge would otherwise have
carried it in. The no-member branch ended its block with `return cardModels`,
which was correct while it was the last filter and wrong once the list filter
followed it: combining the two would have dropped the list filter without a
word. The three now compose.
This commit is contained in:
Daniel Hiller
2026-09-17 03:50:09 +02:00
parent f1e8c59d6e
commit ca035d670e
2 changed files with 863 additions and 0 deletions
@@ -0,0 +1,323 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import debounce from 'lodash/debounce';
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks';
import { usePopup } from '../../../lib/popup';
import { Input, Tooltip } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import { BoardViews, LabelFilterModes } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep';
import ListsFilterStep from '../../lists/ListsFilterStep';
import styles from './Filters.module.scss';
const FilterListChip = React.memo(({ id, onClick }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const list = useSelector((state) => selectListById(state, id));
const [t] = useTranslation();
const handleClick = useCallback(() => {
onClick(id);
}, [id, onClick]);
if (!list) {
return null;
}
return (
<button type="button" className={styles.filterButton} onClick={handleClick}>
<span className={styles.filterLabel}>{list.name || t(`common.${list.type}`)}</span>
</button>
);
});
FilterListChip.propTypes = {
id: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const Filters = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard);
const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId);
const withCurrentUserSelector = useSelector(
(state) => !!selectors.selectCurrentUserMembershipForCurrentBoard(state),
);
const dispatch = useDispatch();
const [t] = useTranslation();
const [search, setSearch] = useState(board.search);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const debouncedSearch = useMemo(
() =>
debounce((nextSearch) => {
dispatch(entryActions.searchInCurrentBoard(nextSearch));
}, 400),
[dispatch],
);
const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
const labelModes = useMemo(
() => ({
...labelIds.reduce(
(result, labelId) => ({
...result,
[labelId]: LabelFilterModes.INCLUDE,
}),
{},
),
...excludedLabelIds.reduce(
(result, labelId) => ({
...result,
[labelId]: LabelFilterModes.EXCLUDE,
}),
{},
),
}),
[labelIds, excludedLabelIds],
);
const cancelSearch = useCallback(() => {
debouncedSearch.cancel();
setSearch('');
dispatch(entryActions.searchInCurrentBoard(''));
searchFieldRef.current.blur();
}, [dispatch, debouncedSearch, searchFieldRef]);
const handleUserSelect = useCallback(
(userId) => {
dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleCurrentUserSelect = useCallback(() => {
dispatch(entryActions.addUserToFilterInCurrentBoard(currentUserId));
}, [currentUserId, dispatch]);
const handleUserDeselect = useCallback(
(userId) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleUserClick = useCallback(
({
currentTarget: {
dataset: { id: userId },
},
}) => {
dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
},
[dispatch],
);
const handleListSelect = useCallback(
(listId) => {
dispatch(entryActions.addListToFilterInCurrentBoard(listId));
},
[dispatch],
);
const handleListDeselect = useCallback(
(listId) => {
dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
},
[dispatch],
);
const handleLabelClick = useCallback(
({
currentTarget: {
dataset: { id: labelId },
},
}) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
},
[dispatch],
);
const handleLabelModeChange = useCallback(
(labelId, mode) => {
dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode));
},
[dispatch],
);
const handleSearchChange = useCallback(
(_, { value }) => {
setSearch(value);
debouncedSearch(value);
},
[debouncedSearch],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
useDidUpdate(() => {
setSearch(board.search);
}, [board.search]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const ListsFilterPopup = usePopup(ListsFilterStep);
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}>
<BoardMembershipsPopup
currentUserIds={userIds}
title="common.filterByMembers"
onUserSelect={handleUserSelect}
onUserDeselect={handleUserDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.members')}:`}</span>
{userIds.length === 0 && <span className={styles.filterLabel}>{t('common.all')}</span>}
</button>
</BoardMembershipsPopup>
<button
type="button"
className={classNames(styles.filterButton, styles.filterLabel)}
onClick={handleNoMemberClick}
>
{t('common.noMember')}
</button>
{userIds.length === 0 && withCurrentUserSelector && (
<Tooltip content={t('action.filterByCurrentUser')}>
<button type="button" className={styles.filterButton} onClick={handleCurrentUserSelect}>
<span className={styles.filterLabel}>
<Icon fitted name="target" className={styles.filterLabelIcon} />
</span>
</button>
</Tooltip>
)}
{userIds.map((userId) => (
<span key={userId} className={styles.filterItem}>
<UserAvatar id={userId} size="tiny" onClick={handleUserClick} />
</span>
))}
</span>
<span className={styles.filter}>
<LabelsPopup
currentIds={[]}
currentModes={labelModes}
isFilterModeEnabled
title="common.filterByLabels"
onModeChange={handleLabelModeChange}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
{labelIds.length === 0 && excludedLabelIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</LabelsPopup>
{labelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" onClick={handleLabelClick} />
</span>
))}
{excludedLabelIds.map((labelId) => (
<span key={labelId} className={styles.filterItem}>
<LabelChip id={labelId} size="small" isExcluded onClick={handleLabelClick} />
</span>
))}
</span>
{isListView && (
<span className={styles.filter}>
<ListsFilterPopup
currentIds={listIds}
title="common.filterByLists"
onSelect={handleListSelect}
onDeselect={handleListDeselect}
>
<button type="button" className={styles.filterButton}>
<span className={styles.filterTitle}>{`${t('common.lists')}:`}</span>
{listIds.length === 0 && (
<span className={styles.filterLabel}>{t('common.all')}</span>
)}
</button>
</ListsFilterPopup>
{listIds.map((listId) => (
<FilterListChip key={listId} id={listId} onClick={handleListDeselect} />
))}
</span>
)}
<span className={styles.filter}>
<Input
ref={handleSearchFieldRef}
value={search}
placeholder={t('common.searchCards')}
maxLength={128}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</>
);
});
export default Filters;