feat(ui+backend): base of data persistence

This commit is contained in:
wpetit 2020-09-11 09:19:18 +02:00
parent c7cea6e46b
commit 7fc1a7f3af
37 changed files with 1298 additions and 195 deletions

View File

@ -28,7 +28,7 @@ down:
docker-compose down -v --remove-orphans docker-compose down -v --remove-orphans
db-shell: db-shell:
docker-compose exec postgres psql -Udaddy docker-compose exec postgres psql -Uguesstimate
migrate: build-server migrate: build-server
( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml -migrate $(MIGRATE) ) ( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml -migrate $(MIGRATE) )

View File

@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { WithLoader } from "../WithLoader"; import { WithLoader } from "../WithLoader";
export interface Item { export interface Item {
id: string id: string|number
[propName: string]: any; [propName: string]: any;
} }
@ -21,7 +21,7 @@ export interface ItemPanelProps {
isLoading?: boolean isLoading?: boolean
items: Item[] items: Item[]
tabs?: TabDefinition[], tabs?: TabDefinition[],
itemKey: (item: Item, index: number) => string itemKey: (item: Item, index: number) => string|number
itemLabel: (item: Item, index: number) => string itemLabel: (item: Item, index: number) => string
itemUrl: (item: Item, index: number) => string itemUrl: (item: Item, index: number) => string
} }

View File

@ -1,5 +1,6 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { ItemPanel } from "./ItemPanel"; import { ItemPanel } from "./ItemPanel";
import { useProjects } from "../../gql/queries/project";
export interface ProjectModelPanelProps { export interface ProjectModelPanelProps {
@ -13,7 +14,7 @@ export const ProjectModelPanel: FunctionComponent<ProjectModelPanelProps> = () =
newItemUrl="/models/new" newItemUrl="/models/new"
items={[]} items={[]}
itemKey={(item) => { return item.id }} itemKey={(item) => { return item.id }}
itemLabel={(item) => { return item.id }} itemLabel={(item) => { return `${item.id}` }}
itemUrl={(item) => { return `/models/${item.id}` }} itemUrl={(item) => { return `/models/${item.id}` }}
/> />
); );

View File

@ -1,19 +1,25 @@
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { ItemPanel } from "./ItemPanel"; import { ItemPanel } from "./ItemPanel";
import { useProjects } from "../../gql/queries/project";
export interface ProjectPanelProps { export interface ProjectPanelProps {
} }
export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => { export const ProjectPanel: FunctionComponent<ProjectPanelProps> = () => {
const { projects } = useProjects({
fetchPolicy: 'cache-and-network',
});
return ( return (
<ItemPanel <ItemPanel
title="Mes projets" title="Mes projets"
className="is-primary" className="is-primary"
newItemUrl="/projects/new" newItemUrl="/projects/new"
items={[]} items={projects}
itemIconClassName="fa fa-file"
itemKey={(item) => { return item.id }} itemKey={(item) => { return item.id }}
itemLabel={(item) => { return item.id }} itemLabel={(item) => { return `#${item.id} - ${item.title ? item.title : 'Projet sans nom'}` }}
itemUrl={(item) => { return `/projects/${item.id}` }} itemUrl={(item) => { return `/projects/${item.id}` }}
/> />
); );

View File

@ -2,7 +2,7 @@ import React, { FunctionComponent, Fragment } from "react";
import { Project } from "../../types/project"; import { Project } from "../../types/project";
import TaskTable from "./TasksTable"; import TaskTable from "./TasksTable";
import { TimePreview } from "./TimePreview"; import { TimePreview } from "./TimePreview";
import FinancialPreview from "./FinancielPreview"; import FinancialPreview from "./FinancialPreview";
import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer"; import { addTask, updateTaskEstimation, removeTask, updateTaskLabel, ProjectReducerActions } from "../../hooks/useProjectReducer";
import { Task, TaskID, EstimationConfidence } from "../../types/task"; import { Task, TaskID, EstimationConfidence } from "../../types/task";
import RepartitionPreview from "./RepartitionPreview"; import RepartitionPreview from "./RepartitionPreview";
@ -18,16 +18,16 @@ const EstimationTab: FunctionComponent<EstimationTabProps> = ({ project, dispatc
dispatch(addTask(task)); dispatch(addTask(task));
}; };
const onTaskRemove = (taskId: TaskID) => { const onTaskRemove = (id: number) => {
dispatch(removeTask(taskId)); dispatch(removeTask(id));
} }
const onTaskLabelUpdate = (taskId: TaskID, label: string) => { const onTaskLabelUpdate = (id: number, label: string) => {
dispatch(updateTaskLabel(taskId, label)); dispatch(updateTaskLabel(id, label));
} }
const onEstimationChange = (taskId: TaskID, confidence: EstimationConfidence, value: number) => { const onEstimationChange = (id: number, confidence: EstimationConfidence, value: number) => {
dispatch(updateTaskEstimation(taskId, confidence, value)); dispatch(updateTaskEstimation(id, confidence, value));
}; };
return ( return (

View File

@ -64,7 +64,7 @@ export const CostDetails:FunctionComponent<CostDetailsProps> = ({ project, cost,
<tbody> <tbody>
{ {
Object.keys(cost.details).map(taskCategoryId => { Object.keys(cost.details).map(taskCategoryId => {
const taskCategory = project.params.taskCategories[taskCategoryId]; const taskCategory = project.taskCategories[parseInt(taskCategoryId)];
const details = cost.details[taskCategoryId]; const details = cost.details[taskCategoryId];
return ( return (
<tr key={`task-category-cost-${taskCategory.id}`}> <tr key={`task-category-cost-${taskCategory.id}`}>

View File

@ -1,14 +1,15 @@
import React, { FunctionComponent, useEffect } from "react"; import React, { FunctionComponent, useEffect } from "react";
import style from "./style.module.css"; import style from "./style.module.css";
import { newProject, Project } from "../../types/project"; import { newProject, Project } from "../../types/project";
import { useProjectReducer, updateProjectLabel } from "../../hooks/useProjectReducer"; import { useProjectReducer, updateProjectTitle, resetProject } from "../../hooks/useProjectReducer";
import EditableText from "../EditableText/EditableText"; import EditableText from "../EditableText/EditableText";
import Tabs from "../../components/Tabs/Tabs"; import Tabs from "../../components/Tabs/Tabs";
import EstimationTab from "./EstimationTab"; import EstimationTab from "./EstimationTab";
import ParamsTab from "./ParamsTab"; import ParamsTab from "./ParamsTab";
import ExportTab from "./ExportTab"; import ExportTab from "./ExportTab";
import { useParams } from "react-router"; import { useParams, useHistory } from "react-router";
import { Page } from "../Page"; import { Page } from "../Page";
import { useProjects } from "../../gql/queries/project";
export interface ProjectProps { export interface ProjectProps {
projectId: string projectId: string
@ -16,10 +17,24 @@ export interface ProjectProps {
export const ProjectPage: FunctionComponent<ProjectProps> = () => { export const ProjectPage: FunctionComponent<ProjectProps> = () => {
const { id } = useParams(); const { id } = useParams();
const [ project, dispatch ] = useProjectReducer(newProject()); const isNew = id === 'new';
const projectId = isNew ? undefined : parseInt(id);
const { projects } = useProjects({ variables: { filter: {ids: projectId !== undefined ? [projectId] : undefined} }});
const [ project, dispatch ] = useProjectReducer(newProject(projectId));
const history = useHistory();
const onProjectLabelChange = (projectLabel: string) => { useEffect(() => {
dispatch(updateProjectLabel(projectLabel)); history.push(`/projects/${project.id}`);
}, [project.id])
useEffect(() => {
if (isNew || projects.length === 0) return;
dispatch(resetProject(projects[0]));
}, [projects.length]);
const onProjectTitleChange = (projectTitle: string) => {
if (project.title === projectTitle) return;
dispatch(updateProjectTitle(projectTitle));
}; };
return ( return (
@ -30,8 +45,8 @@ export const ProjectPage: FunctionComponent<ProjectProps> = () => {
<EditableText <EditableText
editIconClass="is-size-4" editIconClass="is-size-4"
render={(value) => (<h2 className="is-size-3">{value}</h2>)} render={(value) => (<h2 className="is-size-3">{value}</h2>)}
onChange={onProjectLabelChange} onChange={onProjectTitleChange}
value={project.label ? project.label : "Projet sans nom"} value={project.title ? project.title : "Projet sans nom"}
/> />
<div className={`box mt-3 ${style.tabContainer}`}> <div className={`box mt-3 ${style.tabContainer}`}>
<Tabs items={[ <Tabs items={[

View File

@ -21,7 +21,7 @@ const RepartitionPreview: FunctionComponent<RepartitionPreviewProps> = ({ projec
</thead> </thead>
<tbody> <tbody>
{ {
Object.values(project.params.taskCategories).map(tc => { Object.values(project.taskCategories).map(tc => {
let percent = (repartition[tc.id] * 100).toFixed(0); let percent = (repartition[tc.id] * 100).toFixed(0);
return ( return (
<tr key={`task-category-${tc.id}`}> <tr key={`task-category-${tc.id}`}>

View File

@ -55,7 +55,7 @@ const TaskCategoriesTable: FunctionComponent<TaskCategoriesTableProps> = ({ proj
</thead> </thead>
<tbody> <tbody>
{ {
Object.values(project.params.taskCategories).map(tc => { Object.values(project.taskCategories).map(tc => {
return ( return (
<tr key={`task-category-${tc.id}`}> <tr key={`task-category-${tc.id}`}>
<td> <td>

View File

@ -9,23 +9,26 @@ import ProjectTimeUnit from "../ProjectTimeUnit";
export interface TaskTableProps { export interface TaskTableProps {
project: Project project: Project
onTaskAdd: (task: Task) => void onTaskAdd: (task: Task) => void
onTaskRemove: (taskId: TaskID) => void onTaskRemove: (taskId: number) => void
onEstimationChange: (taskId: TaskID, confidence: EstimationConfidence, value: number) => void onEstimationChange: (taskId: number, confidence: EstimationConfidence, value: number) => void
onTaskLabelUpdate: (taskId: TaskID, label: string) => void onTaskLabelUpdate: (taskId: number, label: string) => void
} }
export type EstimationTotals = { [confidence in EstimationConfidence]: number } export type EstimationTotals = { [confidence in EstimationConfidence]: number }
const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => { const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEstimationChange, onTaskRemove, onTaskLabelUpdate }) => {
const [ task, setTask ] = useState(newTask("", null));
const defaultTaskCategory = Object.keys(project.params.taskCategories)[0];
const [ task, setTask ] = useState(newTask("", defaultTaskCategory));
const [ totals, setTotals ] = useState({ const [ totals, setTotals ] = useState({
[EstimationConfidence.Optimistic]: 0, [EstimationConfidence.Optimistic]: 0,
[EstimationConfidence.Likely]: 0, [EstimationConfidence.Likely]: 0,
[EstimationConfidence.Pessimistic]: 0, [EstimationConfidence.Pessimistic]: 0,
} as EstimationTotals); } as EstimationTotals);
useEffect(() => {
if (project.taskCategories.length === 0) return;
setTask({...task, category: project.taskCategories[0]});
}, [project.taskCategories]);
const isPrint = usePrintMediaQuery(); const isPrint = usePrintMediaQuery();
useEffect(() => { useEffect(() => {
@ -33,7 +36,7 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
let likely = 0; let likely = 0;
let pessimistic = 0; let pessimistic = 0;
Object.values(project.tasks).forEach(t => { project.tasks.forEach(t => {
optimistic += t.estimations.optimistic; optimistic += t.estimations.optimistic;
likely += t.estimations.likely; likely += t.estimations.likely;
pessimistic += t.estimations.pessimistic; pessimistic += t.estimations.pessimistic;
@ -49,26 +52,28 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
const onNewTaskCategoryChange = (evt: ChangeEvent) => { const onNewTaskCategoryChange = (evt: ChangeEvent) => {
const value = (evt.currentTarget as HTMLInputElement).value; const value = (evt.currentTarget as HTMLInputElement).value;
setTask({...task, category: value}); const taskCategoryId = parseInt(value);
const category = project.taskCategories.find(tc => tc.id === taskCategoryId);
setTask({...task, category });
}; };
const onTaskLabelChange = (taskId: TaskID, value: string) => { const onTaskLabelChange = (taskId: number, value: string) => {
onTaskLabelUpdate(taskId, value); onTaskLabelUpdate(taskId, value);
}; };
const onAddTaskClick = (evt: MouseEvent) => { const onAddTaskClick = (evt: MouseEvent) => {
onTaskAdd(task); onTaskAdd(task);
setTask(newTask("", defaultTaskCategory)); setTask(newTask("", project.taskCategories[0]));
}; };
const onTaskRemoveClick = (taskId: TaskID, evt: MouseEvent) => { const onTaskRemoveClick = (taskId: number, evt: MouseEvent) => {
onTaskRemove(taskId); onTaskRemove(taskId);
}; };
const withEstimationChange = (confidence: EstimationConfidence, taskID: TaskID, evt: ChangeEvent) => { const withEstimationChange = (confidence: EstimationConfidence, taskId: number, evt: ChangeEvent) => {
const textValue = (evt.currentTarget as HTMLInputElement).value; const textValue = (evt.currentTarget as HTMLInputElement).value;
const value = parseFloat(textValue); const value = parseFloat(textValue);
onEstimationChange(taskID, confidence, value); onEstimationChange(taskId, confidence, value);
}; };
const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic); const onOptimisticChange = withEstimationChange.bind(null, EstimationConfidence.Optimistic);
@ -93,11 +98,11 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
</thead> </thead>
<tbody> <tbody>
{ {
Object.values(project.tasks).map(t => { project.tasks.map((t,i) => {
const category = project.params.taskCategories[t.category]; const category = project.taskCategories.find(tc => tc.id === t.category.id);
const categoryLabel = category ? category.label : '???'; const categoryLabel = category ? category.label : '???';
return ( return (
<tr key={`taks-${t.id}`}> <tr key={`tasks-${t.id}-${i}`}>
<td className={`is-narrow noPrint`}> <td className={`is-narrow noPrint`}>
<button <button
onClick={onTaskRemoveClick.bind(null, t.id)} onClick={onTaskRemoveClick.bind(null, t.id)}
@ -163,9 +168,9 @@ const TaskTable: FunctionComponent<TaskTableProps> = ({ project, onTaskAdd, onEs
</p> </p>
<p className="control"> <p className="control">
<span className="select"> <span className="select">
<select onChange={onNewTaskCategoryChange} value={task.category}> <select onChange={onNewTaskCategoryChange} value={task.category ? task.category.id : -1}>
{ {
Object.values(project.params.taskCategories).map(tc => { Object.values(project.taskCategories).map(tc => {
return ( return (
<option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option> <option key={`task-category-${tc.id}`} value={tc.id}>{tc.label}</option>
); );

View File

@ -0,0 +1,34 @@
import { gql } from '@apollo/client';
export const FRAGMENT_FULL_PROJECT = gql`
fragment FullProject on Project {
id
title
taskCategories {
id
label
costPerTimeUnit
}
tasks {
id
label
category {
id
label
}
estimations {
optimistic
likely
pessimistic
}
}
params {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
}
}
`

View File

@ -0,0 +1,69 @@
import { gql, useMutation, PureQueryOptions } from '@apollo/client';
import { FRAGMENT_FULL_PROJECT } from '../fragments/project';
import { QUERY_PROJECTS } from '../queries/project';
export const MUTATION_CREATE_PROJECT = gql`
mutation createProject($changes: CreateProjectChanges!) {
createProject(changes: $changes) {
...FullProject
}
}
${FRAGMENT_FULL_PROJECT}
`;
export function useProjectCreateMutation() {
return useMutation(MUTATION_CREATE_PROJECT);
}
export const MUTATION_UPDATE_PROJECT_TITLE = gql`
mutation updateProjectTitle($projectId: ID!, $title: String!) {
updateProjectTitle(projectId: $projectId, title: $title) {
...FullProject
}
}
${FRAGMENT_FULL_PROJECT}
`;
export function useUpdateProjectTitleMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_TITLE, {
refetchQueries: ({ variables }: PureQueryOptions) => {
return [
{ query: QUERY_PROJECTS, variables: { filters: { ids: [ variables.projectId ] } }},
];
}
});
}
export const MUTATION_ADD_PROJECT_TASK = gql`
mutation addProjectTask($projectId: ID!, $changes: ProjectTaskChanges!) {
addProjectTask(projectId: $projectId, changes: $changes) {
id
}
}
`;
export function useAddProjectTaskMutation() {
return useMutation(MUTATION_ADD_PROJECT_TASK);
}
export const MUTATION_UPDATE_PROJECT_TASK = gql`
mutation updateProjectTask($projectId: ID!, $taskId: ID!, $changes: ProjectTaskChanges!) {
updateProjectTask(projectId: $projectId, taskId: $taskId, changes: $changes) {
id
}
}
`;
export function useUpdateProjectTaskMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_TASK);
}
export const MUTATION_REMOVE_PROJECT_TASK = gql`
mutation removeProjectTask($projectId: ID!, $taskId: ID!) {
removeProjectTask(projectId: $projectId, taskId: $taskId)
}
`;
export function useRemoveProjectTaskMutation() {
return useMutation(MUTATION_REMOVE_PROJECT_TASK);
}

View File

@ -1,7 +1,7 @@
import { useQuery, DocumentNode } from "@apollo/client"; import { useQuery, DocumentNode, QueryHookOptions } from "@apollo/client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export function useGraphQLData<T>(q: DocumentNode, key: string, defaultValue: T, options = {}) { export function useGraphQLData<T, A = any, R = Record<string, any>>(q: DocumentNode, key: string, defaultValue: T, options: QueryHookOptions<A, R> = {}) {
const query = useQuery(q, options); const query = useQuery(q, options);
const [ data, setData ] = useState<T>(defaultValue); const [ data, setData ] = useState<T>(defaultValue);
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,49 @@
import { gql, useQuery, QueryHookOptions } from '@apollo/client';
import { User } from '../../types/user';
import { useGraphQLData } from './helper';
import { Project } from '../../types/project';
export const QUERY_PROJECTS = gql`
query projects($filter: ProjectsFilter) {
projects(filter: $filter) {
id
title
taskCategories {
id
label
costPerTimeUnit
}
tasks {
id
label
category {
id
label
}
estimations {
optimistic
likely
pessimistic
}
}
params {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
}
}
}`;
export function useProjectsQuery() {
return useQuery(QUERY_PROJECTS);
}
export function useProjects<A = any, R = Record<string, any>>(options: QueryHookOptions<A, R> = {}) {
const { data, loading, error } = useGraphQLData<Project[]>(
QUERY_PROJECTS, 'projects', [], options
);
return { projects: data, loading, error };
}

View File

@ -0,0 +1,95 @@
import { all, select, takeLatest, put, delay } from "redux-saga/effects";
import { client } from '../gql/client';
import { MUTATION_CREATE_PROJECT, MUTATION_UPDATE_PROJECT_TITLE, MUTATION_ADD_PROJECT_TASK, MUTATION_REMOVE_PROJECT_TASK } from "../gql/mutations/project";
import { UPDATE_PROJECT_TITLE, resetProject, ADD_TASK, taskSaved, AddTask, taskAdded, taskRemoved, RemoveTask, REMOVE_TASK } from "./useProjectReducer";
import { Project } from "../types/project";
export function* rootSaga() {
yield all([
createProjectSaga(),
takeLatest(UPDATE_PROJECT_TITLE, updateProjectTitleSaga),
takeLatest(ADD_TASK, addTaskSaga),
takeLatest(REMOVE_TASK, removeTaskSaga),
]);
}
export function* updateProjectTitleSaga() {
yield delay(500);
let project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
yield client.mutate({
mutation: MUTATION_UPDATE_PROJECT_TITLE,
variables: {
projectId: project.id,
title: project.title,
}
});
}
export function* createProjectSaga() {
const project: Project = yield select();
if (project.id !== undefined) return;
const { data } = yield client.mutate({
mutation: MUTATION_CREATE_PROJECT,
variables: {
changes: {
title: project.title,
}
}
});
yield put(resetProject(data.createProject));
return yield select();
}
export function* addTaskSaga({ task }: AddTask) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
const { data } = yield client.mutate({
mutation: MUTATION_ADD_PROJECT_TASK,
variables: {
projectId: project.id,
changes: {
label: task.label,
categoryId: task.category ? task.category.id : -1,
estimations: {
optimistic: task.estimations.optimistic,
likely: task.estimations.likely,
pessimistic: task.estimations.pessimistic,
}
}
}
});
yield put(taskAdded({ ...task, ...data.addProjectTask }));
}
export function* removeTaskSaga({ id }: RemoveTask) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
yield client.mutate({
mutation: MUTATION_REMOVE_PROJECT_TASK,
variables: {
projectId: project.id,
taskId: id,
}
});
yield put(taskRemoved(id));
}

View File

@ -1,6 +1,8 @@
import { Project } from "../types/project"; import { Project } from "../types/project";
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../types/task"; import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../types/task";
import { useReducer } from "react"; import { useReducerAndSaga } from "./useReducerAndSaga";
import { rootSaga } from "./useProjectReducer.sagas";
import { uuid } from "../util/uuid";
export interface Action { export interface Action {
type: string type: string
@ -8,34 +10,41 @@ export interface Action {
export type ProjectReducerActions = export type ProjectReducerActions =
AddTask | AddTask |
TaskSaved |
RemoveTask | RemoveTask |
TaskRemoved |
UpdateTaskEstimation | UpdateTaskEstimation |
UpdateProjectLabel | UpdateProjectTitle |
UpdateTaskLabel | UpdateTaskLabel |
UpdateParam | UpdateParam |
UpdateTaskCategoryLabel | UpdateTaskCategoryLabel |
UpdateTaskCategoryCost | UpdateTaskCategoryCost |
AddTaskCategory | AddTaskCategory |
RemoveTaskCategory RemoveTaskCategory |
ResetProject
export function useProjectReducer(project: Project) { export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project); return useReducerAndSaga<Project>(projectReducer, project, rootSaga);
} }
export function projectReducer(project: Project, action: ProjectReducerActions): Project { export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action); console.log(action);
switch(action.type) {
case ADD_TASK:
return handleAddTask(project, action as AddTask);
case REMOVE_TASK: switch(action.type) {
return handleRemoveTask(project, action as RemoveTask); case TASK_ADDED:
return handleTaskAdded(project, action as TaskAdded);
case TASK_SAVED:
return handleTaskSaved(project, action as TaskSaved);
case TASK_REMOVED:
return handleTaskRemoved(project, action as RemoveTask);
case UPDATE_TASK_ESTIMATION: case UPDATE_TASK_ESTIMATION:
return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation); return handleUpdateTaskEstimation(project, action as UpdateTaskEstimation);
case UPDATE_PROJECT_LABEL: case UPDATE_PROJECT_TITLE:
return handleUpdateProjectLabel(project, action as UpdateProjectLabel); return handleUpdateProjectTitle(project, action as UpdateProjectTitle);
case UPDATE_TASK_LABEL: case UPDATE_TASK_LABEL:
return handleUpdateTaskLabel(project, action as UpdateTaskLabel); return handleUpdateTaskLabel(project, action as UpdateTaskLabel);
@ -55,6 +64,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
case UPDATE_TASK_CATEGORY_COST: case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost); return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
case RESET_PROJECT:
return handleResetProject(project, action as ResetProject);
} }
return project; return project;
@ -70,30 +82,74 @@ export function addTask(task: Task): AddTask {
return { type: ADD_TASK, task }; return { type: ADD_TASK, task };
} }
export function handleAddTask(project: Project, action: AddTask): Project { export interface TaskAdded extends Action {
task: Task
}
export const TASK_ADDED = "TASK_ADDED";
export function taskAdded(task: Task): TaskAdded {
return { type: TASK_ADDED, task };
}
export function handleTaskAdded(project: Project, action: TaskAdded): Project {
const task = { ...action.task }; const task = { ...action.task };
return { return {
...project, ...project,
tasks: { tasks: [
...project.tasks, ...project.tasks,
[task.id]: task, task,
} ]
};
};
export const TASK_SAVED = "TASK_SAVED";
export interface TaskSaved extends Action {
task: Task
}
export function taskSaved(task: Task): TaskSaved {
return { type: TASK_SAVED, task };
}
export function handleTaskSaved(project: Project, action: TaskSaved): Project {
const taskIndex = project.tasks.findIndex(t => t.id === action.task.id);
if (taskIndex === -1) return project;
const tasks = [ ...project.tasks ];
tasks[taskIndex] = { ...tasks[taskIndex], ...action.task };
return {
...project,
tasks
}; };
} }
export interface RemoveTask extends Action { export interface RemoveTask extends Action {
id: TaskID id: number
} }
export const REMOVE_TASK = "REMOVE_TASK"; export const REMOVE_TASK = "REMOVE_TASK";
export function removeTask(id: TaskID): RemoveTask { export function removeTask(id: number): RemoveTask {
return { type: REMOVE_TASK, id }; return { type: REMOVE_TASK, id };
} }
export function handleRemoveTask(project: Project, action: RemoveTask): Project { export interface TaskRemoved extends Action {
const tasks = { ...project.tasks }; id: number
delete tasks[action.id]; }
export const TASK_REMOVED = "TASK_REMOVED";
export function taskRemoved(id: number): TaskRemoved {
return { type: TASK_REMOVED, id };
}
export function handleTaskRemoved(project: Project, action: TaskRemoved): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
tasks.splice(taskIndex, 1);
return { return {
...project, ...project,
tasks tasks
@ -101,20 +157,24 @@ export function handleRemoveTask(project: Project, action: RemoveTask): Project
} }
export interface UpdateTaskEstimation extends Action { export interface UpdateTaskEstimation extends Action {
id: TaskID id: number
confidence: string confidence: string
value: number value: number
} }
export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION"; export const UPDATE_TASK_ESTIMATION = "UPDATE_TASK_ESTIMATION";
export function updateTaskEstimation(id: TaskID, confidence: EstimationConfidence, value: number): UpdateTaskEstimation { export function updateTaskEstimation(id: number, 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 { export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
const estimations = { const estimations = {
...project.tasks[action.id].estimations, ...project.tasks[taskIndex].estimations,
[action.confidence]: action.value [action.confidence]: action.value
}; };
@ -126,57 +186,53 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE
estimations.pessimistic = estimations.likely; estimations.pessimistic = estimations.likely;
} }
project.tasks[taskIndex] = { ...project.tasks[taskIndex], estimations };
return { return {
...project, ...project,
tasks: { tasks
...project.tasks,
[action.id]: {
...project.tasks[action.id],
estimations: estimations,
}
}
}; };
} }
export interface UpdateProjectLabel extends Action { export interface UpdateProjectTitle extends Action {
label: string title: string
} }
export const UPDATE_PROJECT_LABEL = "UPDATE_PROJECT_LABEL"; export const UPDATE_PROJECT_TITLE = "UPDATE_PROJECT_TITLE";
export function updateProjectLabel(label: string): UpdateProjectLabel { export function updateProjectTitle(title: string): UpdateProjectTitle {
return { type: UPDATE_PROJECT_LABEL, label }; return { type: UPDATE_PROJECT_TITLE, title };
} }
export function handleUpdateProjectLabel(project: Project, action: UpdateProjectLabel): Project { export function handleUpdateProjectTitle(project: Project, action: UpdateProjectTitle): Project {
return { return {
...project, ...project,
label: action.label title: action.title
}; };
} }
export interface UpdateTaskLabel extends Action { export interface UpdateTaskLabel extends Action {
id: TaskID id: number
label: string label: string
} }
export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL"; export const UPDATE_TASK_LABEL = "UPDATE_TASK_LABEL";
export function updateTaskLabel(id: TaskID, label: string): UpdateTaskLabel { export function updateTaskLabel(id: number, label: string): UpdateTaskLabel {
return { type: UPDATE_TASK_LABEL, id, label }; return { type: UPDATE_TASK_LABEL, id, label };
} }
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project { export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
tasks[taskIndex] = { ...tasks[taskIndex], label: action.label };
return { return {
...project, ...project,
tasks: { tasks
...project.tasks,
[action.id]: {
...project.tasks[action.id],
label: action.label,
}
}
}; };
} }
@ -215,16 +271,13 @@ export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: strin
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project { export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return { return {
...project, ...project,
params: {
...project.params,
taskCategories: { taskCategories: {
...project.params.taskCategories, ...project.taskCategories,
[action.categoryId]: { [action.categoryId]: {
...project.params.taskCategories[action.categoryId], ...project.taskCategories[action.categoryId],
label: action.label label: action.label
}, },
} }
}
}; };
} }
@ -242,16 +295,13 @@ export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUn
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project { export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return { return {
...project, ...project,
params: {
...project.params,
taskCategories: { taskCategories: {
...project.params.taskCategories, ...project.taskCategories,
[action.categoryId]: { [action.categoryId]: {
...project.params.taskCategories[action.categoryId], ...project.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit costPerTimeUnit: action.costPerTimeUnit
}, },
} }
}
}; };
} }
@ -269,13 +319,10 @@ export function handleAddTaskCategory(project: Project, action: AddTaskCategory)
const taskCategory = { ...action.taskCategory }; const taskCategory = { ...action.taskCategory };
return { return {
...project, ...project,
params: {
...project.params,
taskCategories: { taskCategories: {
...project.params.taskCategories, ...project.taskCategories,
[taskCategory.id]: taskCategory, [taskCategory.id]: taskCategory,
} }
}
}; };
} }
@ -290,13 +337,40 @@ export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCa
} }
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project { export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.params.taskCategories }; const taskCategories = { ...project.taskCategories };
delete taskCategories[action.taskCategoryId]; delete taskCategories[action.taskCategoryId];
return { return {
...project, ...project,
params: { taskCategories: {
...project.params, ...project.taskCategories,
taskCategories ...taskCategories
} }
}; };
} }
export interface ResetProject extends Action {
project: Project
}
export const RESET_PROJECT = "RESET_PROJECT";
export function resetProject(project: Project): ResetProject {
const newProject = JSON.parse(JSON.stringify(project));
newProject.tasks.forEach(t => {
if (!t.localId) t.localId = uuid();
});
newProject.taskCategories.forEach(tc => {
if (!tc.localId) tc.localId = uuid();
});
return { type: RESET_PROJECT, project: newProject };
}
export function handleResetProject(project: Project, action: ResetProject): Project {
return {
...project,
...action.project,
};
}

View File

@ -0,0 +1,24 @@
import { useReducer, useRef, useMemo, useCallback, useEffect } from "react"
import { runSaga, stdChannel } from 'redux-saga'
export function useReducerAndSaga<T>(reducer: React.Reducer<any, any>, initialState, rootSaga): [T, (a: any) => void] {
const [state, reactDispatch] = useReducer(reducer, initialState)
const env = useRef(state)
env.current = state
const channel = useMemo(() => stdChannel(), [])
const dispatch = useCallback((a) => {
setImmediate(channel.put, a)
reactDispatch(a)
}, [])
const getState = useCallback(() => env.current, [])
useEffect(() => {
const task = runSaga({ channel, dispatch, getState }, rootSaga)
return () => task.cancel()
}, [])
return [state, dispatch]
}

View File

@ -11,7 +11,6 @@ export interface TimeUnit {
} }
export interface Params { export interface Params {
taskCategories: TaskCategoriesIndex
timeUnit: TimeUnit timeUnit: TimeUnit
currency: string currency: string
roundUpEstimations: boolean roundUpEstimations: boolean
@ -19,23 +18,7 @@ export interface Params {
} }
export const defaults = { export const defaults = {
taskCategories: { params: {
"RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4": {
id: "RQ15CD3iX1Ey2f9kat7tfLGZmUx9GGc15nS6A7fYtZv76SnS4",
label: "Développement",
costPerTimeUnit: 500,
},
"QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9": {
id: "QRdGS5Pr5si9SSjU84WAq19cjxQ3rUL71jKh8oHSMZSY4bBH9",
label: "Conduite de projet",
costPerTimeUnit: 500,
},
"RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo": {
id: "RPcqFMLdQrgBSomv7Sao7EQSb7on6rtjfDQK5JZNhNSg9DwEo",
label: "Recette",
costPerTimeUnit: 500,
},
},
timeUnit: { timeUnit: {
label: "jour/homme", label: "jour/homme",
acronym: "j/h", acronym: "j/h",
@ -44,28 +27,29 @@ export const defaults = {
currency: "€ H.T.", currency: "€ H.T.",
costPerTimeUnit: 500, costPerTimeUnit: 500,
hideFinancialPreviewOnPrint: false, hideFinancialPreviewOnPrint: false,
}
} }
export function getTimeUnit(project: Project): TimeUnit { export function getTimeUnit(project: Project): TimeUnit {
return project.params.timeUnit ? project.params.timeUnit : defaults.timeUnit; return project.params.timeUnit ? project.params.timeUnit : defaults.params.timeUnit;
} }
export function getRoundUpEstimations(project: Project): boolean { export function getRoundUpEstimations(project: Project): boolean {
return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.roundUpEstimations; return project.params.hasOwnProperty("roundUpEstimations") ? project.params.roundUpEstimations : defaults.params.roundUpEstimations;
} }
export function getCurrency(project: Project): string { export function getCurrency(project: Project): string {
return project.params.currency ? project.params.currency : defaults.currency; return project.params.currency ? project.params.currency : defaults.params.currency;
} }
export function getTaskCategories(project: Project): TaskCategoriesIndex { export function getTaskCategories(project: Project): TaskCategory[] {
return project.params.taskCategories ? project.params.taskCategories : defaults.taskCategories; return project.taskCategories ? project.taskCategories : [];
} }
export function getTaskCategoryCost(taskCategory: TaskCategory): number { export function getTaskCategoryCost(taskCategory: TaskCategory): number {
return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.costPerTimeUnit; return taskCategory.hasOwnProperty("costPerTimeUnit") ? taskCategory.costPerTimeUnit : defaults.params.costPerTimeUnit;
} }
export function getHideFinancialPreviewOnPrint(project: Project): boolean { export function getHideFinancialPreviewOnPrint(project: Project): boolean {
return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.hideFinancialPreviewOnPrint; return project.params.hasOwnProperty("hideFinancialPreviewOnPrint") ? project.params.hideFinancialPreviewOnPrint : defaults.params.hideFinancialPreviewOnPrint;
} }

View File

@ -1,29 +1,27 @@
import { Task, TaskCategory, TaskID, getTaskWeightedMean, TaskCategoryID, getTaskStandardDeviation } from './task'; import { Task, TaskCategory, TaskID, getTaskWeightedMean, TaskCategoryID, getTaskStandardDeviation } from './task';
import { Params, defaults, getTaskCategoryCost } from "./params"; import { Params, defaults, getTaskCategoryCost, TaskCategoriesIndex } from "./params";
import { Estimation } from '../hooks/useProjectEstimations'; import { Estimation } from '../hooks/useProjectEstimations';
export type ProjectID = string; export type ProjectID = number;
export interface Project { export interface Project {
id: ProjectID id: ProjectID
label: string title: string
description: string description: string
tasks: Tasks taskCategories: TaskCategory[]
tasks: Task[]
params: Params params: Params
} }
export interface Tasks { export function newProject(id: number|undefined = undefined): Project {
[id: string]: Task
}
export function newProject(): Project {
return { return {
id: "", id: id,
label: "", title: "",
description: "", description: "",
tasks: {}, tasks: [],
taskCategories: [],
params: { params: {
...defaults ...defaults.params
}, },
}; };
} }
@ -36,7 +34,7 @@ export function getProjectWeightedMean(p : Project): number {
} }
export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, p : Project): number { export function getTaskCategoryWeightedMean(taskCategoryId: TaskCategoryID, p : Project): number {
return Object.values(p.tasks).filter(t => t.category === taskCategoryId).reduce((sum: number, t: Task) => { return Object.values(p.tasks).filter(t => t.category && t.category.id === taskCategoryId).reduce((sum: number, t: Task) => {
sum += getTaskWeightedMean(t); sum += getTaskWeightedMean(t);
return sum; return sum;
}, 0); }, 0);
@ -58,7 +56,7 @@ export function getTaskCategoriesMeanRepartition(project: Project): MeanRepartit
const repartition: MeanRepartition = {}; const repartition: MeanRepartition = {};
Object.values(project.params.taskCategories).forEach(tc => { Object.values(project.taskCategories).forEach(tc => {
repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean; repartition[tc.id] = getTaskCategoryWeightedMean(tc.id, project) / projectMean;
if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0; if (Number.isNaN(repartition[tc.id])) repartition[tc.id] = 0;
}); });
@ -74,34 +72,54 @@ export interface MinMaxCost {
export interface Cost { export interface Cost {
totalCost: number totalCost: number
totalTime: number totalTime: number
details: { [taskCategoryId: string]: { time: number, cost: number } } details: CostDetails[]
}
export interface CostDetails {
time: number
cost: number
taskCategoryId: TaskCategoryID
} }
export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost { export function getMinMaxCosts(project: Project, estimation: Estimation): MinMaxCost {
const max: Cost = {totalCost: 0, totalTime: 0, details: {}}; const max: Cost = {totalCost: 0, totalTime: 0, details: []};
const min: Cost = {totalCost: 0, totalTime: 0, details: {}}; const min: Cost = {totalCost: 0, totalTime: 0, details: []};
const repartition = getTaskCategoriesMeanRepartition(project); const repartition = getTaskCategoriesMeanRepartition(project);
Object.values(project.params.taskCategories).forEach(tc => { Object.values(project.taskCategories).forEach(tc => {
const cost = getTaskCategoryCost(tc); const cost = getTaskCategoryCost(tc);
const maxTime = Math.round((estimation.e + estimation.sd) * repartition[tc.id]); const maxTime = Math.round((estimation.e + estimation.sd) * repartition[tc.id]);
max.details[tc.id] = { let maxDetails = getDetailsForTaskCategory(max.details, tc.id);
time: maxTime, if (!maxDetails) {
cost: Math.ceil(maxTime) * cost, maxDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
}; max.details.push(maxDetails);
max.totalTime += max.details[tc.id].time; }
max.totalCost += max.details[tc.id].cost;
maxDetails.time = maxTime;
maxDetails.cost = Math.ceil(maxTime) * cost;
max.totalTime += maxDetails.time;
max.totalCost += maxDetails.cost;
const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]); const minTime = Math.round((estimation.e - estimation.sd) * repartition[tc.id]);
min.details[tc.id] = { let minDetails = getDetailsForTaskCategory(min.details, tc.id);
time: minTime, if (!minDetails) {
cost: Math.ceil(minTime) * cost, minDetails = { taskCategoryId: tc.id, time: 0, cost: 0 };
}; min.details.push(minDetails);
min.totalTime += min.details[tc.id].time; }
min.totalCost += min.details[tc.id].cost; minDetails.time = minTime;
minDetails.cost = Math.ceil(minTime) * cost;
min.totalTime += minDetails.time;
min.totalCost += minDetails.cost;
}); });
return { max, min }; return { max, min };
} }
function getDetailsForTaskCategory(details: CostDetails[], id: TaskCategoryID): CostDetails|void {
for (let d, i = 0; (d = details[i]); ++i) {
if (d.taskCategoryId === id) return d;
}
}

View File

@ -1,6 +1,7 @@
import { defaults } from "./params"; import { defaults } from "./params";
import { uuid } from "../util/uuid";
export type TaskID = string export type TaskID = number
export enum EstimationConfidence { export enum EstimationConfidence {
Optimistic = "optimistic", Optimistic = "optimistic",
@ -11,21 +12,21 @@ export enum EstimationConfidence {
export interface Task { export interface Task {
id: TaskID id: TaskID
label: string label: string
category: TaskCategoryID category: TaskCategory
estimations: { [confidence in EstimationConfidence]: number } estimations: { [confidence in EstimationConfidence]: number }
} }
export type TaskCategoryID = string export type TaskCategoryID = number
export interface TaskCategory { export interface TaskCategory {
id: TaskCategoryID id: number
label: string label: string
costPerTimeUnit: number costPerTimeUnit: number
} }
export function newTask(label: string, category: TaskCategoryID): Task { export function newTask(label: string, category: TaskCategory): Task {
return { return {
id: '', id: null,
label, label,
category, category,
estimations: { estimations: {
@ -38,8 +39,8 @@ export function newTask(label: string, category: TaskCategoryID): Task {
export function createTaskCategory(): TaskCategory { export function createTaskCategory(): TaskCategory {
return { return {
id: '', id: null,
costPerTimeUnit: defaults.costPerTimeUnit, costPerTimeUnit: defaults.params.costPerTimeUnit,
label: "" label: ""
}; };
} }

35
client/src/util/uuid.ts Normal file
View File

@ -0,0 +1,35 @@
const hex: string[] = [];
for (var i = 0; i < 256; i++) {
hex[i] = (i < 16 ? '0' : '') + (i).toString(16);
}
export function uuid(): string {
const r = crypto.getRandomValues(new Uint8Array(16));
r[6] = r[6] & 0x0f | 0x40;
r[8] = r[8] & 0x3f | 0x80;
return (
hex[r[0]] +
hex[r[1]] +
hex[r[2]] +
hex[r[3]] +
"-" +
hex[r[4]] +
hex[r[5]] +
"-" +
hex[r[6]] +
hex[r[7]] +
"-" +
hex[r[8]] +
hex[r[9]] +
"-" +
hex[r[10]] +
hex[r[11]] +
hex[r[12]] +
hex[r[13]] +
hex[r[14]] +
hex[r[15]]
);
}

View File

@ -79,6 +79,10 @@ func applyMigration(ctx context.Context, ctn *service.Container) error {
// nolint: gochecknoglobals // nolint: gochecknoglobals
var initialModels = []interface{}{ var initialModels = []interface{}{
&model.User{}, &model.User{},
&model.Project{},
&model.Access{},
&model.TaskCategory{},
&model.Task{},
} }
func m000initialSchema() orm.Migration { func m000initialSchema() orm.Migration {
@ -91,15 +95,50 @@ func m000initialSchema() orm.Migration {
} }
} }
// Create foreign keys indexes
err := tx.Model(&model.Access{}).
AddForeignKey("user_id", "users(id)", "CASCADE", "CASCADE").Error
if err != nil {
return errors.WithStack(err)
}
err = tx.Model(&model.TaskCategory{}).
AddForeignKey("project_id", "projects(id)", "CASCADE", "CASCADE").Error
if err != nil {
return errors.WithStack(err)
}
err = tx.Model(&model.Access{}).
AddForeignKey("project_id", "projects(id)", "CASCADE", "CASCADE").Error
if err != nil {
return errors.WithStack(err)
}
err = tx.Model(&model.Access{}).
AddForeignKey("user_id", "users(id)", "CASCADE", "CASCADE").Error
if err != nil {
return errors.WithStack(err)
}
err = tx.Model(&model.Task{}).
AddForeignKey("category_id", "task_categories(id)", "CASCADE", "CASCADE").Error
if err != nil {
return errors.WithStack(err)
}
return nil return nil
}, },
func(ctx context.Context, tx *gorm.DB) error { func(ctx context.Context, tx *gorm.DB) error {
for _, m := range initialModels { for i := len(initialModels) - 1; i >= 0; i-- {
if err := tx.DropTableIfExists(m).Error; err != nil { if err := tx.DropTableIfExists(initialModels[i]).Error; err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
} }
if err := tx.DropTableIfExists("sessions").Error; err != nil {
return errors.WithStack(err)
}
return nil return nil
}, },
) )

View File

@ -90,7 +90,7 @@ func NewDefault() *Config {
Address: ":8081", Address: ":8081",
CookieAuthenticationKey: "", CookieAuthenticationKey: "",
CookieEncryptionKey: "", CookieEncryptionKey: "",
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hour
TemplateDir: "template", TemplateDir: "template",
PublicDir: "public", PublicDir: "public",
FrontendURL: "http://localhost:8080", FrontendURL: "http://localhost:8080",

View File

@ -45,10 +45,7 @@ autobind:
models: models:
ID: ID:
model: model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64 - github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
Int: Int:
model: model:
- github.com/99designs/gqlgen/graphql.Int - github.com/99designs/gqlgen/graphql.Int

View File

@ -0,0 +1,49 @@
package graph
import (
"context"
"strconv"
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
"github.com/pkg/errors"
)
func handleEstimations(ctx context.Context, task *model.Task) (*model.Estimations, error) {
estimations := &model.Estimations{}
if task.Estimations == nil {
return estimations, nil
}
rawOptimistic, exists := task.Estimations[model.EstimationOptimistic]
if exists && rawOptimistic != nil {
optimistic, err := strconv.ParseFloat(*rawOptimistic, 64)
if err != nil {
return nil, errors.WithStack(err)
}
estimations.Optimistic = optimistic
}
rawLikely, exists := task.Estimations[model.EstimationLikely]
if exists && rawLikely != nil {
likely, err := strconv.ParseFloat(*rawLikely, 64)
if err != nil {
return nil, errors.WithStack(err)
}
estimations.Likely = likely
}
rawPessimistic, exists := task.Estimations[model.EstimationPessimistic]
if exists && rawPessimistic != nil {
pessimistic, err := strconv.ParseFloat(*rawPessimistic, 64)
if err != nil {
return nil, errors.WithStack(err)
}
estimations.Pessimistic = pessimistic
}
return estimations, nil
}

View File

@ -2,6 +2,26 @@ input UserChanges {
name: String name: String
} }
input CreateProjectChanges {
title: String!
}
input ProjectTaskChanges {
label: String
categoryId: ID
estimations: ProjectTaskEstimationsChanges
}
input ProjectTaskEstimationsChanges {
optimistic: Float
likely: Float
pessimistic: Float
}
type Mutation { type Mutation {
updateUser(id: ID!, changes: UserChanges!): User! updateUser(id: ID!, changes: UserChanges!): User!
createProject(changes: CreateProjectChanges!): Project!
updateProjectTitle(projectId: ID!, title: String!): Project!
addProjectTask(projectId: ID!, changes: ProjectTaskChanges): Task!
removeProjectTask(projectId: ID!, taskId: ID!): Boolean!
} }

View File

@ -10,10 +10,26 @@ import (
"forge.cadoles.com/Cadoles/guesstimate/internal/model" "forge.cadoles.com/Cadoles/guesstimate/internal/model"
) )
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) { func (r *mutationResolver) UpdateUser(ctx context.Context, id int64, changes model.UserChanges) (*model.User, error) {
return handleUpdateUser(ctx, id, changes) return handleUpdateUser(ctx, id, changes)
} }
func (r *mutationResolver) CreateProject(ctx context.Context, changes model.CreateProjectChanges) (*model.Project, error) {
return handleCreateProject(ctx, changes)
}
func (r *mutationResolver) UpdateProjectTitle(ctx context.Context, projectID int64, title string) (*model.Project, error) {
return handleUpdateProjectTitle(ctx, projectID, title)
}
func (r *mutationResolver) AddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) {
return handleAddProjectTask(ctx, projectID, changes)
}
func (r *mutationResolver) RemoveProjectTask(ctx context.Context, projectID int64, taskID int64) (bool, error) {
return handleRemoveProjectTask(ctx, projectID, taskID)
}
// Mutation returns generated.MutationResolver implementation. // Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} } func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

View File

@ -0,0 +1,96 @@
package graph
import (
"context"
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
model1 "forge.cadoles.com/Cadoles/guesstimate/internal/model"
"github.com/pkg/errors"
)
func handleProjects(ctx context.Context, filter *model1.ProjectsFilter) ([]*model1.Project, error) {
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
if filter == nil {
filter = &model1.ProjectsFilter{
OwnerIds: make([]int64, 0, 1),
}
}
filter.OwnerIds = append(filter.OwnerIds, user.ID)
projects, err := repo.Search(ctx, filter)
if err != nil {
return nil, errors.WithStack(err)
}
return projects, nil
}
func handleCreateProject(ctx context.Context, input model.CreateProjectChanges) (*model.Project, error) {
user, db, err := getSessionUser(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
project, err := repo.Create(ctx, input.Title, user.ID)
if err != nil {
return nil, errors.WithStack(err)
}
return project, nil
}
func handleUpdateProjectTitle(ctx context.Context, projectID int64, title string) (*model.Project, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
project, err := repo.UpdateTitle(ctx, projectID, title)
if err != nil {
return nil, errors.WithStack(err)
}
return project, nil
}
func handleAddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
task, err := repo.AddTask(ctx, projectID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return task, nil
}
func handleRemoveProjectTask(ctx context.Context, projectID int64, taskID int64) (bool, error) {
db, err := getDB(ctx)
if err != nil {
return false, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
if err := repo.RemoveTask(ctx, projectID, taskID); err != nil {
return false, errors.WithStack(err)
}
return true, nil
}

View File

@ -8,6 +8,60 @@ type User {
createdAt: Time! createdAt: Time!
} }
type Project {
id: ID!
title: String!
tasks: [Task]!
params: ProjectParams!
acl: [Access]!
taskCategories: [TaskCategory]!
}
type Task {
id: ID!
label: String
category: TaskCategory
estimations: Estimations
}
type TaskCategory {
id: ID!
label: String!
costPerTimeUnit: Float!
}
type Access {
id: ID!
user: User!
level: String!
}
type ProjectParams {
timeUnit: TimeUnit
currency: String!
roundUpEstimations: Boolean!
hideFinancialPreviewOnPrint: Boolean!
}
type TimeUnit {
label: String!
acronym: String!
}
type Estimations {
optimistic: Float!
likely: Float!
pessimistic: Float!
}
input ProjectsFilter {
ids: [ID]
limit: Int
offset: Int
search: String
}
type Query { type Query {
currentUser: User currentUser: User
projects(filter: ProjectsFilter): [Project]
} }

View File

@ -14,7 +14,19 @@ func (r *queryResolver) CurrentUser(ctx context.Context) (*model1.User, error) {
return handleCurrentUser(ctx) return handleCurrentUser(ctx)
} }
func (r *queryResolver) Projects(ctx context.Context, filter *model1.ProjectsFilter) ([]*model1.Project, error) {
return handleProjects(ctx, filter)
}
func (r *taskResolver) Estimations(ctx context.Context, obj *model1.Task) (*model1.Estimations, error) {
return handleEstimations(ctx, obj)
}
// Query returns generated.QueryResolver implementation. // Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
// Task returns generated.TaskResolver implementation.
func (r *Resolver) Task() generated.TaskResolver { return &taskResolver{r} }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }

View File

@ -19,7 +19,7 @@ func handleCurrentUser(ctx context.Context) (*model.User, error) {
return user, nil return user, nil
} }
func handleUpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) { func handleUpdateUser(ctx context.Context, id int64, changes model.UserChanges) (*model.User, error) {
user, db, err := getSessionUser(ctx) user, db, err := getSessionUser(ctx)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)

21
internal/model/base.go Normal file
View File

@ -0,0 +1,21 @@
package model
import "time"
type Base struct {
ID int64 `gorm:"primary_key,AUTO_INCREMENT" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (b *Base) BeforeSave() (err error) {
now := time.Now()
if b.CreatedAt.IsZero() {
b.CreatedAt = now
}
b.UpdatedAt = now
return nil
}

123
internal/model/project.go Normal file
View File

@ -0,0 +1,123 @@
package model
import (
"encoding/json"
"github.com/jinzhu/gorm/dialects/postgres"
"github.com/pkg/errors"
)
type Project struct {
Base
Title *string `json:"title"`
Tasks []*Task `json:"tasks"`
Params *ProjectParams `gorm:"-" json:"params"`
ParamsJSON postgres.Jsonb `json:"-" gorm:"column:params;"`
TaskCategories []*TaskCategory `json:"taskCategories"`
ACL []*Access `json:"acl"`
}
func (p *Project) BeforeSave() error {
data, err := json.Marshal(p.Params)
if err != nil {
return errors.WithStack(err)
}
p.ParamsJSON = postgres.Jsonb{RawMessage: data}
return nil
}
func (p *Project) AfterFind() error {
params := &ProjectParams{}
if err := json.Unmarshal(p.ParamsJSON.RawMessage, params); err != nil {
return errors.WithStack(err)
}
p.Params = params
return nil
}
type ProjectParams struct {
TimeUnit *TimeUnit `json:"timeUnit"`
Currency string `json:"currency"`
RoundUpEstimations bool `json:"roundUpEstimations"`
HideFinancialPreviewOnPrint bool `json:"hideFinancialPreviewOnPrint"`
}
func NewDefaultProjectParams() *ProjectParams {
return &ProjectParams{
Currency: "€ H.T.",
TimeUnit: &TimeUnit{
Acronym: "J/H",
Label: "Jour/Homme",
},
RoundUpEstimations: false,
HideFinancialPreviewOnPrint: false,
}
}
func NewDefaultTaskCategories() []*TaskCategory {
return []*TaskCategory{
&TaskCategory{
Label: "Développement",
CostPerTimeUnit: 500,
},
&TaskCategory{
Label: "Conduite de projet",
CostPerTimeUnit: 500,
},
&TaskCategory{
Label: "Recette",
CostPerTimeUnit: 500,
},
}
}
const (
LevelOwner = "owner"
LevelReadOnly = "readonly"
LevelContributor = "contributor"
)
type Access struct {
Base
ProjectID int64
Project *Project `json:"-"`
UserID int64
User *User `json:"user"`
Level string `json:"level"`
}
const (
EstimationPessimistic = "pessimistic"
EstimationLikely = "likely"
EstimationOptimistic = "optimistic"
)
type Task struct {
Base
ProjectID int64 `json:"-"`
Project *Project `json:"-"`
Label *string `json:"label"`
CategoryID int64 `json:"-"`
Category *TaskCategory `json:"category"`
Estimations postgres.Hstore `json:"estimations"`
}
type TaskCategory struct {
Base
ProjectID int64 `json:"-"`
Project *Project `json:"-"`
Label string `json:"label"`
CostPerTimeUnit float64 `json:"costPerTimeUnit"`
}
type ProjectsFilter struct {
Ids []*int64 `json:"ids"`
Limit *int `json:"limit"`
Offset *int `json:"offset"`
Search *string `json:"search"`
OwnerIds []int64 `json:"-"`
}

View File

@ -0,0 +1,261 @@
package model
import (
"context"
"fmt"
"strconv"
"github.com/jinzhu/gorm/dialects/postgres"
"forge.cadoles.com/Cadoles/guesstimate/internal/orm"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
type ProjectRepository struct {
db *gorm.DB
}
func (r *ProjectRepository) Create(ctx context.Context, title string, ownerID int64) (*Project, error) {
project := &Project{
Title: &title,
Tasks: make([]*Task, 0),
TaskCategories: NewDefaultTaskCategories(),
Params: NewDefaultProjectParams(),
}
err := orm.WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Save(project).Error; err != nil {
return errors.WithStack(err)
}
err := tx.Model(project).Association("ACL").Append(&Access{
Level: LevelOwner,
UserID: ownerID,
}).Error
if err != nil {
return errors.WithStack(err)
}
err = tx.Model(project).
Preload("ACL").
Preload("ACL.User").
Preload("Tasks").
Preload("Tasks.Category").
Preload("TaskCategories").
Find(project).
Error
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not create user")
}
return project, nil
}
func (r *ProjectRepository) UpdateTitle(ctx context.Context, projectID int64, title string) (*Project, error) {
project := &Project{}
project.ID = projectID
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(project).Update("title", title).Error; err != nil {
return errors.WithStack(err)
}
err := tx.Model(project).
Preload("ACL").
Preload("ACL.User").
Preload("Tasks").
Preload("Tasks.Category").
Preload("TaskCategories").
First(project, "id = ?", projectID).
Error
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return project, nil
}
func (r *ProjectRepository) Search(ctx context.Context, filter *ProjectsFilter) ([]*Project, error) {
projects := make([]*Project, 0)
projectTableName := r.db.NewScope(&Project{}).TableName()
query := r.db.Table(projectTableName).
Preload("ACL").
Preload("ACL.User").
Preload("Tasks").
Preload("Tasks.Category").
Preload("TaskCategories")
if filter != nil {
if len(filter.Ids) > 0 {
query = query.Where(fmt.Sprintf("%s.id in (?)", projectTableName), filter.Ids)
}
if len(filter.OwnerIds) > 0 {
accessTableName := r.db.NewScope(&Access{}).TableName()
query = query.
Joins(
fmt.Sprintf(
"left join %s on %s.project_id = %s.id",
accessTableName, accessTableName, projectTableName,
),
).
Where(fmt.Sprintf("%s.level = ?", accessTableName), LevelOwner).
Where(fmt.Sprintf("%s.user_id IN (?)", accessTableName), filter.OwnerIds)
}
if filter.Limit != nil {
query = query.Limit(*filter.Limit)
}
if filter.Offset != nil {
query = query.Offset(*filter.Offset)
}
}
if err := query.Find(&projects).Error; err != nil {
return nil, errors.WithStack(err)
}
return projects, nil
}
func (r *ProjectRepository) AddTask(ctx context.Context, projectID int64, changes *ProjectTaskChanges) (*Task, error) {
project := &Project{}
project.ID = projectID
task := &Task{}
if changes == nil {
return nil, errors.Errorf("changes should not be nil")
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if changes.Label != nil {
task.Label = changes.Label
}
if changes.CategoryID != nil {
taskCategory := &TaskCategory{}
taskCategory.ID = *changes.CategoryID
task.Category = taskCategory
}
if changes.Estimations != nil {
if task.Estimations == nil {
task.Estimations = postgres.Hstore{}
}
if changes.Estimations.Pessimistic != nil {
pessimistic := strconv.FormatFloat(*changes.Estimations.Pessimistic, 'f', 12, 64)
task.Estimations[EstimationPessimistic] = &pessimistic
}
if changes.Estimations.Likely != nil {
likely := strconv.FormatFloat(*changes.Estimations.Likely, 'f', 12, 64)
task.Estimations[EstimationLikely] = &likely
}
if changes.Estimations.Optimistic != nil {
optimistic := strconv.FormatFloat(*changes.Estimations.Optimistic, 'f', 12, 64)
task.Estimations[EstimationOptimistic] = &optimistic
}
if changes.CategoryID != nil {
taskCategory := &TaskCategory{}
if err := tx.Find(taskCategory, "id = ?", *changes.CategoryID).Error; err != nil {
return errors.Wrap(err, "could not find task category")
}
task.Category = taskCategory
}
}
if err := tx.Save(task).Error; err != nil {
return errors.Wrap(err, "could not create task")
}
err := tx.Model(project).Association("Tasks").Append(task).Error
if err != nil {
return errors.Wrap(err, "could not add task")
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not add task")
}
return task, nil
}
func (r *ProjectRepository) RemoveTask(ctx context.Context, projectID int64, taskID int64) error {
project := &Project{}
project.ID = projectID
err := r.db.Transaction(func(tx *gorm.DB) error {
task := &Task{}
task.ID = taskID
err := tx.Model(project).Association("Tasks").Delete(task).Error
if err != nil {
return errors.Wrap(err, "could not remove task relationship")
}
err = tx.Delete(task, "id = ?", taskID).Error
if err != nil {
return errors.Wrap(err, "could not delete task")
}
return nil
})
if err != nil {
return errors.Wrap(err, "could not remove task")
}
return nil
}
func (r *ProjectRepository) UpdateTaskEstimation(ctx context.Context, projectID, taskID int64, estimation string, value float64) (*Task, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
task := &Task{}
if err := tx.First(task, "id = ?", taskID).Error; err != nil {
return errors.WithStack(err)
}
strValue := strconv.FormatFloat(value, 'f', 12, 64)
task.Estimations[estimation] = &strValue
if err := tx.Save(task).Error; err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not update task")
}
return nil, nil
}
func NewProjectRepository(db *gorm.DB) *ProjectRepository {
return &ProjectRepository{db}
}

View File

@ -5,11 +5,10 @@ import (
) )
type User struct { type User struct {
ID string `json:"id"` Base
Name *string `json:"name"` Name *string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
ConnectedAt time.Time `json:"connectedAt"` ConnectedAt time.Time `json:"connectedAt"`
CreatedAt time.Time `json:"createdAt"`
} }
type UserChanges struct { type UserChanges struct {

View File

@ -2,6 +2,8 @@
set -e set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" template1 -c 'CREATE EXTENSION hstore;'
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra'; CREATE USER hydra WITH ENCRYPTED PASSWORD 'hydra';
CREATE DATABASE hydra; CREATE DATABASE hydra;

View File

@ -11,10 +11,14 @@ modd.conf {
prep: make build prep: make build
prep: [ -e data/config.yml ] || ( mkdir -p data && bin/server -dump-config > data/config.yml ) prep: [ -e data/config.yml ] || ( mkdir -p data && bin/server -dump-config > data/config.yml )
prep: [ -e .env ] || ( cp .env.dist .env ) prep: [ -e .env ] || ( cp .env.dist .env )
prep: make migrate-latest
daemon: ( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml ) daemon: ( set -o allexport && source .env && set +o allexport && bin/server -workdir "./cmd/server" -config ../../data/config.yml )
} }
cmd/server/migration.go {
prep: make migrate-down
prep: make migrate-latest
}
**/*.go { **/*.go {
prep: make test prep: make test
} }