Merge branch 'refactor/autocomplete' into dev
All checks were successful
Build Docker Image / Docker-Build (push) Successful in 2m19s
Build Docker Image / Clean-Registry (push) Successful in -8s

This commit is contained in:
2026-03-17 08:28:15 +01:00
10 changed files with 122 additions and 94 deletions

View File

@@ -2,59 +2,114 @@
import { t } from '../translations.svelte.js' import { t } from '../translations.svelte.js'
import { tick } from "svelte"; import { tick } from "svelte";
let { let {
getCandidates = async text => { conole.log('no handler for getCandidates('+text+')'); return {};}, getCandidates = dummyGetCandidates,
onSelect = text => [] onCommit = dummyOnCommit,
onSelect = dummyOnSelect,
} = $props(); } = $props();
const ignore = ['Escape','Tab','ArrowUp','ArrowLeft','ArrowRight'] const ignore = ['ArrowLeft','ArrowRight'];
let options = $state({}); let candidate = $state({ display : '' });
let text = $state('') let selected = $state([]);
let candidates = $derived(getCandidates(candidate.display));
async function dummyGetCandidates(text){
console.warn(`getCandidates(${text}) not overridden!`);
if (!text) return [];
return [
{
display : 'candidate 1',
explanation : 'candidates need to have a display field'
},
{
display : 'candidate 2',
additional : 'other fields are optional',
more : 'and may be domain specific'
},
{ display : text }
];
}
function dummyOnCommit(candidate){
// if Enter is pressed on the input field, this method gets called with
// either the selected candidate or
// an anonymous object with the entered text in the display field
console.warn(`onCommit(${JSON.stringify(candidate)}) not overridden!`);
}
function dummyOnSelect(candidate){
console.warn(`${candidate.display} selected, but onSelect not overridden!`)
}
async function ondblclick(evt){ async function ondblclick(evt){
const select = evt.target; const select = evt.target;
const key = select.value; const idx = select.value;
text = options[key]; candidate = candidates[idx];
let result = {}; candidates = [];
result[key] = text; selected = [];
options = {}; console.log(candidate);
text = ''; onSelect(candidate);
onSelect(result);
} }
async function onkeyup(evt){ async function onkeyup(ev){
const select = evt.target; if (ignore.includes(ev.key)) return;
const key = evt.key; if (ev.key == 'ArrowDown'){
if (ignore.includes(key)) return; ev.preventDefault();
if (key == 'ArrowDown'){ selected = selected.length < 1 ? [0] : [selected[0]+1]
if (select.selectedIndex == 0) select.selectedIndex=1; if (selected[0] >= candidates.length) selected = [0];
return; return false;
} }
if (key == 'Enter'){ if (ev.key == 'ArrowUp'){
ondblclick(evt); ev.preventDefault();
return; selected = selected.length < 1 ? [-1] : [selected[0]-1]
if (selected[0] < 0) selected = [candidates.length-1];
return false;
} }
if (key == 'Backspace'){ if (ev.key == 'Enter'|| ev.key == 'Tab'){
text = text.substring(0,text.length-1) ev.preventDefault();
} else if (key.length<2){ if (selected.length>0) {
text += evt.key candidate = candidates[selected[0]];
candidates = [];
selected = [];
onSelect(candidate);
return false;
}
if (ev.key == 'Enter') {
candidates = [];
selected = [];
onCommit(candidate);
}
return false;
} }
options = await getCandidates(text); if (ev.key == 'Escape'){
await tick(); ev.preventDefault();
for (let o of select.getElementsByTagName('option')) o.selected = false; candidates = [];
selected = [];
return false;
}
candidates = await getCandidates(candidate.display);
if (selected>candidates.length) selected = candidates.length;
return false;
} }
</script> </script>
<style> <style>
select{ div { position : relative }
min-width: 200px; select { position : absolute; top: 30px; left: 3px; }
}
select { background: black; color: orange; border: 1px solid orange; border-radius: 5px; }
option:checked { background: orange; color: black; }
</style> </style>
{#if options}
<select size={Object.keys(options).length<2?2:Object.keys(options).length+1} {onkeyup} {ondblclick} width="40"> <div>
<option>{text}</option> <input type="text" bind:value={candidate.display} {onkeyup} />
{#each Object.entries(options) as [val,caption]} {#if candidates && candidates.length > 0}
<option value={val}>{caption}</option> <select bind:value={selected} {ondblclick} multiple tabindex="-1">
{/each} {#each candidates as candidate,i}
</select> <option value={i}>{candidate.display}</option>
{/if} {/each}
</select>
{/if}
</div>

View File

@@ -1,8 +1,9 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '../urls.svelte'; import { api, get, post } from '../urls.svelte';
import { t } from '../translations.svelte'; import { t } from '../translations.svelte';
import { error, yikes } from '../warn.svelte';
import Autocomplete from './Autocomplete.svelte'; import Autocomplete from './Autocomplete.svelte';
import PermissionSelector from './PermissionSelector.svelte'; import PermissionSelector from './PermissionSelector.svelte';
@@ -27,7 +28,7 @@
}); });
if (resp.ok){ if (resp.ok){
var json = await resp.json(); var json = await resp.json();
return Object.fromEntries(Object.values(json).map(user => [user.id,user.name])); return Object.values(json).map(user => { return {...user,display:user.name}; });
} else { } else {
return []; return [];
} }
@@ -35,16 +36,10 @@
async function loadPermissions(){ async function loadPermissions(){
const url = api('task/permissions'); const url = api('task/permissions');
const resp = await fetch(url,{credentials: 'include'}); const resp = await get(url);
if (resp.ok){ if (resp.ok){
permissions = await resp.json(); permissions = await resp.json();
} else { } else error(resp);
message = await resp.text();
}
}
function onSelect(entry){
addMember(entry);
} }
onMount(loadPermissions); onMount(loadPermissions);
@@ -66,7 +61,7 @@
<tr> <tr>
<td>{t('add_object',{object:t('member')})}</td> <td>{t('add_object',{object:t('member')})}</td>
<td> <td>
<Autocomplete {getCandidates} {onSelect} /> <Autocomplete {getCandidates} onSelect={addMember} />
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -16,9 +16,7 @@
} }
function onSelect(entry){ function onSelect(entry){
for (let [k,v] of Object.entries(entry)){ users[entry.id] = entry;
users[k] = {name:v,id:k};
}
} }
let sortedUsers = $derived.by(() => Object.values(users).sort((a, b) => a.name.localeCompare(b.name))); let sortedUsers = $derived.by(() => Object.values(users).sort((a, b) => a.name.localeCompare(b.name)));

View File

@@ -37,9 +37,7 @@
if (resp.ok){ if (resp.ok){
yikes(); yikes();
const input = await resp.json(); const input = await resp.json();
return Object.fromEntries( return Object.values(input).map(user => {return {...user,display:user.name}});
Object.entries(input).map(([key, value]) => [key, value.name])
);
} else { } else {
error(resp); error(resp);
return {}; return {};

View File

@@ -22,9 +22,7 @@
if (resp.ok){ if (resp.ok){
yikes(); yikes();
const input = await resp.json(); const input = await resp.json();
return Object.fromEntries( return Object.values(input).map(user => { return {...user, display: user.name}});
Object.entries(input).map(([key, value]) => [key, value.name])
);
} else { } else {
error(resp); error(resp);
return {}; return {};

View File

@@ -20,7 +20,7 @@
let members = $state([]); let members = $state([]);
function addMember(member){ function addMember(member){
for (let [id,name] of Object.entries(member)) update_permissions({user_id:+id,permission:4}); update_permissions({user_id:+member.id,permission:4});
return true; return true;
} }

View File

@@ -28,9 +28,8 @@
let new_state = $state({code:null,name:null}) let new_state = $state({code:null,name:null})
let state_available=$derived(new_state.name && new_state.code && !project.allowed_states[new_state.code]); let state_available=$derived(new_state.name && new_state.code && !project.allowed_states[new_state.code]);
async function addMember(entry){ async function addMember(user){
const ids = Object.keys(entry); return await update({new_member:+user.id});
if (ids) update({new_member:+ids.pop()});
} }
async function addState(){ async function addState(){
@@ -67,21 +66,6 @@
update({drop_member:member.user.id}); update({drop_member:member.user.id});
} }
async function getCandidates(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 [];
}
}
function handleCreate(evt){ function handleCreate(evt){
let json = JSON.parse(evt.data); let json = JSON.parse(evt.data);
json.event = 'create'; json.event = 'create';
@@ -242,7 +226,7 @@
</label> </label>
<div class="em">{t('members')}</div> <div class="em">{t('members')}</div>
<div class="em"> <div class="em">
<PermissionEditor members={project.members} {updatePermission} {addMember} {dropMember} {getCandidates} /> <PermissionEditor members={project.members} {updatePermission} {addMember} {dropMember} />
</div> </div>
{#if project.allowed_states} {#if project.allowed_states}
{#each Object.keys(project.allowed_states) as key,idx} {#each Object.keys(project.allowed_states) as key,idx}

View File

@@ -29,7 +29,8 @@
let router = useTinyRouter(); let router = useTinyRouter();
function addMember(member){ function addMember(member){
for (let uid of Object.keys(member)) task.members[uid] = project.members[uid]; let uid = member.id;
task.members[uid] = project.members[uid];
} }
function flat(json){ function flat(json){
@@ -44,10 +45,9 @@
async function getCandidates(text){ async function getCandidates(text){
const origin = parent_task ? parent_task.members : project.members; const origin = parent_task ? parent_task.members : project.members;
const candidates = Object.values(origin) return Object.values(origin)
.filter(member => member.user.name.toLowerCase().includes(text.toLowerCase())) .filter(member => member.user.name.toLowerCase().includes(text.toLowerCase()))
.map(member => [member.user.id,member.user.name]); .map(member => {return { ...member.user,display:member.user.name}});
return Object.fromEntries(candidates);
} }
async function load(){ async function load(){

View File

@@ -37,9 +37,8 @@
router.navigate(`/task/${id}/add_subtask`); router.navigate(`/task/${id}/add_subtask`);
} }
async function addMember(entry){ async function addMember(newMember){
const ids = Object.keys(entry); return await update({new_member:+newMember.id});
if (ids) update({new_member:+ids.pop()});
} }
async function addTime(){ async function addTime(){
@@ -64,10 +63,10 @@
async function getCandidates(text){ async function getCandidates(text){
const origin = task.parent ? task.parent.members : project.members; const origin = task.parent ? task.parent.members : project.members;
const candidates = Object.values(origin) return Object.values(origin)
.filter(member => member.user.name.toLowerCase().includes(text.toLowerCase())) .filter(member => member.user.name.toLowerCase().includes(text.toLowerCase()))
.map(member => [member.user.id,member.user.name]); .map(member => { return {...member.user,display:member.user.name}});
return Object.fromEntries(candidates);
} }
function gotoKanban(){ function gotoKanban(){
@@ -177,6 +176,8 @@
}); });
if (resp.ok){ if (resp.ok){
yikes(); yikes();
let json = await resp.json();
if (json.members) task.members = json.members;
return true; return true;
} else { } else {
error(resp); error(resp);

View File

@@ -22,9 +22,8 @@
async function addMember(entry){ async function addMember(entry){
let newMembers = JSON.parse(JSON.stringify(page.members)); let newMembers = JSON.parse(JSON.stringify(page.members));
for (var id of Object.keys(entry)){ let id = entry.id;
if (!newMembers[id]) newMembers[id] = { permission : {name:'READ_ONLY'} }; if (!newMembers[id]) newMembers[id] = { permission : {name:'READ_ONLY'} };
}
return patch({members:newMembers}); return patch({members:newMembers});
} }
@@ -74,7 +73,7 @@
}); });
if (resp.ok){ if (resp.ok){
var json = await resp.json(); var json = await resp.json();
return Object.fromEntries(Object.values(json).filter(nonMember).map(user => [user.id,user.name])); return Object.values(json).filter(nonMember).map(user => { return {...user,display:user.name}});
} else { } else {
return []; return [];
} }