Merge branch 'module/poll' into dev
Some checks failed
Build Docker Image / Docker-Build (push) Failing after 3m32s
Build Docker Image / Clean-Registry (push) Successful in -5s

This commit is contained in:
2026-03-09 11:19:22 +01:00
26 changed files with 1459 additions and 20 deletions

View File

@@ -14,8 +14,10 @@
import ContactList from "./routes/contact/Index.svelte";
import DocList from "./routes/document/List.svelte";
import EasyList from "./routes/task/EasyList.svelte";
import EditPoll from "./routes/poll/Edit.svelte";
import EditService from "./routes/user/EditService.svelte";
import EditUser from "./routes/user/EditUser.svelte";
import EvalPoll from "./routes/poll/Evaluate.svelte";
import FileIndex from "./routes/files/Index.svelte";
import Footer from "./Components/Footer.svelte";
import Kanban from "./routes/project/Kanban.svelte";
@@ -25,6 +27,7 @@
import Menu from "./Components/Menu.svelte";
import NewPage from "./routes/wiki/AddPage.svelte";
import Notes from "./routes/notes/Index.svelte";
import PollList from "./routes/poll/Index.svelte";
import ProjectList from "./routes/project/List.svelte";
import ProjectAdd from "./routes/project/Create.svelte";
import ResetPw from "./routes/user/ResetPw.svelte";
@@ -37,6 +40,7 @@
import Times from "./routes/time/Index.svelte";
import User from "./routes/user/User.svelte";
import ViewDoc from "./routes/document/View.svelte";
import ViewPoll from "./routes/poll/View.svelte";
import ViewPrj from "./routes/project/View.svelte";
import ViewTask from "./routes/task/View.svelte";
import WikiIndex from "./routes/wiki/Index.svelte";
@@ -95,6 +99,10 @@
<Route path="/message" component={Messages} />
<Route path="/message/settings" component={MsgSettings} />
<Route path="/notes" component={Notes} />
<Route path="/poll" component={PollList} />
<Route path="/poll/:id/edit" component={EditPoll} />
<Route path="/poll/:id/evaluate" component={EvalPoll} />
<Route path="/poll/:id/view" component={ViewPoll} />
<Route path="/project" component={ProjectList} />
<Route path="/project/add" component={ProjectAdd} />
<Route path="/project/:project_id/add_task" component={AddTask} />
@@ -137,6 +145,7 @@
{/if}
<Route path="/user/reset/pw" component={ResetPw} />
<Route path="/oidc_callback" component={Callback} />
<Route path="/poll/:id/view" component={ViewPoll} />
<Route path="/wiki/:key/view" component={WikiGuest} />
<Route>
<Login />

View File

@@ -1,4 +1,4 @@
<script>
<script>
import { activeField } from './field_sync.svelte.js';
import { t } from '../translations.svelte.js';
@@ -110,7 +110,7 @@
</style>
{#if editable && editing}
<input bind:value={editValue} onkeyup={typed} autofocus />
<input bind:value={editValue} onkeyup={typed} {title} autofocus />
{:else}
<svelte:element this={type} href={href} onclick={ignore} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} {oncontextmenu} class={{editable}} {title} >{value}</svelte:element>
{/if}

View File

@@ -129,8 +129,8 @@
}
</style>
<div class="markdown {editing?'editing':''}">
{#if editing}
<div class="markdown {editing || simple ?'editing':''}">
{#if editing || simple}
<span class="hint">{@html t('markdown_supported')}</span>
{#if stored_source}
<span id="restore_markdown" onclick={restore} class="hint">{t('unsaved_content')}</span>

View File

@@ -10,7 +10,7 @@
let {
addMember = (entry) => console.log(`no handler for addMember(${entry})`),
dropMember = (member) => console.log(`no handler for dropMember(${member})`),
getCandidates = text => {},
getCandidates = defaultGetCandidates,
members,
updatePermission = (uid,perm) => console.log(`no handler for updatePermission(${uid}, ${perm})`)
} = $props();
@@ -18,6 +18,21 @@
let permissions = $state(null);
let sortedMembers = $derived.by(() => Object.values(members).sort((a, b) => a.user.name.localeCompare(b.user.name)));
async function defaultGetCandidates(text){
const url = api('user/search');
const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
body : text
});
if (resp.ok){
var json = await resp.json();
return Object.fromEntries(Object.values(json).map(user => [user.id,user.name]));
} else {
return [];
}
}
async function loadPermissions(){
const url = api('task/permissions');
const resp = await fetch(url,{credentials: 'include'});
@@ -37,7 +52,7 @@
<table>
<tbody>
{#each sortedMembers as member,i}
{#each sortedMembers as member (member.user.id)}
<tr>
<td>{member.user.name}</td>
<td>

View File

@@ -0,0 +1,223 @@
<script>
import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import LineEditor from '../../Components/LineEditor.svelte';
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
import Permissions from '../../Components/PermissionEditor.svelte';
import { api, get, patch, post } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
import { user } from '../../user.svelte.js';
let { id } = $props();
let visible_to_guests = false;
let new_option = $state({name:'',description:{'source':'',rendered:''}});
let new_weight = $state({description:'',weight:0});
let poll = $state(null);
let members = $state([]);
function addMember(member){
for (let [id,name] of Object.entries(member)) update_permissions({user_id:+id,permission:4});
return true;
}
function dropMember(member){
let user_id = member.user.id;
if (update_permissions({user_id,permission:0})){
members = members.filter(m => m.user.id != user_id);
}
}
async function load(){
let url = api('poll/'+id);
let res = await get(url);
if (res.ok){
poll = await res.json();
for (let perm of Object.values(poll.permission)){
members.push({ user : { name : perm.name, id : perm.id }, permission: { name : perm.permission.name, code: perm.permission.code}});
}
visible_to_guests = !poll.private;
yikes();
} else error(res);
}
async function patch_poll(field, newVal){
let url = api(`poll/${id}`);
let data = {}
data[field] = newVal;
let res = await patch(url,data);
if (res.ok) {
yikes();
const json = await res.json();
poll = { ...poll, ...json };
return true;
}
error(res);
return false;
}
async function patch_option(option, field, newVal){
let url = api(`poll/${id}/option/${option.id}`);
let res = await patch(url,{[field]: newVal});
if (res.ok) {
yikes();
const json = await res.json();
if (field == 'name' && newVal == ''){
poll.options = poll.options.filter(o => o.id !== option.id);
} else poll.options = json.options;
return true;
}
error(res);
return false;
}
async function patch_weight(data){
let url = api(`poll/${id}/weight`);
let res = await patch(url,data);
if (res.ok) {
yikes();
const json = await res.json();
const weights = json.weights;
for (let weight of Object.keys(data)){
let desc = data[weight];
if (desc) {
poll.weights[weight] = desc;
} else delete poll.weights[weight]; // TODO: this corrupts the display of the following element!
}
return true;
}
error(res);
return false;
}
async function save_new_option(){
if (!new_option.name) return;
let url = api('poll/'+id+'/option');
let res = await post(url,new_option);
if (res.ok){
yikes();
const json = await res.json();
poll.options = json.options;
} else error(res);
}
function save_new_weight(e){
const data = {};
data[new_weight.weight] = new_weight.description;
patch_weight(data);
}
function toggle_guest(e){
patch_poll('private',!visible_to_guests);
}
async function update_permissions(data){
let url = api(`poll/${id}/permissions`);
let res = await post(url,data);
if (res.ok) {
yikes();
let json = await res.json();
members = members.filter(m => m.user.id != json.user.id);
members.push(json);
console.log({members});
return true;
}
error(res);
return false;
}
onMount(load);
</script>
<fieldset>
<legend>{t('edit poll',{id:id})}</legend>
{#if poll && poll.name}
<fieldset>
<legend>{t('name')}</legend>
<LineEditor bind:value={poll.name} editable={true} onSet={name => patch_poll('name',name)} />
</fieldset>
<fieldset>
<legend>{t('description')}</legend>
<MarkdownEditor bind:value={poll.description} onSet={desc => patch_poll('description',desc)} />
</fieldset>
<fieldset>
<legend>{t('permissions')}</legend>
<Permissions {addMember} {members} {dropMember} updatePermission={(user_id,perm) => update_permissions({user_id,permission:perm.code})} />
<label>
<input type="checkbox" bind:checked={visible_to_guests} onchange={toggle_guest} />
{t('visible_to_guests')}
</label>
</fieldset>
<fieldset>
<legend>{t('options')}</legend>
<table>
<thead>
<tr>
<th>{t('name')}</th>
<th>{t('description')}</th>
</tr>
</thead>
<tbody>
{#each Object.entries(poll.options) as [option_id, option]}
<tr>
<td>
<LineEditor editable={true} value={option.name} onSet={name => patch_option(option,'name',name)} title={t('clear to remove')} />
</td>
<td>
<MarkdownEditor bind:value={option.description} onSet={desc => patch_option(option,'description',desc)} />
</td>
</tr>
{/each}
<tr>
<td class="new_option">
{t('add element',{element:t('options')})}
<input type="text" bind:value={new_option.name} />
</td>
<td>
<MarkdownEditor simple={true} bind:value={new_option.description} />
<button onclick={save_new_option}>{t('save')}</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
<fieldset>
<legend>{t('weights')}</legend>
<table>
<thead>
<tr>
<th>{t('weight')}</th>
<th>{t('description')}</th>
</tr>
</thead>
<tbody>
{#each Object.entries(poll.weights) as [weight,descr] (weight)}
<tr>
<td>
<input type="number" value={weight} />
</td>
<td>
<LineEditor editable={true} value={descr} onSet={desc => patch_weight({[weight]: desc})} />
</td>
</tr>
{/each}
<tr>
<td>
<input type="number" bind:value={new_weight.weight} />
</td>
<td>
<input type="text" bind:value={new_weight.description} />
<button onclick={save_new_weight}>{t('save')}</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
{/if}
</fieldset>

View File

@@ -0,0 +1,85 @@
<script>
import { onMount } from 'svelte';
import { api, get } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
let { id } = $props();
let poll = $state(null);
function average(hist){
let count = 0;
let sum = 0;
for (let [k,v] of Object.entries(hist)) {
count += v;
sum += k*v;
}
return count > 0 ? sum/count : '?';
}
function max_val(hist){
return Math.max(...Object.values(hist));
}
async function load(){
let url = api('poll/evaluate/'+id);
let res = await get(url);
if (res.ok){
poll = await res.json();
yikes();
} else error(res);
}
onMount(load);
</script>
<style>
.histogram > span{
display: inline-block;
border: 1px solid lime;
vertical-align: bottom;
position: relative;
width: 20px;
}
.histogram{
height: 60px;
}
.histogram span span{
position: absolute;
bottom: 0;
}
</style>
{#if poll}
<fieldset>
<legend>{poll.name}</legend>
<div class="description">{@html poll.description.rendered}</div>
<table>
<thead>
<tr>
<td>{t('option')}</td>
<td>{t('average')}</td>
<td>{t('histogram')}</td>
</tr>
</thead>
<tbody>
{#each Object.entries(poll.evaluation) as [option_id,hist]}
<tr>
<td>
{poll.options[option_id].name}
</td>
<td>
{average(hist)}
</td>
<td class="histogram">
{#each Object.entries(hist).sort((a,b) => a[0] - b[0]) as [weight,count]}
<span style="height: {100*count/max_val(hist)}%">
<span>{weight}</span>
</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</fieldset>
{/if}

View File

@@ -0,0 +1,88 @@
<script>
import { onMount } from 'svelte';
import LineEditor from '../../Components/LineEditor.svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, get, post } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
import { user } from '../../user.svelte.js';
let polls = $state([]);
let router = useTinyRouter();
async function create_poll(name){
const url = api('poll');
const res = await post(url,{name});
if (res.ok) {
yikes();
const json = await res.json();
polls.push(json);
}
error(res);
}
function edit(poll){
router.navigate(`/poll/${poll.id}/edit`);
}
function evaluate(poll){
router.navigate(`/poll/${poll.id}/evaluate`);
}
async function load(){
let url = api('poll/list');
let res = await get(url);
if (res.ok){
polls = await res.json();
yikes();
} else error(res);
}
function open(poll){
router.navigate(`/poll/${poll.id}/view`);
}
onMount(load);
</script>
<fieldset>
<legend>{t('polls')}</legend>
<table>
<thead>
<tr>
<th>{t('name')}</th>
<th>{t('description')}</th>
<th>{t('owner')}</th>
<th>{t('actions')}</th>
</tr>
</thead>
<tbody>
{#each polls as poll}
<tr>
<td onclick={e => open(poll)}>{poll.name}</td>
<td>{@html poll.description.rendered}</td>
<td onclick={e => open(poll)}>{poll.owner.name}</td>
<td>
{#if user.id}
{#if user.id == poll.owner.id || (poll.permission[user.id] && poll.permission[user.id].permission.code == 2)}
<button onclick={e => edit(poll)}>{t('edit')}</button>
{/if}
{#if user.id == poll.owner.id || (poll.permission[user.id] && poll.permission[user.id].permission.code > 0)}
<button onclick={e => evaluate(poll)}>{t('evaluate')}</button>
{/if}
{/if}
</td>
</tr>
{/each}
<tr>
<td>
<LineEditor simple={true} onSet={create_poll}/>
</td>
<td colspan="3">{t('Enter name to create new')}</td>
</tr>
</tbody>
</table>
</fieldset>

View File

@@ -0,0 +1,105 @@
<script>
import { onMount } from 'svelte';
import { api, get, post } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { user } from '../../user.svelte';
let { id } = $props();
import { t } from '../../translations.svelte';
let poll = $state(null);
let selection = $state({});
let editor = $state(user ? { name: user.name, user_id : user.id } : { name : '', user_id : -1 })
let disabled = $state(false);
async function load(){
let url = api('poll/'+id);
let res = await get(url);
if (res.ok){
poll = await res.json();
yikes();
console.log(Object.entries(poll.weights).sort((a,b) => a[0] - b[0]));
} else error(res);
}
async function save(ev){
disabled = true;
let url = api(`poll/${id}/select`);
let res = await post(url,{editor,selection});
if (res.ok) {
yikes();
} else error(res);
}
function select(option,weight){
disabled = false;
selection[option.id] = +weight;
}
onMount(load);
</script>
<style>
table td:nth-child(n+2) {
text-align: center;
}
.radio {
vertical-align: middle;
}
</style>
<fieldset>
<legend>{t('User')}</legend>
{#if user.name}
<div>{t('logged in as: {user}',{user:user.name})}</div>
{:else}
<label>
{t('Your name')}
<input type="text" bind:value={editor.name} />
</label>
{/if}
</fieldset>
{#if poll}
<fieldset>
<legend>{t('poll')}: {poll.name}</legend>
<div class="description">
{@html poll.description.rendered}
</div>
<table class="poll">
<thead>
<tr>
<td>{t('option')}</td>
{#each Object.entries(poll.weights).sort((a,b) => a[0] - b[0]) as [weight,name]}
<td class="weight">
{weight}
<span class="description">
{name}
</span>
</td>
{/each}
</tr>
</thead>
<tbody>
{#each Object.entries(poll.options) as [option_id,option]}
<tr>
<td class="option">
{option.name}
<span class="description">
{@html option.description.rendered}
</span>
</td>
{#each Object.entries(poll.weights) as [weight,name]}
<td class="radio" onclick={e => select(option,weight)} title={t('click to select')} >
{#if selection[option_id] == weight}
X
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<button onclick={save} disabled={disabled || !editor.name || !Object.keys(selection).length}>{t('save')} </button>
</fieldset>
<div class="warn">TODO: add notes</div>
<div class="warn">TODO: load previous selection for logged-in user</div>
{/if}