diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index ef6cd18..f855870 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -4,28 +4,57 @@ import { HomePage } from './HomePage/HomePage'; import { ProfilePage } from './ProfilePage/ProfilePage'; import { DashboardPage } from './DashboardPage/DashboardPage'; import { PrivateRoute } from './PrivateRoute'; -import { useLoggedIn, LoggedInContext } from '../hooks/useLoggedIn'; +import { useLoggedIn, LoggedInContext, getSavedLoggedIn, saveLoggedIn } from '../hooks/useLoggedIn'; import { useUserProfile } from '../gql/queries/user'; import { ProjectPage } from './ProjectPage/ProjectPage'; +import { createClient } from '../util/apollo'; +import { ApolloProvider } from '@apollo/client'; export interface AppProps { } export const App: FunctionComponent = () => { - const { user } = useUserProfile(); + const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn()); + + const client = createClient((loggedIn) => { + setLoggedIn(loggedIn); + }); + + useEffect(() => { + saveLoggedIn(loggedIn); + }, [loggedIn]); return ( - - - - - - - - } /> - - - + + + + + + + + + + } /> + + + + ); -} \ No newline at end of file +} + + +interface UserSessionCheckProps { + setLoggedIn: (boolean) => void +} + +const UserSessionCheck: FunctionComponent = ({ setLoggedIn }) => { + const { user, loading } = useUserProfile(); + + useEffect(() => { + if (loading) return; + setLoggedIn(user.id !== ''); + }, [user]); + + return null; +}; \ No newline at end of file diff --git a/client/src/components/EditableText/EditableText.tsx b/client/src/components/EditableText/EditableText.tsx index 71fdd60..27e653f 100644 --- a/client/src/components/EditableText/EditableText.tsx +++ b/client/src/components/EditableText/EditableText.tsx @@ -17,26 +17,23 @@ const EditableText: FunctionComponent = ({ onChange, value, r const [ internalValue, setInternalValue ] = useState(value); const [ editMode, setEditMode ] = useState(false); - useEffect(() => { - if (internalValue === value) return; - if (onChange) onChange(internalValue); - }, [internalValue]); - useEffect(() => { setInternalValue(value); }, [value]) const onEditIconClick = () => { - setEditMode(true); + setEditMode(true); }; const onValidateButtonClick = () => { - setEditMode(false); + setEditMode(false); + if (internalValue === value) return; + if (onChange) onChange(internalValue); } const onValueChange = (evt: ChangeEvent) => { - const currentTarget = evt.currentTarget as HTMLInputElement; - setInternalValue(currentTarget.value); + const currentTarget = evt.currentTarget as HTMLInputElement; + setInternalValue(currentTarget.value); }; return ( diff --git a/client/src/gql/fragments/project.ts b/client/src/gql/fragments/project.ts index 73641c7..64a3b78 100644 --- a/client/src/gql/fragments/project.ts +++ b/client/src/gql/fragments/project.ts @@ -1,5 +1,21 @@ import { gql } from '@apollo/client'; +export const FRAGMENT_FULL_TASK = gql` +fragment FullTask on Task { + id + label + category { + id + label + } + estimations { + optimistic + likely + pessimistic + } +} +`; + export const FRAGMENT_FULL_PROJECT = gql` fragment FullProject on Project { id @@ -10,17 +26,7 @@ fragment FullProject on Project { costPerTimeUnit } tasks { - id - label - category { - id - label - } - estimations { - optimistic - likely - pessimistic - } + ...FullTask } params { timeUnit { @@ -31,4 +37,5 @@ fragment FullProject on Project { hideFinancialPreviewOnPrint } } +${FRAGMENT_FULL_TASK} ` \ No newline at end of file diff --git a/client/src/gql/mutations/project.tsx b/client/src/gql/mutations/project.tsx index 2006fee..af0ace3 100644 --- a/client/src/gql/mutations/project.tsx +++ b/client/src/gql/mutations/project.tsx @@ -1,5 +1,5 @@ import { gql, useMutation, PureQueryOptions } from '@apollo/client'; -import { FRAGMENT_FULL_PROJECT } from '../fragments/project'; +import { FRAGMENT_FULL_PROJECT, FRAGMENT_FULL_TASK } from '../fragments/project'; import { QUERY_PROJECTS } from '../queries/project'; export const MUTATION_CREATE_PROJECT = gql` @@ -37,9 +37,10 @@ export function useUpdateProjectTitleMutation() { export const MUTATION_ADD_PROJECT_TASK = gql` mutation addProjectTask($projectId: ID!, $changes: ProjectTaskChanges!) { addProjectTask(projectId: $projectId, changes: $changes) { - id + ...FullTask } } +${FRAGMENT_FULL_TASK} `; export function useAddProjectTaskMutation() { @@ -49,9 +50,10 @@ export function useAddProjectTaskMutation() { export const MUTATION_UPDATE_PROJECT_TASK = gql` mutation updateProjectTask($projectId: ID!, $taskId: ID!, $changes: ProjectTaskChanges!) { updateProjectTask(projectId: $projectId, taskId: $taskId, changes: $changes) { - id + ...FullTask } } +${FRAGMENT_FULL_TASK} `; export function useUpdateProjectTaskMutation() { diff --git a/client/src/gql/queries/project.ts b/client/src/gql/queries/project.ts index c7ac205..0ff2a7b 100644 --- a/client/src/gql/queries/project.ts +++ b/client/src/gql/queries/project.ts @@ -1,41 +1,15 @@ import { gql, useQuery, QueryHookOptions } from '@apollo/client'; -import { User } from '../../types/user'; import { useGraphQLData } from './helper'; import { Project } from '../../types/project'; +import { FRAGMENT_FULL_PROJECT } from '../fragments/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 - } + ...FullProject } -}`; +} +${FRAGMENT_FULL_PROJECT}`; export function useProjectsQuery() { return useQuery(QUERY_PROJECTS); diff --git a/client/src/hooks/useLoggedIn.tsx b/client/src/hooks/useLoggedIn.tsx index 8c92e4d..0338fed 100644 --- a/client/src/hooks/useLoggedIn.tsx +++ b/client/src/hooks/useLoggedIn.tsx @@ -1,7 +1,22 @@ -import React, { useState, useContext } from "react"; +import React, { useContext, useEffect } from "react"; -export const LoggedInContext = React.createContext(false); +const LOGGED_IN_KEY = 'loggedIn'; + +export const LoggedInContext = React.createContext(getSavedLoggedIn()); export const useLoggedIn = () => { return useContext(LoggedInContext); -}; \ No newline at end of file +}; + +export function saveLoggedIn(loggedIn: boolean) { + window.sessionStorage.setItem(LOGGED_IN_KEY, JSON.stringify(loggedIn)); +} + +export function getSavedLoggedIn(): boolean { + try { + const loggedIn = JSON.parse(window.sessionStorage.getItem(LOGGED_IN_KEY)); + return !!loggedIn; + } catch(err) { + return false; + } +} \ No newline at end of file diff --git a/client/src/hooks/useProjectReducer.sagas.ts b/client/src/hooks/useProjectReducer.sagas.ts index 2427555..7c86995 100644 --- a/client/src/hooks/useProjectReducer.sagas.ts +++ b/client/src/hooks/useProjectReducer.sagas.ts @@ -1,21 +1,21 @@ 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 { MUTATION_CREATE_PROJECT, MUTATION_UPDATE_PROJECT_TITLE, MUTATION_ADD_PROJECT_TASK, MUTATION_REMOVE_PROJECT_TASK, MUTATION_UPDATE_PROJECT_TASK } from "../gql/mutations/project"; +import { UPDATE_PROJECT_TITLE, resetProject, ADD_TASK, taskSaved, AddTask, taskRemoved, RemoveTask, REMOVE_TASK, UPDATE_TASK_ESTIMATION, updateTaskEstimation, UpdateTaskEstimation, UpdateTaskLabel, UPDATE_TASK_LABEL } from "./useProjectReducer"; import { Project } from "../types/project"; export function* rootSaga() { yield all([ createProjectSaga(), takeLatest(UPDATE_PROJECT_TITLE, updateProjectTitleSaga), + takeLatest(UPDATE_TASK_ESTIMATION, updateTaskEstimationSaga), + takeLatest(UPDATE_TASK_LABEL, updateTaskLabelSaga), takeLatest(ADD_TASK, addTaskSaga), takeLatest(REMOVE_TASK, removeTaskSaga), ]); } export function* updateProjectTitleSaga() { - yield delay(500); - let project = yield select(); if (project.id === undefined) { @@ -73,7 +73,7 @@ export function* addTaskSaga({ task }: AddTask) { } }); - yield put(taskAdded({ ...task, ...data.addProjectTask })); + yield put(taskSaved({ ...task, ...data.addProjectTask })); } export function* removeTaskSaga({ id }: RemoveTask) { @@ -92,4 +92,48 @@ export function* removeTaskSaga({ id }: RemoveTask) { }); yield put(taskRemoved(id)); +} + +export function* updateTaskEstimationSaga({ id, confidence, value }: UpdateTaskEstimation) { + let project: Project = yield select(); + + if (project.id === undefined) { + project = yield createProjectSaga(); + } + + const { data } = yield client.mutate({ + mutation: MUTATION_UPDATE_PROJECT_TASK, + variables: { + projectId: project.id, + taskId: id, + changes: { + estimations: { + [confidence]: value, + } + } + } + }); + + yield put(taskSaved({ ...data.updateProjectTask })); +} + +export function* updateTaskLabelSaga({ id, label }: UpdateTaskLabel) { + let project: Project = yield select(); + + if (project.id === undefined) { + project = yield createProjectSaga(); + } + + const { data } = yield client.mutate({ + mutation: MUTATION_UPDATE_PROJECT_TASK, + variables: { + projectId: project.id, + taskId: id, + changes: { + label, + } + } + }); + + yield put(taskSaved({ ...data.updateProjectTask })); } \ No newline at end of file diff --git a/client/src/hooks/useProjectReducer.ts b/client/src/hooks/useProjectReducer.ts index c6d23d8..7877a9a 100644 --- a/client/src/hooks/useProjectReducer.ts +++ b/client/src/hooks/useProjectReducer.ts @@ -28,12 +28,9 @@ export function useProjectReducer(project: Project) { } export function projectReducer(project: Project, action: ProjectReducerActions): Project { - console.log(action); + console.log(action.type, action); switch(action.type) { - case TASK_ADDED: - return handleTaskAdded(project, action as TaskAdded); - case TASK_SAVED: return handleTaskSaved(project, action as TaskSaved); @@ -86,23 +83,6 @@ 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 }; - return { - ...project, - tasks: [ - ...project.tasks, - task, - ] - }; -}; - export const TASK_SAVED = "TASK_SAVED"; export interface TaskSaved extends Action { @@ -115,9 +95,12 @@ export function taskSaved(task: Task): TaskSaved { 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 }; + if (taskIndex === -1) { + tasks.push({ ...action.task }); + } else { + tasks[taskIndex] = { ...tasks[taskIndex], ...action.task }; + } return { ...project, tasks @@ -177,14 +160,6 @@ export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskE ...project.tasks[taskIndex].estimations, [action.confidence]: action.value }; - - if (estimations.likely < estimations.optimistic) { - estimations.likely = estimations.optimistic; - } - - if (estimations.pessimistic < estimations.likely) { - estimations.pessimistic = estimations.likely; - } project.tasks[taskIndex] = { ...project.tasks[taskIndex], estimations }; diff --git a/client/src/index.tsx b/client/src/index.tsx index aa0d99b..64afa92 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './components/App'; -import { client } from './gql/client'; import "./style/index.css"; import "bulma/css/bulma.css"; @@ -13,11 +12,7 @@ import '@fortawesome/fontawesome-free/js/regular' import '@fortawesome/fontawesome-free/js/brands' import './resources/favicon.png'; -import { ApolloProvider } from '@apollo/client'; - ReactDOM.render( - - - , + , document.getElementById('app') ); diff --git a/client/src/util/apollo.ts b/client/src/util/apollo.ts new file mode 100644 index 0000000..27c069f --- /dev/null +++ b/client/src/util/apollo.ts @@ -0,0 +1,77 @@ + +import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client'; +import { Config } from '../config'; +import { WebSocketLink } from "@apollo/client/link/ws"; +import { RetryLink } from "@apollo/client/link/retry"; +import { onError } from "@apollo/client/link/error"; +import { SubscriptionClient } from "subscriptions-transport-ws"; +import { User } from '../types/user'; + +export function createClient(setLoggedIn: (boolean) => void) { + const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, { + reconnect: true, + }); + + const errorLink = onError(({ operation }) => { + const { response } = operation.getContext(); + if (response.status === 401) setLoggedIn(false); + }); + + const retryLink = new RetryLink({attempts: {max: 2}}).split( + (operation) => operation.operationName === 'subscription', + new WebSocketLink(subscriptionClient), + new HttpLink({ + uri: Config.graphQLEndpoint, + credentials: 'include', + }) + ); + + const cache = new InMemoryCache({}); + + return new ApolloClient({ + cache: cache, + link: from([ + errorLink, + retryLink + ]), + defaultOptions: { + watchQuery: { + fetchPolicy: 'cache-and-network', + errorPolicy: 'ignore', + }, + query: { + fetchPolicy: 'network-only', + errorPolicy: 'all', + }, + mutate: { + errorPolicy: 'all', + }, + } + }); +} + +export function mergeArrayByField(fieldName: string) { + return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => { + const merged: any[] = existing ? existing.slice(0) : []; + + const objectFieldToIndex: Record = Object.create(null); + if (existing) { + existing.forEach((obj, index) => { + objectFieldToIndex[readField(fieldName, obj)] = index; + }); + } + + incoming.forEach(obj => { + const field = readField(fieldName, obj); + const index = objectFieldToIndex[field]; + if (typeof index === "number") { + merged[index] = mergeObjects(merged[index], obj); + } else { + objectFieldToIndex[name] = merged.length; + merged.push(obj); + } + }); + + return merged; + } +} \ No newline at end of file diff --git a/internal/graph/estimation_handler.go b/internal/graph/estimation_handler.go index 31f7f3d..6510f56 100644 --- a/internal/graph/estimation_handler.go +++ b/internal/graph/estimation_handler.go @@ -2,48 +2,10 @@ 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 + return task.Estimations, nil } diff --git a/internal/graph/mutation.graphql b/internal/graph/mutation.graphql index fe9179a..4a1cf91 100644 --- a/internal/graph/mutation.graphql +++ b/internal/graph/mutation.graphql @@ -22,6 +22,7 @@ type Mutation { updateUser(id: ID!, changes: UserChanges!): User! createProject(changes: CreateProjectChanges!): Project! updateProjectTitle(projectId: ID!, title: String!): Project! - addProjectTask(projectId: ID!, changes: ProjectTaskChanges): Task! + addProjectTask(projectId: ID!, changes: ProjectTaskChanges!): Task! removeProjectTask(projectId: ID!, taskId: ID!): Boolean! + updateProjectTask(projectId: ID!, taskId: ID!, changes: ProjectTaskChanges!): Task! } \ No newline at end of file diff --git a/internal/graph/mutation.resolvers.go b/internal/graph/mutation.resolvers.go index d707451..02b5882 100644 --- a/internal/graph/mutation.resolvers.go +++ b/internal/graph/mutation.resolvers.go @@ -22,7 +22,7 @@ func (r *mutationResolver) UpdateProjectTitle(ctx context.Context, projectID int return handleUpdateProjectTitle(ctx, projectID, title) } -func (r *mutationResolver) AddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) { +func (r *mutationResolver) AddProjectTask(ctx context.Context, projectID int64, changes model.ProjectTaskChanges) (*model.Task, error) { return handleAddProjectTask(ctx, projectID, changes) } @@ -30,6 +30,10 @@ func (r *mutationResolver) RemoveProjectTask(ctx context.Context, projectID int6 return handleRemoveProjectTask(ctx, projectID, taskID) } +func (r *mutationResolver) UpdateProjectTask(ctx context.Context, projectID int64, taskID int64, changes model.ProjectTaskChanges) (*model.Task, error) { + return handleUpdateProjectTask(ctx, projectID, taskID, changes) +} + // Mutation returns generated.MutationResolver implementation. func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} } diff --git a/internal/graph/project_handler.go b/internal/graph/project_handler.go index a33296b..d2e95e9 100644 --- a/internal/graph/project_handler.go +++ b/internal/graph/project_handler.go @@ -64,7 +64,7 @@ func handleUpdateProjectTitle(ctx context.Context, projectID int64, title string return project, nil } -func handleAddProjectTask(ctx context.Context, projectID int64, changes *model.ProjectTaskChanges) (*model.Task, error) { +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) @@ -94,3 +94,19 @@ func handleRemoveProjectTask(ctx context.Context, projectID int64, taskID int64) return true, nil } + +func handleUpdateProjectTask(ctx context.Context, projectID, taskID 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.UpdateTask(ctx, projectID, taskID, changes) + if err != nil { + return nil, errors.WithStack(err) + } + + return task, nil +} diff --git a/internal/model/project.go b/internal/model/project.go index 1738c9b..a7ce6a7 100644 --- a/internal/model/project.go +++ b/internal/model/project.go @@ -90,20 +90,20 @@ type Access struct { 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"` + ProjectID int64 `json:"-"` + Project *Project `json:"-"` + Label *string `json:"label"` + CategoryID int64 `json:"-"` + Category *TaskCategory `json:"category"` + Estimations *Estimations `gorm:"EMBEDDED;EMBEDDED_PREFIX:estimation_" json:"estimations"` +} + +type Estimations struct { + Optimistic float64 `json:"optimistic"` + Likely float64 `json:"likely"` + Pessimistic float64 `json:"pessimistic"` } type TaskCategory struct { diff --git a/internal/model/project_repository.go b/internal/model/project_repository.go index 81d4048..cf75200 100644 --- a/internal/model/project_repository.go +++ b/internal/model/project_repository.go @@ -3,9 +3,6 @@ package model import ( "context" "fmt" - "strconv" - - "github.com/jinzhu/gorm/dialects/postgres" "forge.cadoles.com/Cadoles/guesstimate/internal/orm" "github.com/jinzhu/gorm" @@ -137,54 +134,14 @@ func (r *ProjectRepository) Search(ctx context.Context, filter *ProjectsFilter) return projects, nil } -func (r *ProjectRepository) AddTask(ctx context.Context, projectID int64, changes *ProjectTaskChanges) (*Task, error) { +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 := updateTaskWithChanges(tx, task, changes); err != nil { + return errors.WithStack(err) } if err := tx.Save(task).Error; err != nil { @@ -219,7 +176,7 @@ func (r *ProjectRepository) RemoveTask(ctx context.Context, projectID int64, tas return errors.Wrap(err, "could not remove task relationship") } - err = tx.Delete(task, "id = ?", taskID).Error + err = tx.Delete(task, "id = ? AND project_id = ?", taskID, projectID).Error if err != nil { return errors.Wrap(err, "could not delete task") } @@ -233,15 +190,21 @@ func (r *ProjectRepository) RemoveTask(ctx context.Context, projectID int64, tas return nil } -func (r *ProjectRepository) UpdateTaskEstimation(ctx context.Context, projectID, taskID int64, estimation string, value float64) (*Task, error) { +func (r *ProjectRepository) UpdateTask(ctx context.Context, projectID, taskID int64, changes ProjectTaskChanges) (*Task, error) { + task := &Task{} + err := r.db.Transaction(func(tx *gorm.DB) error { - task := &Task{} - if err := tx.First(task, "id = ?", taskID).Error; err != nil { + err := tx.Model(task). + Preload("Category"). + First(task, "id = ? AND project_id = ?", taskID, projectID). + Error + if err != nil { return errors.WithStack(err) } - strValue := strconv.FormatFloat(value, 'f', 12, 64) - task.Estimations[estimation] = &strValue + if err := updateTaskWithChanges(tx, task, changes); err != nil { + return errors.WithStack(err) + } if err := tx.Save(task).Error; err != nil { return errors.WithStack(err) @@ -253,7 +216,58 @@ func (r *ProjectRepository) UpdateTaskEstimation(ctx context.Context, projectID, return nil, errors.Wrap(err, "could not update task") } - return nil, nil + return task, nil +} + +func updateTaskWithChanges(db *gorm.DB, task *Task, changes ProjectTaskChanges) 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 { + return nil + } + + if task.Estimations == nil { + task.Estimations = &Estimations{} + } + + if changes.Estimations.Pessimistic != nil { + task.Estimations.Pessimistic = *changes.Estimations.Pessimistic + } + + if changes.Estimations.Likely != nil { + task.Estimations.Likely = *changes.Estimations.Likely + } + + if changes.Estimations.Optimistic != nil { + task.Estimations.Optimistic = *changes.Estimations.Optimistic + } + + if task.Estimations.Likely < task.Estimations.Optimistic { + task.Estimations.Likely = task.Estimations.Optimistic + } + + if task.Estimations.Pessimistic < task.Estimations.Likely { + task.Estimations.Pessimistic = task.Estimations.Likely + } + + if changes.CategoryID != nil { + taskCategory := &TaskCategory{} + if err := db.Find(taskCategory, "id = ?", *changes.CategoryID).Error; err != nil { + return errors.Wrap(err, "could not find task category") + } + + task.Category = taskCategory + } + + return nil } func NewProjectRepository(db *gorm.DB) *ProjectRepository {