From d44959f91e5e586081476aeb223eabcf0b3c456b Mon Sep 17 00:00:00 2001 From: William Petit Date: Sat, 2 Sep 2023 11:22:01 -0600 Subject: [PATCH] feat: tasks manual reordering --- client/src/components/footer/index.tsx | 1 - client/src/hooks/use-project-reducer.ts | 61 +++++++++++++- client/src/hooks/use-stored-project-list.ts | 47 ++++++----- client/src/models/project.ts | 20 ++++- client/src/routes/project/estimation-tab.tsx | 87 +++++++++++--------- client/src/routes/project/index.tsx | 5 +- client/src/routes/project/tasks-table.tsx | 85 +++++++++++++++---- server/internal/model/project.go | 1 + 8 files changed, 219 insertions(+), 88 deletions(-) diff --git a/client/src/components/footer/index.tsx b/client/src/components/footer/index.tsx index 5978e8f..2e9cef7 100644 --- a/client/src/components/footer/index.tsx +++ b/client/src/components/footer/index.tsx @@ -9,7 +9,6 @@ export interface FooterProps { } const Footer: FunctionComponent = ({ ...props}) => { - console.log(__BUILD__) return (
diff --git a/client/src/hooks/use-project-reducer.ts b/client/src/hooks/use-project-reducer.ts index 99850c2..39fec4d 100644 --- a/client/src/hooks/use-project-reducer.ts +++ b/client/src/hooks/use-project-reducer.ts @@ -1,4 +1,4 @@ -import { Project } from "../models/project"; +import { Project, sanitize } from "../models/project"; import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task"; import { useReducer } from "react"; import { generate as diff } from "json-merge-patch"; @@ -27,7 +27,7 @@ export function useProjectReducer(project: Project) { } export function projectReducer(project: Project, action: ProjectReducerActions): Project { - console.log(action); + console.log('action', action); switch(action.type) { case ADD_TASK: return handleAddTask(project, action as AddTask); @@ -43,6 +43,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions): case UPDATE_TASK_LABEL: return handleUpdateTaskLabel(project, action as UpdateTaskLabel); + + case MOVE_TASK: + return handleMoveTask(project, action as MoveTask); case UPDATE_PARAM: return handleUpdateParam(project, action as UpdateParam); @@ -84,6 +87,10 @@ export function handleAddTask(project: Project, action: AddTask): Project { ...project.tasks, [task.id]: task, }, + ordering: [ + ...project.ordering, + task.id + ], updatedAt: new Date(), }; } @@ -101,9 +108,17 @@ export function removeTask(id: TaskID): RemoveTask { export function handleRemoveTask(project: Project, action: RemoveTask): Project { const tasks = { ...project.tasks }; delete tasks[action.id]; + + const ordering = [ ...(project.ordering ?? []) ] + const taskIndex = ordering.findIndex(taskId => taskId === action.id) + if (taskIndex !== -1) { + ordering.splice(taskIndex, 1) + } + return { ...project, tasks, + ordering, updatedAt: new Date(), }; } @@ -183,6 +198,43 @@ export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel) }; } +export const MOVE_TASK = "MOVE_TASK"; + +export interface MoveTask extends Action { + id: TaskID + move: number +} + +export function moveTask(id: TaskID, move: number): MoveTask { + return { type: MOVE_TASK, id, move }; +} + +export function handleMoveTask(project: Project, action: MoveTask): Project { + let ordering = [ + ...(project.ordering ?? []), + ] + + if (ordering.length === 0) { + ordering = Object.keys(project.tasks) + } + + const taskIndex = ordering.findIndex(taskId => action.id === taskId) + + if (taskIndex+action.move < 0 || taskIndex+action.move > ordering.length-1 ) { + return project + } + + ordering.splice(taskIndex, 1) + ordering.splice(taskIndex+action.move, 0, action.id) + + return { + ...project, + ordering, + updatedAt: new Date(), + } +} + + export interface UpdateParam extends Action { name: string value: any @@ -324,7 +376,8 @@ export function handlePatchProject(project: Project, action: PatchProject): Proj console.log('patch to apply', p); if (!p) return project; const patched: Project = applyPatch(project, p) as Project - if (typeof patched.createdAt === 'string') patched.createdAt = new Date(patched.createdAt) - if (typeof patched.updatedAt === 'string') patched.updatedAt = new Date(patched.updatedAt) + + sanitize(patched) + return patched; } \ No newline at end of file diff --git a/client/src/hooks/use-stored-project-list.ts b/client/src/hooks/use-stored-project-list.ts index 50cfb22..0413bc5 100644 --- a/client/src/hooks/use-stored-project-list.ts +++ b/client/src/hooks/use-stored-project-list.ts @@ -1,35 +1,38 @@ -import {Project} from "../models/project"; +import { Project, sanitize } from "../models/project"; import { useState } from "react"; import { ProjectStorageKeyPrefix } from "../util/storage"; export function loadStoredProjects(): Project[] { - const projects: Project[] = []; + const projects: Project[] = []; - Object.keys(window.localStorage).forEach(key => { - if (key.startsWith(ProjectStorageKeyPrefix)) { - try { - const data = window.localStorage.getItem(key); - if (data) { - const project = JSON.parse(data); - projects.push(project); - } - } catch(err) { - console.error(err); - } + Object.keys(window.localStorage).forEach(key => { + if (key.startsWith(ProjectStorageKeyPrefix)) { + try { + const data = window.localStorage.getItem(key); + if (data) { + const project: Project = JSON.parse(data); + + sanitize(project) + + projects.push(project); } - }); + } catch (err) { + console.error(err); + } + } + }); - return projects + return projects } export function useStoredProjectList(): [Project[], () => void] { - const [ projects, setProjects ] = useState(() => { - return loadStoredProjects(); - }); + const [projects, setProjects] = useState(() => { + return loadStoredProjects(); + }); - const refresh = () => { - setProjects(loadStoredProjects()); - }; + const refresh = () => { + setProjects(loadStoredProjects()); + }; - return [ projects, refresh]; + return [projects, refresh]; } \ No newline at end of file diff --git a/client/src/models/project.ts b/client/src/models/project.ts index 6bba944..3575979 100644 --- a/client/src/models/project.ts +++ b/client/src/models/project.ts @@ -1,4 +1,4 @@ -import { Task } from './task'; +import { Task, TaskID } from './task'; import { Params, defaults } from "./params"; import { uuidV4 } from "../util/uuid"; @@ -12,6 +12,7 @@ export interface Project { params: Params createdAt: Date updatedAt: Date + ordering: TaskID[] } export interface Tasks { @@ -29,5 +30,22 @@ export function newProject(id?: string): Project { }, createdAt: new Date(), updatedAt: new Date(), + ordering: [], }; +} + +export function sanitize(project: Project): Project { + if (!Array.isArray(project.ordering)) { + project.ordering = Object.keys(project.tasks) + } + + if (typeof project.updatedAt === 'string') { + project.updatedAt = new Date(project.updatedAt) + } + + if (typeof project.createdAt === 'string') { + project.createdAt = new Date(project.createdAt) + } + + return project } \ No newline at end of file diff --git a/client/src/routes/project/estimation-tab.tsx b/client/src/routes/project/estimation-tab.tsx index 89c1317..b5e3ab1 100644 --- a/client/src/routes/project/estimation-tab.tsx +++ b/client/src/routes/project/estimation-tab.tsx @@ -1,58 +1,63 @@ -import React, { FunctionComponent, Fragment } from "react"; +import React, { FunctionComponent, Fragment, useCallback } from "react"; import { Project } from "../../models/project"; import TaskTable from "./tasks-table"; import TimePreview from "./time-preview"; import FinancialPreview from "./financial-preview"; -import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/use-project-reducer"; +import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions, moveTask } from "../../hooks/use-project-reducer"; import { Task, TaskID, EstimationConfidence } from "../../models/task"; import RepartitionPreview from "./repartition-preview"; import { getHideFinancialPreviewOnPrint } from "../../models/params"; export interface EstimationTabProps { - project: Project - dispatch: (action: ProjectReducerActions) => void + project: Project + dispatch: (action: ProjectReducerActions) => void } const EstimationTab: FunctionComponent = ({ project, dispatch }) => { - const onTaskAdd = (task: Task) => { - dispatch(addTask(task)); - }; + const onTaskAdd = (task: Task) => { + dispatch(addTask(task)); + }; - const onTaskRemove = (taskId: TaskID) => { - dispatch(removeTask(taskId)); - } + const onTaskRemove = (taskId: TaskID) => { + dispatch(removeTask(taskId)); + } - const onTaskLabelUpdate = (taskId: TaskID, label: string) => { - dispatch(updateTaskLabel(taskId, label)); - } + const onTaskLabelUpdate = (taskId: TaskID, label: string) => { + dispatch(updateTaskLabel(taskId, label)); + } - const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => { - dispatch(updateTaskEstimation(taskId, confidence, value)); - }; + const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => { + dispatch(updateTaskEstimation(taskId, confidence, value)); + }; - return ( - -
-
- -
-
- - -
+ const onTaskMove = useCallback((taskId: TaskID, move: number) => { + dispatch(moveTask(taskId, move)) + }, [dispatch]) + + return ( + +
+
+
-
-
- -
+
+ +
- { - Object.keys(project.tasks).length <= 20 ? +
+
+
+ +
+
+ { + Object.keys(project.tasks).length <= 20 ?

⚠️ Attention

@@ -60,10 +65,10 @@ const EstimationTab: FunctionComponent = ({ project, dispatc
: null - } -
- - ); + } +
+ + ); }; export default EstimationTab; diff --git a/client/src/routes/project/index.tsx b/client/src/routes/project/index.tsx index 77f5c7e..d02427a 100644 --- a/client/src/routes/project/index.tsx +++ b/client/src/routes/project/index.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent, useEffect } from "react"; import style from "./style.module.css"; -import { newProject, Project } from "../../models/project"; +import { newProject, Project, sanitize } from "../../models/project"; import { useProjectReducer, updateProjectLabel, patchProject } from "../../hooks/use-project-reducer"; import { getProjectStorageKey } from "../../util/storage"; import { useLocalStorage } from "../../hooks/use-local-storage"; @@ -21,9 +21,10 @@ const Project: FunctionComponent = () => { const { projectId } = useParams<{ projectId: string }>(); const projectStorageKey = getProjectStorageKey(projectId); const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId)); - const [ project, dispatch ] = useProjectReducer(storedProject); + const [ project, dispatch ] = useProjectReducer(sanitize(storedProject)); useServerSync(project, (project: Project) => { + sanitize(project) dispatch(patchProject(project)); }); diff --git a/client/src/routes/project/tasks-table.tsx b/client/src/routes/project/tasks-table.tsx index 4a100e4..c71f6ef 100644 --- a/client/src/routes/project/tasks-table.tsx +++ b/client/src/routes/project/tasks-table.tsx @@ -1,11 +1,11 @@ -import React, { FunctionComponent, useState, useEffect, ChangeEvent, MouseEvent } from "react"; +import React, { FunctionComponent, useState, useEffect, ChangeEvent, MouseEvent, useCallback } from "react"; import style from "./style.module.css"; import { Project } from "../../models/project"; import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task"; import EditableText from "../../components/editable-text"; import { usePrintMediaQuery } from "../../hooks/use-media-query"; -import { defaults, getTimeUnit } from "../../models/params"; import ProjectTimeUnit from "../../components/project-time-unit"; +import { useSort } from "../../hooks/useSort"; export interface TaskTableProps { project: Project @@ -13,11 +13,12 @@ export interface TaskTableProps { onTaskRemove: (taskId: TaskID) => void onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void onTaskLabelUpdate: (taskId: TaskID, label: string) => void + onTaskMove: (taskId: TaskID, move: number) => void } export type EstimationTotals = { [confidence in EstimationConfidence]: number } -const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => { +const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate, onTaskMove }) => { const defaultTaskCategory = Object.keys(project.params.taskCategories)[0]; const [task, setTask] = useState(newTask("", defaultTaskCategory)); @@ -27,6 +28,21 @@ const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEs [EstimationConfidence.Pessimistic]: 0, } as EstimationTotals); + const [tasks, setTasks] = useState([]) + useEffect(() => { + setTasks(Object.values(project.tasks)) + }, [project.tasks]) + + const sortTask = useCallback((a: Task, b: Task) => { + const aIndex = (project.ordering ?? []).findIndex(taskId => a.id === taskId) + const bIndex = (project.ordering ?? []).findIndex(taskId => b.id === taskId) + return aIndex - bIndex; + }, [project.ordering]) + + const sorted = useSort(tasks, sortTask) + + const [ activeTaskActions, setActiveTaskActions ] = useState(null); + const isPrint = usePrintMediaQuery(); useEffect(() => { @@ -43,6 +59,12 @@ const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEs setTotals({ optimistic, likely, pessimistic }); }, [project.tasks]); + const toggleActiveTaskAction = useCallback((evt: MouseEvent) => { + const taskId = evt.currentTarget.dataset.taskId; + if (!taskId) return + setActiveTaskActions(activeTaskActions === taskId ? null : taskId) + }, [activeTaskActions]) + const onNewTaskLabelChange = (evt: ChangeEvent) => { const value = (evt.currentTarget as HTMLInputElement).value; setTask({ ...task, label: value }); @@ -62,9 +84,23 @@ const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEs setTask(newTask("", defaultTaskCategory)); }; - const onTaskRemoveClick = (taskId: TaskID, evt: MouseEvent) => { + const onTaskRemoveClick = useCallback((evt: MouseEvent) => { + const taskId = evt.currentTarget.dataset.taskId; + if (!taskId) return onTaskRemove(taskId); - }; + }, []); + + const onTaskMoveUpClick = useCallback((evt: MouseEvent) => { + const taskId = evt.currentTarget.dataset.taskId; + if (!taskId) return + onTaskMove(taskId, -1); + }, []); + + const onTaskMoveDownClick = useCallback((evt: MouseEvent) => { + const taskId = evt.currentTarget.dataset.taskId; + if (!taskId) return + onTaskMove(taskId, +1); + }, []); const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: ChangeEvent) => { const textValue = (evt.currentTarget as HTMLInputElement).value; @@ -94,17 +130,32 @@ const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEs { - Object.values(project.tasks).map(t => { + sorted.map(t => { const category = project.params.taskCategories[t.category]; const categoryLabel = category ? category.label : '???'; return ( - + = ({ project, onTaskAdd, onEs { isPrint ? {t.estimations.likely} : - @@ -140,9 +191,9 @@ const TaskTable: FunctionComponent = ({ project, onTaskAdd, onEs { isPrint ? {t.estimations.pessimistic} : - diff --git a/server/internal/model/project.go b/server/internal/model/project.go index 502a9d7..bb4a43e 100644 --- a/server/internal/model/project.go +++ b/server/internal/model/project.go @@ -15,6 +15,7 @@ type Project struct { Label string `json:"label"` Description string `json:"description"` Tasks map[TaskID]Task `json:"tasks"` + Ordering []TaskID `json:"ordering"` Params Params `json:"params"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"`