Compare commits

...

3 Commits

19 changed files with 935 additions and 306 deletions

View File

@ -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<AppProps> = () => {
const { user } = useUserProfile();
const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn());
const client = createClient((loggedIn) => {
setLoggedIn(loggedIn);
});
useEffect(() => {
saveLoggedIn(loggedIn);
}, [loggedIn]);
return (
<LoggedInContext.Provider value={user.id !== ''}>
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<PrivateRoute path="/profile" exact component={ProfilePage} />
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
<PrivateRoute path="/projects/:id" exact component={ProjectPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
</LoggedInContext.Provider>
<ApolloProvider client={client}>
<LoggedInContext.Provider value={loggedIn}>
<UserSessionCheck setLoggedIn={setLoggedIn} />
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<PrivateRoute path="/profile" exact component={ProfilePage} />
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
<PrivateRoute path="/projects/:id" exact component={ProjectPage} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</BrowserRouter>
</LoggedInContext.Provider>
</ApolloProvider>
);
}
}
interface UserSessionCheckProps {
setLoggedIn: (boolean) => void
}
const UserSessionCheck: FunctionComponent<UserSessionCheckProps> = ({ setLoggedIn }) => {
const { user, loading } = useUserProfile();
useEffect(() => {
if (loading) return;
setLoggedIn(user.id !== '');
}, [user]);
return null;
};

View File

@ -17,26 +17,23 @@ const EditableText: FunctionComponent<EditableTextProps> = ({ 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 (

View File

@ -1,34 +1,55 @@
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_PARAMS = gql`
fragment FullProjectParams on ProjectParams {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
}
`;
export const FRAGMENT_FULL_TASK_CATEGORY = gql`
fragment FullTaskCategory on TaskCategory {
id
label
costPerTimeUnit
}
`;
export const FRAGMENT_FULL_PROJECT = gql`
fragment FullProject on Project {
id
title
taskCategories {
id
label
costPerTimeUnit
...FullTaskCategory
}
tasks {
id
label
category {
id
label
}
estimations {
optimistic
likely
pessimistic
}
...FullTask
}
params {
timeUnit {
label
acronym
}
currency
hideFinancialPreviewOnPrint
...FullProjectParams
}
}
${FRAGMENT_FULL_TASK}
${FRAGMENT_FULL_PROJECT_PARAMS}
${FRAGMENT_FULL_TASK_CATEGORY}
`

View File

@ -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, FRAGMENT_FULL_PROJECT_PARAMS, FRAGMENT_FULL_TASK_CATEGORY } 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() {
@ -67,3 +69,52 @@ mutation removeProjectTask($projectId: ID!, $taskId: ID!) {
export function useRemoveProjectTaskMutation() {
return useMutation(MUTATION_REMOVE_PROJECT_TASK);
}
export const MUTATION_UPDATE_PROJECT_PARAMS = gql`
mutation updateProjectParams($projectId: ID!, $changes: ProjectParamsChanges!) {
updateProjectParams(projectId: $projectId, changes: $changes) {
...FullProjectParams
}
}
${FRAGMENT_FULL_PROJECT_PARAMS}
`;
export function useUpdateProjectParamsMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_PARAMS);
}
export const MUTATION_ADD_PROJECT_TASK_CATEGORY = gql`
mutation addProjectTaskCategory($projectId: ID!, $changes: ProjectTaskCategoryChanges!) {
addProjectTaskCategory(projectId: $projectId, changes: $changes) {
...FullTaskCategory
}
}
${FRAGMENT_FULL_TASK_CATEGORY}
`;
export function useAddProjectTaskCategoryMutation() {
return useMutation(MUTATION_ADD_PROJECT_TASK_CATEGORY);
}
export const MUTATION_UPDATE_PROJECT_TASK_CATEGORY = gql`
mutation updateProjectTaskCategory($projectId: ID!, $taskCategoryId: ID!, $changes: ProjectTaskCategoryChanges!) {
updateProjectTaskCategory(projectId: $projectId, taskCategoryId: $taskCategoryId, changes: $changes) {
...FullTaskCategory
}
}
${FRAGMENT_FULL_TASK_CATEGORY}
`;
export function useUpdateProjectTaskCategoryMutation() {
return useMutation(MUTATION_UPDATE_PROJECT_TASK_CATEGORY);
}
export const MUTATION_REMOVE_PROJECT_TASK_CATEGORY = gql`
mutation removeProjectTaskCategory($projectId: ID!, $taskCategoryId: ID!) {
removeProjectTaskCategory(projectId: $projectId, taskCategoryId: $taskCategoryId)
}
`;
export function useRemoveProjectTaskCategoryMutation() {
return useMutation(MUTATION_REMOVE_PROJECT_TASK_CATEGORY);
}

View File

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

View File

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

View File

@ -1,21 +1,41 @@
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, MUTATION_UPDATE_PROJECT_PARAMS,
MUTATION_ADD_PROJECT_TASK_CATEGORY,
MUTATION_UPDATE_PROJECT_TASK_CATEGORY,
MUTATION_REMOVE_PROJECT_TASK_CATEGORY
} from "../gql/mutations/project";
import {
UPDATE_PROJECT_TITLE, resetProject,
ADD_TASK, taskSaved, AddTask,
taskRemoved, RemoveTask, REMOVE_TASK,
UPDATE_TASK_ESTIMATION, UpdateTaskEstimation,
UpdateTaskLabel, UPDATE_TASK_LABEL, UpdateParam,
paramsSaved, UPDATE_PARAM, AddTaskCategory,
ADD_TASK_CATEGORY, taskCategorySaved, UpdateTaskCategoryLabel, UPDATE_TASK_CATEGORY_LABEL, UPDATE_TASK_CATEGORY_COST, updateTaskCategoryCost, UpdateTaskCategoryCost, RemoveTaskCategory, taskCategoryRemoved, REMOVE_TASK_CATEGORY, backendError
} 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(UPDATE_PARAM, updateProjectParamsSaga),
takeLatest(ADD_TASK, addTaskSaga),
takeLatest(REMOVE_TASK, removeTaskSaga),
takeLatest(ADD_TASK_CATEGORY, addProjectTaskCategorySaga),
takeLatest(UPDATE_TASK_CATEGORY_LABEL, updateProjectTaskCategoryLabelSaga),
takeLatest(UPDATE_TASK_CATEGORY_COST, updateProjectTaskCategoryCostSaga),
takeLatest(REMOVE_TASK_CATEGORY, removeProjectTaskCategorySaga),
]);
}
export function* updateProjectTitleSaga() {
yield delay(500);
let project = yield select();
if (project.id === undefined) {
@ -73,7 +93,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 +112,161 @@ 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 }));
}
export function* updateProjectParamsSaga({ name, value }: UpdateParam) {
yield delay(500);
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
if (typeof value === 'object') {
delete value.__typename;
}
const { data } = yield client.mutate({
mutation: MUTATION_UPDATE_PROJECT_PARAMS,
variables: {
projectId: project.id,
changes: {
[name]: value,
}
}
});
yield put(paramsSaved({ ...data.updateProjectParams }));
}
export function* addProjectTaskCategorySaga({ taskCategory }: AddTaskCategory) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
const { data } = yield client.mutate({
mutation: MUTATION_ADD_PROJECT_TASK_CATEGORY,
variables: {
projectId: project.id,
changes: {
label: taskCategory.label,
costPerTimeUnit: taskCategory.costPerTimeUnit,
}
}
});
yield put(taskCategorySaved({ ...data.addProjectTaskCategory }));
}
export function* updateProjectTaskCategoryLabelSaga({ categoryId, label }: UpdateTaskCategoryLabel) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
const { data } = yield client.mutate({
mutation: MUTATION_UPDATE_PROJECT_TASK_CATEGORY,
variables: {
projectId: project.id,
taskCategoryId: categoryId,
changes: {
label,
}
}
});
yield put(taskCategorySaved({ ...data.updateProjectTaskCategory }));
}
export function* updateProjectTaskCategoryCostSaga({ categoryId, costPerTimeUnit }: UpdateTaskCategoryCost) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
const { data } = yield client.mutate({
mutation: MUTATION_UPDATE_PROJECT_TASK_CATEGORY,
variables: {
projectId: project.id,
taskCategoryId: categoryId,
changes: {
costPerTimeUnit,
}
}
});
yield put(taskCategorySaved({ ...data.updateProjectTaskCategory }));
}
export function* removeProjectTaskCategorySaga({ taskCategoryId }: RemoveTaskCategory) {
let project: Project = yield select();
if (project.id === undefined) {
project = yield createProjectSaga();
}
try {
yield client.mutate({
mutation: MUTATION_REMOVE_PROJECT_TASK_CATEGORY,
variables: {
projectId: project.id,
taskCategoryId,
}
});
} catch(err) {
yield put(backendError(err));
return
}
yield put(taskCategoryRemoved(taskCategoryId));
}

View File

@ -3,6 +3,11 @@ import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from
import { useReducerAndSaga } from "./useReducerAndSaga";
import { rootSaga } from "./useProjectReducer.sagas";
import { uuid } from "../util/uuid";
import { Params } from "../types/params";
export interface ProjectState extends Project {
lastBackendError: Error
}
export interface Action {
type: string
@ -17,23 +22,24 @@ UpdateTaskEstimation |
UpdateProjectTitle |
UpdateTaskLabel |
UpdateParam |
ParamsSaved |
UpdateTaskCategoryLabel |
UpdateTaskCategoryCost |
AddTaskCategory |
RemoveTaskCategory |
ResetProject
TaskCategoryRemoved |
TaskCategorySaved |
ResetProject |
BackendError
export function useProjectReducer(project: Project) {
return useReducerAndSaga<Project>(projectReducer, project, rootSaga);
return useReducerAndSaga<ProjectState>(projectReducer, project, rootSaga);
}
export function projectReducer(project: Project, action: ProjectReducerActions): Project {
console.log(action);
export function projectReducer(project: ProjectState, action: ProjectReducerActions): ProjectState {
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);
@ -51,21 +57,21 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
case UPDATE_PARAM:
return handleUpdateParam(project, action as UpdateParam);
case ADD_TASK_CATEGORY:
return handleAddTaskCategory(project, action as AddTaskCategory);
case REMOVE_TASK_CATEGORY:
return handleRemoveTaskCategory(project, action as RemoveTaskCategory);
case UPDATE_TASK_CATEGORY_LABEL:
return handleUpdateTaskCategoryLabel(project, action as UpdateTaskCategoryLabel);
case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
case PARAMS_SAVED:
return handleParamsSaved(project, action as ParamsSaved);
case TASK_CATEGORY_SAVED:
return handleTaskCategorySaved(project, action as TaskCategorySaved);
case TASK_CATEGORY_REMOVED:
return handleTaskCategoryRemoved(project, action as TaskCategoryRemoved);
case RESET_PROJECT:
return handleResetProject(project, action as ResetProject);
case BACKEND_ERROR:
return handleBackendError(project, action as BackendError);
}
@ -86,23 +92,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 {
@ -113,11 +102,14 @@ export function taskSaved(task: Task): TaskSaved {
return { type: TASK_SAVED, task };
}
export function handleTaskSaved(project: Project, action: TaskSaved): Project {
export function handleTaskSaved(project: ProjectState, action: TaskSaved): ProjectState {
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
@ -145,7 +137,7 @@ export function taskRemoved(id: number): TaskRemoved {
}
export function handleTaskRemoved(project: Project, action: TaskRemoved): Project {
export function handleTaskRemoved(project: ProjectState, action: TaskRemoved): ProjectState {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
@ -168,7 +160,7 @@ export function updateTaskEstimation(id: number, confidence: EstimationConfidenc
return { type: UPDATE_TASK_ESTIMATION, id, confidence, value };
}
export function handleUpdateTaskEstimation(project: Project, action: UpdateTaskEstimation): Project {
export function handleUpdateTaskEstimation(project: ProjectState, action: UpdateTaskEstimation): ProjectState {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
@ -177,14 +169,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 };
@ -205,7 +189,7 @@ export function updateProjectTitle(title: string): UpdateProjectTitle {
return { type: UPDATE_PROJECT_TITLE, title };
}
export function handleUpdateProjectTitle(project: Project, action: UpdateProjectTitle): Project {
export function handleUpdateProjectTitle(project: ProjectState, action: UpdateProjectTitle): ProjectState {
return {
...project,
title: action.title
@ -223,7 +207,7 @@ export function updateTaskLabel(id: number, label: string): UpdateTaskLabel {
return { type: UPDATE_TASK_LABEL, id, label };
}
export function handleUpdateTaskLabel(project: Project, action: UpdateTaskLabel): Project {
export function handleUpdateTaskLabel(project: ProjectState, action: UpdateTaskLabel): ProjectState {
const tasks = [...project.tasks];
const taskIndex = project.tasks.findIndex(t => t.id === action.id);
if (taskIndex === -1) return project;
@ -247,7 +231,7 @@ export function updateParam(name: string, value: any): UpdateParam {
return { type: UPDATE_PARAM, name, value };
}
export function handleUpdateParam(project: Project, action: UpdateParam): Project {
export function handleUpdateParam(project: ProjectState, action: UpdateParam): ProjectState {
return {
...project,
params: {
@ -257,6 +241,51 @@ export function handleUpdateParam(project: Project, action: UpdateParam): Projec
};
}
export interface ParamsSaved extends Action {
params: Params
}
export const PARAMS_SAVED = "PARAMS_SAVED";
export function paramsSaved(params: Params): ParamsSaved {
return { type: PARAMS_SAVED, params };
}
function handleParamsSaved(project: ProjectState, action: ParamsSaved): ProjectState {
return {
...project,
params: {
...action.params
}
}
}
export interface TaskCategorySaved extends Action {
taskCategory: TaskCategory
}
export const TASK_CATEGORY_SAVED = "TASK_CATEGORY_SAVED";
export function taskCategorySaved(taskCategory: TaskCategory): TaskCategorySaved {
return { type: TASK_CATEGORY_SAVED, taskCategory };
}
export function handleTaskCategorySaved(project: ProjectState, action: TaskCategorySaved): ProjectState {
const taskCategories = [...project.taskCategories];
const taskCategoryIndex = taskCategories.findIndex(tc => tc.id === action.taskCategory.id);
if (taskCategoryIndex === -1) {
taskCategories.push({ ...action.taskCategory });
} else {
taskCategories[taskCategoryIndex] = { ...action.taskCategory };
}
return {
...project,
taskCategories
};
}
export interface UpdateTaskCategoryLabel extends Action {
categoryId: TaskCategoryID
label: string
@ -268,19 +297,6 @@ export function updateTaskCategoryLabel(categoryId: TaskCategoryID, label: strin
return { type: UPDATE_TASK_CATEGORY_LABEL, categoryId, label };
}
export function handleUpdateTaskCategoryLabel(project: Project, action: UpdateTaskCategoryLabel): Project {
return {
...project,
taskCategories: {
...project.taskCategories,
[action.categoryId]: {
...project.taskCategories[action.categoryId],
label: action.label
},
}
};
}
export interface UpdateTaskCategoryCost extends Action {
categoryId: TaskCategoryID
costPerTimeUnit: number
@ -292,19 +308,6 @@ export function updateTaskCategoryCost(categoryId: TaskCategoryID, costPerTimeUn
return { type: UPDATE_TASK_CATEGORY_COST, categoryId, costPerTimeUnit };
}
export function handleUpdateTaskCategoryCost(project: Project, action: UpdateTaskCategoryCost): Project {
return {
...project,
taskCategories: {
...project.taskCategories,
[action.categoryId]: {
...project.taskCategories[action.categoryId],
costPerTimeUnit: action.costPerTimeUnit
},
}
};
}
export const ADD_TASK_CATEGORY = "ADD_TASK_CATEGORY";
export interface AddTaskCategory extends Action {
@ -315,17 +318,6 @@ export function addTaskCategory(taskCategory: TaskCategory): AddTaskCategory {
return { type: ADD_TASK_CATEGORY, taskCategory };
}
export function handleAddTaskCategory(project: Project, action: AddTaskCategory): Project {
const taskCategory = { ...action.taskCategory };
return {
...project,
taskCategories: {
...project.taskCategories,
[taskCategory.id]: taskCategory,
}
};
}
export interface RemoveTaskCategory extends Action {
taskCategoryId: TaskCategoryID
}
@ -336,18 +328,28 @@ export function removeTaskCategory(taskCategoryId: TaskCategoryID): RemoveTaskCa
return { type: REMOVE_TASK_CATEGORY, taskCategoryId };
}
export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCategory): Project {
const taskCategories = { ...project.taskCategories };
delete taskCategories[action.taskCategoryId];
export interface TaskCategoryRemoved extends Action {
id: number
}
export const TASK_CATEGORY_REMOVED = "TASK_CATEGORY_REMOVED";
export function taskCategoryRemoved(id: number): TaskCategoryRemoved {
return { type: TASK_CATEGORY_REMOVED, id };
}
export function handleTaskCategoryRemoved(project: ProjectState, action: TaskCategoryRemoved): ProjectState {
const taskCategories = [...project.taskCategories];
const taskCategoryIndex = project.taskCategories.findIndex(tc => tc.id === action.id);
if (taskCategoryIndex === -1) return project;
taskCategories.splice(taskCategoryIndex, 1);
return {
...project,
taskCategories: {
...project.taskCategories,
...taskCategories
}
taskCategories
};
}
export interface ResetProject extends Action {
project: Project
}
@ -368,9 +370,26 @@ export function resetProject(project: Project): ResetProject {
return { type: RESET_PROJECT, project: newProject };
}
export function handleResetProject(project: Project, action: ResetProject): Project {
export function handleResetProject(project: ProjectState, action: ResetProject): ProjectState {
return {
...project,
...action.project,
};
}
export interface BackendError extends Action {
err: Error
}
export const BACKEND_ERROR = "BACKEND_ERROR";
export function backendError(err: Error): BackendError {
return { type: BACKEND_ERROR, err };
}
export function handleBackendError(project: ProjectState, action: BackendError): ProjectState {
return {
...project,
lastBackendError: action.err,
};
}

View File

@ -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(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
<App />,
document.getElementById('app')
);

77
client/src/util/apollo.ts Normal file
View File

@ -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<any>({
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<T>(fieldName: string) {
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
const merged: any[] = existing ? existing.slice(0) : [];
const objectFieldToIndex: Record<string, number> = 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;
}
}

14
internal/graph/error.go Normal file
View File

@ -0,0 +1,14 @@
package graph
import (
"github.com/vektah/gqlparser/v2/gqlerror"
)
var (
ErrAssociatedTaskExist = &gqlerror.Error{
Message: "",
Extensions: map[string]interface{}{
"code": "associated-task-exist",
},
}
)

View File

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

View File

@ -18,10 +18,32 @@ input ProjectTaskEstimationsChanges {
pessimistic: Float
}
input ProjectParamsChanges {
timeUnit: TimeUnitChanges
currency: String
roundUpEstimations: Boolean
hideFinancialPreviewOnPrint: Boolean
}
input TimeUnitChanges {
label: String
acronym: String
}
input ProjectTaskCategoryChanges {
label: String
costPerTimeUnit: Float
}
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!
addProjectTaskCategory(projectId: ID!, changes: ProjectTaskCategoryChanges!): TaskCategory!
updateProjectTaskCategory(projectId: ID!, taskCategoryId: ID!, changes: ProjectTaskCategoryChanges!): TaskCategory!
removeProjectTaskCategory(projectId: ID!, taskCategoryId: ID!): Boolean!
updateProjectParams(projectId: ID!, changes: ProjectParamsChanges!): ProjectParams!
}

View File

@ -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,26 @@ 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)
}
func (r *mutationResolver) AddProjectTaskCategory(ctx context.Context, projectID int64, changes model.ProjectTaskCategoryChanges) (*model.TaskCategory, error) {
return handleAddProjectTaskCategory(ctx, projectID, changes)
}
func (r *mutationResolver) UpdateProjectTaskCategory(ctx context.Context, projectID int64, taskCategoryID int64, changes model.ProjectTaskCategoryChanges) (*model.TaskCategory, error) {
return handleUpdateProjectTaskCategory(ctx, projectID, taskCategoryID, changes)
}
func (r *mutationResolver) RemoveProjectTaskCategory(ctx context.Context, projectID int64, taskCategoryID int64) (bool, error) {
return handleRemoveProjectTaskCategory(ctx, projectID, taskCategoryID)
}
func (r *mutationResolver) UpdateProjectParams(ctx context.Context, projectID int64, changes model.ProjectParamsChanges) (*model.ProjectParams, error) {
return handleUpdateProjectParams(ctx, projectID, changes)
}
// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

View File

@ -3,6 +3,8 @@ package graph
import (
"context"
"github.com/99designs/gqlgen/graphql"
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
model1 "forge.cadoles.com/Cadoles/guesstimate/internal/model"
"github.com/pkg/errors"
@ -64,7 +66,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 +96,88 @@ 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
}
func handleUpdateProjectParams(ctx context.Context, projectID int64, changes model.ProjectParamsChanges) (*model.ProjectParams, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
project, err := repo.UpdateParams(ctx, projectID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return project.Params, nil
}
func handleAddProjectTaskCategory(ctx context.Context, projectID int64, changes model.ProjectTaskCategoryChanges) (*model.TaskCategory, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
taskCategory, err := repo.AddTaskCategory(ctx, projectID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return taskCategory, nil
}
func handleUpdateProjectTaskCategory(ctx context.Context, projectID int64, taskCategoryID int64, changes model.ProjectTaskCategoryChanges) (*model.TaskCategory, error) {
db, err := getDB(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
taskCategory, err := repo.UpdateTaskCategory(ctx, projectID, taskCategoryID, changes)
if err != nil {
return nil, errors.WithStack(err)
}
return taskCategory, nil
}
func handleRemoveProjectTaskCategory(ctx context.Context, projectID int64, taskCategoryID int64) (bool, error) {
db, err := getDB(ctx)
if err != nil {
return false, errors.WithStack(err)
}
repo := model.NewProjectRepository(db)
if err := repo.RemoveTaskCategory(ctx, projectID, taskCategoryID); err != nil {
if errors.Is(err, model.ErrAssociatedTaskExist) {
graphql.AddError(ctx, ErrAssociatedTaskExist)
return false, nil
}
return false, errors.WithStack(err)
}
return true, nil
}

View File

@ -18,15 +18,7 @@ func (r *queryResolver) Projects(ctx context.Context, filter *model1.ProjectsFil
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.
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 taskResolver struct{ *Resolver }

7
internal/model/error.go Normal file
View File

@ -0,0 +1,7 @@
package model
import "errors"
var (
ErrAssociatedTaskExist = errors.New("associated task exist")
)

View File

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

View File

@ -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,58 +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 := tx.Save(task).Error; err != nil {
return errors.Wrap(err, "could not create task")
if err := updateTaskWithChanges(tx, task, changes); err != nil {
return errors.WithStack(err)
}
err := tx.Model(project).Association("Tasks").Append(task).Error
@ -219,7 +172,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 +186,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 +212,218 @@ func (r *ProjectRepository) UpdateTaskEstimation(ctx context.Context, projectID,
return nil, errors.Wrap(err, "could not update task")
}
return nil, nil
return task, nil
}
func (r *ProjectRepository) UpdateParams(ctx context.Context, projectID int64, changes ProjectParamsChanges) (*Project, error) {
project := &Project{}
project.ID = projectID
err := r.db.Transaction(func(tx *gorm.DB) error {
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)
}
if project.Params == nil {
project.Params = &ProjectParams{}
}
if changes.Currency != nil {
project.Params.Currency = *changes.Currency
}
if changes.HideFinancialPreviewOnPrint != nil {
project.Params.HideFinancialPreviewOnPrint = *changes.HideFinancialPreviewOnPrint
}
if changes.RoundUpEstimations != nil {
project.Params.RoundUpEstimations = *changes.RoundUpEstimations
}
if changes.TimeUnit != nil {
if project.Params.TimeUnit == nil {
project.Params.TimeUnit = &TimeUnit{}
}
if changes.TimeUnit.Acronym != nil {
project.Params.TimeUnit.Acronym = *changes.TimeUnit.Acronym
}
if changes.TimeUnit.Label != nil {
project.Params.TimeUnit.Label = *changes.TimeUnit.Label
}
}
if err := tx.Save(project).Error; err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not update project params")
}
return project, nil
}
func (r *ProjectRepository) AddTaskCategory(ctx context.Context, projectID int64, changes ProjectTaskCategoryChanges) (*TaskCategory, error) {
project := &Project{}
project.ID = projectID
taskCategory := &TaskCategory{}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := updateTaskCategoryWithChanges(tx, taskCategory, changes); err != nil {
return errors.WithStack(err)
}
err := tx.Model(project).Association("TaskCategories").Append(taskCategory).Error
if err != nil {
return errors.Wrap(err, "could not add task category")
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not add task category")
}
return taskCategory, nil
}
func (r *ProjectRepository) RemoveTaskCategory(ctx context.Context, projectID int64, taskCategoryID int64) error {
project := &Project{}
project.ID = projectID
err := r.db.Transaction(func(tx *gorm.DB) error {
taskCategory := &TaskCategory{}
taskCategory.ID = taskCategoryID
var totalAssociatedTasks int
if err := tx.Model(&Task{}).Where("category_id = ?", taskCategoryID).Count(&totalAssociatedTasks).Error; err != nil {
return errors.Wrap(err, "could not count associated tasks")
}
if totalAssociatedTasks != 0 {
return errors.WithStack(ErrAssociatedTaskExist)
}
err := tx.Model(project).Association("TaskCategories").Delete(taskCategory).Error
if err != nil {
return errors.Wrap(err, "could not remove task category relationship")
}
err = tx.Delete(taskCategory, "id = ? AND project_id = ?", taskCategoryID, projectID).Error
if err != nil {
return errors.Wrap(err, "could not delete task category")
}
return nil
})
if err != nil {
return errors.Wrap(err, "could not remove task category")
}
return nil
}
func (r *ProjectRepository) UpdateTaskCategory(ctx context.Context, projectID, taskCategoryID int64, changes ProjectTaskCategoryChanges) (*TaskCategory, error) {
taskCategory := &TaskCategory{}
err := r.db.Transaction(func(tx *gorm.DB) error {
err := tx.Model(taskCategory).
First(taskCategory, "id = ? AND project_id = ?", taskCategoryID, projectID).
Error
if err != nil {
return errors.WithStack(err)
}
if err := updateTaskCategoryWithChanges(tx, taskCategory, changes); err != nil {
return errors.WithStack(err)
}
if err := tx.Save(taskCategory).Error; err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "could not update task category")
}
return taskCategory, 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 updateTaskCategoryWithChanges(db *gorm.DB, taskCategory *TaskCategory, changes ProjectTaskCategoryChanges) error {
if changes.Label != nil {
taskCategory.Label = *changes.Label
}
if changes.CostPerTimeUnit != nil {
taskCategory.CostPerTimeUnit = *changes.CostPerTimeUnit
}
return nil
}
func NewProjectRepository(db *gorm.DB) *ProjectRepository {