Files
Umbrella/frontend/src/routes/project/Kanban.svelte

256 lines
9.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, target } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
import { user } from '../../user.svelte.js';
import Card from './KanbanCard.svelte';
import LineEditor from '../../Components/LineEditor.svelte';
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
let { id } = $props();
let descr = $state(false);
let filter_input = $state('');
let router = useTinyRouter();
if (router.hasQueryParam('filter')) filter_input = router.getQueryParam('filter');
let dragged = null;
let highlight = $state({});
let filter = $derived(filter_input.toLowerCase());
let project = $state(null);
let tasks = $state({});
let users = {};
let columns = $derived(project.allowed_states?Object.keys(project.allowed_states).length+1:1);
$effect(() => updateUrl(filter_input));
async function do_archive(ex){
ex.preventDefault();
var task = dragged;
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify({no_index:true})
});
delete highlight.archive;
if (resp.ok){
yikes();
delete tasks[task.assignee][task.status][task.id]
} else {
error(resp);
}
}
function updateUrl(){
let url = window.location.origin + window.location.pathname;
if (filter_input) url += '?filter=' + encodeURI(filter_input);
window.history.replaceState(history.state, '', url);
}
async function create(name,user_id,state){
var url = api('task/add');
let task = {
description : '',
members : {},
name : name,
project_id : +id,
status : { code : +state}
}
task.members[user_id] = { permission: { name : 'ASSIGNEE' }};
task.members[user.id] = { permission: { name : 'OWNER' }};
const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : JSON.stringify(task)
});
if (resp.ok) {
task = await resp.json();
task.assignee = user_id;
if (!tasks[user_id]) tasks[user_id] = {};
if (!tasks[user_id][state]) tasks[user_id][state] = {};
tasks[user_id][state][task.id] = task;
yikes();
} else {
error(resp);
}
}
async function drop(user_id,state){
let task = dragged;
dragged = null;
highlight = {};
if (task.assignee == user_id && task.status == state) return; // no change
let patch = {members:{},status:+state}
patch.members[user_id] = 'ASSIGNEE';
//console.log('sending patch:',patch)
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify(patch)
});
if (resp.ok){
delete tasks[task.assignee][task.status][task.id]
if (!tasks[user_id]) tasks[user_id] = {}
if (!tasks[user_id][state]) tasks[user_id][state] = {}
tasks[user_id][state][task.id] = task;
task.assignee = user_id;
task.status = state;
yikes();
} else {
error(resp);
}
}
async function load(){
try {
await loadProject();
await loadTasks({project_id:+id,parent_task_id:0});
} catch (ignored) {}
}
async function loadProject(){
const url = api(`project/${id}`);
const resp = await fetch(url,{credentials:'include'});
if (resp.ok){
project = await resp.json();
for (var uid of Object.keys(project.members)){
let member = project.members[uid];
users[uid] = member.user.name;
if (!tasks[uid]) tasks[uid] = {};
}
yikes();
} else {
error(resp);
}
}
async function loadTasks(selector){
const url = api('task/list');
selector.show_closed = true;
selector.no_index = true;
var resp = await fetch(url,{
credentials :'include',
method : 'POST',
body : JSON.stringify(selector)
});
if (resp.ok){
var json = await resp.json();
for (var task_id of Object.keys(json)) {
let task = json[task_id];
if (task.no_index) continue;
let state = task.status;
let owner = null;
let assignee = null;
for (var user_id of Object.keys(task.members)){
var member = task.members[user_id];
if (member.permission.name == 'OWNER') owner = user_id;
if (member.permission.name == 'ASSIGNEE') assignee = user_id;
}
if (!assignee) assignee = owner;
task.assignee = assignee;
if (!tasks[assignee]) tasks[assignee] = {};
if (!tasks[assignee][state]) tasks[assignee][state] = {};
tasks[assignee][state][task_id] = task;
}
} else {
error(resp);
}
}
function hover(ev,user_id,state){
ev.preventDefault();
highlight = {user:user_id,state:state};
}
function hover_archive(ev){
ev.preventDefault();
highlight.archive = true;
}
function openTask(task_id){
window.open(`/task/${task_id}/view`, '_blank').focus();
}
async function save_bookmark(){
const user_ids = Object.values(project.members).map(member => member.user.id);
const data = {
url: location.href,
tags: ['Kanban', project.name, filter_input],
comment: `${project.name}: ${filter_input}`,
share: user_ids
}
const url = api('bookmark');
const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : JSON.stringify(data)
});
if (resp.ok) {
yikes();
router.navigate('/bookmark');
} else {
error(resp);
}
console.log(data);
}
onMount(load);
</script>
<svelte:head>
<title>Umbrella {project?.name}</title>
</svelte:head>
{#if project}
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1>
{/if}
{#if project}
<fieldset class="kanban description {descr?'active':''}" onclick={e => descr = !descr}>
<legend>{t('description')} {t('expand_on_click')}</legend>
{@html target(project.description.rendered)}
</fieldset>
<div class="kanban" style="display: grid; grid-template-columns: {`repeat(${columns}, auto)`}">
<span class="filter">
<input type="text" bind:value={filter_input} autofocus />
{t('filter')}
<button style="visibility:{filter_input ? 'visible' : 'hidden'}" onclick={save_bookmark}>
<span class="symbol"></span> {t('save_object',{object:t('bookmark')})}
</button>
</span>
<div class="head">{t('user')}</div>
{#if project.allowed_states}
{#each Object.entries(project.allowed_states) as [sid,state]}
<div class="head">{sid%10?state:t('state_'+state.toLowerCase())}</div>
{/each}
{/if}
{#each Object.entries(tasks) as [uid,stateList]}
<div class="user">{users[uid]}</div>
{#each Object.entries(project.allowed_states) as [state,name]}
<div class={['state_'+state, highlight.user == uid && highlight.state == state ? 'highlight':'']} ondragover={ev => hover(ev,uid,state)} ondragleave={e => delete highlight.user} ondrop={ev => drop(uid,state)} >
{#if stateList[state]}
{#each Object.values(stateList[state]).sort((a,b) => a.name.localeCompare(b.name)) as task}
{#if !filter || task.name.toLowerCase().includes(filter) || (task.tags && task.tags.filter(tag => tag.toLowerCase().includes(filter)).length)}
<Card onclick={e => openTask(task.id)} ondragstart={ev => dragged=task} {task} tag_colors={project.tag_colors} />
{/if}
{/each}
{/if}
<div class="add_task">
<LineEditor value={t('add_object',{object:t('task')})} editable={true} onSet={(name) => create(name,uid,state)}/>
</div>
</div>
{/each}
{/each}
</div>
<div class="archive {highlight.archive?'hover':''}" ondragover={hover_archive} ondragleave={e => delete highlight.archive} ondrop={do_archive} >
{t('hide')}
</div>
{/if}