325 lines
11 KiB
Svelte
325 lines
11 KiB
Svelte
<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) {
|
||
if (method == 'create') task_form = false; // task has been created by current user
|
||
} else {
|
||
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){
|
||
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} assignee={task_form.assignee} state_id={task_form.state_id} />
|
||
</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 --> |