Gisle Aune
3 years ago
29 changed files with 524 additions and 64 deletions
-
4api/project.go
-
2database/postgres/projectgroups.go
-
5models/project.go
-
2services/loader.go
-
12svelte-ui/src/App.svelte
-
17svelte-ui/src/clients/stufflog.ts
-
6svelte-ui/src/components/ChildEntry.svelte
-
17svelte-ui/src/components/ColoredNumber.svelte
-
4svelte-ui/src/components/FocusHandler.svelte
-
3svelte-ui/src/components/Modal.svelte
-
11svelte-ui/src/components/OptionRow.svelte
-
111svelte-ui/src/components/ProjectGroupMenu.svelte
-
19svelte-ui/src/components/ProjectGroupSelect.svelte
-
1svelte-ui/src/components/ProjectSelect.svelte
-
3svelte-ui/src/components/QLList.svelte
-
14svelte-ui/src/components/QLListItem.svelte
-
84svelte-ui/src/components/QuestLog.svelte
-
2svelte-ui/src/components/StatusColor.svelte
-
2svelte-ui/src/components/Tag.svelte
-
19svelte-ui/src/forms/ProjectForm.svelte
-
105svelte-ui/src/forms/ProjectGroupForm.svelte
-
3svelte-ui/src/models/project.ts
-
18svelte-ui/src/models/projectgroup.ts
-
2svelte-ui/src/pages/ProjectPage.svelte
-
36svelte-ui/src/pages/QLPage.svelte
-
2svelte-ui/src/stores/markStale.ts
-
6svelte-ui/src/stores/modal.ts
-
48svelte-ui/src/stores/projectGroup.ts
-
28svelte-ui/src/utils/sorters.ts
@ -0,0 +1,17 @@ |
|||
<script lang="ts"> |
|||
import StatusColor from "./StatusColor.svelte"; |
|||
|
|||
export let selected: boolean = false; |
|||
export let status: string = "to do"; |
|||
export let number: number | null | undefined = void(0); |
|||
|
|||
let entry: {statusTag: string} |
|||
|
|||
$: entry = {statusTag: status}; |
|||
</script> |
|||
|
|||
{#if (!!number)} |
|||
<StatusColor selected={selected} affects="project" entry={entry}> |
|||
<div class="sccfg">{number}</div> |
|||
</StatusColor> |
|||
{/if} |
@ -0,0 +1,111 @@ |
|||
<script lang="ts"> |
|||
import { tick } from "svelte"; |
|||
import { navigate } from "svelte-routing"; |
|||
|
|||
import type { ProjectGroupResult } from "../models/projectgroup"; |
|||
import modalStore from "../stores/modal"; |
|||
import projectGroupStore from "../stores/projectGroup"; |
|||
import { sortProjects } from "../utils/sorters"; |
|||
import ColoredNumber from "./ColoredNumber.svelte"; |
|||
|
|||
export let groups: ProjectGroupResult[] = []; |
|||
export let selected: string; |
|||
|
|||
let failedCount = 0; |
|||
|
|||
function onClickAdd() { |
|||
modalStore.set({name: "projectgroup.add"}); |
|||
} |
|||
|
|||
function onNavigate(group: ProjectGroupResult) { |
|||
if (group.projects.length === 0) { |
|||
navigate(`/questlog/${group.id}`); |
|||
} else { |
|||
const projetcs = [...group.projects].sort(sortProjects); |
|||
|
|||
navigate(`/questlog/${group.id}/${projetcs[0].id}`); |
|||
|
|||
// There's some weirdness with navigate. This hack will just do a |
|||
// groups = [...groups] in the sttore to get the page to update. |
|||
tick().then(() => { |
|||
projectGroupStore.fakeRefresh(); |
|||
}) |
|||
} |
|||
} |
|||
|
|||
</script> |
|||
|
|||
<div class="group-menu"> |
|||
{#each groups as group (group.id)} |
|||
<div class="group-entry" on:click={() => onNavigate(group)} class:selected={selected === group.id}> |
|||
<div class="name">{group.abbreviation}</div> |
|||
<div class="counts"> |
|||
<ColoredNumber selected={selected === group.id} status="active" number={group.projectCounts["active"]} /> |
|||
<ColoredNumber selected={selected === group.id} status="background" number={group.projectCounts["background"]} /> |
|||
<ColoredNumber selected={selected === group.id} status="progress" number={group.projectCounts["progress"]} /> |
|||
<ColoredNumber selected={selected === group.id} status="to do" number={group.projectCounts["to do"]} /> |
|||
<ColoredNumber selected={selected === group.id} status="on hold" number={group.projectCounts["on hold"]} /> |
|||
</div> |
|||
</div> |
|||
{/each} |
|||
|
|||
<div class="group-entry add" on:click={onClickAdd}> |
|||
<div>+</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
div.group-menu { |
|||
display: flex; |
|||
flex-direction: row; |
|||
padding: 0 2ch; |
|||
border-bottom: 1px solid #333; |
|||
margin-bottom: 0.5em; |
|||
|
|||
-webkit-user-select: none; |
|||
-moz-user-select: none; |
|||
} |
|||
|
|||
div.group-entry { |
|||
padding: 0.05em 1ch; |
|||
padding-bottom: 0.15em; |
|||
margin-right: 2ch; |
|||
border: 1px solid #333; |
|||
border-bottom: none; |
|||
border-top-left-radius: 0.5em; |
|||
border-top-right-radius: 0.5em; |
|||
cursor: pointer; |
|||
color: #777; |
|||
font-weight: 300; |
|||
} |
|||
div.group-entry.add { |
|||
color: #333; |
|||
} |
|||
div.group-entry.add > div { |
|||
font-size: 2em; |
|||
line-height: 1em; |
|||
} |
|||
div.group-entry.selected { |
|||
background-color: #191919; |
|||
color: #AAA; |
|||
} |
|||
div.group-entry:hover { |
|||
color: #CCC; |
|||
background-color: #222222; |
|||
} |
|||
|
|||
div.name { |
|||
text-align: center; |
|||
padding: 0em 0.4ch; |
|||
} |
|||
|
|||
div.counts { |
|||
font-size: 0.666em; |
|||
font-weight: 400; |
|||
text-align: center; |
|||
} |
|||
div.counts :global(div) { |
|||
display: inline-block; |
|||
padding: 0em 0.2ch; |
|||
} |
|||
</style> |
@ -0,0 +1,19 @@ |
|||
<script lang="ts"> |
|||
import projectGroupStore from "../stores/projectGroup"; |
|||
|
|||
export let value = ""; |
|||
export let name = ""; |
|||
export let disabled = false; |
|||
export let optional = false; |
|||
</script> |
|||
|
|||
<select name={name} bind:value={value} disabled={disabled || $projectGroupStore.loading}> |
|||
{#if optional} |
|||
<option value={""} selected={"" === value}>None</option> |
|||
{/if} |
|||
{#each $projectGroupStore.groups as group (group.id)} |
|||
{#if group.id !== "META_UNGROUPED"} |
|||
<option value={group.id} selected={group.id === value}>{group.name}</option> |
|||
{/if} |
|||
{/each} |
|||
</select> |
@ -0,0 +1,105 @@ |
|||
<script lang="ts"> |
|||
import { navigate } from "svelte-routing"; |
|||
|
|||
import stuffLogClient from "../clients/stufflog"; |
|||
import Modal from "../components/Modal.svelte"; |
|||
import type ProjectGroup from "../models/projectgroup"; |
|||
import markStale from "../stores/markStale"; |
|||
import modalStore from "../stores/modal"; |
|||
|
|||
export let deletion = false; |
|||
export let creation = false; |
|||
|
|||
const md = $modalStore; |
|||
let group: ProjectGroup = { |
|||
id: "", |
|||
name: "", |
|||
description: "", |
|||
abbreviation: "", |
|||
categoryNames: {}, |
|||
} |
|||
let verb = "Add"; |
|||
if (md.name === "projectgroup.edit" || md.name === "projectgroup.delete") { |
|||
group = md.projectGroup; |
|||
verb = (md.name === "projectgroup.edit") ? "Edit" : "Delete"; |
|||
} else if (md.name !== "projectgroup.add") { |
|||
throw new Error("Wrong form") |
|||
} |
|||
|
|||
let name = group.name; |
|||
let description = group.description; |
|||
let abbreviation = group.abbreviation; |
|||
let categoryNames = group.categoryNames || {}; |
|||
let error = null; |
|||
let loading = false; |
|||
|
|||
function onSubmit() { |
|||
loading = true; |
|||
error = null; |
|||
|
|||
if (creation) { |
|||
stuffLogClient.createProjectGroup({ |
|||
name, abbreviation, description, categoryNames |
|||
}).then(newGroup => { |
|||
markStale("project"); |
|||
modalStore.close(); |
|||
navigate(`/questlog/${newGroup.id}`); |
|||
}).catch(err => { |
|||
error = err.message ? err.message : err.toString(); |
|||
}).finally(() => { |
|||
loading = false; |
|||
}) |
|||
} else if (deletion) { |
|||
stuffLogClient.deleteProjectGroup(group.id).then(() => { |
|||
markStale("project"); |
|||
modalStore.close(); |
|||
navigate("/questlog/"); |
|||
}).catch(err => { |
|||
error = err.message ? err.message : err.toString(); |
|||
}).finally(() => { |
|||
loading = false; |
|||
}) |
|||
} else { |
|||
stuffLogClient.updateProjectGroup(group.id, { |
|||
name, abbreviation, description, |
|||
setCategoryNames: categoryNames, |
|||
}).then(() => { |
|||
markStale("project"); |
|||
modalStore.close(); |
|||
}).catch(err => { |
|||
error = err.message ? err.message : err.toString(); |
|||
}).finally(() => { |
|||
loading = false; |
|||
}) |
|||
} |
|||
} |
|||
|
|||
function onClose() { |
|||
modalStore.close(); |
|||
} |
|||
</script> |
|||
|
|||
<Modal show title="{verb} Project Group" error={error} closable on:close={onClose}> |
|||
<form on:submit|preventDefault={onSubmit}> |
|||
<label for="name">Name</label> |
|||
<input disabled={deletion} name="name" type="text" bind:value={name} /> |
|||
<label for="abbreviation">Abbreviation</label> |
|||
<input disabled={deletion} name="abbreviation" bind:value={abbreviation} /> |
|||
<label for="description">Description</label> |
|||
<textarea disabled={deletion} name="description" bind:value={description} /> |
|||
|
|||
<label for="name">Custom Labels</label> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Deadlines" bind:value={categoryNames["deadlines"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Active" bind:value={categoryNames["active"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Background" bind:value={categoryNames["background"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Progress" bind:value={categoryNames["progress"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="To Do" bind:value={categoryNames["to do"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="On Hold" bind:value={categoryNames["on hold"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Completed" bind:value={categoryNames["completed"]} /> |
|||
<input disabled={deletion} name="name" type="text" placeholder="Failed" bind:value={categoryNames["failed"]} /> |
|||
|
|||
<hr /> |
|||
|
|||
<button disabled={loading} type="submit">{verb} Project Group</button> |
|||
</form> |
|||
</Modal> |
@ -0,0 +1,48 @@ |
|||
import { writable } from "svelte/store"; |
|||
import stuffLogClient from "../clients/stufflog"; |
|||
import type { ProjectGroupResult } from "../models/projectgroup"; |
|||
|
|||
interface ProjectStoreData { |
|||
loading: boolean |
|||
stale: boolean |
|||
groups: ProjectGroupResult[] |
|||
} |
|||
|
|||
function createProjectGroupStore() { |
|||
const {update, subscribe} = writable<ProjectStoreData>({ |
|||
loading: false, |
|||
stale: true, |
|||
groups: [], |
|||
}); |
|||
|
|||
return { |
|||
subscribe, |
|||
|
|||
markStale() { |
|||
update(v => ({...v, stale: true})); |
|||
}, |
|||
|
|||
async loadOne(id: string) { |
|||
update(v => ({...v, loading: true})); |
|||
const group = await stuffLogClient.findProjectGroup(id); |
|||
update(v => ({...v, loading: false, groups: [ |
|||
...v.groups.filter(g => g.id === id), |
|||
group, |
|||
]})); |
|||
}, |
|||
|
|||
fakeRefresh() { |
|||
update(v => ({...v, groups: [...v.groups]})) |
|||
}, |
|||
|
|||
async load() { |
|||
update(v => ({...v, loading: true, stale: false})); |
|||
const groups = await stuffLogClient.listProjectGroups(); |
|||
update(v => ({...v, loading: false, groups: groups})); |
|||
}, |
|||
} |
|||
} |
|||
|
|||
const projectGroupStore = createProjectGroupStore(); |
|||
|
|||
export default projectGroupStore; |
@ -0,0 +1,28 @@ |
|||
import type { ProjectResult } from "../models/project"; |
|||
|
|||
const STATUS_ORDER: (string | undefined | null)[] = [ |
|||
"deadlines", |
|||
null, |
|||
void(0), |
|||
"active", |
|||
"background", |
|||
"progress", |
|||
"to do", |
|||
"on hold", |
|||
"completed", |
|||
"failed", |
|||
"declined", |
|||
] |
|||
|
|||
export function sortProjects(a: ProjectResult, b: ProjectResult) { |
|||
const as = STATUS_ORDER.indexOf(a.statusTag); |
|||
const bs = STATUS_ORDER.indexOf(b.statusTag); |
|||
if (as !== bs) { |
|||
return as - bs; |
|||
} |
|||
|
|||
const aName = `${a.tags.slice(0, 1).map(t => t+":").join("")} ${a.name}`.trim(); |
|||
const bName = `${b.tags.slice(0, 1).map(t => t+":").join("")} ${b.name}`.trim(); |
|||
|
|||
return aName.localeCompare(bName); |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue