133 lines
13 KiB
JavaScript
133 lines
13 KiB
JavaScript
/* Graphical editors for typed settings; serialization stays inside the client. */
|
||
'use strict';
|
||
|
||
const editorTypes = {string:'Text',integer:'Ganze Zahl',number:'Zahl',boolean:'Ja / Nein',object:'Gruppe',array:'Liste',null:'Nicht gesetzt'};
|
||
const handledEditorEvents = new WeakSet();
|
||
const editorLabels = {global:'Allgemein',network:'Netzwerk',disk_setup:'Systemdatenträger',filesystem:'Dateisystem',selection:'Plattenauswahl',zfs:'ZFS',raid:'RAID-Modus',filter:'Geräteauswahl',filter_match:'Verknüpfung',expected_count:'Erwartete Plattenanzahl',expected_serials:'Bestätigte Plattenkennungen',inventory_evidence:'Inventarnachweis',root_secret_id:'Root-Zugang',keyboard:'Tastatur',country:'Land',timezone:'Zeitzone',mailto:'E-Mail',fqdn:'Hostname',cidr:'IP-Adresse mit Präfix',gateway:'Gateway',dns:'DNS-Server',source:'Quelle',values:'Einstellungen',steps:'Schritte',parameters:'Parameter',secret_refs:'Geheimnisse',required:'Erforderlich',name:'Name',version:'Version',status:'Status',id:'Kennung',properties:'Felder',items:'Listenelemente',type:'Typ',description:'Beschreibung',default:'Standardwert',enum:'Auswahlwerte',additionalProperties:'Weitere Felder erlauben',minimum:'Minimum',maximum:'Maximum',minLength:'Mindestlänge',maxLength:'Maximallänge',minItems:'Mindesteinträge',maxItems:'Maximale Einträge',uniqueItems:'Eindeutige Einträge',pattern:'Formatregel'};
|
||
const editorLabel = key => editorLabels[key] || String(key).replace(/[_-]/g,' ');
|
||
Object.assign(editorLabels,{allowed_versions:'Erlaubte PVE-Versionen',dns_names:'Zu prüfende DNS-Namen',minimum_free_mb:'Freier Speicher in MiB',url:'Adresse',suite:'Distribution',components:'Komponenten',keyring:'Signaturschlüssel',packages:'Pakete',users:'Benutzer',authorized_keys:'Öffentliche SSH-Schlüssel',servers:'Zeitserver',enabled:'Aktiviert',path:'Pfad',content:'Inhalte',storage_ids:'Storage-Namen',require_time_sync:'Zeitsynchronisation erforderlich',resolved:'Wirksame Einstellungen',provenance:'Herkunft der Einstellungen',profiles:'Profile',reboot_budget:'Erlaubte Neustarts',timeout_seconds:'Zeitlimit in Sekunden',retry_safe:'Wiederholung erlaubt'});
|
||
const editorType = value => value===null?'null':Array.isArray(value)?'array':typeof value==='number'?(Number.isInteger(value)?'integer':'number'):typeof value;
|
||
const editorDefault = (type,schema={}) => Object.hasOwn(schema,'default')?structuredClone(schema.default):schema.enum?.length?structuredClone(schema.enum[0]):({object:{},array:[],boolean:false,integer:0,number:0,null:null,string:''}[type]??'');
|
||
function editorSchemaType(schema,value) {
|
||
const declared=Array.isArray(schema.type)?schema.type:[schema.type];
|
||
const actual=editorType(value);
|
||
return declared.includes(actual)?actual:declared.includes('number')&&actual==='integer'?'number':declared.find(type=>Object.hasOwn(editorTypes,type))||actual;
|
||
}
|
||
function dataEditor(name,value={},options={}) {
|
||
return `<section class="data-editor full" data-editor="${esc(name)}" aria-label="${esc(options.label||editorLabel(name))}">${dataNode(value,options.schema||{},options.label||editorLabel(name))}</section>`;
|
||
}
|
||
function dataNode(value,schema={},label='Wert') {
|
||
schema=schema && typeof schema==='object'?schema:{};
|
||
const type=editorSchemaType(schema,value);
|
||
const declared=Array.isArray(schema.type)?schema.type:[schema.type];
|
||
const fixed=declared.filter(Boolean).length===1 || Array.isArray(schema.enum) || Object.hasOwn(schema,'const');
|
||
const choice=fixed?`<span class="data-kind">${esc(editorTypes[type]||type)}</span>`:`<label class="data-kind-picker"><span class="sr-only">Datentyp für ${esc(label)}</span><select data-value-type>${Object.entries(editorTypes).map(([key,text])=>`<option value="${key}"${key===type?' selected':''}>${text}</option>`).join('')}</select></label>`;
|
||
return `<div class="data-node" data-value-node data-value-kind="${esc(type)}" data-value-schema="${esc(json(schema))}"><div class="data-heading"><strong>${esc(label)}</strong>${choice}</div>${schema.description?`<p class="field-help">${esc(schema.description)}</p>`:''}<div class="data-node-content">${dataNodeContent(value,type,schema,label)}</div></div>`;
|
||
}
|
||
function dataNodeContent(value,type,schema,label) {
|
||
if(Object.hasOwn(schema,'const'))return `<p class="field-help">Fest vorgegeben: ${esc(String(schema.const))}</p>`;
|
||
if(Array.isArray(schema.enum)) {
|
||
const choices=[...schema.enum];
|
||
if(value!==undefined&&!choices.some(item=>json(item)===json(value)))choices.push(value);
|
||
return `<label><span class="sr-only">${esc(label)}</span><select data-enum-value>${choices.map(item=>`<option value="${esc(json(item))}"${json(item)===json(value)?' selected':''}>${esc(typeof item==='object'?editorType(item):String(item))}</option>`).join('')}</select></label>`;
|
||
}
|
||
if(type==='object'||type==='array') {
|
||
const object=type==='object';
|
||
const properties=schema.properties||{};
|
||
const entries=object?Object.entries(value&&typeof value==='object'&&!Array.isArray(value)?value:{}):Array.isArray(value)?value.map((item,index)=>[String(index),item]):[];
|
||
if(object)for(const key of schema.required||[])if(!entries.some(([name])=>name===key))entries.push([key,editorDefault(editorSchemaType(properties[key]||{},''),properties[key]||{})]);
|
||
const children=entries.map(([key,item],index)=>dataEntry(key,item,object,object?properties[key]||{}:schema.items||{},index,(schema.required||[]).includes(key),object&&Object.hasOwn(properties,key))).join('');
|
||
const allowed=object?Object.keys(properties):[];
|
||
const picker=object&&allowed.length?`<label class="data-add-choice"><span class="sr-only">Feld hinzufügen</span><select data-new-property>${allowed.map(key=>`<option value="${esc(key)}">${esc(properties[key]?.title||editorLabel(key))}</option>`).join('')}${schema.additionalProperties!==false?'<option value="">Eigenes Feld</option>':''}</select></label>`:'';
|
||
const addAllowed=!object||schema.additionalProperties!==false||allowed.length;
|
||
return `<div class="data-children">${children}</div>${addAllowed?`<div class="data-add">${picker}<button type="button" class="button small" data-edit-action="add">${svg('plus')}${object?'Feld hinzufügen':'Eintrag hinzufügen'}</button></div>`:''}`;
|
||
}
|
||
if(type==='boolean')return `<label class="checkbox"><input type="checkbox" data-scalar-value${value?' checked':''}><span>${esc(label)}</span></label>`;
|
||
if(type==='null')return '<p class="field-help">Kein Wert gesetzt.</p>';
|
||
if(type==='number'||type==='integer')return `<label><span class="sr-only">${esc(label)}</span><input type="number" data-scalar-value value="${esc(value??0)}" step="${type==='integer'?'1':'any'}"${schema.minimum!==undefined?` min="${esc(schema.minimum)}"`:''}${schema.maximum!==undefined?` max="${esc(schema.maximum)}"`:''} required></label>`;
|
||
return `<label><span class="sr-only">${esc(label)}</span><textarea data-scalar-value rows="${String(value??'').includes('\n')?'4':'1'}"${schema.minLength?` minlength="${Number(schema.minLength)}"`:''}${schema.maxLength?` maxlength="${Number(schema.maxLength)}"`:''}>${esc(value??'')}</textarea></label>`;
|
||
}
|
||
function dataEntry(key,value,object,schema,index,required=false,known=false) {
|
||
const title=schema.title|| (object?editorLabel(key):`Eintrag ${index+1}`);
|
||
return `<div class="data-entry" data-entry>${object?`<label class="data-key-label"${known?' hidden':''}>Feldname<input data-entry-key value="${esc(key)}" required${required||known?' readonly':''}></label>`:''}<div class="data-entry-value">${dataNode(value,schema,title)}</div><div class="data-entry-actions">${!object?'<button type="button" class="icon-button" data-edit-action="up" aria-label="Eintrag nach oben">↑</button><button type="button" class="icon-button" data-edit-action="down" aria-label="Eintrag nach unten">↓</button>':''}${!required?'<button type="button" class="icon-button" data-edit-action="remove" aria-label="Eintrag entfernen">×</button>':'<span class="field-help">Pflichtfeld</span>'}</div></div>`;
|
||
}
|
||
function readDataEditor(container,name) {
|
||
const editor=[...(container.matches?.('[data-editor]')?[container]:[]),...container.querySelectorAll('[data-editor]')].find(item=>item.dataset.editor===name);
|
||
if(!editor)throw new Error(`Das Eingabefeld „${editorLabel(name)}“ ist nicht verfügbar.`);
|
||
return readDataNode(editor.querySelector('[data-value-node]'));
|
||
}
|
||
function readDataNode(node) {
|
||
const type=node.dataset.valueKind;
|
||
const schema=JSON.parse(node.dataset.valueSchema||'{}');
|
||
if(Object.hasOwn(schema,'const'))return structuredClone(schema.const);
|
||
const content=node.querySelector(':scope > .data-node-content');
|
||
const enumInput=content.querySelector(':scope > label > [data-enum-value]');
|
||
if(enumInput)return JSON.parse(enumInput.value);
|
||
if(type==='object'||type==='array') {
|
||
const entries=[...content.querySelector(':scope > .data-children').children];
|
||
if(type==='array')return entries.map(entry=>readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]')));
|
||
const result={};
|
||
for(const entry of entries) {
|
||
const key=entry.querySelector(':scope > .data-key-label > [data-entry-key]').value.trim();
|
||
if(!key)throw new Error('Bitte jedem Feld einen Namen geben.');
|
||
if(Object.hasOwn(result,key))throw new Error(`Das Feld „${key}“ ist doppelt vorhanden.`);
|
||
Object.defineProperty(result,key,{value:readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]')),enumerable:true,writable:true,configurable:true});
|
||
}
|
||
return result;
|
||
}
|
||
if(type==='null')return null;
|
||
const input=content.querySelector('[data-scalar-value]');
|
||
if(type==='boolean')return input.checked;
|
||
if(type==='number'||type==='integer') {
|
||
const number=Number(input.value);
|
||
if(!input.value.trim()||!Number.isFinite(number)||(type==='integer'&&!Number.isInteger(number)))throw new Error('Bitte eine gültige Zahl eingeben.');
|
||
return number;
|
||
}
|
||
return input.value;
|
||
}
|
||
function wireDataEditors(container) {
|
||
if(container.dataset.dataEditorsBound)return;
|
||
container.dataset.dataEditorsBound='true';
|
||
container.addEventListener('click',event=>{
|
||
const button=event.target.closest('[data-edit-action]');
|
||
if(!button||!container.contains(button))return;
|
||
if(handledEditorEvents.has(event))return;
|
||
handledEditorEvents.add(event);
|
||
event.preventDefault();
|
||
const action=button.dataset.editAction;
|
||
const node=button.closest('[data-value-node]');
|
||
const schema=JSON.parse(node.dataset.valueSchema||'{}');
|
||
const entry=button.closest('[data-entry]');
|
||
if(action==='remove'){entry.remove();return;}
|
||
if(action==='up'){if(entry.previousElementSibling)entry.previousElementSibling.before(entry);return;}
|
||
if(action==='down'){if(entry.nextElementSibling)entry.nextElementSibling.after(entry);return;}
|
||
if(action==='add') {
|
||
const object=node.dataset.valueKind==='object';
|
||
const content=node.querySelector(':scope > .data-node-content');
|
||
const children=content.querySelector(':scope > .data-children');
|
||
const key=content.querySelector(':scope > .data-add [data-new-property]')?.value||'';
|
||
if(object&&key&&[...children.children].some(row=>row.querySelector('[data-entry-key]')?.value===key)){toast('Dieses Feld ist bereits vorhanden.',true);return;}
|
||
const childSchema=object?schema.properties?.[key]||{}:schema.items||{};
|
||
const type=editorSchemaType(childSchema,'');
|
||
children.insertAdjacentHTML('beforeend',dataEntry(key,editorDefault(type,childSchema),object,childSchema,children.children.length,(schema.required||[]).includes(key),object&&Object.hasOwn(schema.properties||{},key)));
|
||
children.lastElementChild.querySelector('input,select,textarea')?.focus();
|
||
}
|
||
});
|
||
container.addEventListener('change',event=>{
|
||
if(!event.target.matches('[data-value-type]'))return;
|
||
if(handledEditorEvents.has(event))return;
|
||
handledEditorEvents.add(event);
|
||
const node=event.target.closest('[data-value-node]');
|
||
const type=event.target.value;
|
||
node.dataset.valueKind=type;
|
||
node.querySelector(':scope > .data-node-content').innerHTML=dataNodeContent(editorDefault(type),type,JSON.parse(node.dataset.valueSchema||'{}'),'Wert');
|
||
});
|
||
}
|
||
function dataView(value) {
|
||
if(value===null||value===undefined)return '<span class="muted">Nicht gesetzt</span>';
|
||
if(typeof value==='boolean')return `<span class="badge ${value?'green':''}">${value?'Ja':'Nein'}</span>`;
|
||
if(Array.isArray(value))return value.length?`<ol class="data-view-list">${value.map(item=>`<li>${dataView(item)}</li>`).join('')}</ol>`:'<span class="muted">Keine Einträge</span>';
|
||
if(typeof value==='object')return Object.keys(value).length?`<dl class="data-view">${Object.entries(value).map(([key,item])=>`<dt>${esc(editorLabel(key))}</dt><dd>${dataView(item)}</dd>`).join('')}</dl>`:'<span class="muted">Keine zusätzlichen Einstellungen</span>';
|
||
return `<span class="data-text">${esc(value)}</span>`;
|
||
}
|