From 4aed800f3efbff52319ea5c8ed75ffab4dd75d5c Mon Sep 17 00:00:00 2001 From: Matt <1009003+tantaman@users.noreply.github.com> Date: Wed, 13 Dec 2023 17:07:11 -0500 Subject: [PATCH 1/3] add creator, created and modified filters --- client/src/app.tsx | 79 +++++++---- client/src/filters.ts | 180 +++++++++++++++----------- client/src/hooks/query-state-hooks.ts | 38 ++++-- client/src/issue/issue.ts | 3 + client/src/layout/layout.tsx | 7 +- client/src/layout/top-filter.tsx | 153 ++++++++++++++++++---- client/src/widgets/filter-menu.tsx | 147 ++++++++++++++++++++- 7 files changed, 465 insertions(+), 142 deletions(-) diff --git a/client/src/app.tsx b/client/src/app.tsx index a7386d35..320a041c 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -11,7 +11,10 @@ import {generateKeyBetween} from 'fractional-indexing'; import type {UndoManager} from '@rocicorp/undo'; import {HotKeys} from 'react-hotkeys'; import { + useCreatedFilterState, + useCreatorFilterState, useIssueDetailState, + useModifiedFilterState, useOrderByState, usePriorityFilterState, useStatusFilterState, @@ -27,11 +30,23 @@ import { IssueUpdateWithID, ISSUE_KEY_PREFIX, } from 'shared'; -import {getFilters, getIssueOrder} from './filters'; import {Layout} from './layout/layout'; import {db} from './materialite/db'; import {useQuery} from '@vlcn.io/materialite-react'; import {issueFromKeyAndValue} from './issue/issue'; +import { + getCreatedFilter, + getCreatorFilter, + getCreators, + getIssueOrder, + getModifiedFilter, + getPriorities, + getPriorityFilter, + getStatuses, + getStatusFilter, + getViewFilter, + getViewStatuses, +} from './filters'; type AppProps = { rep: Replicache; @@ -72,34 +87,58 @@ function onNewDiff(diff: Diff) { const App = ({rep, undoManager}: AppProps) => { const [view] = useViewState(); - const [priorityFilter] = usePriorityFilterState(); - const [statusFilter] = useStatusFilterState(); const [orderBy] = useOrderByState(); const [detailIssueID, setDetailIssueID] = useIssueDetailState(); const [menuVisible, setMenuVisible] = useState(false); + const [priorityFilter] = usePriorityFilterState(); + const [statusFilter] = useStatusFilterState(); + const [createdFilter] = useCreatedFilterState(); + const [creatorFilter] = useCreatorFilterState(); + const [modifiedFilter] = useModifiedFilterState(); - const [filters, setFilters] = useState( - getFilters(view, priorityFilter, statusFilter), - ); const issueOrder = getIssueOrder(view, orderBy); const [, filterdIssues] = useQuery(() => { const start = performance.now(); const source = db.issues.getSortedSource(issueOrder); - const ret = source.stream - .filter(issue => filters.issuesFilter(issue)) - .materialize(source.comparator); + const viewStatuses = getViewStatuses(view); + const statuses = getStatuses(statusFilter); + const filterFns = [ + getStatusFilter(viewStatuses, statuses), + getPriorityFilter(getPriorities(priorityFilter)), + getCreatorFilter(getCreators(creatorFilter)), + getCreatedFilter(createdFilter), + getModifiedFilter(modifiedFilter), + ]; + + let {stream} = source; + for (const filter of filterFns) { + if (filter !== null) { + stream = stream.filter(filter); + } + } + + const ret = stream.materialize(source.comparator); console.log(`Filter update duration: ${performance.now() - start}ms`); return ret; - }, [issueOrder, filters]); + }, [ + view, + issueOrder, + priorityFilter, + statusFilter, + createdFilter, + creatorFilter, + modifiedFilter, + ]); + const [, viewIssueCount] = useQuery(() => { + const viewStatuses = getViewStatuses(view); + const viewFilterFn = getViewFilter(viewStatuses); + const source = db.issues.getSortedSource(issueOrder); - return source.stream - .filter(issue => filters.viewFilter(issue)) - .size() - .materializeValue(0); - }, [filters]); + return source.stream.filter(viewFilterFn).size().materializeValue(0); + }, [view]); const partialSync = useSubscribe( rep, @@ -132,14 +171,6 @@ const App = ({rep, undoManager}: AppProps) => { }); }, [rep]); - useEffect(() => { - const f = getFilters(view, priorityFilter, statusFilter); - if (f.equals(filters)) { - return; - } - setFilters(f); - }, [view, priorityFilter?.join(), statusFilter?.join()]); - const handleCreateIssue = useCallback( async (issue: Omit, description: Description) => { const minKanbanOrderIssue = db.issues @@ -245,10 +276,10 @@ const App = ({rep, undoManager}: AppProps) => { view={view} detailIssueID={detailIssueID} isLoading={!partialSyncComplete} - filters={filters} viewIssueCount={viewIssueCount || 0} filteredIssues={filterdIssues} rep={rep} + hasNonViewFilters={false} onCloseMenu={handleCloseMenu} onToggleMenu={handleToggleMenu} onUpdateIssues={handleUpdateIssues} diff --git a/client/src/filters.ts b/client/src/filters.ts index 5b6f8646..b0c1db2f 100644 --- a/client/src/filters.ts +++ b/client/src/filters.ts @@ -1,91 +1,125 @@ -import {isEqual} from 'lodash'; import {Issue, Order, Priority, Status} from 'shared'; -export class Filters { - private readonly _viewStatuses: Set | undefined; - private readonly _issuesStatuses: Set | undefined; - private readonly _issuesPriorities: Set | undefined; - readonly hasNonViewFilters: boolean; - constructor( - view: string | null, - priorityFilter: Priority[] | null, - statusFilter: Status[] | null, - ) { - this._viewStatuses = undefined; - switch (view?.toLowerCase()) { - case 'active': - this._viewStatuses = new Set(['IN_PROGRESS', 'TODO']); - break; - case 'backlog': - this._viewStatuses = new Set(['BACKLOG']); - break; - default: - this._viewStatuses = undefined; - } +export type Op = '<=' | '>='; +export type DateQueryArg = `${number}|${Op}`; - this._issuesStatuses = undefined; - this._issuesPriorities = undefined; - this.hasNonViewFilters = false; - if (statusFilter) { - this._issuesStatuses = new Set(); - for (const s of statusFilter) { - if (!this._viewStatuses || this._viewStatuses.has(s)) { - this.hasNonViewFilters = true; - this._issuesStatuses.add(s); - } - } - } - if (!this.hasNonViewFilters) { - this._issuesStatuses = this._viewStatuses; +export function hasNonViewFilters( + viewStatuses: Set, + statuses: Set, +) { + for (const s of statuses) { + if (!viewStatuses.has(s)) { + return true; } + } - if (priorityFilter) { - this._issuesPriorities = new Set(); - for (const p of priorityFilter) { - this.hasNonViewFilters = true; - this._issuesPriorities.add(p); - } - if (this._issuesPriorities.size === 0) { - this._issuesPriorities = undefined; - } - } + return false; +} + +export function getViewStatuses(view: string | null): Set { + switch (view?.toLowerCase()) { + case 'active': + return new Set(['IN_PROGRESS', 'TODO']); + case 'backlog': + return new Set(['BACKLOG']); + default: + return new Set(); + } +} + +export function getStatuses(statusFilter: Status[] | null): Set { + return new Set(statusFilter ? statusFilter : []); +} + +export function getPriorities( + priorityFilter: Priority[] | null, +): Set { + return new Set(priorityFilter ? priorityFilter : []); +} + +export function getPriorityFilter( + priorities: Set, +): null | ((issue: Issue) => boolean) { + if (priorities.size === 0) { + return null; + } + return issue => priorities.has(issue.priority); +} + +export function getStatusFilter( + viewStatuses: Set, + statuses: Set, +): null | ((issue: Issue) => boolean) { + const allStatuses = new Set([...viewStatuses, ...statuses]); + if (allStatuses.size === 0) { + return null; } + return issue => allStatuses.has(issue.status); +} - viewFilter(issue: Issue): boolean { - return this._viewStatuses ? this._viewStatuses.has(issue.status) : true; +export function getCreatorFilter( + creators: Set, +): null | ((issue: Issue) => boolean) { + if (creators.size === 0) { + return null; } + return issue => creators.has(issue.creator.toLowerCase()); +} + +export function getViewFilter( + viewStatuses: Set, +): (issue: Issue) => boolean { + return issue => + viewStatuses.size === 0 ? true : viewStatuses.has(issue.status); +} + +export function getModifiedFilter( + args: DateQueryArg[] | null, +): (issue: Issue) => boolean { + return createTimeFilter('modified', args); +} + +export function getCreatedFilter( + args: DateQueryArg[] | null, +): (issue: Issue) => boolean { + return createTimeFilter('created', args); +} - issuesFilter(issue: Issue): boolean { - if (this._issuesStatuses) { - if (!this._issuesStatuses.has(issue.status)) { - return false; - } +function createTimeFilter( + property: 'created' | 'modified', + args: DateQueryArg[] | null, +): (issue: Issue) => boolean { + let before: number | null = null; + let after: number | null = null; + for (const arg of args || []) { + const [timePart, op] = arg.split('|') as [string, Op]; + const time = parseInt(timePart); + switch (op) { + case '<=': + before = before ? Math.min(before, time) : time; + break; + case '>=': + after = after ? Math.max(after, time) : time; + break; } - if (this._issuesPriorities) { - if (!this._issuesPriorities.has(issue.priority)) { - return false; - } + } + return issue => { + if (before && issue[property] > before) { + return false; + } + if (after && issue[property] < after) { + return false; } return true; - } + }; +} - equals(other: Filters): boolean { - return ( - this === other || - (isEqual(this._viewStatuses, other._viewStatuses) && - isEqual(this._issuesStatuses, other._issuesStatuses) && - isEqual(this._issuesPriorities, other._issuesPriorities) && - isEqual(this.hasNonViewFilters, other.hasNonViewFilters)) - ); - } +export function getCreators(creatorFilter: string[] | null): Set { + return new Set(creatorFilter ? creatorFilter : []); } -export function getFilters( - view: string | null, - priorityFilter: Priority[] | null, - statusFilter: Status[] | null, -): Filters { - return new Filters(view, priorityFilter, statusFilter); +export function getTitleFilter(title: string): (issue: Issue) => boolean { + return issue => issue.title.toLowerCase().includes(title.toLowerCase()); } export function getIssueOrder( diff --git a/client/src/hooks/query-state-hooks.ts b/client/src/hooks/query-state-hooks.ts index 3b882be8..722a8f5a 100644 --- a/client/src/hooks/query-state-hooks.ts +++ b/client/src/hooks/query-state-hooks.ts @@ -3,34 +3,31 @@ import useQueryState, { QueryStateProcessor, } from './useQueryState'; import {Order, Priority, Status} from 'shared'; +import {DateQueryArg} from '../filters'; const processOrderBy: QueryStateProcessor = { toString: (value: Order) => value, fromString: (value: string | null) => (value ?? 'MODIFIED') as Order, }; -const processStatuFilter: QueryStateProcessor = { - toString: (value: Status[]) => value.join(','), - fromString: (value: string | null) => - value === null ? null : (value.split(',') as Status[]), -}; - -const processPriorityFilter: QueryStateProcessor = { - toString: (value: Priority[]) => value.join(','), - fromString: (value: string | null) => - value === null ? null : (value.split(',') as Priority[]), -}; +function makeStringSetProcessor(): QueryStateProcessor { + return { + toString: (value: T[]) => value.join(','), + fromString: (value: string | null) => + value === null ? null : (value.split(',') as T[]), + }; +} export function useOrderByState() { return useQueryState('orderBy', processOrderBy); } export function useStatusFilterState() { - return useQueryState('statusFilter', processStatuFilter); + return useQueryState('statusFilter', makeStringSetProcessor()); } export function usePriorityFilterState() { - return useQueryState('priorityFilter', processPriorityFilter); + return useQueryState('priorityFilter', makeStringSetProcessor()); } export function useViewState() { @@ -40,3 +37,18 @@ export function useViewState() { export function useIssueDetailState() { return useQueryState('iss', identityProcessor); } + +export function useCreatorFilterState() { + return useQueryState('creatorFilter', makeStringSetProcessor()); +} + +export function useCreatedFilterState() { + return useQueryState('createdFilter', makeStringSetProcessor()); +} + +export function useModifiedFilterState() { + return useQueryState( + 'modifiedFilter', + makeStringSetProcessor(), + ); +} diff --git a/client/src/issue/issue.ts b/client/src/issue/issue.ts index 37282451..ec1d6eb4 100644 --- a/client/src/issue/issue.ts +++ b/client/src/issue/issue.ts @@ -29,6 +29,9 @@ export const statusOrderValues: Record = { export enum Filter { PRIORITY, STATUS, + CREATOR, + CREATED, + MODIFIED, } const filterEnumSchema = z.nativeEnum(Filter); diff --git a/client/src/layout/layout.tsx b/client/src/layout/layout.tsx index 5e945f8b..e0d445ba 100644 --- a/client/src/layout/layout.tsx +++ b/client/src/layout/layout.tsx @@ -3,7 +3,6 @@ import classNames from 'classnames'; import {memo} from 'react'; import {Replicache} from 'replicache'; import {Comment, Description, Issue, IssueUpdate} from 'shared'; -import {Filters} from '../filters'; import IssueBoard from '../issue/issue-board'; import IssueDetail from '../issue/issue-detail'; import IssueList from '../issue/issue-list'; @@ -29,8 +28,8 @@ export interface LayoutProps { view: string | null; detailIssueID: string | null; isLoading: boolean; - filters: Filters; viewIssueCount: number; + hasNonViewFilters: boolean; filteredIssues: PersistentTreeView['value']; rep: Replicache; onCloseMenu: () => void; @@ -49,9 +48,9 @@ const RawLayout = ({ view, detailIssueID, isLoading, - filters, viewIssueCount, filteredIssues, + hasNonViewFilters, rep, onCloseMenu, onToggleMenu, @@ -78,7 +77,7 @@ const RawLayout = ({ onToggleMenu={onToggleMenu} title={getTitle(view)} filteredIssuesCount={ - filters.hasNonViewFilters ? filteredIssues.size : undefined + hasNonViewFilters ? filteredIssues.size : undefined } issuesCount={viewIssueCount} showSortOrderMenu={view !== 'board'} diff --git a/client/src/layout/top-filter.tsx b/client/src/layout/top-filter.tsx index 9f82b084..704ec92f 100644 --- a/client/src/layout/top-filter.tsx +++ b/client/src/layout/top-filter.tsx @@ -6,11 +6,15 @@ import SortOrderMenu from '../widgets/sort-order-menu'; import FilterMenu from '../widgets/filter-menu'; import {noop} from 'lodash'; import { + useCreatedFilterState, + useCreatorFilterState, + useModifiedFilterState, useOrderByState, usePriorityFilterState, useStatusFilterState, } from '../hooks/query-state-hooks'; import {Priority, Status} from 'shared'; +import type {DateQueryArg, Op} from '../filters'; interface Props { title: string; @@ -21,12 +25,18 @@ interface Props { } interface FilterStatusProps { - filter: Status[] | Priority[] | null; + filter: Status[] | Priority[] | string[] | null; onDelete: () => void; label: string; } -const displayStrings: Record = { +type DateFilterStatusProps = { + filter: DateQueryArg[] | null; + onDelete: () => void; + label: string; +}; + +const displayStrings: Record = { NONE: 'None', LOW: 'Low', MEDIUM: 'Medium', @@ -48,7 +58,7 @@ const FilterStatus = ({filter, onDelete, label}: FilterStatusProps) => { {label} is - {filter.map(f => displayStrings[f]).join(', ')} + {filter.map(f => displayStrings[f] || f).join(', ')} { ); }; +function DateFilterStatus({filter, onDelete, label}: DateFilterStatusProps) { + if (!filter || filter.length === 0) return null; + return ( +
+ + {label} is + + + {filter + .map(f => { + const [time, op] = f.split('|') as [string, Op]; + return `${op} ${new Date(parseInt(time)).toLocaleDateString()}`; + }) + .join(' AND ')} + + + × + +
+ ); +} + const TopFilter = ({ title, onToggleMenu = noop, @@ -70,6 +105,9 @@ const TopFilter = ({ const [orderBy, setOrderByParam] = useOrderByState(); const [statusFilters, setStatusFilterByParam] = useStatusFilterState(); const [priorityFilters, setPriorityFilterByParam] = usePriorityFilterState(); + const [creatorFilters, setCreatorFilterByParam] = useCreatorFilterState(); + const [createdFilters, setCreatedFilterByParam] = useCreatedFilterState(); + const [modifiedFilters, setModifiedFilterByParam] = useModifiedFilterState(); return ( <> @@ -91,28 +129,38 @@ const TopFilter = ({ {issuesCount} )} { - const prioritySet = new Set(priorityFilters); - if (prioritySet.has(priority)) { - prioritySet.delete(priority); - } else { - prioritySet.add(priority); - } - await setPriorityFilterByParam( - prioritySet.size === 0 ? null : [...prioritySet], - ); - }} - onSelectStatus={async status => { - const statusSet = new Set(statusFilters); - if (statusSet.has(status)) { - statusSet.delete(status); - } else { - statusSet.add(status); - } - await setStatusFilterByParam( - statusSet.size === 0 ? null : [...statusSet], - ); - }} + onSelectPriority={createEnumSetFilterHandler( + priorityFilters, + setPriorityFilterByParam, + )} + onSelectStatus={createEnumSetFilterHandler( + statusFilters, + setStatusFilterByParam, + )} + onCreatorEntered={createEnumSetFilterHandler( + creatorFilters, + setCreatorFilterByParam, + )} + onCreatedAfterEntered={createDateFilterHandler( + createdFilters, + setCreatedFilterByParam, + '>=', + )} + onCreatedBeforeEntered={createDateFilterHandler( + createdFilters, + setCreatedFilterByParam, + '<=', + )} + onModifiedAfterEntered={createDateFilterHandler( + modifiedFilters, + setModifiedFilterByParam, + '>=', + )} + onModifiedBeforeEntered={createDateFilterHandler( + modifiedFilters, + setModifiedFilterByParam, + '<=', + )} /> @@ -127,7 +175,10 @@ const TopFilter = ({ {(statusFilters && statusFilters.length) || - (priorityFilters && priorityFilters.length) ? ( + (priorityFilters && priorityFilters.length) || + (creatorFilters && creatorFilters.length) || + (createdFilters && createdFilters.length) || + (modifiedFilters && modifiedFilters.length) ? (
setPriorityFilterByParam(null)} label="Priority" /> + setCreatorFilterByParam(null)} + label="Creator" + /> + setCreatedFilterByParam(null)} + label="Created" + /> + setModifiedFilterByParam(null)} + label="Modified" + />
) : null} ); }; +function createDateFilterHandler( + filters: DateQueryArg[] | null, + setFilters: (f: DateQueryArg[] | null) => void, + op: Op, +) { + // TODO: do not allow more than one op of same type. + return async (date: Date) => { + const set = new Set(filters); + const encoded: DateQueryArg = `${ + date.getTime() + date.getTimezoneOffset() * 60 * 1000 + }|${op}`; + if (set.has(encoded)) { + set.delete(encoded); + } else { + set.add(encoded); + } + setFilters(set.size === 0 ? null : [...set]); + }; +} + +function createEnumSetFilterHandler( + filters: T[] | null, + setFilters: (f: T[] | null) => void, +) { + return async (e: T) => { + const set = new Set(filters); + if (set.has(e)) { + set.delete(e); + } else { + set.add(e); + } + setFilters(set.size === 0 ? null : [...set]); + }; +} + export default memo(TopFilter); diff --git a/client/src/widgets/filter-menu.tsx b/client/src/widgets/filter-menu.tsx index a4167d2e..7e912484 100644 --- a/client/src/widgets/filter-menu.tsx +++ b/client/src/widgets/filter-menu.tsx @@ -1,4 +1,4 @@ -import React, {RefObject, useRef, useState} from 'react'; +import React, {KeyboardEvent, RefObject, useRef, useState} from 'react'; import {usePopper} from 'react-popper'; import {Filter} from '../issue/issue'; import {useClickOutside} from '../hooks/useClickOutside'; @@ -7,12 +7,30 @@ import TodoIcon from '../assets/icons/circle.svg?react'; import {statusOpts} from './priority-menu'; import {statuses} from './status-menu'; import {Priority, Status} from 'shared'; +import UserIcon from './assets/icons/avatar.svg'; +import DateIcon from './assets/icons/due-date.svg'; +import useId from '@mui/utils/useId'; + +type DateCallback = (date: Date) => void; interface Props { onSelectStatus: (filter: Status) => void; onSelectPriority: (filter: Priority) => void; + onCreatorEntered: (creator: string) => void; + onCreatedAfterEntered: DateCallback; + onCreatedBeforeEntered: DateCallback; + onModifiedAfterEntered: DateCallback; + onModifiedBeforeEntered: DateCallback; } -const FilterMenu = ({onSelectStatus, onSelectPriority}: Props) => { +const FilterMenu = ({ + onSelectStatus, + onSelectPriority, + onCreatorEntered, + onCreatedAfterEntered, + onCreatedBeforeEntered, + onModifiedAfterEntered, + onModifiedBeforeEntered, +}: Props) => { const [filterRef, setFilterRef] = useState(null); const [popperRef, setPopperRef] = useState(null); const [filter, setFilter] = useState(null); @@ -39,6 +57,9 @@ const FilterMenu = ({onSelectStatus, onSelectPriority}: Props) => { const filterBys = [ [SignalStrongIcon, Filter.PRIORITY, 'Priority'], [TodoIcon, Filter.STATUS, 'Status'], + [UserIcon, Filter.CREATOR, 'Creator'], + [DateIcon, Filter.MODIFIED, 'Modified'], + [DateIcon, Filter.CREATED, 'Created'], ] as const; // @@ -79,6 +100,56 @@ const FilterMenu = ({onSelectStatus, onSelectPriority}: Props) => { ); }); + case Filter.CREATOR: + return [ + , +
, + ]; + case Filter.CREATED: + return [ + + Before + , + + After + , +
, + ]; + case Filter.MODIFIED: + return [ + + Before + , + + After + , +
, + ]; default: return filterBys.map(([Icon, filter, label], idx) => { return ( @@ -121,4 +192,76 @@ const FilterMenu = ({onSelectStatus, onSelectPriority}: Props) => { ); }; +function CreatorInput({ + setFilter, + setFilterDropDownVisible, + onCreatorEntered, +}: { + setFilter: (p: null) => void; + setFilterDropDownVisible: (p: boolean) => void; + onCreatorEntered: (p: string) => void; +}) { + const [creator, setCreator] = useState(''); + function handleKeyPress(e: KeyboardEvent) { + if (e.key === 'Enter') { + setFilter(null); + setFilterDropDownVisible(false); + onCreatorEntered(creator); + } else if (e.key === 'Escape') { + setFilter(null); + setFilterDropDownVisible(false); + } + } + return ( +
+ setCreator(e.target.value)} + /> +
+ ); +} + +function DateInput({ + setFilter, + setFilterDropDownVisible, + onDateEntered, + children, +}: { + setFilter: (p: null) => void; + setFilterDropDownVisible: (p: boolean) => void; + onDateEntered: (p: Date) => void; + children: React.ReactNode; +}) { + function onChange(e: React.ChangeEvent) { + onDateEntered(new Date(e.target.value)); + setFilter(null); + setFilterDropDownVisible(false); + } + const id = useId() as string; + return ( +
+ + (e.target as any).showPicker()} + /> +
+ ); +} + export default FilterMenu; From af3256f5fc392ab1add1e3e8364d336e8716ce35 Mon Sep 17 00:00:00 2001 From: Matt <1009003+tantaman@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:19:38 -0500 Subject: [PATCH 2/3] fix status filter selection to behave more like linear when in specific views (backlog, active) --- client/src/app.tsx | 23 ++++++++++----- client/src/filters.ts | 18 ++++++++---- client/src/layout/left-menu.tsx | 25 +++++++++++++++-- client/src/widgets/filter-menu.tsx | 45 +++++++++++++++++------------- 4 files changed, 76 insertions(+), 35 deletions(-) diff --git a/client/src/app.tsx b/client/src/app.tsx index 320a041c..10728566 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -46,6 +46,7 @@ import { getStatusFilter, getViewFilter, getViewStatuses, + hasNonViewFilters as doesHaveNonViewFilters, } from './filters'; type AppProps = { @@ -95,6 +96,7 @@ const App = ({rep, undoManager}: AppProps) => { const [createdFilter] = useCreatedFilterState(); const [creatorFilter] = useCreatorFilterState(); const [modifiedFilter] = useModifiedFilterState(); + const [hasNonViewFilters, setHasNonViewFilters] = useState(false); const issueOrder = getIssueOrder(view, orderBy); @@ -104,14 +106,21 @@ const App = ({rep, undoManager}: AppProps) => { const viewStatuses = getViewStatuses(view); const statuses = getStatuses(statusFilter); + const statusFilterFn = getStatusFilter(viewStatuses, statuses); const filterFns = [ - getStatusFilter(viewStatuses, statuses), + statusFilterFn, getPriorityFilter(getPriorities(priorityFilter)), getCreatorFilter(getCreators(creatorFilter)), getCreatedFilter(createdFilter), getModifiedFilter(modifiedFilter), ]; + const hasNonViewFilters = !!( + doesHaveNonViewFilters(viewStatuses, statuses) || + filterFns.filter(f => f !== null && f !== statusFilterFn).length > 0 + ); + setHasNonViewFilters(hasNonViewFilters); + let {stream} = source; for (const filter of filterFns) { if (filter !== null) { @@ -125,11 +134,11 @@ const App = ({rep, undoManager}: AppProps) => { }, [ view, issueOrder, - priorityFilter, - statusFilter, - createdFilter, - creatorFilter, - modifiedFilter, + priorityFilter?.join(), + statusFilter?.join(), + createdFilter?.join(), + creatorFilter?.join(), + modifiedFilter?.join(), ]); const [, viewIssueCount] = useQuery(() => { @@ -279,7 +288,7 @@ const App = ({rep, undoManager}: AppProps) => { viewIssueCount={viewIssueCount || 0} filteredIssues={filterdIssues} rep={rep} - hasNonViewFilters={false} + hasNonViewFilters={hasNonViewFilters} onCloseMenu={handleCloseMenu} onToggleMenu={handleToggleMenu} onUpdateIssues={handleUpdateIssues} diff --git a/client/src/filters.ts b/client/src/filters.ts index b0c1db2f..937bbb4c 100644 --- a/client/src/filters.ts +++ b/client/src/filters.ts @@ -8,7 +8,7 @@ export function hasNonViewFilters( statuses: Set, ) { for (const s of statuses) { - if (!viewStatuses.has(s)) { + if (viewStatuses.has(s)) { return true; } } @@ -50,11 +50,11 @@ export function getStatusFilter( viewStatuses: Set, statuses: Set, ): null | ((issue: Issue) => boolean) { - const allStatuses = new Set([...viewStatuses, ...statuses]); - if (allStatuses.size === 0) { + const filterStatuses = statuses.size === 0 ? viewStatuses : statuses; + if (filterStatuses.size === 0) { return null; } - return issue => allStatuses.has(issue.status); + return issue => filterStatuses.has(issue.status); } export function getCreatorFilter( @@ -75,13 +75,19 @@ export function getViewFilter( export function getModifiedFilter( args: DateQueryArg[] | null, -): (issue: Issue) => boolean { +): ((issue: Issue) => boolean) | null { + if (args === null) { + return null; + } return createTimeFilter('modified', args); } export function getCreatedFilter( args: DateQueryArg[] | null, -): (issue: Issue) => boolean { +): null | ((issue: Issue) => boolean) { + if (args === null) { + return null; + } return createTimeFilter('created', args); } diff --git a/client/src/layout/left-menu.tsx b/client/src/layout/left-menu.tsx index cbc376b6..0305c507 100644 --- a/client/src/layout/left-menu.tsx +++ b/client/src/layout/left-menu.tsx @@ -10,9 +10,14 @@ import IssueModal from '../issue/issue-modal'; import ReactLogo from '../assets/images/logo.svg?react'; import AboutModal from '../about-modal'; import {noop} from 'lodash'; -import {useIssueDetailState, useViewState} from '../hooks/query-state-hooks'; +import { + useIssueDetailState, + useStatusFilterState, + useViewState, +} from '../hooks/query-state-hooks'; import useQueryState, {identityProcessor} from '../hooks/useQueryState'; import {Description, Issue} from 'shared'; +import {getViewStatuses} from '../filters'; interface Props { // Show menu (for small screen only) @@ -32,6 +37,7 @@ const LeftMenu = ({menuVisible, onCloseMenu = noop, onCreateIssue}: Props) => { const ref = useRef() as RefObject; const [issueModalVisible, setIssueModalVisible] = useState(false); const [aboutModalVisible, setAboutModalVisible] = useState(true); + const [statusFilter, setStatusFilter] = useStatusFilterState(); const classes = classnames( 'absolute lg:static inset-0 lg:relative lg:translate-x-0 flex flex-col flex-shrink-0 w-56 font-sans text-sm border-r lg:shadow-none justify-items-start bg-gray border-gray-850 text-white bg-opacity-1', @@ -49,6 +55,11 @@ const LeftMenu = ({menuVisible, onCloseMenu = noop, onCreateIssue}: Props) => { } }); + function pruneStatuses(view: string) { + const viewStatuses = getViewStatuses(view); + return statusFilter?.filter(s => viewStatuses.has(s)) ?? null; + } + return ( <>
@@ -108,7 +119,11 @@ const LeftMenu = ({menuVisible, onCloseMenu = noop, onCreateIssue}: Props) => {
{ - await Promise.all([setView('active'), setIss(null)]); + await Promise.all([ + setView('active'), + setStatusFilter(pruneStatuses('active')), + setIss(null), + ]); onCloseMenu && onCloseMenu(); }} > @@ -118,7 +133,11 @@ const LeftMenu = ({menuVisible, onCloseMenu = noop, onCreateIssue}: Props) => {
{ - await Promise.all([setView('backlog'), setIss(null)]); + await Promise.all([ + setView('backlog'), + setStatusFilter(pruneStatuses('backlog')), + setIss(null), + ]); onCloseMenu && onCloseMenu(); }} > diff --git a/client/src/widgets/filter-menu.tsx b/client/src/widgets/filter-menu.tsx index 7e912484..faae1c2e 100644 --- a/client/src/widgets/filter-menu.tsx +++ b/client/src/widgets/filter-menu.tsx @@ -4,12 +4,14 @@ import {Filter} from '../issue/issue'; import {useClickOutside} from '../hooks/useClickOutside'; import SignalStrongIcon from '../assets/icons/signal-strong.svg?react'; import TodoIcon from '../assets/icons/circle.svg?react'; +import UserIcon from '../assets/icons/avatar.svg?react'; +import DateIcon from '../assets/icons/due-date.svg?react'; import {statusOpts} from './priority-menu'; import {statuses} from './status-menu'; import {Priority, Status} from 'shared'; -import UserIcon from './assets/icons/avatar.svg'; -import DateIcon from './assets/icons/due-date.svg'; import useId from '@mui/utils/useId'; +import {useViewState} from '../hooks/query-state-hooks'; +import {getViewStatuses} from '../filters'; type DateCallback = (date: Date) => void; interface Props { @@ -35,6 +37,7 @@ const FilterMenu = ({ const [popperRef, setPopperRef] = useState(null); const [filter, setFilter] = useState(null); const [filterDropDownVisible, setFilterDropDownVisible] = useState(false); + const [view] = useViewState(); const {styles, attributes, update} = usePopper(filterRef, popperRef, { placement: 'bottom-start', @@ -83,23 +86,27 @@ const FilterMenu = ({ ); }); - case Filter.STATUS: - return statuses.map(([Icon, status, label], idx) => { - return ( -
{ - onSelectStatus(status as Status); - setFilter(null); - setFilterDropDownVisible(false); - }} - > - - {label} -
- ); - }); + case Filter.STATUS: { + const viewStatuses = getViewStatuses(view); + return statuses + .filter(s => viewStatuses.size === 0 || viewStatuses.has(s[1])) + .map(([Icon, status, label], idx) => { + return ( +
{ + onSelectStatus(status as Status); + setFilter(null); + setFilterDropDownVisible(false); + }} + > + + {label} +
+ ); + }); + } case Filter.CREATOR: return [ Date: Tue, 19 Dec 2023 12:16:59 -0500 Subject: [PATCH 3/3] pass outer-ref to `useClickOutside` not doing so causes the filter menu to disappear in the case where React removes the clicked item from the dom before `useClickOutside` is called --- client/src/widgets/filter-menu.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/src/widgets/filter-menu.tsx b/client/src/widgets/filter-menu.tsx index faae1c2e..b41ec235 100644 --- a/client/src/widgets/filter-menu.tsx +++ b/client/src/widgets/filter-menu.tsx @@ -38,6 +38,7 @@ const FilterMenu = ({ const [filter, setFilter] = useState(null); const [filterDropDownVisible, setFilterDropDownVisible] = useState(false); const [view] = useViewState(); + const bodyRef = useRef(document.body); const {styles, attributes, update} = usePopper(filterRef, popperRef, { placement: 'bottom-start', @@ -50,12 +51,16 @@ const FilterMenu = ({ setFilterDropDownVisible(!filterDropDownVisible); }; - useClickOutside(ref, () => { - if (filterDropDownVisible) { - setFilter(null); - setFilterDropDownVisible(false); - } - }); + useClickOutside( + ref, + () => { + if (filterDropDownVisible) { + setFilter(null); + setFilterDropDownVisible(false); + } + }, + bodyRef, + ); const filterBys = [ [SignalStrongIcon, Filter.PRIORITY, 'Priority'],