From 6e0ccd5575332d332b9909f1e50dcd1fc015f5f3 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 21 Apr 2020 09:24:39 +0200 Subject: [PATCH] Editable tasks and project labels --- src/components/editable-text/index.tsx | 56 ++++++++ src/components/editable-text/style.css | 17 +++ src/components/editable-text/style.css.d.ts | 3 + src/hooks/use-project-reducer.ts | 151 ++++++++++++++------ src/routes/project/index.tsx | 22 ++- src/routes/project/tasks-table.tsx | 23 ++- 6 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 src/components/editable-text/index.tsx create mode 100644 src/components/editable-text/style.css create mode 100644 src/components/editable-text/style.css.d.ts diff --git a/src/components/editable-text/index.tsx b/src/components/editable-text/index.tsx new file mode 100644 index 0000000..010e345 --- /dev/null +++ b/src/components/editable-text/index.tsx @@ -0,0 +1,56 @@ +import { FunctionalComponent, h, Component, ComponentChild, Fragment } from "preact"; +import { Link } from "preact-router/match"; +import * as style from "./style.css"; +import { useState, useEffect } from "preact/hooks"; + +export interface EditableTextProps { + value: string + class?: string + editIconClass?: string + onChange?: (value: string) => void + render: (value: string) => ComponentChild +} + +const EditableText: FunctionalComponent = ({ onChange, value, render, ...props }) => { + const [ internalValue, setInternalValue ] = useState(value); + const [ editMode, setEditMode ] = useState(false); + + useEffect(() => { + if (onChange) onChange(internalValue); + }, [internalValue]); + + const onEditIconClick = () => { + setEditMode(true); + }; + + const onValidateButtonClick = () => { + setEditMode(false); + } + + const onValueChange = (evt: Event) => { + const currentTarget = evt.currentTarget as HTMLInputElement; + setInternalValue(currentTarget.value); + }; + + return ( +
+ { + editMode ? +
+
+ +
+
+ ✔️ +
+
: + + { render(internalValue) } + 🖋️ + + } +
+ ); +}; + +export default EditableText; diff --git a/src/components/editable-text/style.css b/src/components/editable-text/style.css new file mode 100644 index 0000000..6349e65 --- /dev/null +++ b/src/components/editable-text/style.css @@ -0,0 +1,17 @@ +.editableText { + display: inherit; +} + +.editableText > * { + display: inline-block; +} + +.editIcon { + visibility: hidden; + margin-left: 0.25em; + cursor: pointer; +} + +.editableText:hover > .editIcon { + visibility: visible; +} \ No newline at end of file diff --git a/src/components/editable-text/style.css.d.ts b/src/components/editable-text/style.css.d.ts new file mode 100644 index 0000000..b392275 --- /dev/null +++ b/src/components/editable-text/style.css.d.ts @@ -0,0 +1,3 @@ +// This file is automatically generated from your CSS. Any edits will be overwritten. +export const editableText: string; +export const editIcon: string; diff --git a/src/hooks/use-project-reducer.ts b/src/hooks/use-project-reducer.ts index ac5a841..be89e1e 100644 --- a/src/hooks/use-project-reducer.ts +++ b/src/hooks/use-project-reducer.ts @@ -7,9 +7,11 @@ export interface Action { } export type ProjectReducerActions = - AddTaskAction | - RemoveTaskAction | - UpdateTaskEstimation + AddTask | + RemoveTask | + UpdateTaskEstimation | + UpdateProjectLabel | + UpdateTaskLabel export function useProjectReducer(project: Project) { return useReducer(projectReducer, project); @@ -18,69 +20,64 @@ export function useProjectReducer(project: Project) { export function projectReducer(project: Project, action: ProjectReducerActions): Project { switch(action.type) { case ADD_TASK: - const task = { ...(action as AddTaskAction).task }; - return { - ...project, - tasks: { - ...project.tasks, - [task.id]: task, - } - }; + return handleAddTask(project, action as AddTask); + case REMOVE_TASK: - action = action as RemoveTaskAction; - const tasks = { ...project.tasks }; - delete tasks[action.id]; - return { - ...project, - tasks - }; + return handleRemoveTask(project, action as RemoveTask); + case UPDATE_TASK_ESTIMATION: - action = action as UpdateTaskEstimation; - const estimations = { - ...project.tasks[action.id].estimations, - [(action as UpdateTaskEstimation).confidence]: (action as UpdateTaskEstimation).value - }; - if (estimations.likely <= estimations.optimistic) { - estimations.likely = estimations.optimistic + 1; - } - if (estimations.pessimistic <= estimations.likely) { - estimations.pessimistic = estimations.likely + 1; - } - return { - ...project, - tasks: { - ...project.tasks, - [action.id]: { - ...project.tasks[action.id], - estimations: estimations, - } - } - }; + return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation); + + case UPDATE_PROJECT_LABEL: + return handleUpdateProjectLabel(project, action as UpdateProjectLabel); + + case UPDATE_TASK_LABEL: + return handleUpdateTaskLabel(project, action as UpdateTaskLabel); } return project; } -export interface AddTaskAction extends Action { +export interface AddTask extends Action { task: Task } export const ADD_TASK = "ADD_TASK"; -export function addTask(task: Task): AddTaskAction { +export function addTask(task: Task): AddTask { return { type: ADD_TASK, task }; } -export interface RemoveTaskAction extends Action { +export function handleAddTask(project: Project, action: AddTask): Project { + const task = { ...action.task }; + return { + ...project, + tasks: { + ...project.tasks, + [task.id]: task, + } + }; +} + +export interface RemoveTask extends Action { id: TaskID } export const REMOVE_TASK = "REMOVE_TASK"; -export function removeTask(id: TaskID): RemoveTaskAction { +export function removeTask(id: TaskID): RemoveTask { return { type: REMOVE_TASK, id }; } +export function handleRemoveTask(project: Project, action: RemoveTask): Project { + const tasks = { ...project.tasks }; + delete tasks[action.id]; + return { + ...project, + tasks + }; +} + export interface UpdateTaskEstimation extends Action { id: TaskID confidence: string @@ -91,4 +88,72 @@ export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION"; export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation { return { type: UPDATE_TASK_ESTIMATION, id, confidence, value }; +} + +export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project { + const estimations = { + ...project.tasks[action.id].estimations, + [action.confidence]: action.value + }; + + if (estimations.likely <= estimations.optimistic) { + estimations.likely = estimations.optimistic + 1; + } + + if (estimations.pessimistic <= estimations.likely) { + estimations.pessimistic = estimations.likely + 1; + } + + return { + ...project, + tasks: { + ...project.tasks, + [action.id]: { + ...project.tasks[action.id], + estimations: estimations, + } + } + }; +} + + +export interface UpdateProjectLabel extends Action { + label: string +} + +export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL"; + +export function updateProjectLabel(label: string): UpdateProjectLabel { + return { type: UPDATE_PROJECT_LABEL, label }; +} + +export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project { + return { + ...project, + label: action.label + }; +} + +export interface UpdateTaskLabel extends Action { + id: TaskID + label: string +} + +export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL"; + +export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel { + return { type: UPDATE_TASK_LABEL, id, label }; +} + +export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project { + return { + ...project, + tasks: { + ...project.tasks, + [action.id]: { + ...project.tasks[action.id], + label: action.label, + } + } + }; } \ No newline at end of file diff --git a/src/routes/project/index.tsx b/src/routes/project/index.tsx index d91062e..58d57c8 100644 --- a/src/routes/project/index.tsx +++ b/src/routes/project/index.tsx @@ -5,10 +5,11 @@ import { newProject } from "../../models/project"; import TaskTable from "./tasks-table"; import TimePreview from "./time-preview"; import FinancialPreview from "./financial-preview"; -import { useProjectReducer, addTask, updateTaskEstimation, removeTask } from "../../hooks/use-project-reducer"; +import { useProjectReducer, addTask, updateTaskEstimation, removeTask, updateProjectLabel, updateTaskLabel } from "../../hooks/use-project-reducer"; import { Task, TaskID, EstimationConfidence } from "../../models/task"; import { getProjectStorageKey } from "../../util/storage"; import { useLocalStorage } from "../../hooks/use-local-storage"; +import EditableText from "../../components/editable-text"; export interface ProjectProps { projectId: string @@ -27,10 +28,18 @@ const Project: FunctionalComponent = ({ projectId }) => { dispatch(removeTask(taskId)); } + const onTaskLabelUpdate = (taskId: TaskID, label: string) => { + dispatch(updateTaskLabel(taskId, label)); + } + const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => { dispatch(updateTaskEstimation(taskId, confidence, value)); }; + const onProjectLabelChange = (projectLabel: string) => { + dispatch(updateProjectLabel(projectLabel)); + }; + // Save project in local storage on change useEffect(()=> { storeProject(project); @@ -38,11 +47,11 @@ const Project: FunctionalComponent = ({ projectId }) => { return (
-

- {project.label ? project.label : "Projet sans nom"} -   - 🖋️ -

+ (

{value}

)} + onChange={onProjectLabelChange} + value={project.label ? project.label : "Projet sans nom"} />
  • @@ -65,6 +74,7 @@ const Project: FunctionalComponent = ({ projectId }) => { project={project} onTaskAdd={onTaskAdd} onTaskRemove={onTaskRemove} + onTaskLabelUpdate={onTaskLabelUpdate} onEstimationChange={onEstimationChange} />
diff --git a/src/routes/project/tasks-table.tsx b/src/routes/project/tasks-table.tsx index dcf8e9e..0d200f1 100644 --- a/src/routes/project/tasks-table.tsx +++ b/src/routes/project/tasks-table.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "preact/hooks"; import * as style from "./style.css"; import { Project } from "../../models/project"; import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task"; +import EditableText from "../../components/editable-text"; @@ -11,11 +12,12 @@ export interface TaskTableProps { onTaskAdd: (task: Task) => void onTaskRemove: (taskId: TaskID) => void onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void + onTaskLabelUpdate: (taskId: TaskID, label: string) => void } export type EstimationTotals = { [confidence in EstimationConfidence]: number } -const TaskTable: FunctionalComponent = ({ project, onTaskAdd, onEstimationChange, onTaskRemove }) => { +const TaskTable: FunctionalComponent = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => { const defaultTaskCategory = Object.keys(project.params.taskCategories)[0]; const [ task, setTask ] = useState(newTask("", defaultTaskCategory)); @@ -39,16 +41,20 @@ const TaskTable: FunctionalComponent = ({ project, onTaskAdd, on setTotals({ optimistic, likely, pessimistic }); }, [project.tasks]); - const onTaskLabelChange = (evt: Event) => { + const onNewTaskLabelChange = (evt: Event) => { const value = (evt.currentTarget as HTMLInputElement).value; setTask({...task, label: value}); }; - const onTaskCategoryChange = (evt: Event) => { + const onNewTaskCategoryChange = (evt: Event) => { const value = (evt.currentTarget as HTMLInputElement).value; setTask({...task, category: value}); }; + const onTaskLabelChange = (taskId: TaskID, value: string) => { + onTaskLabelUpdate(taskId, value); + }; + const onAddTaskClick = (evt: Event) => { onTaskAdd(task); setTask(newTask("", defaultTaskCategory)); @@ -98,7 +104,12 @@ const TaskTable: FunctionalComponent = ({ project, onTaskAdd, on 🗑️ - {t.label} + + ({value})} + onChange={onTaskLabelChange.bind(null, t.id)} + value={t.label} /> + { categoryLabel } = ({ project, onTaskAdd, on

+ value={task.label} onChange={onNewTaskLabelChange} />

- { Object.values(project.params.taskCategories).map(tc => { return (