Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[materialite] incrementally maintained kanban [3/n] #13

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -84,55 +67,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, setIssueOrder] = useState(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(),
]);
}, [view, 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