Editable tasks and project labels

This commit is contained in:
wpetit 2020-04-21 09:24:39 +02:00
parent 034bc0e90b
commit 6e0ccd5575
6 changed files with 217 additions and 55 deletions

View File

@ -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<EditableTextProps> = ({ 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 (
<div class={`${style.editableText} ${props.class ? props.class : ''}`}>
{
editMode ?
<div class="field has-addons">
<div class="control">
<input class="input is-expanded" type="text" value={internalValue} onChange={onValueChange} />
</div>
<div class="control">
<a class="button" onClick={onValidateButtonClick}></a>
</div>
</div> :
<Fragment>
{ render(internalValue) }
<i class={`${style.editIcon} icon ${props.editIconClass ? props.editIconClass : ''}`} onClick={onEditIconClick}>🖋</i>
</Fragment>
}
</div>
);
};
export default EditableText;

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
}
}
};
}

View File

@ -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<ProjectProps> = ({ 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<ProjectProps> = ({ projectId }) => {
return (
<div class={`container ${style.estimation}`}>
<h3 class="is-size-3">
{project.label ? project.label : "Projet sans nom"}
&nbsp;
<i class="icon is-size-4">🖋</i>
</h3>
<EditableText
editIconClass="is-size-4"
render={(value) => (<h3 class="is-size-3">{value}</h3>)}
onChange={onProjectLabelChange}
value={project.label ? project.label : "Projet sans nom"} />
<div class="tabs">
<ul>
<li class="is-active">
@ -65,6 +74,7 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
project={project}
onTaskAdd={onTaskAdd}
onTaskRemove={onTaskRemove}
onTaskLabelUpdate={onTaskLabelUpdate}
onEstimationChange={onEstimationChange} />
</div>
<div class="column is-3">

View File

@ -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<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove }) => {
const TaskTable: FunctionalComponent<TaskTableProps> = ({ 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<TaskTableProps> = ({ 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<TaskTableProps> = ({ project, onTaskAdd, on
🗑
</button>
</td>
<td class={style.mainColumn}>{t.label}</td>
<td class={style.mainColumn}>
<EditableText
render={(value) => (<span>{value}</span>)}
onChange={onTaskLabelChange.bind(null, t.id)}
value={t.label} />
</td>
<td>{ categoryLabel }</td>
<td>
<input class="input" type="number" value={t.estimations.optimistic}
@ -135,11 +146,11 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<div class="field has-addons">
<p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle tâche"
value={task.label} onChange={onTaskLabelChange} />
value={task.label} onChange={onNewTaskLabelChange} />
</p>
<p class="control">
<span class="select">
<select onChange={onTaskCategoryChange} value={task.category}>
<select onChange={onNewTaskCategoryChange} value={task.category}>
{
Object.values(project.params.taskCategories).map(tc => {
return (