add project linking.
continuous-integration/drone/push Build is passing

This commit is contained in:
2021-04-03 16:30:43 +02:00
parent 2c5d87134d
commit 2a6c04893d
17 changed files with 365 additions and 46 deletions
+60
View File
@@ -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 {
+5
View File
@@ -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
}
+88 -8
View File
@@ -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 {
@@ -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
}
@@ -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
+10
View File
@@ -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
}
+12 -1
View File
@@ -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,8 +301,19 @@ 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 {
foundLink := false
for _, link := range links {
if link.TaskID == task.ID && link.ProjectID == project.ID {
foundLink = true
break
}
}
if !foundLink {
continue
}
}
taskResult := &models.TaskResult{
Task: *task,
+3
View File
@@ -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 @@
<ModalRoute name="goal.add"> <GoalForm creation /></ModalRoute>
<ModalRoute name="goal.edit"> <GoalForm /></ModalRoute>
<ModalRoute name="goal.delete"> <GoalForm deletion /></ModalRoute>
<ModalRoute name="tasklink.add"> <TaskLinkForm creation /></ModalRoute>
<ModalRoute name="tasklink.delete"> <TaskLinkForm deletion /></ModalRoute>
{:else}
<LoginForm />
{/if}
+13 -1
View File
@@ -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<TaskLink> {
const data = await this.fetch("PUT", `/api/task/${taskId}/link/${projectId}`);
return data.taskLink;
}
async deleteTaskLink(projectId: string, taskId: string): Promise<TaskLink> {
const data = await this.fetch("DELETE", `/api/task/${taskId}/link/${projectId}`);
return data.taskLink;
}
async findTask(id: string): Promise<TaskResult> {
const data = await this.fetch("GET", `/api/task/${id}`);
return data.task;
@@ -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);
}
</script>
<div class="progress">
@@ -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}
<OptionRow>
<Option open={mdAddTask}>Add Task</Option>
<Option open={mdLinkTask}>Link Task</Option>
<Option open={mdProjectEdit}>Edit</Option>
<Option open={mdProjectDelete}>Delete</Option>
{#if canComplete}
@@ -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")
+9 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import { tick } from "svelte";
import stuffLogClient from "../clients/stufflog";
import type Project from "../models/project";
@@ -23,7 +23,9 @@ import { tick } from "svelte";
let mdLogAdd: ModalData;
let mdTaskEdit: ModalData;
let mdTaskDelete: ModalData;
let mdTaskUnlink: ModalData;
let isMoving = false;
let isLinked = false;
function toggleShowLogs() {
showLogs = !showLogs;
@@ -66,6 +68,8 @@ import { tick } from "svelte";
$: mdLogAdd = {name: "log.add", task: {...task, project}};
$: mdTaskEdit = {name: "task.edit", task: {...task, project}};
$: mdTaskDelete = {name: "task.delete", task: {...task, project}};
$: mdTaskUnlink = {name: "tasklink.delete", task, project};
$: isLinked = task.projectId !== project.id;
</script>
<StatusColor affects="task" entry={task}>
@@ -81,6 +85,9 @@ import { tick } from "svelte";
<Option open={mdLogAdd}>Add Log</Option>
{#if showAllOptions}
<Option open={mdTaskEdit}>Edit</Option>
{#if isLinked}
<Option open={mdTaskUnlink}>Unlink</Option>
{:else}
<Option open={mdTaskDelete}>Delete</Option>
·
{#if !isMoving && (!task.active) }
@@ -102,6 +109,7 @@ import { tick } from "svelte";
<Option color="red" on:click={moveToFailed}>Failed</Option>
{/if}
{/if}
{/if}
</OptionRow>
{#if showLogs && task.logs.length > 0}
<div class="log-list">
@@ -0,0 +1,36 @@
<script lang="ts">
import projectStore from "../stores/project";
export let value = "";
export let name = "";
export let disabled = false;
export let optional = false;
$: {
if ($projectStore.stale && !$projectStore.loading) {
projectStore.load({});
}
}
$: {
if ($projectStore.projects.length > 0 && value === "" && !optional) {
const nonEmpty = $projectStore.projects.find(g => g.tasks.length > 0);
if (nonEmpty != null) {
value = nonEmpty.tasks[0].id;
}
}
}
</script>
<select name={name} bind:value={value} disabled={disabled || $projectStore.loading}>
{#if optional}
<option value={""} selected={"" === value}>None</option>
{/if}
{#each $projectStore.projects as project (project.id)}
<optgroup label={project.name}>
{#each project.tasks as task (task.id)}
<option value={task.id} selected={task.id === value}>{task.name}</option>
{/each}
</optgroup>
{/each}
</select>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import stuffLogClient from "../clients/stufflog";
import Modal from "../components/Modal.svelte";
import ProjectSelect from "../components/ProjectSelect.svelte";
import TaskSelect from "../components/TaskSelect.svelte";
import markStale from "../stores/markStale";
import modalStore from "../stores/modal";
export let deletion = false;
export let creation = false;
const md = $modalStore;
let projectId = "";
let taskId = "";
let verb = "Add";
if (md.name === "tasklink.delete") {
projectId = md.project.id;
taskId = md.task.id;
verb = "Delete";
} else if (md.name === "tasklink.add") {
projectId = (md.project||{id:""}).id;
taskId = (md.task||{id:""}).id;
} else {
throw new Error(`Wrong form ${md.name}`)
}
let error = null;
let loading = false;
function onSubmit() {
loading = true;
error = null;
if (creation) {
stuffLogClient.createTaskLink(projectId, taskId).then(() => {
markStale("goal", "project");
modalStore.close();
}).catch(err => {
error = err.message ? err.message : err.toString();
}).finally(() => {
loading = false;
})
} else if (deletion) {
stuffLogClient.deleteTaskLink(projectId, taskId).then(() => {
markStale("goal", "project");
modalStore.close();
}).catch(err => {
error = err.message ? err.message : err.toString();
}).finally(() => {
loading = false;
})
}
}
</script>
<Modal show title="{verb} Link" error={error} closable on:close={modalStore.close}>
<form on:submit|preventDefault={onSubmit}>
<label for="taskId">Source Task</label>
<TaskSelect disabled={deletion} name="taskId" bind:value={taskId} />
<label for="projectId">Destination Project</label>
<ProjectSelect disabled={deletion} name="projectId" bind:value={projectId} />
<hr />
<button disabled={loading} type="submit">{verb} Link</button>
</form>
</Modal>
+6
View File
@@ -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
+1 -1
View File
@@ -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({});
}
}
+6 -2
View File
@@ -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<ModalData>({name: "none"});