Browse Source

add project linking.

main
Gisle Aune 4 years ago
parent
commit
2a6c04893d
  1. 60
      api/task.go
  2. 5
      database/postgres/project.go
  3. 96
      database/postgres/tasks.go
  4. 15
      migrations/postgres/20210403142913_create_table_task_link.sql
  5. 10
      models/task.go
  6. 13
      services/loader.go
  7. 3
      svelte-ui/src/App.svelte
  8. 14
      svelte-ui/src/clients/stufflog.ts
  9. 5
      svelte-ui/src/components/ProgressNumbers.svelte
  10. 3
      svelte-ui/src/components/ProjectEntry.svelte
  11. 14
      svelte-ui/src/components/ProjectSelect.svelte
  12. 10
      svelte-ui/src/components/TaskEntry.svelte
  13. 36
      svelte-ui/src/components/TaskSelect.svelte
  14. 69
      svelte-ui/src/forms/TaskLinkForm.svelte
  15. 6
      svelte-ui/src/models/task.ts
  16. 2
      svelte-ui/src/pages/QLPage.svelte
  17. 8
      svelte-ui/src/stores/modal.ts

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

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

96
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, nil, err
}
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 nil, err
}
return res, nil
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
}

15
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

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

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

14
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<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;

5
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);
}
</script>
<div class="progress">

3
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}
<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
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")

10
svelte-ui/src/components/TaskEntry.svelte

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

36
svelte-ui/src/components/TaskSelect.svelte

@ -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
svelte-ui/src/forms/TaskLinkForm.svelte

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

2
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({});
}
}

8
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<ModalData>({name: "none"});

Loading…
Cancel
Save