Editable tasks and project labels
This commit is contained in:
parent
034bc0e90b
commit
6e0ccd5575
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
|
@ -7,9 +7,11 @@ export interface Action {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectReducerActions =
|
export type ProjectReducerActions =
|
||||||
AddTaskAction |
|
AddTask |
|
||||||
RemoveTaskAction |
|
RemoveTask |
|
||||||
UpdateTaskEstimation
|
UpdateTaskEstimation |
|
||||||
|
UpdateProjectLabel |
|
||||||
|
UpdateTaskLabel
|
||||||
|
|
||||||
export function useProjectReducer(project: Project) {
|
export function useProjectReducer(project: Project) {
|
||||||
return useReducer(projectReducer, project);
|
return useReducer(projectReducer, project);
|
||||||
|
@ -18,69 +20,64 @@ export function useProjectReducer(project: Project) {
|
||||||
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ADD_TASK:
|
case ADD_TASK:
|
||||||
const task = { ...(action as AddTaskAction).task };
|
return handleAddTask(project, action as AddTask);
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
tasks: {
|
|
||||||
...project.tasks,
|
|
||||||
[task.id]: task,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
case REMOVE_TASK:
|
case REMOVE_TASK:
|
||||||
action = action as RemoveTaskAction;
|
return handleRemoveTask(project, action as RemoveTask);
|
||||||
const tasks = { ...project.tasks };
|
|
||||||
delete tasks[action.id];
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
tasks
|
|
||||||
};
|
|
||||||
case UPDATE_TASK_ESTIMATION:
|
case UPDATE_TASK_ESTIMATION:
|
||||||
action = action as UpdateTaskEstimation;
|
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
|
||||||
const estimations = {
|
|
||||||
...project.tasks[action.id].estimations,
|
case UPDATE_PROJECT_LABEL:
|
||||||
[(action as UpdateTaskEstimation).confidence]: (action as UpdateTaskEstimation).value
|
return handleUpdateProjectLabel(project, action as UpdateProjectLabel);
|
||||||
};
|
|
||||||
if (estimations.likely <= estimations.optimistic) {
|
case UPDATE_TASK_LABEL:
|
||||||
estimations.likely = estimations.optimistic + 1;
|
return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
|
||||||
}
|
|
||||||
if (estimations.pessimistic <= estimations.likely) {
|
|
||||||
estimations.pessimistic = estimations.likely + 1;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
tasks: {
|
|
||||||
...project.tasks,
|
|
||||||
[action.id]: {
|
|
||||||
...project.tasks[action.id],
|
|
||||||
estimations: estimations,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddTaskAction extends Action {
|
export interface AddTask extends Action {
|
||||||
task: Task
|
task: Task
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ADD_TASK = "ADD_TASK";
|
export const ADD_TASK = "ADD_TASK";
|
||||||
|
|
||||||
export function addTask(task: Task): AddTaskAction {
|
export function addTask(task: Task): AddTask {
|
||||||
return { type: ADD_TASK, task };
|
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
|
id: TaskID
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REMOVE_TASK = "REMOVE_TASK";
|
export const REMOVE_TASK = "REMOVE_TASK";
|
||||||
|
|
||||||
export function removeTask(id: TaskID): RemoveTaskAction {
|
export function removeTask(id: TaskID): RemoveTask {
|
||||||
return { type: REMOVE_TASK, id };
|
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 {
|
export interface UpdateTaskEstimation extends Action {
|
||||||
id: TaskID
|
id: TaskID
|
||||||
confidence: string
|
confidence: string
|
||||||
|
@ -91,4 +88,72 @@ export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
|
||||||
|
|
||||||
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
|
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation {
|
||||||
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
|
@ -5,10 +5,11 @@ import { newProject } from "../../models/project";
|
||||||
import TaskTable from "./tasks-table";
|
import TaskTable from "./tasks-table";
|
||||||
import TimePreview from "./time-preview";
|
import TimePreview from "./time-preview";
|
||||||
import FinancialPreview from "./financial-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 { Task, TaskID, EstimationConfidence } from "../../models/task";
|
||||||
import { getProjectStorageKey } from "../../util/storage";
|
import { getProjectStorageKey } from "../../util/storage";
|
||||||
import { useLocalStorage } from "../../hooks/use-local-storage";
|
import { useLocalStorage } from "../../hooks/use-local-storage";
|
||||||
|
import EditableText from "../../components/editable-text";
|
||||||
|
|
||||||
export interface ProjectProps {
|
export interface ProjectProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
|
@ -27,10 +28,18 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
||||||
dispatch(removeTask(taskId));
|
dispatch(removeTask(taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onTaskLabelUpdate = (taskId: TaskID, label: string) => {
|
||||||
|
dispatch(updateTaskLabel(taskId, label));
|
||||||
|
}
|
||||||
|
|
||||||
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
|
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => {
|
||||||
dispatch(updateTaskEstimation(taskId, confidence, value));
|
dispatch(updateTaskEstimation(taskId, confidence, value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onProjectLabelChange = (projectLabel: string) => {
|
||||||
|
dispatch(updateProjectLabel(projectLabel));
|
||||||
|
};
|
||||||
|
|
||||||
// Save project in local storage on change
|
// Save project in local storage on change
|
||||||
useEffect(()=> {
|
useEffect(()=> {
|
||||||
storeProject(project);
|
storeProject(project);
|
||||||
|
@ -38,11 +47,11 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`container ${style.estimation}`}>
|
<div class={`container ${style.estimation}`}>
|
||||||
<h3 class="is-size-3">
|
<EditableText
|
||||||
{project.label ? project.label : "Projet sans nom"}
|
editIconClass="is-size-4"
|
||||||
|
render={(value) => (<h3 class="is-size-3">{value}</h3>)}
|
||||||
<i class="icon is-size-4">🖋️</i>
|
onChange={onProjectLabelChange}
|
||||||
</h3>
|
value={project.label ? project.label : "Projet sans nom"} />
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<ul>
|
<ul>
|
||||||
<li class="is-active">
|
<li class="is-active">
|
||||||
|
@ -65,6 +74,7 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
|
||||||
project={project}
|
project={project}
|
||||||
onTaskAdd={onTaskAdd}
|
onTaskAdd={onTaskAdd}
|
||||||
onTaskRemove={onTaskRemove}
|
onTaskRemove={onTaskRemove}
|
||||||
|
onTaskLabelUpdate={onTaskLabelUpdate}
|
||||||
onEstimationChange={onEstimationChange} />
|
onEstimationChange={onEstimationChange} />
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-3">
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { useState, useEffect } from "preact/hooks";
|
||||||
import * as style from "./style.css";
|
import * as style from "./style.css";
|
||||||
import { Project } from "../../models/project";
|
import { Project } from "../../models/project";
|
||||||
import { newTask, Task, TaskID, EstimationConfidence } from "../../models/task";
|
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
|
onTaskAdd: (task: Task) => void
|
||||||
onTaskRemove: (taskId: TaskID) => void
|
onTaskRemove: (taskId: TaskID) => void
|
||||||
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
|
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void
|
||||||
|
onTaskLabelUpdate: (taskId: TaskID, label: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EstimationTotals = { [confidence in EstimationConfidence]: number }
|
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 defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
|
||||||
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
|
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
|
||||||
|
@ -39,16 +41,20 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
|
||||||
setTotals({ optimistic, likely, pessimistic });
|
setTotals({ optimistic, likely, pessimistic });
|
||||||
}, [project.tasks]);
|
}, [project.tasks]);
|
||||||
|
|
||||||
const onTaskLabelChange = (evt: Event) => {
|
const onNewTaskLabelChange = (evt: Event) => {
|
||||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||||
setTask({...task, label: value});
|
setTask({...task, label: value});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTaskCategoryChange = (evt: Event) => {
|
const onNewTaskCategoryChange = (evt: Event) => {
|
||||||
const value = (evt.currentTarget as HTMLInputElement).value;
|
const value = (evt.currentTarget as HTMLInputElement).value;
|
||||||
setTask({...task, category: value});
|
setTask({...task, category: value});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTaskLabelChange = (taskId: TaskID, value: string) => {
|
||||||
|
onTaskLabelUpdate(taskId, value);
|
||||||
|
};
|
||||||
|
|
||||||
const onAddTaskClick = (evt: Event) => {
|
const onAddTaskClick = (evt: Event) => {
|
||||||
onTaskAdd(task);
|
onTaskAdd(task);
|
||||||
setTask(newTask("", defaultTaskCategory));
|
setTask(newTask("", defaultTaskCategory));
|
||||||
|
@ -98,7 +104,12 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
|
||||||
🗑️
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</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>{ categoryLabel }</td>
|
||||||
<td>
|
<td>
|
||||||
<input class="input" type="number" value={t.estimations.optimistic}
|
<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">
|
<div class="field has-addons">
|
||||||
<p class="control is-expanded">
|
<p class="control is-expanded">
|
||||||
<input class="input" type="text" placeholder="Nouvelle tâche"
|
<input class="input" type="text" placeholder="Nouvelle tâche"
|
||||||
value={task.label} onChange={onTaskLabelChange} />
|
value={task.label} onChange={onNewTaskLabelChange} />
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<span class="select">
|
<span class="select">
|
||||||
<select onChange={onTaskCategoryChange} value={task.category}>
|
<select onChange={onNewTaskCategoryChange} value={task.category}>
|
||||||
{
|
{
|
||||||
Object.values(project.params.taskCategories).map(tc => {
|
Object.values(project.params.taskCategories).map(tc => {
|
||||||
return (
|
return (
|
||||||
|
|
Loading…
Reference in New Issue