Browse Source

add more modals

master
Gisle Aune 2 years ago
parent
commit
98d09f99e7
  1. 9
      frontend/src/lib/components/contexts/ModalContext.svelte
  2. 2
      frontend/src/lib/components/contexts/ProjectContext.svelte
  3. 60
      frontend/src/lib/components/contexts/ProjectListContext.svelte
  4. 4
      frontend/src/lib/components/project/ItemEntry.svelte
  5. 4
      frontend/src/lib/components/project/ProjectEntry.svelte
  6. 2
      frontend/src/lib/components/project/RequirementEntry.svelte
  7. 6
      frontend/src/lib/components/scope/ProjectMenu.svelte
  8. 151
      frontend/src/lib/modals/DeletionModal.svelte
  9. 57
      frontend/src/lib/modals/ItemCreateModal.svelte
  10. 2
      frontend/src/lib/utils/date.ts
  11. 33
      frontend/src/routes/[scope=prettyid]/__layout.svelte
  12. 2
      frontend/src/routes/[scope=prettyid]/projects/[project=prettyid].svelte
  13. 7
      ports/httpapi/items.go
  14. 13
      usecases/items/result.go
  15. 13
      usecases/items/service.go

9
frontend/src/lib/components/contexts/ModalContext.svelte

@ -4,9 +4,14 @@
export type ModalData =
| { name: "closed" }
| { name: "item.create", requirement?: Requirement }
| { name: "item.edit", item: Item }
| { name: "item.acquire", item: Item }
| { name: "item.delete", item: Item }
| { name: "requirement.create", project: ProjectEntry }
| { name: "requirement.edit", requirement: Requirement }
| { name: "requirement.delete", requirement: Requirement }
| { name: "project.delete", project: Project }
| { name: "stat.delete", stat: Stat }
interface ModalContextData {
currentModal: Readable<ModalData>
@ -23,7 +28,9 @@
import { writable, type Readable } from "svelte/store";
import { getContext, onMount, setContext } from "svelte";
import type { ProjectEntry, Requirement } from "$lib/models/project";
import type Item from "$lib/models/item";
import type Item from "$lib/models/item";
import type Project from "$lib/models/project";
import type Stat from "$lib/models/stat";
let store = writable<ModalData>({name: "closed"});

2
frontend/src/lib/components/contexts/ProjectContext.svelte

@ -49,8 +49,6 @@
}
$: projectWritable.set(project);
$: console.log($projectWritable);
</script>
<slot></slot>

60
frontend/src/lib/components/contexts/ProjectListContext.svelte

@ -0,0 +1,60 @@
<script lang="ts" context="module">
const contextKey = {ctx: "projectListCtx"};
interface ProjectListContextData {
projects: Readable<ProjectEntry[]>,
reloadProjectList(): Promise<void>,
};
const fallback: ProjectListContextData = {
projects: readable([]),
reloadProjectList: () => Promise.resolve()
};
export function getProjectListContext() {
return getContext(contextKey) as ProjectListContextData || fallback
}
</script>
<script lang="ts">
import { readable, writable, type Readable } from "svelte/store";
import { getContext, setContext } from "svelte";
import { sl3 } from "$lib/clients/sl3";
import { getScopeContext } from "./ScopeContext.svelte";
import type { ProjectEntry } from "$lib/models/project";
export let projects: ProjectEntry[];
const {scope} = getScopeContext();
let projectWritable = writable<ProjectEntry[]>(projects);
let loading = false;
let lastSet = projects;
setContext<ProjectListContextData>(contextKey, {
projects: {subscribe: projectWritable.subscribe},
reloadProjectList,
});
async function reloadProjectList() {
if (loading) {
return
}
try {
const newProjects = await sl3(fetch).listProjects($scope.id)
projectWritable.set(newProjects);
} catch(_) {}
loading = false;
}
$: {
if (lastSet !== projects) {
projectWritable.set(projects);
lastSet = projects;
}
}
</script>
<slot></slot>

4
frontend/src/lib/components/project/ItemEntry.svelte

@ -17,8 +17,8 @@
{#if item.acquiredTime == null}
<Option open={{name: "item.acquire", item}} color="green">Acquire</Option>
{/if}
<Option>Edit</Option>
<Option color="red">Delete</Option>
<Option open={{name: "item.edit", item}}>Edit</Option>
<Option open={{name: "item.delete", item}} color="red">Delete</Option>
</OptionsRow>
<AmountRow>
{#if item.acquiredTime != null}

4
frontend/src/lib/components/project/ProjectEntry.svelte

@ -5,7 +5,7 @@
import Option from "$lib/components/layout/Option.svelte";
import OptionsRow from "$lib/components/layout/OptionsRow.svelte";
import { getProjectContext } from "../contexts/ProjectContext.svelte";
import RequirementEntry from "./RequirementEntry.svelte";
import RequirementEntry from "./RequirementEntry.svelte";
const {project} = getProjectContext();
</script>
@ -17,7 +17,7 @@ import RequirementEntry from "./RequirementEntry.svelte";
<OptionsRow slot="right">
<Option open={{name: "requirement.create", project: $project}}>Add Requirement</Option>
<Option>Edit</Option>
<Option color="red">Delete</Option>
<Option open={{name: "project.delete", project: $project}} color="red">Delete</Option>
</OptionsRow>
{#each $project.requirements as requirement (requirement.id)}
<RequirementEntry requirement={requirement} />

2
frontend/src/lib/components/project/RequirementEntry.svelte

@ -21,7 +21,7 @@ import ItemEntry from "./ItemEntry.svelte";
<OptionsRow slot="right">
<Option open={{name: "item.create", requirement}}>Add Item</Option>
<Option open={{name: "requirement.edit", requirement}}>Edit</Option>
<Option color="red">Delete</Option>
<Option open={{name: "requirement.delete", requirement}} color="red">Delete</Option>
</OptionsRow>
<LabeledProgressRow>
{#each requirement.stats as stat (stat.id)}

6
frontend/src/lib/components/scope/ProjectMenu.svelte

@ -18,15 +18,15 @@
import MenuItem from "../common/MenuItem.svelte";
import { getScopeContext } from "../contexts/ScopeContext.svelte";
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds";
import { getProjectListContext } from "../contexts/ProjectListContext.svelte";
const {scope} = getScopeContext();
export let projects: ProjectEntry[];
const {projects} = getProjectListContext();
let projectsByStatus: Record<Status, ProjectEntry[]>;
let scopeUrlPrefix: string;
$: projectsByStatus = projects.reduce((p, c) => (
$: projectsByStatus = $projects.reduce((p, c) => (
{...p, [c.status]: [...p[c.status], c]}
), {0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: []});

151
frontend/src/lib/modals/DeletionModal.svelte

@ -0,0 +1,151 @@
<script lang="ts" context="module">
interface DeletionSideEffect {
name: string
op: string
}
</script>
<script lang="ts">
import { goto } from "$app/navigation";
import { sl3 } from "$lib/clients/sl3";
import Modal from "$lib/components/common/Modal.svelte";
import ModalBody from "$lib/components/common/ModalBody.svelte";
import { getModalContext } from "$lib/components/contexts/ModalContext.svelte";
import { getProjectContext } from "$lib/components/contexts/ProjectContext.svelte";
import { getProjectListContext } from "$lib/components/contexts/ProjectListContext.svelte";
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import { scopePrettyId } from "$lib/utils/prettyIds";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const {project, reloadProject} = getProjectContext();
const {reloadProjectList} = getProjectListContext();
let endpoint: string;
let error: string
let loading: boolean;
let show: boolean;
let noun: string;
let sideEffects: DeletionSideEffect[];
let name: string;
let dangerZone: boolean;
let dangerZoneConfirm: string;
let navigate: string;
$: switch ($currentModal.name) {
case "item.delete":
init(`items/${$currentModal.item.id}`)
noun = "Item";
name = $currentModal.item.name;
break;
case "stat.delete":
init(`stats/${$currentModal.stat.id}`)
noun = "Stat";
name = $currentModal.stat.name;
dangerZone = true;
break;
case "requirement.delete":
init(`projects/${$project.id}/requirements/${$currentModal.requirement.id}`,
$currentModal.requirement.items.map(i => ({
op: "Detach item",
name: i.name,
}))
);
noun = "Requirement";
name = $currentModal.requirement.name;
break;
case "project.delete":
init(`projects/${$currentModal.project.id}`,
$currentModal.project.requirements.flatMap(r => ([{
op: "Delete requirement",
name: `${r.name} (${r.statusName})`,
}, ...r.items.map(i => ({
op: "Detach item",
name: i.name,
}))]))
)
noun = "Project";
name = $currentModal.project.name;
dangerZone = true;
navigate = `/${scopePrettyId($scope)}`;
break;
default:
loading = false;
error = null;
show = false;
}
function init(newEndpoint: string, newSideEffects: DeletionSideEffect[] = []) {
endpoint = `scopes/${$scope.id}/${newEndpoint}`;
sideEffects = newSideEffects;
show = true;
error = null;
dangerZone = false;
dangerZoneConfirm = "";
navigate = null;
}
async function submit() {
error = null;
loading = true;
try {
await sl3(fetch).fetch("DELETE", endpoint);
if (navigate) {
if (noun === "Project") {
await reloadProjectList();
}
goto(navigate);
} else {
// Wait for project reload if it's updating a project
await reloadProject();
// TODO: History context upsert
}
closeModal();
} catch(err) {
if (err.statusCode != null) {
error = err.statusMessage;
} else {
error = err
}
} finally {
loading = false;
}
}
let disabled;
$: disabled = loading || (!!dangerZone && name !== dangerZoneConfirm)
</script>
<form on:submit|preventDefault={submit}>
<Modal closable show={show} verb="Delete" noun={noun} disabled={disabled} error={error}>
<ModalBody>
<p>Are you sure you want to delete this {noun.toLocaleLowerCase()}?</p>
{#if sideEffects.length > 0}
<p>There are specific side-effects to this deletion.</p>
<ul>
{#each sideEffects as sideEffect}
<li>{sideEffect.op} {sideEffect.name}</li>
{/each}
</ul>
{/if}
<label for="name">{noun} name</label>
<input type="text" disabled name="name" value={name} />
{#if dangerZone}
<label for="confirm">Re-type name to confirm</label>
<input type="text" name="confirm" bind:value={dangerZoneConfirm} />
{/if}
</ModalBody>
</Modal>
</form>

57
frontend/src/lib/modals/ItemCreateModal.svelte

@ -8,17 +8,23 @@
import { getScopeContext } from "$lib/components/contexts/ScopeContext.svelte";
import AcquiredTimeInput from "$lib/components/controls/AcquiredTimeInput.svelte";
import StatInput from "$lib/components/controls/StatInput.svelte";
import type Item from "$lib/models/item";
import type { ItemInput } from "$lib/models/item";
import type { Requirement } from "$lib/models/project";
import type Scope from "$lib/models/scope";
import { formatFormTime } from "$lib/utils/date";
import { statDiff } from "$lib/utils/stat";
const {currentModal, closeModal} = getModalContext();
const {scope} = getScopeContext();
const projectCtx = getProjectContext();
const {project, reloadProject} = getProjectContext();
let item: ItemInput
let itemId: number
let currentStats: ItemInput["stats"]
let openedDate: Date
let requirementName: string
let op: string
let error: string
let loading: boolean
@ -26,7 +32,10 @@
$: switch ($currentModal.name) {
case "item.create":
init($scope, $currentModal.requirement)
initCreate($scope, $currentModal.requirement)
break;
case "item.edit":
initEdit($currentModal.item)
break;
default:
@ -35,7 +44,7 @@
show = false;
}
function init(scope: Scope, requirement?: Requirement) {
function initCreate(scope: Scope, requirement?: Requirement) {
let stats = requirement?.stats.map(s => ({statId: s.id, required: 0, acquired: 0}));
if (stats == null) {
stats = scope.stats.map(s => ({statId: s.id, required: 0, acquired: 0}))
@ -50,22 +59,42 @@
scheduledDate: null,
}
op = "Create"
openedDate = new Date();
requirementName = requirement?.name;
show = true;
}
function initEdit(current: Item) {
const req = $project.requirements?.find(r => r.id === current.requirementId);
item = {
name: current.name,
description: current.description,
requirementId: current.requirementId,
stats: current.stats.map(s => ({statId: s.id, acquired: s.acquired, required: s.required})),
acquiredTime: formatFormTime(current.acquiredTime),
scheduledDate: current.scheduledDate,
};
itemId = current.id;
currentStats = [...item.stats];
op = "Edit"
openedDate = new Date();
requirementName = req?.name;
show = true;
}
async function submit() {
error = null;
loading = true;
const submission: ItemInput = {
...item,
scheduledDate: item.scheduledDate || void(0),
acquiredTime: item.acquiredTime ? new Date(item.acquiredTime).toISOString() : void(0),
stats: item.stats.filter(s => s.required > 0).map(s => {
if (!!item.acquiredTime) {
return {...s, acquired: s.required}
return s
} else {
return {...s, acquired: 0}
}
@ -73,11 +102,19 @@
}
try {
await sl3(fetch).createItem($scope.id, submission);
switch (op) {
case "Create":
await sl3(fetch).createItem($scope.id, submission);
break;
case "Edit":
submission.stats = statDiff(currentStats, submission.stats)
await sl3(fetch).updateItem($scope.id, itemId, submission);
break;
}
// Wait for project reload if it's updating a project
if (projectCtx != null && item.requirementId != null) {
await projectCtx.reloadProject();
if (item.requirementId != null) {
await reloadProject();
}
// TODO: History context upsert
@ -97,7 +134,7 @@
</script>
<form on:submit|preventDefault={submit}>
<Modal wide closable show={show} verb="Create" noun="Item" disabled={loading} error={error}>
<Modal wide closable show={show} verb={op} noun="Item" disabled={loading} error={error}>
<ModalBody>
<label for="name">Name</label>
<input name="name" type="text" bind:value={item.name} />
@ -112,7 +149,7 @@
<input disabled name="req" type="text" value={requirementName} />
{/if}
<label for="stats">Stats</label>
<StatInput showRequired hideUnseen={!!item.requirementId} bind:value={item.stats} />
<StatInput showAcquired={op === "Edit" && !!item.acquiredTime} showRequired hideUnseen={!!item.requirementId} bind:value={item.stats} />
</ModalBody>
</Modal>
</form>

2
frontend/src/lib/utils/date.ts

@ -1,5 +1,5 @@
export function formatFormTime(time: Date | string): string {
if (time === "") {
if (!time) {
return "";
}
if (!(time instanceof Date)) {

33
frontend/src/routes/[scope=prettyid]/__layout.svelte

@ -26,25 +26,28 @@
import MenuCategory from "$lib/components/common/MenuCategory.svelte";
import MenuItem from "$lib/components/common/MenuItem.svelte";
import { scopePrettyId } from "$lib/utils/prettyIds";
import ProjectListContext from "$lib/components/contexts/ProjectListContext.svelte";
export let scope: Scope;
export let projects: ProjectEntry[];
</script>
<ScopeContext scope={scope}>
<Columns wide>
<Column>
<Header subtitle={scope.name}>{scope.abbreviation}</Header>
<MenuCategory>
<MenuItem href={`/`}>Home</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}`}>Overview</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/sprints`}>Sprints</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/history`}>History</MenuItem>
</MenuCategory>
<ProjectMenu projects={projects} />
</Column>
<Column span={4}>
<slot></slot>
</Column>
</Columns>
<ProjectListContext projects={projects}>
<Columns wide>
<Column>
<Header subtitle={scope.name}>{scope.abbreviation}</Header>
<MenuCategory>
<MenuItem href={`/`}>Home</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}`}>Overview</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/sprints`}>Sprints</MenuItem>
<MenuItem href={`/${scopePrettyId(scope)}/history`}>History</MenuItem>
</MenuCategory>
<ProjectMenu />
</Column>
<Column span={4}>
<slot></slot>
</Column>
</Columns>
</ProjectListContext>
</ScopeContext>

2
frontend/src/routes/[scope=prettyid]/projects/[project=prettyid].svelte

@ -21,6 +21,7 @@
import ItemCreateModal from "$lib/modals/ItemCreateModal.svelte";
import RequirementCreateModal from "$lib/modals/RequirementCreateModal.svelte";
import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
import DeletionModal from "$lib/modals/DeletionModal.svelte";
export let project: Project;
</script>
@ -30,4 +31,5 @@ import ItemAcquireModal from "$lib/modals/ItemAcquireModal.svelte";
<ItemCreateModal />
<ItemAcquireModal />
<RequirementCreateModal />
<DeletionModal />
</ProjectContext>

7
ports/httpapi/items.go

@ -200,7 +200,10 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}))
g.PUT("/:item_id", handler("item", func(c *gin.Context) (interface{}, error) {
input := models.ItemUpdate{}
input := struct {
models.ItemUpdate
Stats []entities.ItemStat `json:"stats"`
}{}
err := c.BindJSON(&input)
if err != nil {
return nil, models.BadInputError{
@ -214,7 +217,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
return nil, err
}
return items.Update(c.Request.Context(), id, input)
return items.Update(c.Request.Context(), id, input.ItemUpdate, input.Stats)
}))
g.PUT("/:item_id/stats/:stat_id", handler("item", func(c *gin.Context) (interface{}, error) {

13
usecases/items/result.go

@ -26,12 +26,21 @@ func (r *Result) Stat(statID int) *ResultStat {
func (r *Result) AddStat(scope scopes.Result, stat entities.ItemStat) {
for i, stat2 := range r.Stats {
if stat2.ID == stat.StatID {
r.Stats[i].Acquired = stat.Acquired
r.Stats[i].Required = stat.Required
if stat.Required <= 0 {
r.Stats = append(r.Stats[:i], r.Stats[i+1:]...)
} else {
r.Stats[i].Acquired = stat.Acquired
r.Stats[i].Required = stat.Required
}
return
}
}
if stat.Required <= 0 {
return
}
scopeStat := scope.Stat(stat.StatID)
if scopeStat == nil {
return

13
usecases/items/service.go

@ -165,7 +165,7 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti
return &result, nil
}
func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate) (*Result, error) {
func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate, stats []entities.ItemStat) (*Result, error) {
scope := s.Scopes.Context(ctx).Scope
if update.OwnerID != nil {
if _, ok := scope.Members[*update.OwnerID]; !ok {
@ -173,7 +173,7 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate)
}
}
if update.Name != nil {
if update.Name != nil && *update.Name == "" {
return nil, models.BadInputError{
Object: "ItemUpdate",
Field: "name",
@ -209,6 +209,15 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate)
}
item.Item.ApplyUpdate(update)
for _, stat := range stats {
stat.ItemID = item.ID
err = s.Repository.UpdateStat(ctx, stat)
if err == nil {
item.AddStat(scope, stat)
}
}
return item, nil
}

Loading…
Cancel
Save