Browse Source

implemented task editing right from the project list

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
kanban
Stephan Richter 3 months ago
parent
commit
5bc84f1321
  1. 111
      core/src/main/java/de/srsoftware/umbrella/core/model/Task.java
  2. 40
      frontend/src/Components/LineEditor.svelte
  3. 28
      frontend/src/Components/ListTask.svelte
  4. 37
      frontend/src/Components/MarkdownEditor.svelte
  5. 45
      frontend/src/Components/MultilineEditor.svelte
  6. 9
      task/src/main/java/de/srsoftware/umbrella/task/SqliteDb.java
  7. 40
      task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java

111
core/src/main/java/de/srsoftware/umbrella/core/model/Task.java

@ -1,12 +1,13 @@
/* © SRSoftware 2025 */ /* © SRSoftware 2025 */
package de.srsoftware.umbrella.core.model; package de.srsoftware.umbrella.core.model;
import static de.srsoftware.tools.Optionals.getPath;
import static de.srsoftware.tools.Optionals.nullIfEmpty; import static de.srsoftware.tools.Optionals.nullIfEmpty;
import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Util.LOG;
import static de.srsoftware.umbrella.core.Util.markdown; import static de.srsoftware.umbrella.core.Util.markdown;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidFieldException; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.invalidFieldException;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.missingFieldException;
import static java.lang.System.Logger.Level.WARNING;
import de.srsoftware.tools.Mappable; import de.srsoftware.tools.Mappable;
import org.json.JSONObject; import org.json.JSONObject;
@ -14,10 +15,74 @@ import org.json.JSONObject;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashMap; import java.util.*;
import java.util.Map;
public class Task implements Mappable {
public static final System.Logger LOG = System.getLogger(Task.class.getSimpleName());
private final long id, projectId;
private final Long parentTaskId;
private String name;
private String description;
private Status status;
private final Double estimatedTime;
private final LocalDate start, dueDate;
private boolean showClosed;
private final boolean noIndex;
private final Map<Long, Member> members;
private final Set<String> dirtyFields = new HashSet<>();
public Task (long id, long projectId, Long parentTaskId, String name, String description, Status status, Double estimatedTime, LocalDate start, LocalDate dueDate, boolean showClosed, boolean noIndex, Map<Long,Member> members){
this.id = id;
this.projectId = projectId;
this.parentTaskId = parentTaskId;
this.name = name;
this.description = description;
this.status = status;
this.estimatedTime = estimatedTime;
this.start = start;
this.dueDate = dueDate;
this.showClosed = showClosed;
this.noIndex = noIndex;
this.members = members;
}
public void clean() {
dirtyFields.clear();
}
public String description(){
return description;
}
public LocalDate dueDate(){
return dueDate;
}
public Double estimatedTime(){
return estimatedTime;
}
public long id(){
return id;
}
public boolean isDirty() {
return !dirtyFields.isEmpty();
}
public Map<Long,Member> members(){
return members;
}
public String name(){
return name;
}
public boolean noIndex(){
return noIndex;
}
public record Task(long id, long projectId, Long parentTaskId, String name, String description, Status status, Double estimatedTime, LocalDate start, LocalDate dueDate, boolean showClosed, boolean noIndex, Map<Long,Member> members) implements Mappable {
public static Task of(ResultSet rs) throws SQLException { public static Task of(ResultSet rs) throws SQLException {
var estTime = rs.getDouble(EST_TIME); var estTime = rs.getDouble(EST_TIME);
var parentTaskId = rs.getLong(PARENT_TASK_ID); var parentTaskId = rs.getLong(PARENT_TASK_ID);
@ -69,6 +134,43 @@ public record Task(long id, long projectId, Long parentTaskId, String name, Stri
return new Task(0,prjId.longValue(),parentTaskId,name,description,status,estimatedTime,startDate,dueDate,showClosed,noIndex,new HashMap<>()); return new Task(0,prjId.longValue(),parentTaskId,name,description,status,estimatedTime,startDate,dueDate,showClosed,noIndex,new HashMap<>());
} }
public Task patch(JSONObject json) {
for (var key : json.keySet()){
switch (key){
case DESCRIPTION: description = json.getString(key); break;
case NAME: name = json.getString(key); break;
case SHOW_CLOSED: showClosed = json.getBoolean(SHOW_CLOSED); break;
case STATUS: status = json.get(key) instanceof Number number ? Status.of(number.intValue()) : Status.valueOf(json.getString(key)); break;
default: {
key = null;
LOG.log(WARNING,"Tried to patch field ''{0}'' of task, which is not implemented!");
}
}
if (key != null) dirtyFields.add(key);
}
return this;
}
public Long parentTaskId(){
return parentTaskId;
}
public long projectId(){
return projectId;
}
public boolean showClosed(){
return showClosed;
}
public LocalDate start(){
return start;
}
public Status status(){
return status;
}
@Override @Override
public Map<String, Object> toMap() { public Map<String, Object> toMap() {
var map = new HashMap<String,Object>(); var map = new HashMap<String,Object>();
@ -95,4 +197,5 @@ public record Task(long id, long projectId, Long parentTaskId, String name, Stri
public boolean hasMember(UmbrellaUser user) { public boolean hasMember(UmbrellaUser user) {
return members.containsKey(user.id()); return members.containsKey(user.id());
} }
} }

40
frontend/src/Components/LineEditor.svelte

@ -2,10 +2,17 @@
import { activeField } from './field_sync.svelte.js'; import { activeField } from './field_sync.svelte.js';
import { t } from '../translations.svelte.js'; import { t } from '../translations.svelte.js';
let { editable = false, value = $bindable(null), onSet = (newVal) => {return true;} } = $props(); let {
editable = false,
onclick = evt => {},
onSet = newVal => {return true;},
type = 'div',
value = $bindable(null)
} = $props();
let editing = $state(false); let editing = $state(false);
let editValue = value; let editValue = value;
let start = 0;
async function applyEdit(){ async function applyEdit(){
let success = await onSet(editValue); let success = await onSet(editValue);
@ -28,6 +35,35 @@
if (ev.keyCode == 27) resetEdit(); if (ev.keyCode == 27) resetEdit();
} }
function measured(evt,duration){
if (duration < 500){
onclick(evt);
} else {
startEdit();
}
}
function onmousedown(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function onmouseup(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
function ontouchstart(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function ontouchend(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
activeField.subscribe((val) => resetEdit()); activeField.subscribe((val) => resetEdit());
</script> </script>
@ -48,5 +84,5 @@
{#if editable && editing} {#if editable && editing}
<input bind:value={editValue} onkeyup={typed} autofocus /> <input bind:value={editValue} onkeyup={typed} autofocus />
{:else} {:else}
<div ondblclick={startEdit} class={{editable}} title={t('double_click_to_edit')} >{value}</div> <svelte:element this={type} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} class={{editable}} title={t('double_click_to_edit')} >{value}</svelte:element>
{/if} {/if}

28
frontend/src/Components/ListTask.svelte

@ -2,16 +2,18 @@
import { t } from '../translations.svelte.js'; import { t } from '../translations.svelte.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router'; import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../urls.svelte.js';
import TaskList from './TaskList.svelte'; import TaskList from './TaskList.svelte';
import LineEditor from './LineEditor.svelte';
const router = useTinyRouter(); const router = useTinyRouter();
let { estimated_time, show_closed, task } = $props(); let { estimated_time, show_closed, task } = $props();
let children = $state(null); let children = $state(null);
let error = $state(null); let error = $state(null);
let start = 0;
async function loadChildren(){ async function loadChildren(){
const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/task/list`; const url = api('task/list');
var data = { var data = {
parent_task_id:+task.id, parent_task_id:+task.id,
show_closed: show_closed show_closed: show_closed
@ -30,11 +32,21 @@
} }
} }
function openTask(evt){ function openTask(){
evt.preventDefault();
console.log('openTask(…)',evt,task);
router.navigate(`/task/${task.id}/view`); router.navigate(`/task/${task.id}/view`);
//location.href = `https://umbrella.srsoftware.de/task/${task.id}/view`; }
async function patchTask(newName){
console.log('patchTask('+newName+')');
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials:'include',
method: 'PATCH',
body: JSON.stringify({name:newName})
});
let ok = resp.ok;
console.log({ok:ok});
return ok;
} }
if (task.estimated_time){ if (task.estimated_time){
@ -46,9 +58,7 @@
</script> </script>
<li class="task {task.status.name.toLowerCase()}"> <li class="task {task.status.name.toLowerCase()}">
<span class="name" onclick={openTask}> <LineEditor bind:value={task.name} onclick={openTask} editable={true} onSet={patchTask} type="span" />
{task.name}
</span>
{#if task.estimated_time} {#if task.estimated_time}
<span class="estimated_time">({+task.estimated_time}&nbsp;h)</span> <span class="estimated_time">({+task.estimated_time}&nbsp;h)</span>
{/if} {/if}

37
frontend/src/Components/MarkdownEditor.svelte

@ -4,9 +4,11 @@
let { let {
editable = true, editable = true,
onclick = evt => {},
onSet = newVal => {return true;},
simple = false, simple = false,
value = $bindable({source:null,rendered:null}), type = 'div',
onSet = (newVal) => {} value = $bindable({source:null,rendered:null})
} = $props(); } = $props();
let editing = $state(false); let editing = $state(false);
@ -14,6 +16,7 @@
let editValue = $state({source:value.source,rendered:value.rendered}); let editValue = $state({source:value.source,rendered:value.rendered});
let timer = null; let timer = null;
let start = 0;
async function applyEdit(){ async function applyEdit(){
let success = await onSet(editValue.source); let success = await onSet(editValue.source);
@ -55,6 +58,34 @@
timer = setTimeout(render,500); timer = setTimeout(render,500);
} }
function measured(evt,duration){
if (duration < 500){
onclick(evt);
} else {
startEdit();
}
}
function onmousedown(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function onmouseup(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
function ontouchstart(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function ontouchend(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
activeField.subscribe((val) => resetEdit()); activeField.subscribe((val) => resetEdit());
if (simple) startEdit(); if (simple) startEdit();
</script> </script>
@ -76,4 +107,4 @@
{#if editing} {#if editing}
<textarea bind:value={editValue.source} onkeyup={typed} autofocus={!simple}></textarea> <textarea bind:value={editValue.source} onkeyup={typed} autofocus={!simple}></textarea>
{/if} {/if}
<div ondblclick={startEdit} class={{editable}} title={t('double_click_to_edit')} >{@html editValue.rendered}</div> <svelte:element this={type} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} class={{editable}} title={t('double_click_to_edit')} >{@html editValue.rendered}</svelte:element>

45
frontend/src/Components/MultilineEditor.svelte

@ -2,12 +2,18 @@
import { activeField } from './field_sync.svelte.js'; import { activeField } from './field_sync.svelte.js';
import { t } from '../translations.svelte.js'; import { t } from '../translations.svelte.js';
let { editable = false, value = $bindable(null), onSet = (newVal) => {} } = $props(); let {
let editing = $state(false); editable = false,
onclick = evt => {},
onSet = newVal => {return true;},
type = 'div',
value = $bindable(null)
} = $props();
let editing = $state(false);
let editValue = $state(value); let editValue = $state(value);
let timer = null; let timer = null;
let start = 0;
async function applyEdit(){ async function applyEdit(){
let success = await onSet(editValue); let success = await onSet(editValue);
@ -29,7 +35,34 @@
if (ev.keyCode == 13 && ev.ctrlKey) applyEdit(); if (ev.keyCode == 13 && ev.ctrlKey) applyEdit();
if (ev.keyCode == 27) resetEdit(); if (ev.keyCode == 27) resetEdit();
} }
console.log(value);
function measured(evt,duration){
if (duration < 500){
onclick(evt);
} else {
startEdit();
}
}
function onmousedown(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function onmouseup(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
function ontouchstart(evt){
evt.preventDefault();
start = evt.timeStamp;
}
function ontouchend(evt){
evt.preventDefault();
measured(evt, evt.timeStamp - start);
}
activeField.subscribe((val) => resetEdit()); activeField.subscribe((val) => resetEdit());
</script> </script>
@ -52,10 +85,10 @@
<textarea bind:value={editValue} onkeyup={typed} autofocus></textarea> <textarea bind:value={editValue} onkeyup={typed} autofocus></textarea>
{:else} {:else}
{#if value} {#if value}
<div ondblclick={startEdit} class={{editable}} title={t('double_click_to_edit')} > <svelte:element this={type} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} class={{editable}} title={t('double_click_to_edit')} >
{#each value.split("\n") as line} {#each value.split("\n") as line}
{line}<br/> {line}<br/>
{/each} {/each}
</div> </svelte:element>
{/if} {/if}
{/if} {/if}

9
task/src/main/java/de/srsoftware/umbrella/task/SqliteDb.java

@ -216,7 +216,14 @@ CREATE TABLE IF NOT EXISTS {0} (
if (taskId == null) throw new UmbrellaException("Failed to save task {0}",task.name()); if (taskId == null) throw new UmbrellaException("Failed to save task {0}",task.name());
return new Task(taskId,task.projectId(),task.parentTaskId(),task.name(),task.description(),task.status(),task.estimatedTime(),task.start(),task.dueDate(),task.showClosed(),task.noIndex(),task.members()); return new Task(taskId,task.projectId(),task.parentTaskId(),task.name(),task.description(),task.status(),task.estimatedTime(),task.start(),task.dueDate(),task.showClosed(),task.noIndex(),task.members());
} }
throw new UmbrellaException(HTTP_NOT_IMPLEMENTED,"updating task in SqliteDb.save(task) not implemented"); if (task.isDirty()) {
update(TABLE_TASKS).set(PROJECT_ID,PARENT_TASK_ID,NAME,DESCRIPTION,STATUS,EST_TIME,START_DATE,DUE_DATE,SHOW_CLOSED,NO_INDEX)
.where(ID,equal(task.id())).prepare(db)
.apply(task.projectId(),task.parentTaskId(),task.name(),task.description(),task.status().code(),task.estimatedTime(),task.start(),task.dueDate(),task.showClosed(),task.noIndex())
.close();
task.clean();
}
return task;
} catch (SQLException e){ } catch (SQLException e){
throw new UmbrellaException(HTTP_SERVER_ERROR,"Failed to save task {0}",task.name()); throw new UmbrellaException(HTTP_SERVER_ERROR,"Failed to save task {0}",task.name());
} }

40
task/src/main/java/de/srsoftware/umbrella/task/TaskModule.java

@ -9,9 +9,11 @@ import static de.srsoftware.umbrella.core.Paths.*;
import static de.srsoftware.umbrella.core.ResponseCode.HTTP_NOT_IMPLEMENTED; import static de.srsoftware.umbrella.core.ResponseCode.HTTP_NOT_IMPLEMENTED;
import static de.srsoftware.umbrella.core.Util.mapValues; import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*; import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.core.model.Permission.READ_ONLY;
import static de.srsoftware.umbrella.project.Constants.PERMISSIONS; import static de.srsoftware.umbrella.project.Constants.PERMISSIONS;
import static de.srsoftware.umbrella.task.Constants.*; import static de.srsoftware.umbrella.task.Constants.*;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_OK;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.configuration.Configuration; import de.srsoftware.configuration.Configuration;
@ -75,6 +77,33 @@ public class TaskModule extends BaseHandler implements TaskService {
} }
} }
@Override
public boolean doOptions(Path path, HttpExchange ex) throws IOException {
addCors(ex);
return sendEmptyResponse(HTTP_OK,ex);
}
@Override
public boolean doPatch(Path path, HttpExchange ex) throws IOException {
addCors(ex);
try {
Optional<Token> token = SessionToken.from(ex).map(Token::of);
var user = users.loadUser(token);
if (user.isEmpty()) return unauthorized(ex);
var head = path.pop();
return switch (head) {
case null -> super.doGet(path,ex);
default -> {
var taskId = Long.parseLong(head);
head = path.pop();
yield head == null ? patchTask(ex,taskId,user.get()) : super.doGet(path,ex);
}
};
} catch (UmbrellaException e){
return send(ex,e);
}
}
@Override @Override
public boolean doPost(Path path, HttpExchange ex) throws IOException { public boolean doPost(Path path, HttpExchange ex) throws IOException {
addCors(ex); addCors(ex);
@ -173,6 +202,15 @@ public class TaskModule extends BaseHandler implements TaskService {
return mappedTask; return mappedTask;
} }
private boolean patchTask(HttpExchange ex, long taskId, UmbrellaUser user) throws IOException {
var task = loadMembers(taskDb.load(taskId));
var member = task.members().get(user.id());
if (member == null || member.permission() == READ_ONLY ) throw forbidden("You are not a allowed to edit {0}!",task.name());
taskDb.save(task.patch(json(ex)));
return sendContent(ex,task);
}
private boolean postNewTask(UmbrellaUser user, HttpExchange ex) throws IOException { private boolean postNewTask(UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex); var json = json(ex);
if (!(json.has(PROJECT_ID) && json.get(PROJECT_ID) instanceof Number pid)) throw missingFieldException(PROJECT_ID); if (!(json.has(PROJECT_ID) && json.get(PROJECT_ID) instanceof Number pid)) throw missingFieldException(PROJECT_ID);
@ -181,7 +219,7 @@ public class TaskModule extends BaseHandler implements TaskService {
var project = projects.load(projectId); var project = projects.load(projectId);
projects.loadMembers(List.of(project)); projects.loadMembers(List.of(project));
var member = project.members().get(user.id()); var member = project.members().get(user.id());
if (member == null || member.permission() == Permission.READ_ONLY) throw forbidden("You are not allowed to create new tasks in this project"); if (member == null || member.permission() == READ_ONLY) throw forbidden("You are not allowed to create new tasks in this project");
Task task = Task.of(json); Task task = Task.of(json);
task = taskDb.save(task); task = taskDb.save(task);
for (var key : memberData.keySet()){ for (var key : memberData.keySet()){

Loading…
Cancel
Save