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

324 lines
11 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 { onDestroy, onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, eventStream, target } from '../../urls.svelte.js';
import { error, messages, 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';
import TaskForm from '../task/Add.svelte';
let eventSource = null;
let connectionStatus = 'disconnected';
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);
let info = $state(null);
let task_form = $state(false);
let stateList = {};
$effect(() => updateUrl(filter_input));
function byName(a,b) {
return a.name.localeCompare(b.name);
}
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();
return 'reset';
} else {
error(resp);
return false;
}
}
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);
}
}
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';
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify(patch)
});
if (resp.ok){
yikes();
} else {
error(resp);
}
}
function handleCreateEvent(evt){
handleEvent(evt,'create');
}
function handleEvent(evt,method){
let json = JSON.parse(evt.data);
if (json.task && json.user){
// drop from kanban
for (let uid in tasks){
if (!uid) continue;
for (let state in tasks[uid]) delete tasks[uid][state][json.task.id];
}
// (re) add to kanban
if (method != 'delete') processTask(json.task);
// show notification
if (json.user.id != user.id) {
let term = "user_updated_entity";
if (method == 'create') term = "user_created_entity";
if (method == 'delete') term = "user_deleted_entity";
info = t(term,{user:json.user.name,entity:json.task.name});
setTimeout(() => { info = null; },2500);
}
}
}
function handleDeleteEvent(evt){
handleEvent(evt,'delete');
}
function handleUpdateEvent(evt){
handleEvent(evt,'update');
}
function hover(ev,user_id,state){
ev.preventDefault();
highlight = {user:user_id,state:state};
}
function hover_archive(ev){
ev.preventDefault();
highlight.archive = true;
}
function is_custom(state){
return [10,20,40,60,100].includes(state);
}
async function load(){
try {
eventSource = eventStream(handleCreateEvent,handleUpdateEvent,handleDeleteEvent);
await loadProject();
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){
const json = await resp.json();
project = json;
// current user first
users.push(user);
if (!tasks[user.id]) tasks[user.id] = tasks[user.id] = {};
for (var member of Object.values(json.members).map(a => a.user).sort(byName)){
if (member.id != user.id) users.push(member);
if (!tasks[member.id]) tasks[member.id] = {};
for (var state of Object.keys(json.allowed_states)) tasks[member.id][state] = {};
}
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];
processTask(task);
}
} else {
error(resp);
}
}
function openTask(task_id){
window.open(`/task/${task_id}/view`, '_blank').focus();
}
function processTask(task){
if (task.no_index) return;
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;
}
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);
}
}
function show_task_form(project_id,assignee,state_id){
console.log({project_id,assignee,state_id});
task_form = {project_id,assignee,state_id};
}
function updateUrl(){
let url = window.location.origin + window.location.pathname;
if (filter_input) url += '?filter=' + encodeURI(filter_input);
window.history.replaceState(window.history.state, '', url);
}
onDestroy(() => {
if (eventSource) eventSource.close();
});
onMount(load);
</script>
<svelte:head>
<title>Umbrella {project?.name}</title>
</svelte:head>
{#if project}
{#if task_form}
<div class="overlay">
<TaskForm project_id={task_form.project_id} user_id={task_form.user_id} state={task_form.state} />
</div>
{/if} <!-- task form -->
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1>
{/if}
{#if info}
<div class="info">{info}</div>
{/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 users as u}
<div class="user">{u.name}</div>
{#each Object.entries(project.allowed_states) as [state,name]}
<div class={['state_'+state, is_custom(state) ? '':'state_custom' ,highlight.user == u.id && highlight.state == state ? 'highlight':'']} ondragover={ev => hover(ev,u.id,state)} ondragleave={e => delete highlight.user} ondrop={ev => drop(u.id,state)} >
{#each Object.values(tasks[u.id][state]).sort(byName) 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}
<div class="add_task" onclick={ev => show_task_form(project.id,u.id,+state)}>
{t('add_object',{object:t('task')})}
</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} <!-- project -->