diff --git a/client/src/app.tsx b/client/src/app.tsx index 43412b7..bccb23d 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -11,13 +11,9 @@ import {generateKeyBetween} from 'fractional-indexing'; import type {UndoManager} from '@rocicorp/undo'; import {HotKeys} from 'react-hotkeys'; import { - useCreatedFilterState, - useCreatorFilterState, + useFilters, useIssueDetailState, - useModifiedFilterState, useOrderByState, - usePriorityFilterState, - useStatusFilterState, useViewState, } from './hooks/query-state-hooks'; import {useSubscribe} from 'replicache-react'; @@ -34,20 +30,7 @@ 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, - hasNonViewFilters as doesHaveNonViewFilters, -} from './filters'; +import {getIssueOrder, getViewFilter, getViewStatuses} from './filters'; type AppProps = { rep: Replicache; @@ -84,12 +67,7 @@ const App = ({rep, undoManager}: AppProps) => { 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 [hasNonViewFilters, setHasNonViewFilters] = useState(false); + const {filters, hasNonViewFilters} = useFilters(); const [issueOrder, setIssueOrder] = useState(getIssueOrder(view, orderBy)); @@ -97,42 +75,15 @@ const App = ({rep, undoManager}: AppProps) => { const start = performance.now(); const source = db.issues.getSortedSource(issueOrder); - const viewStatuses = getViewStatuses(view); - const statuses = getStatuses(statusFilter); - const statusFilterFn = getStatusFilter(viewStatuses, statuses); - const filterFns = [ - 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) { - stream = stream.filter(filter); - } + for (const filter of filters) { + stream = stream.filter(filter); } const ret = stream.materialize(source.comparator); console.log(`Filter update duration: ${performance.now() - start}ms`); return ret; - }, [ - view, - issueOrder, - priorityFilter?.join(), - statusFilter?.join(), - createdFilter?.join(), - creatorFilter?.join(), - modifiedFilter?.join(), - ]); + }, [view, issueOrder, filters]); const [, viewIssueCount] = useQuery(() => { const viewStatuses = getViewStatuses(view); diff --git a/client/src/hooks/query-state-hooks.ts b/client/src/hooks/query-state-hooks.ts index 722a8f5..c0a7aae 100644 --- a/client/src/hooks/query-state-hooks.ts +++ b/client/src/hooks/query-state-hooks.ts @@ -2,8 +2,21 @@ import useQueryState, { identityProcessor, QueryStateProcessor, } from './useQueryState'; -import {Order, Priority, Status} from 'shared'; -import {DateQueryArg} from '../filters'; +import {Issue, Order, Priority, Status} from 'shared'; +import { + DateQueryArg, + getCreatedFilter, + getCreatorFilter, + getCreators, + getModifiedFilter, + getPriorities, + getPriorityFilter, + getStatuses, + getStatusFilter, + getViewStatuses, + hasNonViewFilters as doesHaveNonViewFilters, +} from '../filters'; +import {useState} from 'react'; const processOrderBy: QueryStateProcessor = { toString: (value: Order) => value, @@ -52,3 +65,63 @@ export function useModifiedFilterState() { makeStringSetProcessor(), ); } + +export function useFilterStates() { + const [statusFilter] = useStatusFilterState(); + const [priorityFilter] = usePriorityFilterState(); + const [creatorFilter] = useCreatorFilterState(); + const [createdFilter] = useCreatedFilterState(); + const [modifiedFilter] = useModifiedFilterState(); + + return { + statusFilter, + priorityFilter, + creatorFilter, + createdFilter, + modifiedFilter, + filtersIdentity: `${statusFilter?.join('')}-${priorityFilter?.join( + '', + )}-${creatorFilter?.join('')}-${createdFilter?.join( + '', + )}-${modifiedFilter?.join('')}`, + }; +} + +export function useFilters() { + const baseStates = useFilterStates(); + const [prevIdentity, setPrevIdentity] = useState(null); + const [view] = useViewState(); + const [prevView, setPrevView] = useState(null); + + const [state, setState] = useState<{ + filters: ((issue: Issue) => boolean)[]; + hasNonViewFilters: boolean; + }>({ + filters: [], + hasNonViewFilters: false, + }); + + if (prevIdentity !== baseStates.filtersIdentity || prevView !== view) { + setPrevIdentity(baseStates.filtersIdentity); + setPrevView(view); + + const viewStatuses = getViewStatuses(view); + const statuses = getStatuses(baseStates.statusFilter); + const statusFilterFn = getStatusFilter(viewStatuses, statuses); + const filterFns = [ + statusFilterFn, + getPriorityFilter(getPriorities(baseStates.priorityFilter)), + getCreatorFilter(getCreators(baseStates.creatorFilter)), + getCreatedFilter(baseStates.createdFilter), + getModifiedFilter(baseStates.modifiedFilter), + ].filter(f => f !== null) as ((issue: Issue) => boolean)[]; + + const hasNonViewFilters = !!( + doesHaveNonViewFilters(viewStatuses, statuses) || + filterFns.filter(f => f !== statusFilterFn).length > 0 + ); + setState({filters: filterFns, hasNonViewFilters}); + } + + return state; +} diff --git a/client/src/issue/issue-board.tsx b/client/src/issue/issue-board.tsx index fa437b9..6bebb3d 100644 --- a/client/src/issue/issue-board.tsx +++ b/client/src/issue/issue-board.tsx @@ -1,52 +1,30 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {PersistentTreeView} from '@vlcn.io/materialite'; +import {DifferenceStream, PersistentTreeView} from '@vlcn.io/materialite'; +import {useQuery} from '@vlcn.io/materialite-react'; import {generateNKeysBetween} from 'fractional-indexing'; -import {groupBy, indexOf} from 'lodash'; import {memo, useCallback} from 'react'; import {DragDropContext, DropResult} from 'react-beautiful-dnd'; import {Issue, IssueUpdate, Priority, Status} from 'shared'; import IssueCol from './issue-col'; - -export type IssuesByStatusType = { - BACKLOG: Issue[]; - TODO: Issue[]; - IN_PROGRESS: Issue[]; - DONE: Issue[]; - CANCELED: Issue[]; -}; - -// TODO (mlaw): Group kanban board via filtered queries against materialite rather than this thing here. -export const getIssueByType = ( - allIssues: PersistentTreeView['value'], -): IssuesByStatusType => { - const issuesBySType = groupBy([...allIssues], 'status'); - const defaultIssueByType = { - BACKLOG: [], - TODO: [], - IN_PROGRESS: [], - DONE: [], - CANCELED: [], - }; - const result = {...defaultIssueByType, ...issuesBySType}; - return result; -}; +import {db} from '../materialite/db'; +import {useFilters} from '../hooks/query-state-hooks'; export function getKanbanOrderIssueUpdates( issueToMove: Issue, issueToInsertBefore: Issue, issues: PersistentTreeView['value'], ): IssueUpdate[] { - const indexInKanbanOrder = indexOf(issues, issueToInsertBefore); + const indexInKanbanOrder = issues.findIndex(issueToInsertBefore); let beforeKey: string | null = null; if (indexInKanbanOrder > 0) { - beforeKey = issues[indexInKanbanOrder - 1].kanbanOrder; + beforeKey = issues.at(indexInKanbanOrder - 1).kanbanOrder; } let afterKey: string | null = null; const issuesToReKey: Issue[] = []; // If the issues we are trying to move between // have identical kanbanOrder values, we need to fix up the // collision by re-keying the issues. - for (let i = indexInKanbanOrder; i < issues.length; i++) { + for (let i = indexInKanbanOrder; i < issues.size; i++) { if (issues.at(i).kanbanOrder !== beforeKey) { afterKey = issues.at(i).kanbanOrder; break; @@ -75,51 +53,99 @@ export function getKanbanOrderIssueUpdates( } interface Props { - issues: PersistentTreeView['value']; onUpdateIssues: (issueUpdates: IssueUpdate[]) => void; onOpenDetail: (issue: Issue) => void; } -function IssueBoard({issues, onUpdateIssues, onOpenDetail}: Props) { +function applyFilters( + stream: DifferenceStream, + filters: ((x: T) => boolean)[], +): DifferenceStream { + for (const filter of filters) { + stream = stream.filter(filter); + } + + return stream; +} + +function IssueBoard({onUpdateIssues, onOpenDetail}: Props) { const start = performance.now(); - const issuesByType = getIssueByType(issues); + const {filters} = useFilters(); + const issuesByType = { + BACKLOG: useQuery(() => { + const source = db.issues.getSortedSource('KANBAN'); + return applyFilters( + source.stream.filter(issue => issue.status === 'BACKLOG'), + filters, + ).materialize(source.comparator); + }, [filters])[1], + TODO: useQuery(() => { + const source = db.issues.getSortedSource('KANBAN'); + return applyFilters( + source.stream.filter(issue => issue.status === 'TODO'), + filters, + ).materialize(source.comparator); + }, [filters])[1], + IN_PROGRESS: useQuery(() => { + const source = db.issues.getSortedSource('KANBAN'); + return applyFilters( + source.stream.filter(issue => issue.status === 'IN_PROGRESS'), + filters, + ).materialize(source.comparator); + }, [filters])[1], + DONE: useQuery(() => { + const source = db.issues.getSortedSource('KANBAN'); + return applyFilters( + source.stream.filter(issue => issue.status === 'DONE'), + filters, + ).materialize(source.comparator); + }, [filters])[1], + CANCELED: useQuery(() => { + const source = db.issues.getSortedSource('KANBAN'); + return applyFilters( + source.stream.filter(issue => issue.status === 'CANCELED'), + filters, + ).materialize(source.comparator); + }, [filters])[1], + }; console.log(`Issues by type duration: ${performance.now() - start}ms`); - const handleDragEnd = useCallback( - ({source, destination}: DropResult) => { - if (!destination) { - return; - } - const sourceStatus = source?.droppableId as Status; - const draggedIssue = issuesByType[sourceStatus][source.index]; - if (!draggedIssue) { - return; - } - const newStatus = destination.droppableId as Status; - const newIndex = - sourceStatus === newStatus && source.index < destination.index - ? destination.index + 1 - : destination.index; - const issueToInsertBefore = issuesByType[newStatus][newIndex]; - if (draggedIssue === issueToInsertBefore) { - return; - } - const issueUpdates = issueToInsertBefore - ? getKanbanOrderIssueUpdates(draggedIssue, issueToInsertBefore, issues) - : [{issue: draggedIssue, issueChanges: {}}]; - if (newStatus !== sourceStatus) { - issueUpdates[0] = { - ...issueUpdates[0], - issueChanges: { - ...issueUpdates[0].issueChanges, - status: newStatus, - }, - }; - } - onUpdateIssues(issueUpdates); - }, - [issues, issuesByType, onUpdateIssues], - ); + const handleDragEnd = ({source, destination}: DropResult) => { + if (!destination) { + return; + } + const sourceStatus = source?.droppableId as Status; + const draggedIssue = issuesByType[sourceStatus].at(source.index); + if (!draggedIssue) { + return; + } + const newStatus = destination.droppableId as Status; + const newIndex = + sourceStatus === newStatus && source.index < destination.index + ? destination.index + 1 + : destination.index; + const issueToInsertBefore = issuesByType[newStatus].at(newIndex); + if (draggedIssue === issueToInsertBefore) { + return; + } + const issueUpdates = issueToInsertBefore + ? getKanbanOrderIssueUpdates( + draggedIssue, + issueToInsertBefore, + issuesByType[newStatus], + ) + : [{issue: draggedIssue, issueChanges: {}}]; + if (newStatus !== sourceStatus) { + issueUpdates[0] = { + ...issueUpdates[0], + issueChanges: { + ...issueUpdates[0].issueChanges, + status: newStatus, + }, + }; + } + onUpdateIssues(issueUpdates); + }; const handleChangePriority = useCallback( (issue: Issue, priority: Priority) => { diff --git a/client/src/issue/issue-col.tsx b/client/src/issue/issue-col.tsx index ed2dc02..ccb3a82 100644 --- a/client/src/issue/issue-col.tsx +++ b/client/src/issue/issue-col.tsx @@ -11,11 +11,12 @@ import IssueItem from './issue-item'; import {FixedSizeList} from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import {Issue, Priority, Status} from 'shared'; +import {PersistentTreeView} from '@vlcn.io/materialite'; interface Props { status: Status; title: string; - issues: Array; + issues: PersistentTreeView['value']; onChangePriority: (issue: Issue, priority: Priority) => void; onOpenDetail: (issue: Issue) => void; } @@ -23,7 +24,7 @@ interface Props { interface RowProps { index: number; data: { - issues: Array; + issues: PersistentTreeView['value']; onChangePriority: (issue: Issue, priority: Priority) => void; onOpenDetail: (issue: Issue) => void; }; @@ -31,7 +32,7 @@ interface RowProps { } const RowPreMemo = ({data, index, style}: RowProps) => { - const issue = data.issues[index]; + const issue = data.issues.at(index); // We are rendering an extra item for the placeholder. // To do this we increased our data set size to include one 'fake' item. if (!issue) { @@ -88,7 +89,7 @@ function IssueCol({ {statusIcon}
{title}
- {issues?.length || 0} + {issues?.size || 0}
@@ -100,7 +101,7 @@ function IssueCol({ type="category" mode="virtual" renderClone={(provided, _snapshot, rubric) => { - const issue = issues[rubric.source.index]; + const issue = issues.at(rubric.source.index); return (
{({height, width}: {width: number; height: number}) => { diff --git a/client/src/issue/issue-detail.tsx b/client/src/issue/issue-detail.tsx index e10b0ca..60d45f6 100644 --- a/client/src/issue/issue-detail.tsx +++ b/client/src/issue/issue-detail.tsx @@ -83,7 +83,8 @@ export default function IssueDetail({ useEffect(() => { if (detailIssueID) { - const index = issues.findIndex( + // TODO: can do better if we pass actual issue rather than issue id + const index = issues.findIndexByPredicate( (issue: Issue) => issue.id === detailIssueID, ); setCurrentIssueIdx(index); diff --git a/client/src/layout/layout.tsx b/client/src/layout/layout.tsx index e0d445b..b240070 100644 --- a/client/src/layout/layout.tsx +++ b/client/src/layout/layout.tsx @@ -102,7 +102,6 @@ const RawLayout = ({ > {view === 'board' ? ( diff --git a/server/src/seed/sample-issues.ts b/server/src/seed/sample-issues.ts index 681b5d6..dc9948a 100644 --- a/server/src/seed/sample-issues.ts +++ b/server/src/seed/sample-issues.ts @@ -79,7 +79,7 @@ export async function getReactSampleData(): Promise { // Can use this to generate artifically larger datasets for stress testing or // smaller for debugging. - const factor = 10; + const factor = 1; let commentId = 1; if (factor >= 1) { const multiplied: SampleData[] = [];