Skip to content

Commit

Permalink
incrementally maintained kanban
Browse files Browse the repository at this point in the history
  • Loading branch information
tantaman committed Dec 19, 2023
1 parent 44c53a8 commit 108a3d9
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 133 deletions.
61 changes: 6 additions & 55 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<M>;
Expand Down Expand Up @@ -91,55 +74,23 @@ 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 = getIssueOrder(view, orderBy);

const [, filterdIssues] = useQuery(() => {
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(),
]);
}, [issueOrder, filters]);

const [, viewIssueCount] = useQuery(() => {
const viewStatuses = getViewStatuses(view);
Expand Down
77 changes: 75 additions & 2 deletions client/src/hooks/query-state-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Order> = {
toString: (value: Order) => value,
Expand Down Expand Up @@ -52,3 +65,63 @@ export function useModifiedFilterState() {
makeStringSetProcessor<DateQueryArg>(),
);
}

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<string | null>(null);
const [view] = useViewState();
const [prevView, setPrevView] = useState<string | null>(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;
}
160 changes: 93 additions & 67 deletions client/src/issue/issue-board.tsx
Original file line number Diff line number Diff line change
@@ -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<Issue>['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<Issue>['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;
Expand Down Expand Up @@ -75,51 +53,99 @@ export function getKanbanOrderIssueUpdates(
}

interface Props {
issues: PersistentTreeView<Issue>['value'];
onUpdateIssues: (issueUpdates: IssueUpdate[]) => void;
onOpenDetail: (issue: Issue) => void;
}

function IssueBoard({issues, onUpdateIssues, onOpenDetail}: Props) {
function applyFilters<T>(
stream: DifferenceStream<T>,
filters: ((x: T) => boolean)[],
): DifferenceStream<T> {
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) => {
Expand Down
Loading

0 comments on commit 108a3d9

Please sign in to comment.