diff --git a/api/task.go b/api/task.go index 67c0245..45c7a78 100644 --- a/api/task.go +++ b/api/task.go @@ -92,6 +92,15 @@ func Task(g *gin.RouterGroup, db database.Database) { return nil, slerrors.NotFound("Destination project") } + // Delete any links that would have been ran over. + err = db.Tasks().DeleteLink(c.Request.Context(), models.TaskLink{ + TaskID: task.ID, + ProjectID: project.ID, + }) + if err != nil { + return nil, slerrors.BadRequest(err.Error()) + } + task.ProjectID = project.ID } @@ -111,6 +120,57 @@ func Task(g *gin.RouterGroup, db database.Database) { return task, nil })) + g.PUT("/:id/link/:project_id", handler("taskLink", func(c *gin.Context) (interface{}, error) { + task, err := l.FindTask(c.Request.Context(), c.Param("id")) + if err != nil { + return nil, err + } + + project, err := l.FindProject(c.Request.Context(), c.Param("project_id")) + if err != nil { + return nil, err + } + + if task.ProjectID == project.ID { + return nil, slerrors.BadRequest("You cannot link a task within its own project") + } + + err = db.Tasks().CreateLink(c.Request.Context(), models.TaskLink{ + TaskID: task.ID, + ProjectID: project.ID, + }) + + return &models.TaskLink{ + TaskID: task.ID, + ProjectID: project.ID, + }, nil + })) + + g.DELETE("/:id/link/:project_id", handler("taskLink", func(c *gin.Context) (interface{}, error) { + task, err := l.FindTask(c.Request.Context(), c.Param("id")) + if err != nil { + return nil, err + } + + project, err := l.FindProject(c.Request.Context(), c.Param("project_id")) + if err != nil { + return nil, err + } + + err = db.Tasks().DeleteLink(c.Request.Context(), models.TaskLink{ + TaskID: task.ID, + ProjectID: project.ID, + }) + if err != nil { + return nil, err + } + + return &models.TaskLink{ + TaskID: task.ID, + ProjectID: project.ID, + }, nil + })) + g.DELETE("/:id", handler("task", func(c *gin.Context) (interface{}, error) { task, err := l.FindTask(c.Request.Context(), c.Param("id")) if err != nil { diff --git a/database/postgres/project.go b/database/postgres/project.go index ae02e24..fdd626b 100644 --- a/database/postgres/project.go +++ b/database/postgres/project.go @@ -105,5 +105,10 @@ func (r *projectRepository) Delete(ctx context.Context, project models.Project) return err } + _, err = r.db.ExecContext(ctx, `DELETE FROM task_link WHERE project_id=$1`, project.ID) + if err != nil && err != sql.ErrNoRows { + return err + } + return nil } diff --git a/database/postgres/tasks.go b/database/postgres/tasks.go index 71b9e2d..3c6212b 100644 --- a/database/postgres/tasks.go +++ b/database/postgres/tasks.go @@ -28,7 +28,17 @@ func (r *taskRepository) Find(ctx context.Context, id string) (*models.Task, err } func (r *taskRepository) List(ctx context.Context, filter models.TaskFilter) ([]*models.Task, error) { - sq := squirrel.Select("task.*", "p.icon").From("task").PlaceholderFormat(squirrel.Dollar) + tasks, _, err := r.ListWithLinks(ctx, filter) + return tasks, err +} + +func (r *taskRepository) ListWithLinks(ctx context.Context, filter models.TaskFilter) ([]*models.Task, []*models.TaskLink, error) { + type tasksWithLinks struct { + models.Task + LinkedProjectID *string `db:"tl_project_id"` + } + + sq := squirrel.Select("task.*", "tl.project_id as tl_project_id", "p.icon").From("task").PlaceholderFormat(squirrel.Dollar) sq = sq.Where(squirrel.Eq{"task.user_id": filter.UserID}) if filter.Active != nil { sq = sq.Where(squirrel.Eq{"task.active": *filter.Active}) @@ -47,7 +57,12 @@ func (r *taskRepository) List(ctx context.Context, filter models.TaskFilter) ([] sq = sq.Where(squirrel.Eq{"task.item_id": filter.ItemIDs}) } if filter.ProjectIDs != nil { - sq = sq.Where(squirrel.Eq{"task.project_id": filter.ProjectIDs}) + sq = sq.LeftJoin("task_link AS tl ON task.task_id = tl.task_id") + + sq = sq.Where(squirrel.Or{ + squirrel.Eq{"task.project_id": filter.ProjectIDs}, + squirrel.Eq{"tl.project_id": filter.ProjectIDs}, + }) } sq = sq.InnerJoin("project AS p ON task.project_id = p.project_id") @@ -55,19 +70,37 @@ func (r *taskRepository) List(ctx context.Context, filter models.TaskFilter) ([] query, args, err := sq.ToSql() if err != nil { - return nil, err + return nil, nil, err } - res := make([]*models.Task, 0, 8) - err = r.db.SelectContext(ctx, &res, query, args...) + rows := make([]tasksWithLinks, 0, 8) + err = r.db.SelectContext(ctx, &rows, query, args...) if err != nil { if err == sql.ErrNoRows { - return res, nil + return []*models.Task{}, []*models.TaskLink{}, nil } - return nil, err + return nil, nil, err } - return res, nil + added := make(map[string]bool) + tasks := make([]*models.Task, 0, len(rows)) + links := make([]*models.TaskLink, 0, len(rows)) + for _, row := range rows { + if row.LinkedProjectID != nil { + links = append(links, &models.TaskLink{ + TaskID: row.Task.ID, + ProjectID: *row.LinkedProjectID, + }) + } + + if !added[row.Task.ID] { + task := row.Task + tasks = append(tasks, &task) + added[row.Task.ID] = true + } + } + + return tasks, links, nil } func (r *taskRepository) Insert(ctx context.Context, task models.Task) error { @@ -101,7 +134,7 @@ func (r *taskRepository) Update(ctx context.Context, task models.Task) error { if err != nil { return err } - + _, err = r.db.NamedExecContext(ctx, `UPDATE log SET item_id = :item_id WHERE task_id=:task_id`, &task) if err != nil { return err @@ -110,6 +143,48 @@ func (r *taskRepository) Update(ctx context.Context, task models.Task) error { return nil } +func (r *taskRepository) CreateLink(ctx context.Context, link models.TaskLink) error { + _, err := r.db.NamedExecContext(ctx, ` + INSERT INTO task_link (project_id, task_id) VALUES (:project_id, :task_id) + ON CONFLICT DO NOTHING + `, &link) + + return err +} + +func (r *taskRepository) DeleteLink(ctx context.Context, link models.TaskLink) error { + _, err := r.db.NamedExecContext(ctx, ` + DELETE FROM task_link WHERE task_id=:task_id AND project_id=:project_id + `, &link) + if err == sql.ErrNoRows { + err = slerrors.NotFound("Link") + } + + return err +} + +func (r *taskRepository) UnlinkTask(ctx context.Context, task models.Task) error { + _, err := r.db.ExecContext(ctx, ` + DELETE FROM task_link WHERE task_id=$1; + `, task.ID) + if err == sql.ErrNoRows { + err = nil + } + + return err +} + +func (r *taskRepository) UnlinkProject(ctx context.Context, project models.Project) error { + _, err := r.db.ExecContext(ctx, ` + DELETE FROM task_link WHERE task_id=$1; + `, project.ID) + if err == sql.ErrNoRows { + err = nil + } + + return err +} + func (r *taskRepository) Delete(ctx context.Context, task models.Task) error { _, err := r.db.ExecContext(ctx, `DELETE FROM task WHERE task_id=$1`, task.ID) if err != nil { @@ -119,5 +194,10 @@ func (r *taskRepository) Delete(ctx context.Context, task models.Task) error { return err } + _, err = r.db.ExecContext(ctx, `DELETE FROM task_link WHERE task_id=$1`, task.ID) + if err != nil && err != sql.ErrNoRows { + return err + } + return nil } diff --git a/migrations/postgres/20210403142913_create_table_task_link.sql b/migrations/postgres/20210403142913_create_table_task_link.sql new file mode 100644 index 0000000..8411d8c --- /dev/null +++ b/migrations/postgres/20210403142913_create_table_task_link.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE task_link ( + project_id CHAR(16) NOT NULL, + task_id CHAR(16) NOT NULL, + + PRIMARY KEY (project_id, task_id), + UNIQUE (task_id, project_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE task_link; +-- +goose StatementEnd diff --git a/models/task.go b/models/task.go index 4f2feb3..36e1ffc 100644 --- a/models/task.go +++ b/models/task.go @@ -67,6 +67,11 @@ type TaskUpdate struct { ProjectID *string `json:"projectId"` } +type TaskLink struct { + TaskID string `json:"taskId" db:"task_id"` + ProjectID string `json:"projectId" db:"project_id"` +} + type TaskResult struct { Task Item *Item `json:"item"` @@ -87,7 +92,12 @@ type TaskFilter struct { type TaskRepository interface { Find(ctx context.Context, id string) (*Task, error) List(ctx context.Context, filter TaskFilter) ([]*Task, error) + ListWithLinks(ctx context.Context, filter TaskFilter) ([]*Task, []*TaskLink, error) Insert(ctx context.Context, task Task) error Update(ctx context.Context, task Task) error + CreateLink(ctx context.Context, link TaskLink) error + DeleteLink(ctx context.Context, link TaskLink) error + UnlinkTask(ctx context.Context, task Task) error + UnlinkProject(ctx context.Context, project Project) error Delete(ctx context.Context, task Task) error } diff --git a/services/loader.go b/services/loader.go index 35fd582..422e761 100644 --- a/services/loader.go +++ b/services/loader.go @@ -265,7 +265,7 @@ func (l *Loader) ListProjects(ctx context.Context, filter models.ProjectFilter) projectIDs = append(projectIDs, project.ID) } - tasks, err := l.DB.Tasks().List(ctx, models.TaskFilter{ + tasks, links, err := l.DB.Tasks().ListWithLinks(ctx, models.TaskFilter{ UserID: auth.UserID(ctx), ProjectIDs: projectIDs, }) @@ -301,7 +301,18 @@ func (l *Loader) ListProjects(ctx context.Context, filter models.ProjectFilter) results[i].Tasks = make([]*models.TaskResult, 0, 16) for _, task := range tasks { if task.ProjectID != project.ID { - continue + foundLink := false + + for _, link := range links { + if link.TaskID == task.ID && link.ProjectID == project.ID { + foundLink = true + break + } + } + + if !foundLink { + continue + } } taskResult := &models.TaskResult{ diff --git a/svelte-ui/src/App.svelte b/svelte-ui/src/App.svelte index 101c371..fd158b7 100644 --- a/svelte-ui/src/App.svelte +++ b/svelte-ui/src/App.svelte @@ -19,6 +19,7 @@ import ProjectForm from "./forms/ProjectForm.svelte"; import ItemForm from "./forms/ItemForm.svelte"; import TaskForm from "./forms/TaskForm.svelte"; + import TaskLinkForm from "./forms/TaskLinkForm.svelte"; import LoginForm from "./forms/LoginForm.svelte"; import ModalRoute from "./components/ModalRoute.svelte"; @@ -71,6 +72,8 @@ + + {:else} {/if} diff --git a/svelte-ui/src/clients/stufflog.ts b/svelte-ui/src/clients/stufflog.ts index 6645520..65fba9b 100644 --- a/svelte-ui/src/clients/stufflog.ts +++ b/svelte-ui/src/clients/stufflog.ts @@ -1,7 +1,7 @@ import { getJwt } from "./amplify"; import type { GoalFilter, GoalInput, GoalResult, GoalUpdate } from "../models/goal"; import type { ProjectFilter, ProjectInput, ProjectResult, ProjectUpdate } from "../models/project"; -import type { TaskFilter, TaskInput, TaskResult, TaskUpdate } from "../models/task"; +import type { TaskFilter, TaskInput, TaskLink, TaskResult, TaskUpdate } from "../models/task"; import type { LogFilter, LogInput, LogResult, LogUpdate } from "../models/log"; import type { GroupInput, GroupResult, GroupUpdate } from "../models/group"; import type { ItemInput, ItemResult, ItemUpdate } from "../models/item"; @@ -132,6 +132,18 @@ export class StufflogClient { + async createTaskLink(projectId: string, taskId: string): Promise { + const data = await this.fetch("PUT", `/api/task/${taskId}/link/${projectId}`); + return data.taskLink; + } + + async deleteTaskLink(projectId: string, taskId: string): Promise { + const data = await this.fetch("DELETE", `/api/task/${taskId}/link/${projectId}`); + return data.taskLink; + } + + + async findTask(id: string): Promise { const data = await this.fetch("GET", `/api/task/${id}`); return data.task; diff --git a/svelte-ui/src/components/ProgressNumbers.svelte b/svelte-ui/src/components/ProgressNumbers.svelte index 617fad9..11270e3 100644 --- a/svelte-ui/src/components/ProgressNumbers.svelte +++ b/svelte-ui/src/components/ProgressNumbers.svelte @@ -50,7 +50,6 @@ const boatTarget = Math.ceil(timeProgress * progressTarget); boat = progressAmount - boatTarget; - console.log(progressTarget, boatTarget); } else { boat = null; } @@ -67,10 +66,6 @@ } } } - - $: { - console.log(boat); - }
diff --git a/svelte-ui/src/components/ProjectEntry.svelte b/svelte-ui/src/components/ProjectEntry.svelte index 7b5112b..9adaf50 100644 --- a/svelte-ui/src/components/ProjectEntry.svelte +++ b/svelte-ui/src/components/ProjectEntry.svelte @@ -26,6 +26,7 @@ import stuffLogClient from "../clients/stufflog"; let mdAddTask: ModalData; let mdProjectEdit: ModalData; let mdProjectDelete: ModalData; + let mdLinkTask: ModalData; let linkTarget: string = ""; let activeTasks: TaskResult[] = []; let inactiveTasks: TaskResult[] = []; @@ -73,6 +74,7 @@ import stuffLogClient from "../clients/stufflog"; } $: mdAddTask = {name:"task.add", project}; + $: mdLinkTask = {name:"tasklink.add", project}; $: mdProjectEdit = {name:"project.edit", project}; $: mdProjectDelete = {name:"project.delete", project}; @@ -114,6 +116,7 @@ import stuffLogClient from "../clients/stufflog"; {#if showAllOptions} + {#if canComplete} diff --git a/svelte-ui/src/components/ProjectSelect.svelte b/svelte-ui/src/components/ProjectSelect.svelte index 85fb3e5..e8b84a5 100644 --- a/svelte-ui/src/components/ProjectSelect.svelte +++ b/svelte-ui/src/components/ProjectSelect.svelte @@ -14,12 +14,6 @@ let optGroups: OptGroup[] - $: { - if ($projectStore.stale && !$projectStore.loading) { - projectStore.load({}); - } - } - $: { optGroups = [ { @@ -42,6 +36,14 @@ status: "Completed", projects: $projectStore.projects.filter(p => !p.active && p.statusTag === "completed") }, + { + status: "Background", + projects: $projectStore.projects.filter(p => !p.active && p.statusTag === "background") + }, + { + status: "Progress", + projects: $projectStore.projects.filter(p => !p.active && p.statusTag === "progress") + }, { status: "Failed", projects: $projectStore.projects.filter(p => !p.active && p.statusTag === "failed") diff --git a/svelte-ui/src/components/TaskEntry.svelte b/svelte-ui/src/components/TaskEntry.svelte index 6f37d28..5f948e6 100644 --- a/svelte-ui/src/components/TaskEntry.svelte +++ b/svelte-ui/src/components/TaskEntry.svelte @@ -1,5 +1,5 @@ @@ -81,25 +85,29 @@ import { tick } from "svelte"; {#if showAllOptions} - - · - {#if !isMoving && (!task.active) } - - {/if} - {#if !isMoving && (task.statusTag !== "to do") } - - {/if} - {#if !isMoving && (task.statusTag !== "on hold") } - - {/if} - {#if !isMoving && (task.statusTag !== "declined") } - - {/if} - {#if !isMoving && task.active } - - {/if} - {#if !isMoving && (task.statusTag !== "failed") } - + {#if isLinked} + + {:else} + + · + {#if !isMoving && (!task.active) } + + {/if} + {#if !isMoving && (task.statusTag !== "to do") } + + {/if} + {#if !isMoving && (task.statusTag !== "on hold") } + + {/if} + {#if !isMoving && (task.statusTag !== "declined") } + + {/if} + {#if !isMoving && task.active } + + {/if} + {#if !isMoving && (task.statusTag !== "failed") } + + {/if} {/if} {/if} diff --git a/svelte-ui/src/components/TaskSelect.svelte b/svelte-ui/src/components/TaskSelect.svelte new file mode 100644 index 0000000..b28147b --- /dev/null +++ b/svelte-ui/src/components/TaskSelect.svelte @@ -0,0 +1,36 @@ + + + \ No newline at end of file diff --git a/svelte-ui/src/forms/TaskLinkForm.svelte b/svelte-ui/src/forms/TaskLinkForm.svelte new file mode 100644 index 0000000..38d7c60 --- /dev/null +++ b/svelte-ui/src/forms/TaskLinkForm.svelte @@ -0,0 +1,69 @@ + + + +
+ + + + + +
+ + + +
\ No newline at end of file diff --git a/svelte-ui/src/models/task.ts b/svelte-ui/src/models/task.ts index 892b4a7..9732ca0 100644 --- a/svelte-ui/src/models/task.ts +++ b/svelte-ui/src/models/task.ts @@ -23,6 +23,12 @@ export interface TaskResult extends Task { project?: Project } +// TaskLink is only returned by the API. The tasks are returned transparently. +export interface TaskLink { + taskId: string + projectId: string +} + export interface TaskFilter { active?: boolean expiring?: boolean diff --git a/svelte-ui/src/pages/QLPage.svelte b/svelte-ui/src/pages/QLPage.svelte index 7bc040c..4b826be 100644 --- a/svelte-ui/src/pages/QLPage.svelte +++ b/svelte-ui/src/pages/QLPage.svelte @@ -4,7 +4,7 @@ import projectStore from "../stores/project"; $: { - if (($projectStore.stale || $projectStore.filter.active != null) && !$projectStore.loading) { + if ($projectStore.stale && !$projectStore.loading) { projectStore.load({}); } } diff --git a/svelte-ui/src/stores/modal.ts b/svelte-ui/src/stores/modal.ts index 9b85583..0aa62e9 100644 --- a/svelte-ui/src/stores/modal.ts +++ b/svelte-ui/src/stores/modal.ts @@ -3,8 +3,10 @@ import type { GoalResult } from "../models/goal"; import type { GroupResult } from "../models/group"; import type { ItemResult } from "../models/item"; import type { LogResult } from "../models/log"; -import type { ProjectResult } from "../models/project"; -import type { TaskResult } from "../models/task"; +import type {ProjectResult} from "../models/project"; +import type {TaskResult} from "../models/task"; +import type Project from "../models/project"; +import type Task from "../models/task"; export type ModalData = | { name: "none" } @@ -26,6 +28,8 @@ export type ModalData = | { name: "goal.add" } | { name: "goal.edit", goal: GoalResult } | { name: "goal.delete", goal: GoalResult } + | { name: "tasklink.add", project?: Project, task?: Task } + | { name: "tasklink.delete", project: Project, task: Task } function createModalStore() { const {set, subscribe} = writable({name: "none"});