Gisle Aune
1 year ago
19 changed files with 359 additions and 47 deletions
-
8frontend/src/lib/clients/sl3.ts
-
4frontend/src/lib/components/common/BurndownChart.svelte
-
20frontend/src/lib/components/common/Modal.svelte
-
17frontend/src/lib/components/contexts/ModalContext.svelte
-
52frontend/src/lib/components/contexts/RequirementListContext.svelte
-
6frontend/src/lib/components/controls/StatInput.svelte
-
16frontend/src/lib/components/layout/SubSection.svelte
-
7frontend/src/lib/components/project/RequirementReference.svelte
-
2frontend/src/lib/components/project/RequirementSection.svelte
-
52frontend/src/lib/components/project/RequirementSubSection.svelte
-
6frontend/src/lib/modals/DeletionModal.svelte
-
10frontend/src/lib/modals/ItemCreateModal.svelte
-
19frontend/src/lib/modals/RequirementCreateModal.svelte
-
160frontend/src/lib/modals/RequirementJumpModal.svelte
-
13frontend/src/lib/models/project.ts
-
1frontend/src/lib/utils/date.ts
-
7frontend/src/routes/__layout.svelte
-
4frontend/src/routes/index.svelte
-
2frontend/src/routes/login.svelte
@ -0,0 +1,52 @@ |
|||
<script lang="ts" context="module"> |
|||
const contextKey = {ctx: "requirementListCtx"}; |
|||
|
|||
interface RequirementListContextData { |
|||
requirements: Readable<RequirementWithoutItems[]>, |
|||
reloadRequirementList(): Promise<void>, |
|||
}; |
|||
|
|||
const fallback: RequirementListContextData = { |
|||
requirements: readable([]), |
|||
reloadRequirementList: () => Promise.resolve() |
|||
}; |
|||
|
|||
export function getRequirementListContext() { |
|||
return getContext(contextKey) as RequirementListContextData || fallback |
|||
} |
|||
</script> |
|||
|
|||
<script lang="ts"> |
|||
import { readable, writable, type Readable } from "svelte/store"; |
|||
import { getContext, onMount, setContext } from "svelte"; |
|||
import { sl3 } from "$lib/clients/sl3"; |
|||
import { getScopeContext } from "./ScopeContext.svelte"; |
|||
import type { RequirementWithoutItems } from "$lib/models/project"; |
|||
|
|||
const {scope} = getScopeContext(); |
|||
|
|||
let requirementsWritable = writable<RequirementWithoutItems[]>([]); |
|||
let loading = false; |
|||
|
|||
setContext<RequirementListContextData>(contextKey, { |
|||
requirements: {subscribe: requirementsWritable.subscribe}, |
|||
reloadRequirementList, |
|||
}); |
|||
|
|||
async function reloadRequirementList() { |
|||
if (loading) { |
|||
return |
|||
} |
|||
|
|||
loading = true; |
|||
|
|||
try { |
|||
const newProjects = await sl3(fetch).listRequirements($scope?.id || null) |
|||
requirementsWritable.set(newProjects); |
|||
} catch(_) {} |
|||
|
|||
loading = false; |
|||
} |
|||
</script> |
|||
|
|||
<slot></slot> |
@ -0,0 +1,52 @@ |
|||
<script lang="ts"> |
|||
import Markdown from "$lib/components/common/Markdown.svelte"; |
|||
import Progress from "$lib/components/common/Progress.svelte"; |
|||
import Option from "$lib/components/layout/Option.svelte"; |
|||
import OptionsRow from "$lib/components/layout/OptionsRow.svelte"; |
|||
import type { RequirementWithoutItems } from "$lib/models/project"; |
|||
import { STATUS_ICONS } from "../common/StatusIcon.svelte"; |
|||
import Icon from "../layout/Icon.svelte"; |
|||
import { projectPrettyId, scopePrettyId } from "$lib/utils/prettyIds"; |
|||
import TagRow from "../common/TagRow.svelte"; |
|||
import SubSection from "../layout/SubSection.svelte"; |
|||
import RequirementReference from "./RequirementReference.svelte"; |
|||
import { getScopeContext } from "../contexts/ScopeContext.svelte"; |
|||
import { getScopeListContext } from "../contexts/ScopeListContext.svelte"; |
|||
|
|||
export let requirement: RequirementWithoutItems; |
|||
|
|||
const {scope} = getScopeContext(); |
|||
const {scopes} = getScopeListContext(); |
|||
|
|||
let tags: string[] |
|||
$: { |
|||
tags = [...requirement.tags, ...(requirement.project?.tags||[])].sort().filter((t, i, a) => a[i-1] !== t); |
|||
} |
|||
|
|||
let projectLink: string |
|||
let requirementLink: string |
|||
$: { |
|||
let actualScope = $scope; |
|||
if (actualScope == null || actualScope.id !== requirement.project.scopeId) { |
|||
actualScope = $scopes.find(s => s.id === requirement.project.scopeId); |
|||
} |
|||
|
|||
projectLink = `/${scopePrettyId(actualScope)}/projects/${projectPrettyId(requirement.project)}` |
|||
requirementLink = `${projectLink}#${projectPrettyId(requirement)}` |
|||
} |
|||
|
|||
</script> |
|||
|
|||
<div id={projectPrettyId(requirement)} /> |
|||
<SubSection href={requirementLink} small title={requirement.name} icon={STATUS_ICONS[requirement.status]} status={requirement.status}> |
|||
<Progress alwaysSmooth titlePercentageOnly thin green status={requirement.status} count={requirement.totalAcquired} target={requirement.totalRequired} /> |
|||
<Progress alwaysSmooth titlePercentageOnly thinner gray count={requirement.totalPlanned} target={requirement.totalRequired} /> |
|||
<RequirementReference projectOnly project={requirement.project} requirement={requirement} scopeId={requirement.project.scopeId} /> |
|||
<TagRow names={tags} /> |
|||
<Markdown source={requirement.description} /> |
|||
<OptionsRow slot="right"> |
|||
<Option open={{name: "item.create", requirement}}><Icon name="plus" /></Option> |
|||
<Option open={{name: "requirement.edit", requirement}}><Icon name="pen" /></Option> |
|||
<Option open={{name: "requirement.delete", requirement}} color="red"><Icon name="trash" /></Option> |
|||
</OptionsRow> |
|||
</SubSection> |
@ -0,0 +1,160 @@ |
|||
<script lang="ts"> |
|||
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 { getRequirementListContext } from "$lib/components/contexts/RequirementListContext.svelte"; |
|||
import RequirementSubSection from "$lib/components/project/RequirementSubSection.svelte"; |
|||
import type { RequirementWithoutItems } from "$lib/models/project"; |
|||
import Status from "$lib/models/status"; |
|||
import { onMount, tick } from "svelte"; |
|||
|
|||
const {currentModal, closeModal} = getModalContext(); |
|||
const {requirements, reloadRequirementList} = getRequirementListContext(); |
|||
|
|||
let error: string |
|||
let loading: boolean; |
|||
let show: boolean; |
|||
let search: string = ""; |
|||
let searchRaw: string = ""; |
|||
let searchBar: HTMLInputElement; |
|||
let filteredRequirements: RequirementWithoutItems[] = []; |
|||
let searchTimeout: any = null; |
|||
|
|||
$: if ($currentModal.name !== "closed") { |
|||
show = false; |
|||
} |
|||
|
|||
$: { |
|||
clearTimeout(searchTimeout); |
|||
let newSearch = searchRaw.slice(); |
|||
searchTimeout = setTimeout(() => { |
|||
search = newSearch; |
|||
}, 100); |
|||
} |
|||
|
|||
$: { |
|||
filteredRequirements = $requirements.filter(r => r.status > Status.Blocked && r.status < Status.Completed); |
|||
if (search.length > 0) { |
|||
const scoreMap = new Map<number, number>(); |
|||
const searchLc = search.toLocaleLowerCase(); |
|||
const searchWords = searchLc.split(" "); |
|||
|
|||
for (const req of filteredRequirements) { |
|||
const projectNameLc = req.project.name?.toLowerCase() || ""; |
|||
const nameLc = req.name.toLowerCase(); |
|||
const nameWords = nameLc.split(" "); |
|||
let score = 0; |
|||
|
|||
// prefix |
|||
if (nameLc.startsWith(searchLc)) { |
|||
score += 30; |
|||
} |
|||
|
|||
// prefix |
|||
if (projectNameLc.startsWith(searchLc)) { |
|||
score += 25; |
|||
} |
|||
|
|||
// abbreviation |
|||
if (searchWords.length === 1) { |
|||
const nameAbbr = nameWords.map(w => w.slice(0, 1)).filter(w => w.length).join(""); |
|||
if (nameAbbr == searchLc) { |
|||
score += 30; |
|||
} |
|||
if (nameAbbr.startsWith(searchLc)) { |
|||
score += 25; |
|||
} |
|||
} |
|||
|
|||
// tags |
|||
const allTags = [...req.tags, ...(req.project?.tags||[])]; |
|||
for (const tag of allTags) { |
|||
const tagLc = tag.toLowerCase(); |
|||
|
|||
if (tagLc === tag) { |
|||
score += 30 |
|||
} else if (tagLc.startsWith(searchLc)) { |
|||
score += 30 - (allTags.length * 3) |
|||
} |
|||
} |
|||
|
|||
// project partial match |
|||
if (projectNameLc.includes(searchLc)) { |
|||
score += 15; |
|||
} |
|||
|
|||
// description partial match |
|||
if (req.description.toLocaleLowerCase().includes(searchLc)) { |
|||
score += 4 * searchLc.length; |
|||
} |
|||
|
|||
// letters in order |
|||
let j = 0; |
|||
let b = 1; |
|||
for (let i = 0; i < nameLc.length; i++) { |
|||
if (nameLc.charAt(i) == searchLc.charAt(j)) { |
|||
score += b; |
|||
j += 1; |
|||
b += 1; |
|||
if (searchLc.charAt(j) === " ") { |
|||
j += 1; |
|||
} |
|||
} else { |
|||
b = 1; |
|||
} |
|||
} |
|||
score -= (searchLc.length - j) * 2 |
|||
|
|||
scoreMap.set(req.id, score); |
|||
} |
|||
|
|||
filteredRequirements = filteredRequirements |
|||
.filter(r => scoreMap.get(r.id) > searchLc.length ) |
|||
.sort((a,b) => scoreMap.get(b.id) - scoreMap.get(a.id)); |
|||
} else if (filteredRequirements.length > 3) { |
|||
filteredRequirements = []; |
|||
} |
|||
} |
|||
|
|||
onMount(() => { |
|||
function onHotkey(e: KeyboardEvent) { |
|||
if ($currentModal.name === "closed") { |
|||
if (e.shiftKey && e.key.toLowerCase() === "r") { |
|||
show = true; |
|||
tick().then(() => searchBar.focus()); |
|||
reloadRequirementList(); |
|||
closeModal(); |
|||
} else if (e.key.toLowerCase() === "escape") { |
|||
show = false; |
|||
} |
|||
} |
|||
} |
|||
|
|||
window.addEventListener("keyup", onHotkey); |
|||
|
|||
return () => window.removeEventListener("keyup", onHotkey); |
|||
}) |
|||
|
|||
async function submit() {} |
|||
</script> |
|||
|
|||
<form on:submit|preventDefault={submit}> |
|||
<Modal wide nobody show={show} verb="Jump" noun={"to Requrirement"} disabled={loading} error={error}> |
|||
<ModalBody> |
|||
<input bind:value={searchRaw} bind:this={searchBar} placeholder="type to search requirements" /> |
|||
<div class="requirement-list"> |
|||
{#each filteredRequirements as req (req.id)} |
|||
<RequirementSubSection requirement={req} /> |
|||
{/each} |
|||
</div> |
|||
</ModalBody> |
|||
</Modal> |
|||
</form> |
|||
|
|||
<style lang="sass"> |
|||
@import "../css/colors" |
|||
|
|||
input |
|||
font-size: 1.5em |
|||
background-color: $color-entry1 !important |
|||
</style> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue