// Start htmlDOMnode /** * Creates HTML string that displays the common known rotating fan. * * Symbolizes an ongoing process. * * @param {string} color A CSS color value, the color of the fan. * @param {string} size A CSS value in px for the width and height of the fan. * * @return {string} HTML with an animated SVG image. * */ let waitSymbol = function(color = '#acb7b2',size = '62px'){ let h = ['']; for (let r = 0;r < 10;r++){ h.push(''); h.push(''); } h.push(''); return h.join(''); }; /** * Is used for reducing amount of code when producing HTML elements. * * @param {string} type A standard HTML tag, like 'p', 'h1', 'div', 'select', 'option' etc.. * @param {object} attrs The attributes of the produced elements, like 'style', 'class', 'data-oblig' etc.. * * @return {object} An HTML object (not just a HTML string). * * @author Terje Rudi, Rudi Multimedia -20. * */ let mkNode = function(type = 'div',attrs = {}){ // Multitool by Terje Rudi let freshNode = false; try{ freshNode = document.createElement(type); for (let k in attrs){ if (['click','submit','change','focus','blur','keydown','keyup','copy','paste'].indexOf(k) < 0){ // Not an event if (k == 'innerText' || k == 'innerHTML' || k == 'text'){ freshNode[k] = attrs[k]; }else if (k == 'options'){ if (type.toLowerCase() == 'select'){ for (let o in attrs[k]){ freshNode.appendChild(mkNode('option',{ 'value' : o, 'text' : (!!attrs[k][o] ? attrs[k][o] : o) })); } }else{ console.error('Element ' + type + ' skal ikke ha option values') } }else{ freshNode.setAttribute(k,attrs[k]); } }else{ freshNode.addEventListener(k,attrs[k]); } }; }catch(e){ freshNode = mkNode('p',{ 'class' : 'error', 'innerHTML' : 'Feil: Kunne ikke tegne element ' + type + ': ' + e }); }; // Tidy up (removes data-oblig, if not set to true). if (!!freshNode.getAttribute('data-oblig') && freshNode.getAttribute('data-oblig') != 'true'){ freshNode.removeAttribute('data-oblig'); } // DEV: /*if (!freshNode.hasAttribute('list') && (freshNode.type != 'search' || freshNode.classList.contains('search')) && (freshNode.hasAttribute('data-oblig') || freshNode.hasAttribute('required'))){ freshNode.addEventListener('blur',() => {freshNode.reportValidity()}); }*/ return freshNode; }; /** * Creates a block HTML element containing a label element and an input element. * * Normally all nedeed to be provided is the label string, the alternative label string ('bokmål') and the input HTML object. Styling targeted for this form solution. * * @see Function 'mkNode'. * @see Object 'core' inside this function for a possible model of the 'content' argument. * * @param {object} content Values that might be provided, and that overrides the default values of the object variable 'core' set inside the function. * * @return {object} An HTML object (not just a HTML string). * * @example mkRow({input:htmlInputObject} // Where 'htmlInputObject' normally is an object created with 'mkNode'. * * @author Terje Rudi, HVL -21. * */ function mkRow(content = {}){ let id = (!!content.input && !!content.input.id ? content.input.id : 'Uspesifisert'); // Merk: 'input' kan bare inneholde ett - 1 - HTML-DOM-objekt! let core = { label : (!!content.label ? content.label : id).replaceAll('_',' ').replace('[]',''), input : (!!content.input ? content.input : mkNode('input',{id:'Uspesifisert',name:'Uspesifisert'})), wrap : (!!content.wrap ? content.wrap : 'div'), class : (!!content.input && !!content.input.type && /(checkbox|radio)/i.test(content.input.type) ? 'checkRow ' : 'fieldRow ') + (!!content.input && !!content.input.tagName && (content.input.tagName.toUpperCase() == 'TEXTAREA') ? 'topValign' : (!!content.input && !!content.input.type && /(checkbox|radio)/i.test(content.input.type) ? 'topValign ' : 'centerValign ')) } let wrap = mkNode(core.wrap,{class:core.class}); if (!!core.input && !!core.input.type && (/(checkbox|radio)/i.test(core.input.type))){ wrap.appendChild(core.input); wrap.appendChild(mkNode('label',{innerHTML:core.label,for:id})); }else{ wrap.appendChild(mkNode('label',{innerHTML:core.label + ':',for:id,style:'margin-right: .8em;'})); wrap.appendChild(core.input); } if (!!content['data-alt-lang']){ try{ if (!!wrap.querySelector('label')){ wrap.querySelector('label').setAttribute('data-alt-lang',content['data-alt-lang']); } }catch(e){ console.info('Kunne ikkje sette alternativ label-tekst:' + e); } } return wrap; } /** * Expands a string to the left by a certain length by appending zeroes. * @param {int} number A number. * @param {int} width The total length of the returned string. */ function zeroFillLeft( number, width ){ width -= number.toString().length; if ( width > 0 ){ return new Array( width + (/\./.test( number ) ? 2 : 1) ).join( '0' ) + number; } return number + ""; // always return a string } /** * Returns a HTML-selectobject with the slectable values of half hours in a day. * @param {string} id The id and name of the select. * @dependency {int} Function zeroFillleft. */ function mkClockSelect(id,mandatory = true,density = ['00','30']){ let sel = mkNode('select',{id:id,name:id,style:'min-width: 4rem;'}); if (mandatory){ sel.setAttribute('data-oblig','true'); } for(let i = 0; i < 23; i++){ let j = zeroFillLeft(i, 2); density.forEach((item) => { sel.appendChild(mkNode('option',{value:j+':'+item,text:j+':'+item})); }); } return sel; } /** * Creates a block HTML element containing a label element and a textarea element. * * @see Function 'mkNode'. * * @param {string} id Will become value of the id of the textarea. * @param {string} mandatory If attribute data-oblig of textarea is to be set to TRUE or FALSE. * @param {string} label Will become value of the label. If false, no label will be created. * @param {string} altLabel Akternative langue text for label. * @param {int} maxlen Maximum number of characters of the text value. * * @note Yes, we know about the misspelling of the function name. * */ function mkTexareaRow(id,mandatory = false,label,altLabel,placeholder = 'Skriv her...',altLangPlaceholder = 'Skriv her...',maxLen = 500){ let textareaRow = mkNode('div',{}); if (!!label){ textareaRow.appendChild(mkNode('label',{ innerText : label, 'data-alt-lang' : altLabel, for : id })); } let opts = { id : id, class : 'fullSpace', placeholder : placeholder, 'data-alt-lang-placeholder' : altLangPlaceholder, maxlength : maxLen }; if (!!mandatory){ opts['data-oblig'] = 'true' } textareaRow.appendChild(mkNode('textarea',opts)); return textareaRow; } /** * Creates an HTML element displaying at list of messages. * @param {array} msgs List of HTML strings. * @param {string} id Id attribute of parent display element. * @param {string} type Type of messages for setting the right styles onto them and the parent element. * * @dependency Function 'mkNode'. * * @returns {DOM-object} */ function createMsgListDisplay(msgs = ['Ingen feilmeldinger oppgitt.'],id = 'messageListDisplay',type = 'errors'){ let display = document.getElementById(id); if (!!display){ display.innerHTML = ''; }else{ display = mkNode('div',{id:id,role:'alert','aria-live':'polite',class:type}); } msgs.forEach(m => { display.appendChild(mkNode('p',{innerHTML:m})); }) return display; } /** * Activates an input field to function as a search box for searches in an external source and receive a value by the selection the user makes. * * @param {object} target The input field to be activated and to be filled with the end result at the end of the process. * @param {url} endPoint The REST-url to search with, note that the search/query string will be appendend to the end of it. * @param {function} callback A function receiving JSON response and returning massaged data in format of a simple array with values to 'populateOptions'. * @param {integer} minLen The minimum length of a query string before REST call is initiated. * * @dependency Function 'mkNode'. * * @see Used in 'Verv', 'Varsel om fare for ikke bestått'. Callback is used in an added focus event listener! * */ function autocompleteFromRemote(target,endPoint,callback,minLen = 4){ let suggestionBoxId = false; if (!!target){ suggestionBoxId = 'suggestions' + target.id; let setKeyEvent = function(event,obj){ let key = event.which || event.keyCode; if (key > 31){ // Human readable chars. if (obj.value.length >= minLen){ obj.classList.toggle('active'); let u = endPoint + encodeURIComponent(obj.value); fetch(u) .then(response => response.json()) .then(json => { // Success let arr = callback(json); populateOptions(suggestionBoxId,arr); obj.classList.toggle('active'); }) .catch(e => { obj.classList.toggle('active'); console.error('An error occured while getting data via REST: ' + e) }) }else{ document.getElementById(suggestionBoxId).innerHTML = ''; } } } let populateOptions = function(id,arr){ try{ if (arr.length > 0){ // Creates a link that both put a value into the searchfield, and potentially updates a 'tag' field. See tagValSrc in /includes/admin-view.php. let liStart = `
  • `; document.getElementById(id).innerHTML = liStart + arr.join(`
  • ${liStart}`) + ``; }else{ document.getElementById(id).innerHTML = ''; } }catch(e){ console.error('Could not deliver suggestions: ' + e + ', type: ' + typeof arr); } return true; } try{ if (document.querySelector('#' + suggestionBoxId) != null){ return true; }else{ // Create regime for searching and displaying hits/result. suggestionBox = mkNode('ul',{id:suggestionBoxId,class:'suggestionsContainer'}); target.placeholder = 'Skriv litt for å få forslag...'; // Pauls forslag til engelsk: Begin writing to open suggestions... target.style.marginBottom = '0'; target.parentNode.insertAdjacentElement('afterend',suggestionBox); // UX for entering field. target.addEventListener('focus',function(){ if (!this.classList.contains("search")){ this.classList.add('search'); } }); // UX for leaving field. target.addEventListener('blur',function(){ if (this.value.length > 0){ this.classList.remove('search'); }else{ this.classList.add('search'); } }); // UX for changes of field value. target.addEventListener('change',function(){ if (this.value.length > 0){ this.classList.remove('search'); }else{ this.classList.add('search'); } }); // Logic (getting and displaying suggestions). target.addEventListener('keyup',function(){setKeyEvent(event,this)}, false); } }catch(e){ console.error('Could not attach REST suggestions: ' + e); } } } /** * Creates an invisible datalistnode filled with options created from remote JSON. * * @see Function 'populateDatalist'. * * @param {string} listId Id attribute of datalist used to reference by element supposed to use datalist. * @param {string} src URL to JSON-data with upper key named 'data' containing an array of option values. * */ function mkDatalist(listId,src){ setTimeout(function(){ populateDatalist(document.getElementById(listId)); },2000); return mkNode('datalist',{ 'id': listId, 'data-src' : src }); } /** * Collects option values from a remote service to use in a datalist element. * * Returned JSON data must be, when success key equals true, available under the data key as an array of values. * * @param {object} datalist An already created datalist element to be filled with options. The source url must be available in it's data-src attribute. * */ let populateDatalist = function(datalist){ fetch(datalist.dataset.src) .then(function(response) { return response.json(); }) .then(function(json) { if (json['success'] == true){ let opt = false; for (let i = 0; i < json['data'].length;i++){ opt = document.createElement('option'); opt.value = json['data'][i]; document.getElementById(datalist.id).appendChild(opt); } }else{ console.error('HVL: Fikk feil ved mottak til dataliste ' + datalist.id + ': ' + json['errormsg']); } }) .catch(function(err) { console.error('HVL: Fikk feil ved henting til dataliste ' + datalist.id + ': ' + err); }); } /** * Creates a HTML-select element object with options and possibly an onchange event function added. Either replaces an existing object of the DOM or returns the object. * * @see Functions 'waitSymbol' and 'mkNode'. * * @param {string} idAndName Will become value of the id and name attribute of the select, and will replace the DOM element with the same value og it's id if present. * @param {object|string} options Either an object with keys and values for corresponding options value and text (A), or a URL to be fetched a result where the data key of the JSON returned equals (A). * @param {function} onchange Optional function to be executed when selection of the select is changed. * */ async function mkSelect(idAndName = 'Untitled',options = {'' : 'Menyen manglar val'},onchange = false){ // Check if this elementId already exists const isPresent = document.querySelector('#' + idAndName); let node = false; // Check if options is delivered as a JSON object, then just return a rendered select as object if (typeof options == 'object'){ node = mkNode('select',{ 'id' : idAndName, 'name' : idAndName, 'options' : options }) }else{ // Check if options is a URL let regex = new RegExp('^https\:\/\/','i'); if (regex.test(options)){ if (isPresent != null){ // Insert a waiting symbol into the DOM element isPresent.innerHTML = waitSymbol(null,'20px'); } const response = await fetch(options); const data = await response.json(); if (!!data){ // Benytter denne samme funksjonen, siden vi nå har options-verdiene. setTimeout(function(){ return mkSelect(idAndName,data,onchange); },500); }else{ // An error was received instead of values for the options console.error('FEIL: data i format {key1:val1,key2:val2} kom ikkje som svar for kall til url ' + options + ' for nedtrekksmeny'); node = mkNode('p',{ 'innerHTML':`Kunne ikkje hente nedtrekksmeny.` }); return node; } }else{ // Options is not a URL console.error('FEIL: Url ' + options + ' er ikkje gyldig for å henta inn verdiar til nedtrekksmeny'); node = mkNode('p',{ 'innerHTML' : 'Kunne ikkje hente inn nedtrekksmeny.' }); } } // Add a function to the node? if (typeof onchange == 'function' && node.tagName.toUpperCase() == 'SELECT'){ node.addEventListener('change',onchange); } if (isPresent != null){ // Remove current element in DOM and append the one created here let parent = isPresent.parentNode; isPresent.outerHTML = ''; parent.appendChild(node); return true; } // Just return the created element return node; } /** * Creates a select element, after first fetching option values from a remote script. Result from call must contain 'data' with an object of key value pairs. * * @param {string} selectName Value of name attribute of select node. * @param {object} coreVars WILL NOT BE USED, CAN BE 'NULL'. Global settings from parent script. * @param {url} url Used by fetch to get a list of key value pairs to populate the options of the node. * @param {obj} attrs Settings for the node, like style or if mandatory (data-oblig). * * @return {object} A HTML Select node with option values and option textvalues. */ let mkSelectByRemoteOptions = function(selectName,coreVars = null,url = null,attrs = {}){ let div = mkNode('span',{'id':selectName + 'Wrap','class' : 'blockLimWidth'}) div.innerHTML = waitSymbol('black','30px'); try{ // Starting by fetching options fetch(url) .then((response) => { return response.json(); }) .then((jsonval) => { let selWrap = document.getElementById(selectName + 'Wrap'); if (jsonval.success != null && jsonval.data != null){ let mand = {'name' : selectName,'id' : selectName}; // Please note, that javascript might sort by keys, if the har of number values. let opts = {'options' : jsonval.data}; let sel = mkNode('select',Object.assign(mand,opts,attrs)); selWrap.innerHTML = ''; selWrap.appendChild(sel); tapDataAndInsertIntoForm(formInstanceFieldValues); }else if (jsonval.success == false){ selWrap.innerHTML = '

    ' + jsonval.errormsg + '

    '; } }) .catch(function(err) { console.error('HVL: Fikk feil ved henting til options fra url ' + url + ': ' + err); }); }catch(e){ console.error('HVL: Feil oppstod ved oppretting av select-node med eksterne verdiar: ' + e); div.innerHTML = '

    Ein feil oppstod.

    '; } return div; } /** * Animates whole form rows using css display = flex, out or in. * * @param {object} elmObj Input element object of the row - not the row itself. * @param {string} inout Enters 'in' or 'out'. * @param {string} dir Enter down (enterDn), Enter up (enterUp), Sortie down (sortieDn), Sortie up (sortieUp). * */ let enterSortieRow = function(elmObj,inout = 'in',dir = 'down'){ if (inout == 'in'){ elmObj.parentNode.querySelector('label').className = 'enterLeft'; elmObj.className = 'enter' + (dir == 'down' ? 'Dn' : 'Up'); elmObj.parentNode.style.display = 'flex'; }else{ elmObj.parentNode.querySelector('label').className = 'sortieRight'; elmObj.className = 'sortie' + (dir == 'down' ? 'Dn' : 'Up'); setTimeout(function(){ elmObj.parentNode.style.display = 'none'; },400); } } /** * Fetches values from a JSON-url and creates a select or datalist bound input. * * @see Used in mkOrgAffiliations. * * @param {string} trgt Where returned object is to be delivered into the form. Parameter format fitted for querySelectorAll. * @param {string} checkBoxName Value of name attribute og checkbox input element. * @param {url} jsonSrc Url to script that returns expected JSON. * @param {string} firstOptText Text of the first option of the select element. If null or false, a datalist bound input is created. * @param {object} styleOverride Overwrites elements in object variable 'style' if same key is present in this object. * @param {function} callback Function to be performed after multi select has been delivered to the form. * @param {bool} minNumOfCheckeds Minimum mandatory number of selections. * * @return {object} Returns a HTML element ready to receive another HTML element asyncronesly. * */ let mkMultiSelect = function(trgt,checkBoxName,jsonSrc,firstOptText = 'Velg...',styleOverride = {},callback = null,minNumOfCheckeds = 1){ let style = { 'row' : 'display: flex;padding: .5rem .65rem;background: #f6f8f9;border: 0;border-bottom:1px solid #c9d5da;width: 100%;', 'select' : 'margin-top: .3rem;min-width: 100%;', 'inputdatalist' : 'min-width: 100% !important;' }; for (let s in styleOverride){ try{ style[s] = styleOverride[s]; }catch(e){ console.error('HVL: Stilelement ' + s + ' er ikkje tilgjenge'); } } // Trgt brukes i querySelectorAll // checkBoxName (feltnavn) maa slutte paa [] // Se https://v.hvl.no/verktyg/org/organisasjon.php?lang=en for eksempel JSON-struktur let interval = setInterval(function(){ let dumpInto = document.querySelectorAll(trgt); if (dumpInto.length > 0){ clearInterval(interval); dumpInto[0].className = 'multiselect'; // Fjernes, da nettverksproblemer setter in vanlig input som fallback (se under): dumpInto[0].innerHTML = waitSymbol(); // (da treng sannsynligvis ikke waitSymbol() heller lengre) let fallBackInput = mkNode('input',{ 'id' : checkBoxName.replace(/\[\]$/,'') + '_manuelt_innstastet', 'class' : 'inputArea', 'type' : 'text', 'name' : checkBoxName }); dumpInto[0].innerHTML = fallBackInput.outerHTML; fetch(jsonSrc) .then((response) => { return response.json(); }) .then((json) => { // An ID reference might be neccessary to store within the value, but will be hidden in the label of the checkbox text. let hideNumberAtEnd = new RegExp(',[0-9]*$'); let selectId = checkBoxName.replace('[]','') + 'Select'; let domNodes = []; let HelperFunctions = function(inpName){ const thisClass = this; this.mkCheckBx = function(v,html = false){ let chckBxWrap = mkNode('div',{'class' : 'enterUp','style' : 'display: flex;padding: .5rem .65rem;background: #f6f8f9;border: 0;border-bottom:1px solid #c9d5da;width: 100%;position: fixed;left: -8000px;'}); let chckBx = mkNode('input',{'type':'checkbox','name':inpName,'value':v,'style': 'zoom: 1.4;margin: 0 .8rem 0 0;'}); chckBx.addEventListener('change',function(){ thisClass.refreshCheckList(this.name); }); chckBxWrap.appendChild(chckBx); chckBxWrap.appendChild(mkNode('label',{'innerHTML' : (html === false ? v.replace(hideNumberAtEnd,'') : html)})); return chckBxWrap; }; this.refreshCheckList = function(n,minNumOfCheckeds){ let elementsByName = document.querySelectorAll('[name="' + inpName + '"]'); let countChecked = 0; try{ elementsByName.forEach(function(b){ if (b.checked){ b.parentNode.style.position = 'relative'; b.parentNode.style.left = '0'; b.parentNode.style.display = 'flex'; b.parentNode.className = 'enterUp'; countChecked++; }else{ /*try{ $(b.parentNode).slideUp(200); }catch(e){*/ b.parentNode.style.display = 'none'; /*}*/ } }); }catch(e){console.error('HVL: Refreshchecklist: ' + e)} // Bindinger til selecten - minst én boks må være avkrysset let boundSelect = document.getElementById(selectId); if (minNumOfCheckeds != undefined && minNumOfCheckeds == 1){ boundSelect.dataset.oblig = 'true'; if (countChecked >= minNumOfCheckeds){ delete boundSelect.dataset.oblig; } } if (countChecked > 0){ boundSelect.removeAttribute('required'); }else{ boundSelect.setAttribute('required','required'); } }; } let hlp = new HelperFunctions(checkBoxName); // SELECT eller INPUT MED DATALIST: if (firstOptText !== 0){ // SELECT let sel = mkNode('select',{ 'id' : selectId, 'name' : selectId, 'style' : style['select'] }); if (minNumOfCheckeds != undefined && parseInt(minNumOfCheckeds) > 0){ sel.dataset['obligCheckbox'] = checkBoxName; } sel.appendChild(mkNode('option',{'value' : '','text' : firstOptText})); let cnt = 0; let maxCnt = 250; let msg = 'For mange element for ' + checkBoxName.replace('[]','') + '-rullegardin. Kontakt utviklar!'; for(let k in json){ if (typeof json[k] == 'object'){ let optGr = mkNode('optgroup',{'label' : k}); for(let n in json[k]){ if (cnt >= maxCnt){ alert(msg); break; } optGr.appendChild(mkNode('option',{'value' : json[k][n],'text' : json[k][n]})); domNodes.push(hlp.mkCheckBx(json[k][n])); cnt++; } sel.appendChild(optGr); }else{ if (cnt >= maxCnt){ alert(msg); break; } sel.appendChild(mkNode('option',{'value' : k,'text' : (typeof json[k] == 'string' ? json[k] : k )})); domNodes.push(hlp.mkCheckBx(k)); cnt++; } } sel.addEventListener('change',function(){ if (this.selectedIndex != 0){ let v = this.options[this.selectedIndex].value; if (v == '+'){ let newName = prompt('Skriv inn det nye'); if (newName){ v = newName; dumpInto[0].insertBefore(hlp.mkCheckBx(v),dumpInto[0].lastChild); }else{ this.selectedIndex = 0; return false; } } try{ // let elementsByName = document.getElementsByName(checkBoxName); // <- Pre Chromium Edge malfunction let elementsByName = document.querySelectorAll('[name="' + checkBoxName + '"]'); elementsByName.forEach(function(elm){ if (elm.value == v){ elm.checked = true; } }); }catch(e){console.error('HVL: Adding event listener: ' + e)} hlp.refreshCheckList(checkBoxName); // Denne kommer *etter* hlp.refreshCheckList, slik at andre elementer kan lytte til når avkrysninger er oppdatert this.selectedIndex = 0; } }); domNodes.push(sel); }else{ // INPUT WITH DATALIST (argument firstOptText === 0) <- spesifikk for Tilsette[], ikke optimal løsning // Se mkOrgAffiliations for eksempel på kall, json-fetch og callback // Creating checkboxes with label from potentially previously saved values if (formInstanceFieldValues[checkBoxName] != undefined){ let storedVals = formInstanceFieldValues[checkBoxName]; for(let a = 0;a < storedVals.length;a++){ let chBx = hlp.mkCheckBx(storedVals[a]); chBx.querySelector('input[type="checkbox"]').checked = true; domNodes.push(chBx); } }else{ console.info('HVL: Nettverket er treigt.'); } let inp = mkNode('input',{ 'id': selectId, 'style' : style['inputdatalist'], 'autocomplete':'off', 'placeholder':'Søk...', 'type':'text', 'list': selectId + 'Datalist' }); if (minNumOfCheckeds != undefined && parseInt(minNumOfCheckeds) > 0){ inp.dataset['obligCheckbox'] = checkBoxName; } let datalist = mkNode('datalist',{'id': selectId + 'Datalist'}); for(let k in json['data']){ datalist.appendChild(mkNode('option',{ 'value' : json['data'][k] })); } domNodes.push(inp,datalist); } dumpInto[0].innerHTML = ''; domNodes.forEach (function(n){ dumpInto[0].appendChild(n); }); // Dersom behov for funksjon etter at multiselect er opprettet, f.eks. fylle inn verdier ... if (callback != null){ try{ callback(hlp); }catch(e){ console.error('HVL: Kunne ikke starte callback: ' + e); } } hlp.refreshCheckList(checkBoxName); }) .catch((e) => { console.error('HVL:Feil oppstod ved opprettelse av multiselect [-1]: ' + e); }); }else{ console.error('HVL: Venter på ' + trgt); } },1000); }; /** * Filopplasting blir gjort med iframe, men referansenummer blir session-id. JS leser fra iframen om opplasting er faktisk utført. * * @fires message * * @param {string} uploadLabel Teksten som kommer over velg-fil-knappen. * @param {string} trgtInput Id på felt som skal inneholde referanseteksten til opplastet fil. * @param {string} schemaNameSpace Referansestreng for dette skjema (hentes fra sessionvariabel i PHP). * @param {string} schemaInstance Referansestreng for denne sesjonen (hentes fra sessionvariabel i PHP). * @param {string} currLang Om det er norsk eller engelsk språk (ikke målform). * * @return {object} HTML-element */ let mkPrivateFileUpload = function(uploadLabel = 'Last opp:',trgtInput,schemaNameSpace,schemaInstance,currLang = 'no'){ let filopplasterKonvolutt = mkNode('div',{ 'id' : trgtInput + 'Rad', 'class' : 'fileUploadBox' }); // Response viser resultat av opplasting let fileOpplasterResponseRad = mkNode('div',{}); fileOpplasterResponseRad.appendChild(mkNode('label',{ id : trgtInput + 'ResponseLabel', 'innerText' : (currLang == 'no' ? 'Lasta opp til no:' : 'Current Uploads:'), 'style' : 'white-space: nowrap;display: none;' })); fileOpplasterResponseRad.appendChild(mkNode('ul',{ id : trgtInput + 'Response', style : 'font-family: Arial,sans-serif;font-size: small;list-style-type: none;' })); filopplasterKonvolutt.appendChild(fileOpplasterResponseRad); // iFrame let filopplastingsUrl = 'https://v.hvl.no/verktyg/filopplastar/index.php?id=' + escape(trgtInput) + '&ns=' + schemaNameSpace + '&instance=' + schemaInstance; let fileOpplasterRad = mkNode('div',{ 'class' : 'fieldRow' }); // Selv om flere filer kan lastes opp, er det bare referansen til filene som trenger bli lagret, altså ikke array // Merk at data-oblig blir endret av aktivitet i andre element enn dette fileOpplasterRad.appendChild(mkNode('label',{ 'innerHTML' : uploadLabel, 'id' : trgtInput + 'Label', 'for' : trgtInput + 'IFrame', 'style' : 'white-space: nowrap;' })); let fileOpplasterIFrame = mkNode('iframe',{ id : trgtInput + 'IFrame', // MÅ ha samme navn som feltet som lagrer sesjons-id for de opplastede filer - 'IFrame' name : trgtInput + 'IFrame', src : filopplastingsUrl, style : 'height: 4rem;width: 100%;border:none;scroll: auto;' }); fileOpplasterRad.appendChild(fileOpplasterIFrame); window.addEventListener('message', iframeMessage, false); function iframeMessage(event) { if (event.data != null){ try{ if (event.data['success'] != null){ if (event.data['data']['id'] == trgtInput){ // <- Siden window.eventListener(message) kan bestå av flere kall, må vi ha denne id-en try{ // Her er reaksjonen på ferdig opplasting document.getElementById(event.data['data']['id']).value = atob(event.data['data']['ref']); //document.getElementById(trgtInput + 'ResponseLabel').className = 'skjemaLabel'; document.getElementById(trgtInput + 'ResponseLabel').style = 'white-space: nowrap'; let fileUploadRespons = document.getElementById(trgtInput + 'Response'); fileUploadRespons.appendChild(mkNode('li',{ innerHTML : ' «' + atob(event.data['data']['file']) + '»' })); document.getElementById(trgtInput + 'IFrame').src = filopplastingsUrl; }catch(e){ console.error('HVL: Kunne ikke sette verdi til element ' + event.data['id'] + ': ' + e); document.getElementById(trgtInput + 'IFrame').src = filopplastingsUrl; } } }// No else here, it goes into error loop when upload is just on initial fileupload page, and not on file receiver page }catch(e){ console.error('HVL: iFrame-kommunikasjon: ' + e); document.getElementById(trgtInput + 'IFrame').src = filopplastingsUrl; } }else{ console.error('HVL: Ingen datarespons fra filopplaster'); document.getElementById(trgtInput + 'IFrame').src = filopplastingsUrl; } } filopplasterKonvolutt.appendChild(fileOpplasterRad); return filopplasterKonvolutt; } /** * Lager en rad med avkrysningsboks med tekst * * @fires change * * @param {string} inputName Navn (name-egenskap) på input * @param {string} checkboxValue Verdien avkrysningsboksen inneholder * @param {string} labelText Teksten som er lesbar ved siden av checkboxen * @param {string} labelAltLangText Teksten i alternativ målform som er lesbar ved siden av checkboxen * @param {bool} isOblig Om det skal kreves at avkrysning er gjort * * @return {object} HTML-element */ let mkCheckboxWithLabel = function(feltNavn,checkboxValue = 'Verdi manglar',labelText = 'Ingen tekst er definert',labelAltLangText = 'Ingen alternativ tekst er definert',isOblig = null){ let confirmationItem = mkNode('div',{'class' : 'enterUp checkboxRow'}); //checkboxRow let confCheckBox = mkNode('input',{ 'id' : feltNavn, 'name' : feltNavn, 'type' : 'checkbox', 'value' : checkboxValue }); let obligClass = ''; if (isOblig != null){ confCheckBox.dataset.oblig = 'true'; obligClass = ' mandatory'; } confirmationItem.appendChild(confCheckBox); confirmationItem.appendChild(mkNode('label',{ 'for' : feltNavn, 'class' : 'fieldRow' + obligClass })); confirmationItem.querySelector('label').appendChild(mkNode('span',{ 'innerText' : labelText, 'data-alt-lang' : labelAltLangText })); return confirmationItem; } /** * Creates a field row where label is custom and input is a checkbox with a claim. * When checked, a text input (default) og custom element shows up under. * * @param {string|object} idOrElement Id for final default input, a statement instead of question OR an HTML input element with attibutes like id. * @param {string} data.checkedVal The value of the checkbox. * @param {string} data.claim Will be the id of the final input, remember underscores for spaces. * @param {string} data.claimAltLang Same as above, but will not become the id and is in a different language and. * @param {string} data.placeholder Placeholder of the final input. * * @dependencies Functions 'mkNode', 'conditionalRequired' and CSS. * * @return {HTMLobject} * * @example let replacementConfirmationRow = mkAppendElementIfChecked('Replacement',{label:'Check if true',checkedVal:'Yes',claim:'There is a replacement',claimAltLang:'Es gibt einen Ersatz',placeholder:'Full Name...'}) */ let mkAppendElementIfChecked = function(idOrElement,data = {label:'Check',checkedVal:'Yes',claim:'What we are confirming',claimAltLang:'What we are confirming in alternative language',placeholder:'Text...'},attrs = {required:'true'}){ let claimAsId = data.claim.replaceAll(' ','_'); const regex = new RegExp("^[A-ZÆØÅa-zæøå][A-ZÆØÅa-zæøå0-9_:\.-]*$"); if (regex.test(claimAsId) === true){ let wrap = mkNode('div',{id:'wrap-' + claimAsId,class:'wrapCheckBoxWithInp'}); let checkBox = mkCheckboxWithLabel(claimAsId,data.checkedVal,data.claim,data.claimAltLang); wrap.appendChild(checkBox); let questionChecked = false; if (typeof idOrElement == 'object'){ questionChecked = idOrElement; }else{ if (regex.test(idOrElement) === true){ questionChecked = mkNode('input',{idOrElement,type:'text',placeholder:data.placeholder,required:attrs.required}); }else{ questionChecked = mkNode('p',{class:'errors',innerText:'Error: Variable idOrElement Has Incorrect Syntax: ' + idOrElement}); } } conditionalRequired(questionChecked,[checkBox.querySelector('input[type=checkbox]'),true]); wrap.appendChild(questionChecked); return mkRow({input:wrap,label:data.label}); }else{ return mkNode('p',{class:'errors',innerText:'Error: Not Valid Claim Id: ' + claimAsId}); } } /** * Lager en rad med avkrysningsboks med tekst og med filopplaster som dukker opp når kryss er aktivt. * * @fires change * @fires mkPrivateFileUpload * * @param {string} referenceFieldName Navn på skjult felt som skal lagre en for mennesker ikke-tydbar referanse til opplastet fil. * @param {string} checkboxName Navn på felt som inneholder verdien for hva man krysser av på. * @param {string} checkboxValue Teksten bruker ser ved siden av avkrysningsboks. * @param {string} checkboxAltLangText Alternativ målformversjon av checkboxValue. * @param {bool} isOblig Om det skal kreves at fil er opplastet når checkboxName er checked. * @param {string} fileUploadLabel Teksten som kommer over velg-fil-knappen. * @param {string} noDataAlert Teksten som blir varsel dersom isOblig er sann og fil ikke er lastet opp. * * @dependency Den globale konstanten JSSKJEMA_ENV må være initialisert. * * @return {object} HTML-element */ let mkFileUpload = function(referenceFieldName,checkboxName,checkboxValue,checkboxText,checkboxAltLangText,isOblig = false,fileUploadLabel = 'Last opp dokument',noDataAlert = 'Fil må lastes opp!'){ let wrapper = mkNode('div',{id : referenceFieldName + 'UploadUI'}); let refField = mkNode('input',{ 'type' : 'hidden', 'name' : referenceFieldName, 'id' : referenceFieldName, 'data-nodata-alert-override' : noDataAlert }); let obligClass = ''; if (isOblig == true){ refField.dataset.oblig = 'true'; obligClass = ' mandatory'; } let item = mkNode('div',{'class' : 'checkboxRow'}); item.appendChild(refField); let checkBox = mkNode('input',{ 'id' : checkboxName, 'name' : checkboxName, 'type' : 'checkbox', 'value' : checkboxValue, }); checkBox.addEventListener('change',function(){ let tUpl = document.getElementById(referenceFieldName + 'Rad'); if (this.checked == true){ tUpl.style.display = 'block'; if (isOblig == true){ document.getElementById(referenceFieldName).dataset.oblig = 'true'; } }else{ tUpl.style.display = 'none'; if (isOblig != true){ try{ delete document.getElementById(referenceFieldName).dataset.oblig; }catch(e){ console.error('HVL: Kunne ikkje avsetja oblig-parameter ' + e); } } } }); item.appendChild(checkBox); item.appendChild(mkNode('label',{ 'id' : checkboxName, 'class' : obligClass, 'for' : checkboxName, 'innerText' : checkboxText, 'data-alt-lang' : checkboxAltLangText })); wrapper.appendChild(item); let fileUpl = mkPrivateFileUpload(fileUploadLabel,referenceFieldName,JSSKJEMA_ENV.meta.tag,JSSKJEMA_ENV.meta.session); fileUpl.style.display = 'none'; wrapper.appendChild(fileUpl); return wrapper; } /** * Lager en rad med avkrysningsboks med tekst og med filopplaster som dukker opp når kryss er aktivt. * * @fires change * @fires mkPrivateFileUpload * * @param {string} referenceFieldName Navn på skjult felt som skal lagre en for mennesker ikke-tydbar referanse til opplastet fil. * @param {string} checkboxName Navn på felt som inneholder verdien for hva man krysser av på. * @param {string} checkboxValue Teksten bruker ser ved siden av avkrysningsboks. * @param {string} checkboxAltLangText Alternativ målformversjon av checkboxValue. * @param {string} schemaNameSpace Referansestreng for dette skjema (hentes fra sessionvariabel i PHP). * @param {string} schemaInstance Referansestreng for denne sesjonen (hentes fra sessionvariabel i PHP). * @param {bool} isOblig Om det skal kreves at fil er opplastet når checkboxName er checked. * @param {string} fileUploadLabel Teksten som kommer over velg-fil-knappen. * @param {string} noDataAlert Teksten som blir varsel dersom isOblig er sann og fil ikke er lastet opp. * * @return {object} HTML-element */ let mkCheckboxWithFileUpload = function(referenceFieldName,checkboxName,checkboxValue,checkboxText,checkboxAltLangText,schemaNameSpace,schemaInstance,isOblig = false,fileUploadLabel = 'Last opp dokument',noDataAlert = 'Fil må lastes opp!'){ let wrapper = mkNode('div',{id : referenceFieldName + 'UploadUI'}); let refField = mkNode('input',{ 'type' : 'hidden', 'name' : referenceFieldName, 'id' : referenceFieldName, 'data-nodata-alert-override' : noDataAlert }); let item = mkNode('div',{'class' : 'checkboxRow'}); item.appendChild(refField); let checkBox = mkNode('input',{ 'id' : checkboxName, 'name' : checkboxName, 'type' : 'checkbox', 'value' : checkboxValue, }); let obligClass = ''; if (isOblig == true){ refField.dataset.oblig = 'true'; checkBox.dataset.oblig = 'true'; obligClass = ' mandatory'; } checkBox.addEventListener('change',function(){ let tUpl = document.getElementById(referenceFieldName + 'Rad'); if (this.checked == true){ tUpl.style.display = 'block'; if (isOblig == true){ document.getElementById(referenceFieldName).dataset.oblig = 'true'; document.getElementById(checkboxName).dataset.oblig = 'true'; } }else{ tUpl.style.display = 'none'; if (isOblig != true){ try{ delete document.getElementById(referenceFieldName).dataset.oblig; delete document.getElementById(checkboxName).dataset.oblig; }catch(e){ console.error('HVL: Kunne ikkje avsetja oblig-parameter ' + e); } } } }); item.appendChild(checkBox); item.appendChild(mkNode('label',{ 'class' : obligClass, 'for' : checkboxName, 'innerText' : checkboxText, 'data-alt-lang' : checkboxAltLangText })); wrapper.appendChild(item); let fileUpl = mkPrivateFileUpload(fileUploadLabel,referenceFieldName,schemaNameSpace,schemaInstance); fileUpl.style.display = 'none'; wrapper.appendChild(fileUpl); return wrapper; } /** * Oppdaterer visning av en liste med checkboxes og label etter endring av avkrysning. * For det meste uaktuell i selvstendig bruk. * * @param {string} selectId Id for select eller input som leverer data til inputs, f.eks. input med navn Tilsett[] gir selectId TilsettSelect. * @param {string} n Navn (egenskap 'name') til checkbox-input som skal oppdateres. * @param {bool} minNumOfCheckeds Minimum antall valgte verdier. * */ let refreshACheckboxLabelList = function(selectId,n,minNumOfCheckeds = 1){ let elementsByName = document.querySelectorAll('[name="' + n + '"]'); let countChecked = 0; try{ elementsByName.forEach(function(b){ if (b.checked){ b.parentNode.style.position = 'relative'; b.parentNode.style.left = '0'; b.parentNode.style.display = 'flex'; countChecked++; }else{ /*try{ $(b.parentNode).slideUp(200); }catch(e){*/ b.parentNode.style.display = 'none'; /*}*/ } }); }catch(e){console.error('HVL: Refreshchecklist: ' + e)} // Bindinger til selecten - minst én boks må være avkrysset let boundSelect = document.getElementById(selectId); boundSelect.dataset.oblig = 'true'; if (countChecked >= minNumOfCheckeds){ delete boundSelect.dataset.oblig; } }; /** * * Makes a two lined row with a long text above a select. * @see mkNode. * * @param {string} longLabel A string to be visualized above the select element. * @param {string} longAltLangLabel A string to be used by language swap. * @param {string} forRef Id of the select element, but to be used in the for attribute in the label. * @param {object} elm A HTML DOM element (normally a select element). * * @return {object} An HTML-element to be used as a row in a fieldset. * */ function mkDblLineSelect(longLabel,longAltLangLabel,forRef,elm){ let dblLineRow = mkNode('div',{}); dblLineRow.appendChild(mkNode('label',{ 'for' : forRef, 'innerText' : longLabel, 'data-alt-lang' : longAltLangLabel })); dblLineRow.appendChild(elm); return dblLineRow; } /** * Creates a sub form within the main form, which allows the user to attach unlimited number og sub data to the main data. * Concept of 'on to many'. * * @see mkNode, CSS, includes/admin-view.php (collect formData). * * @param {string} id The id of the element in the main form that will store the JSON string of the instances from the inserted sub data. * @param {function} collectFormDataFunction The same function that is used to loop through the parent form and check and collect input ids and values. * @param {function} mkSubFormCallback A function that defines the sub form elements (in the same manner a the main form i defined). * * @return {object} A HTML-element that contains all logic for handling the data of the sub form submission instances. * */ let mkDynamicSubForm = function(id,collectFormDataFunction,mkSubFormCallback){ let drawSubmittedSubForms = function(sfId){ try{ let lookup = getLabelForId(); let j = getValAsObject(sfId); let t = document.getElementById(sfId + 'ShowInserted'); t.style.display = 'none'; t.innerHTML = ''; for(let k in j){ if (j[k] != null){ t.style.display = 'block'; let elm = mkNode('li',{'class':'enterDn subFormElement'}); // Remove item button (Cross in square symbol) let closeItemUI = mkNode('a',{'innerHTML':'×','title':'Fjern element','href':'#' + id + 'ShowInserted','class':'closeSymb','style':'margin-right: .6rem;'}); closeItemUI.addEventListener('click',function(){ if (confirm('Sikker på at du vil slette?')){ let i = getValAsObject(sfId); if (delete i[k]){ let cleanArray = i.filter(item => (item != null)); document.getElementById(sfId).value = JSON.stringify(cleanArray); drawSubmittedSubForms(sfId); }else{ alert('Kunne ikke fjerne element'); } } event.preventDefault(); }); elm.appendChild(closeItemUI); // Render content for item let dl = mkNode('dl',{}); for(let lbl in j[k]){ if (j[k][lbl].length > 0){ dl.appendChild(mkNode('dt',{'innerHTML' : (lookup[lbl] != undefined ? lookup[lbl].replace(/\:$/,'') : lbl.replace(/\_/g,' ').replace(/(\[\])$/i,''))})); dl.appendChild(mkNode('dd',{'innerHTML' : j[k][lbl]})); } } elm.appendChild(dl); t.appendChild(elm); } } }catch(e){ console.error('Kunne ikkje teikne opp innskrivne element. ' + e); } } let getLabelForId = function(){ let subForm = new mkSubFormCallback; let labels = subForm.querySelectorAll('label'); let lookup = {}; for(let i = 0; i < labels.length;i++){ if (labels[i].getAttribute('for') != undefined){ lookup[labels[i].getAttribute('for')] = labels[i].innerHTML; } } return lookup; } let getValAsObject = function(fId){ let clickAddElm = function(dfId){ // Open subform if no value has been set try{ document.getElementById(dfId + 'Add').click(); }catch(e){ console.info('HVL: Kunne ikkje opna underskjema ' + dfId + ': ' + e); } } let valueContainer = document.getElementById(fId); let j = []; if (!!valueContainer && valueContainer.value.length > 1){ if (valueContainer.value == '[]'){ clickAddElm(fId); } j = JSON.parse(valueContainer.value); }else{ clickAddElm(fId); } return j; } let mkAddSubFormUI = function(){ let closeSubForm = function(sfId){ try{ let ui = document.getElementById(sfId + 'UI'); ui.removeChild(document.getElementById(sfId + 'SubForm')); ui.appendChild(mkAddSubFormUI()); }catch(e){ console.error('HVL: ' + e); } } let addUI = mkNode('button',{ 'id' : id + 'Add', 'innerText' : '+ Legg til', 'class' : 'button button--text button--footer enterDn', 'type' : 'button' }); addUI.addEventListener('click',function(){ // Create sub form object, but add cancel and save interface to it first let subForm = new mkSubFormCallback; subForm.id = id + 'SubForm'; let cancelSaveUI = mkNode('div',{ 'style' : 'border-top: dotted 1px lightgrey;padding: .4rem 0 .6rem 0;text-align: right;' }); // Cancel button let cancelUI = mkNode('button',{ 'innerText' : 'Avbryt', 'class' : 'button button--text button--footer', 'style' : 'margin-right: .5rem;', 'type' : 'button' }); cancelUI.addEventListener('click',function(){ if (confirm('Sikker? Du må skrive inn omatt om du angrar.')){ closeSubForm(id); } }); cancelSaveUI.appendChild(cancelUI); // Save button (id is used to check if subform is unsaved) let saveUI = mkNode('button',{ 'id' : 'subFormSaveUI', 'innerText' : 'Legg dette til', 'class' : 'button button--text button--footer', 'type' : 'button' }); saveUI.addEventListener('click',function(){ try{ let valueContainer = document.getElementById(id); let collectedData = collectFormDataFunction(document.getElementById(id + 'SubForm')); if (collectedData !== false){ let j = getValAsObject(id); j.push(collectedData); valueContainer.value = JSON.stringify(j); closeSubForm(id); drawSubmittedSubForms(id); document.querySelector('#' + id + 'Form').scrollIntoView(); } }catch(e){ console.error('HVL: ' + e); alert('Kunne ikkje samle data. Kontakt webtutvikler@hvl.no!'); } }) cancelSaveUI.appendChild(saveUI); subForm.appendChild(cancelSaveUI); // Remove interface for adding, replacing with the sub form let ui = document.getElementById(id + 'UI'); ui.removeChild(document.getElementById(id + 'Add')); ui.appendChild(subForm); }); return addUI; } let subForm = mkNode('div',{ 'id' : id + 'Form' }); subFormDataStorage = mkNode('input',{ 'id' : id, 'type' : 'hidden' }); subForm.appendChild(subFormDataStorage); subForm.appendChild(mkNode('ol',{ 'id' : id + 'ShowInserted' })); let subFormUI = mkNode('div',{ 'id' : id + 'UI' }); subFormUI.appendChild(mkAddSubFormUI()); subForm.appendChild(subFormUI); // Refresh list if any present setTimeout(function(){ drawSubmittedSubForms(id); },2000); return subForm; } /** * Creates a select element for choosing a single year form an offered span of years, with current year as reference point. * @param {string} id Id and name attribute for select element. * @param {int} from Number of years before current year. * @param {int} to Number of years after current year. * @dependencies mkNode, mkRow. * @note Select is always mandatory */ function mkSelectYear(id = 'År',from = -4,to = 10){ const d = new Date(); let year = d.getFullYear(); let opts = {}; if (parseInt(from) && parseInt(from)){ for (let i = parseInt(year) + from;i < (parseInt(year) + to + 1);i++){ opts[String(i)] = i; } }else{ opts[String(year)] = year; } let nodeOpts = { id : id, 'data-oblig' : true, options : opts }; let sel = mkNode('select',nodeOpts); // add default option. // the default option is created afterwards and not inside of the opts object due to objects looping through integers before string keys, and the '' option should be first. // has to be defaultSelected as otherwise the browser will choose the first option in insertion order. sel.add(new Option('...', '', true, true), 0) return(mkRow({input:sel})); } /** * Creates an input with a date chooser calendar attached. * * @param {string} id The id of the field. * @param {string} oblig If field value input is mandatory. * @param {int} minY The first chooseable year. * @param {int} maxY The last chooseable year. * * @return {object} HTML-input element that creates a calendar instance on click. * * Fjernet: 'data-type' : 'date', readonly : 'readonly' på input, den 8/2-24. * Samt datepicker, siden den impl. i admin-view.php. */ let mkDateField = function(id,oblig = false,minY = 2000,maxY = 2038){ let fld = mkNode('input',{ 'id' : id, type : 'date', lang : 'no', 'class' : 'datovelger', placeholder : 'YYYY-MM-DD', min: `${minY}-01-01`, max: `${maxY}-12-31`, }); if (oblig){ fld.dataset.oblig = 'true'; } return fld; } /** * Gives the user the opprtunity to collect a study subject with relation to previously chosen faculty. * * @see Function 'mkOrgAffiliations'. Note: Faculty must have been selected! Jsonfeed: https://-vår-tjener-/verktyg/studieinfo/finn-emne.php. * * @param {bool} singleSelect If true, only one subject allowed to be selected. * @param {bool} showSingleEmneValue If true, shows chosen subject in a none writeable input field. * @param {bool} codeOnly Pupulates datalist with only subject codes, otherwise both code and subject name. * @param {bool} date Deprecated (see comment int the function code!) * * @return {object} HTML-input element connected to a datalist. * */ let mkEmneSelect = function(singleSelect = false,showSingleEmneValue = false,codeOnly = false,date = false,oblig = true){ let emneValgSection = mkNode('div',{}); let emneValgChosenRow = mkNode('div',{ 'id' : 'chosenEmner' }); emneValgSection.appendChild(emneValgChosenRow); let emneValgMainRow = mkNode('div',{ 'class' : 'fieldRow centerValign', 'style' : 'width: 100%;' }); emneValgMainRow.appendChild(mkNode('label',{ 'innerText' : 'Legg til emne:', 'data-english' : 'Add a Subject:', 'data-alt-lang' : 'Legg til emner:', 'for' : 'emnesoek' })); let emneValgMsg = mkNode('input',{ class : 'search', 'id' : 'emnesoek', 'list' : 'emneListe', 'type' : 'text', 'placeholder' : 'Skriv inn del av emnekode eller emnenamn...', 'data-alt-lang-placeholder' : 'Skriv inn del av emnekode eller emnenavn...' }); emneValgMsg.addEventListener('focus',function(){ let fs = document.querySelector('#FakultetSelect'); if (!!fs){ let checkedFakultet = document.querySelectorAll('[name="Fakultet[]"]:checked'); if (checkedFakultet.length == 0){ alert('Du må først velje fakultet for å kunne søkje i emnekodar'); this.blur(); try{ fs.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}); fs.className = 'markedStrongly'; fs.addEventListener('change',function(){this.className = 'markedOK'}); }catch(e){ console.error('HVL: Kunne ikkje rulle til Fakultets-select: ' + e); } } } if (!!singleSelect){ let chosenEmner = document.getElementById('chosenEmner').getElementsByTagName('div'); if (!!chosenEmner && chosenEmner.length > 0){ alert('Du kan berre velje eitt emne!'); this.blur(); } } }); emneValgMsg.addEventListener('blur',function(){ // change <- Pre Chromium Edge malfunction if (!(this.value) || this.value.length < 1){ //console.error('HVL: Ingen tekststreng for emne'); return false; } // Sjekker emnenavnsgyldighet try{ let listOfEmner = document.getElementById('emneListe').querySelectorAll('option'); let i = 0; for(i;i < listOfEmner.length;i++){ if (listOfEmner[i].value == this.value){ //console.info('Fant likhet ved option nummer ' + i + ' av i alt ' + listOfEmner.length + ' emner'); if (!!singleSelect){ try{ let soekRow = document.getElementById('emnesoek').parentNode; soekRow.style.transform = 'scaleY(0)'; soekRow.style.transitionDuration = '.4s'; }catch(e){ console.error('HVL: Kunne ikkje skjule emnesøkefelt: ' + e); } } break; } } if (i == listOfEmner.length){ alert('Ukjent emne!'); //console.info('Letet igjennom ' + i + ' emner'); this.value = ''; this.focus(); return false; } }catch(e){ console.error('Kunne ikke kontrollere emnenavn: ' + e); } if (!!document.getElementById('Emne')){ // ved singleSelect er dette feltet tilgjengelig document.getElementById('Emne').value = this.value; } let chosenEmne = mkCheckboxWithLabel("Emner[]", this.value.toUpperCase(), this.value.toUpperCase(), this.value.toUpperCase(),true); chosenEmne.style = 'width: 100%;align-items: center;'; chosenEmne.classList.add('enterUp'); let chk = chosenEmne.querySelector('input[type=checkbox]'); chk.checked = true; let regex = / /g; // HTML5 tillater alle tegn utenom space. Erstatter space med underscore let id = 'Dato_' + this.value.toUpperCase().replace(regex,'_'); chk.addEventListener('click',function(){ if (this.checked != true){ if (confirm('Fjerne dette emnet?')){ this.parentNode.outerHTML = ''; if (!!singleSelect){ document.getElementById('Emne').value = ''; try{ let soekRow = document.getElementById('emnesoek').parentNode; soekRow.style.transform = 'scaleY(1)'; soekRow.style.transitionDuration = '.4s'; }catch(e){ console.error('HVL: Kunne ikkje gjere emnesøkefelt synleg: ' + e); } } }else{ this.checked = true; } } }); document.getElementById('chosenEmner').appendChild(chosenEmne); this.value = ''; }); // Lytte på select for å finne ut hvilke(t) fakultet som har blitt valgt og dermed hente inn dataliste for emnevalg // Brukes for å foreta søk etter emner pr. fakultet, samt sette data-attributt i datalist for om emner for dette fakultetet allerede er lastet inn /* Fram til 1. jan -24: let emneSokFakultetKeys = { 'FakultetforkonomiogsamfunnsvitskapFS' : '40', 'FakultetforlrarutdanningkulturogidrettFLKI' : '30', 'FakultetforingenirognaturvitskapFIN' : '20', 'FakultetforhelseogsosialvitskapFHS' : '10' } */ let emneSokFakultetKeys = { 'FakultetforlrarutdanningkulturogidrettFLKI' : '30', 'FakultetforteknologimiljoogsamfunnsvitskapFTMS' : '20', 'FakultetforhelseogsosialvitskapFHS' : '10' } // Sørger for at datalista med emner ut ifra valgt fakultet blir oppdatert ettersom valg blir gjort med fakultets-selecten let fakultetInterval = setInterval(function(){ try{ if (document.body.contains(document.getElementById('FakultetSelect'))){ let sel = document.getElementById('FakultetSelect'); sel.addEventListener('change',function(){ if (this.selectedIndex == 0){ // Selecten stiller seg tilbake til førstevalg først etter at fakultetslista er tegnet opp let regex = /[^a-z0-9]+/gi; let fakultetsNumbers = []; document.querySelectorAll('[name="Fakultet[]"]').forEach(function(elm){ if (elm.checked == true){ try{ fakultetsNumbers.push(emneSokFakultetKeys[elm.value.replace(regex,'')]); }catch(e){ console.error('HVL: Kunne ikke finne key i var emneSokFakultetKeys: ' + e); } } }); if (fakultetsNumbers.length > 0){ // Sletter først bestående emneliste, lager heller en ny helt fresh (for alfabetiseringens del) if (document.body.contains(document.getElementById('emneListe'))){ document.getElementById('emneListe').remove(); } // Lager ny emneliste (for testing er denne også tilgjengelig: emne-fake-test.php) let datalistSrc = 'https://v.hvl.no/verktyg/studieinfo/finn-emne.php?compact=1&faculty=' + fakultetsNumbers.join(',') + (!!codeOnly ? '&short=1' : ''); document.body.appendChild(mkNode('datalist',{ 'id': 'emneListe', 'data-src' : datalistSrc })); // Fyller datalista (se /includes/admin-view.php) populateDatalist(document.getElementById('emneListe')); }else{ console.error('HVL: Ingen fakultetsnumre ble generert'); } } }); clearInterval(fakultetInterval); } }catch(e){ console.error('HVL: FEIL: Finn ikkje fakultetsselecten: ' + e); } },500); emneValgMainRow.appendChild(emneValgMsg); emneValgSection.appendChild(emneValgMainRow); if (!!singleSelect){ if (!!showSingleEmneValue){ emneValgSection.appendChild(mkRow({ label:'Oppgitt emne', input : mkNode('input',{id:'Emne',style:'cursor:pointer;',type: 'text',readonly:'readonly','data-oblig':oblig,click:function(){alert('Bruk «Legg til emne» for å sette inn verdi her!')}}) })); }else{ emneValgSection.appendChild(mkNode('input',{id:'Emne',type: 'hidden',readonly:'readonly','data-oblig':true,'data-nodata-alert-override' : 'Det må oppgis et emne!'})); } } return emneValgSection; } let mkChooseTimeSpan = function(id = 'chooseTimeSpan',labels = { 'from' : 'Frå', 'to' : 'Til', 'fromAlt' : 'Fra', 'toAlt' : 'Til', 'placeholder' : 'Klikk for å velje dato', 'placeholderAlt' : 'Klikk for å velge dato' }){ // Alltid obligatoriske - kans skjules med CSS om ikke ønsket let cals = mkNode('div',{'id':id}); let cal = mkNode('div',{ 'class' : 'fieldRow centerValign' }); cal.appendChild(mkNode('label',{ 'innerText' : labels['from'] + ':', 'data-english' : 'From:', 'data-alt-lang' : labels['fromAlt'] + ':', 'for' : 'Tidsgyldigheit' })); cal.appendChild(mkNode('input',{ 'id' : labels['from'], 'type' : 'text', 'placeholder' : labels['placeholder'], 'data-alt-lang-placeholder' : labels['placeholderAlt'], 'data-type' : 'date' })); cals.appendChild(cal); // Reset cal = mkNode('div',{ 'class' : 'fieldRow centerValign' }); cal.appendChild(mkNode('label',{ 'innerText' : labels['to'] + ':', 'data-english' : 'To:', 'data-alt-lang' : labels['toAlt'] + ':', 'for' : 'Tidsgyldigheit' })); cal.appendChild(mkNode('input',{ 'id' : labels['to'], 'type' : 'text', 'placeholder' : labels['placeholder'], 'data-alt-lang-placeholder' : labels['placeholderAlt'], 'data-type' : 'date' })); cals.appendChild(cal); return cals; } /** * Creates search field which presents suggestions from a function call (like 'autocompleteFromRemote') * and lets the user select one singe element. * * @param {string} inputId The id of the input containing the selected value. * @param {obj} attrs JS object with element attributes to append to the search input. * @param {function} call The function that collects and presents the suggestion data. * * @return {object} An HTML-element representing a whole row with label and all in a form. */ let mkAutocompleteSingleSelect = function(inputId,rowWrapped = true,attrs = {required:'required'},call = console.info(`No Function Defined In Element Created By 'mkAutocompleteSingleSelect'`)){ const mergedAttrs = Object.assign({id:inputId,type:'text',class:'search',autocomplete:'off',focus: call},attrs); const inp = mkNode('input',mergedAttrs); if (rowWrapped === true){ return mkRow({label:inputId,input : inp}); } return inp; } /** * * Creates select for choosing one or more employees at HVL. * * @param {object} coreVars Global settings from parent script. * @param {string} currLang Value of 'no' sets language to norwegian, oterhwise english. Not to be confused with 'data-alt-lang'. * @param {string} fieldName HTML-input id. * @param {string} label The label in the default language. * @param {string} labelAlt The label when alternative langue is displayed. * @param {array} checkboxVal Which node(s) of info from REST call to be set as value. Defaults to only tilsattnummer. Example: ['Navn','job_title']. * * @note Custom for HVL! * * @return {object} A pack of HTML-elements. The select itself is put automatically async to the DOM targets. */ let mkSelectEmployees = function(coreVars,currLang,fieldName = 'Tilsett',label = 'HVL-kontaktpersonar',labelAlt = 'HVL-kontaktpersoner',checkboxVal = []){ let employeeR = mkNode('div',{ 'class': 'fieldRow topValign' }); employeeR.appendChild(mkNode('label',{ 'innerText' : label + ':', 'data-english' : 'HVL Contacts:', 'data-alt-lang' : labelAlt + ':', 'for' : fieldName + 'Select' })); employeeR.appendChild(mkNode('div',{ 'id' : fieldName })); let asyncEmployeeInsert = function(helperClass = false){ tapDataAndInsertIntoForm(formInstanceFieldValues); // Konverterer ansattnummer til navn og lenke: let renderEmployees = function(items){ for (let i = 0;i < items.length;i++){ let inpVal = items[i].value; let label = items[i].parentNode.querySelector('label'); if (inpVal == label.innerText){ label.innerHTML += waitSymbol(false,'10px'); fetch('https://v.hvl.no/verktyg/tilsette/fetchByEmployeeNumber.php?q=' + inpVal) .then(response => {return response.json()}) .then(json => { let html = []; for(let id in json['data']){ // Her komponeres den informasjonen om tilsatt som vises for brukeren. html.push('
    ' + json['data'][id]['Navn'] + '
    ' + (json['data'][id]['job_title'] != undefined ? json['data'][id]['job_title'] + ', ' : '' ) + json['data'][id]['Avdeling'] + '
    '); if (checkboxVal.length > 0){ let newCheckBoxVal = []; for(let k = 0;k < checkboxVal.length; k++){ if (typeof json['data'][id][checkboxVal[k]] != 'undefined'){ newCheckBoxVal.push(json['data'][id][checkboxVal[k]]); } } items[i].value = newCheckBoxVal.join(', '); } //items[i].value = `${json['data'][id]['Navn']}`; // html.push(``); } label.innerHTML = html.join(''); }) .catch(e => { alert('Fetch error [-1]: ' + e); }) } } } renderEmployees(document.querySelectorAll('input[name="' + fieldName + '[]"]')); let employeeSelect = document.querySelector('#' + fieldName + 'Select'); employeeSelect.addEventListener('change',function(){ fetch('https://v.hvl.no/verktyg/tilsette/fetchByLnameFname.php?q=' + this.value) .then(response => {return response.json()}) .then(json => { if (json['data'] != undefined && json['data']['Ansattnummer'] != undefined){ let v = json['data']['Ansattnummer']; while(v.charAt(0) === '0'){ v = v.substr(1); } let isPresent = document.querySelectorAll('input[name="' + fieldName + '[]"][value="' + v + '"]'); if (isPresent.length > 0){ isPresent[0].checked = true; setTimeout(function(){ isPresent[0].parentNode.className = 'fadeIn'; },500); }else{ this.parentNode.insertBefore(helperClass.mkCheckBx(v),this); try{ let elementsByName = document.querySelectorAll('input[name="' + fieldName + '[]"]'); elementsByName.forEach(function(elm){ if (elm.value == v){ elm.checked = true; } }); }catch(e){console.error('HVL: Adding event listener datalist: ' + e)} } helperClass.refreshCheckList(fieldName + '[]',1); // Denne kommer *etter* refreshCheckList, slik at andre elementer kan lytte til når avkrysninger er oppdatert this.value = ''; setTimeout(function(){ try{ renderEmployees(document.querySelectorAll('input[name="' + fieldName + '[]"]')); }catch(e){ console.error('Feil ved renderEmployees: ' + e); } },300); } }) .catch(e => { alert('Fetch error [-2]: ' + e); }) }); }; let drawTilsattDatalistInput = mkMultiSelect('#' + fieldName,fieldName + '[]','https://v.hvl.no/verktyg/tilsette/alle-etternamn-forenamn.php',0,{},asyncEmployeeInsert); return employeeR; } /** * Creates selects for assorted ways to describe affiliation to our organization. * * @param {object} coreVars Global settings from parent script. * @param {string} currLang Value of 'no' sets language to norwegian, oterhwise english. Not to be confused with 'data-alt-lang'. * @param {object} nodes Decides which elements to be generated (by setting 'display' to 'true'). * * @return {object} A pack of HTML-elements. The select itselves is put automatically async to the DOM targets. */ let mkOrgAffiliations = function(coreVars,currLang = 'no',nodes = {'campus' : {'display' : true},'faculty' : {'display' : true},'unit' : {'display' : false},'study' : {'display' : false},'employee' : {'display' : false}}){ let nodePack = mkNode('div',{'class' : 'orgAffiliation'}); // FJERNET 24. januar -24: innerHTML : '

    MERK: Frå 01.01.24 er fakulteta FIN og FØS slått saman til det nye fakultetet FTMS!

    ', // Campus if (nodes['campus'] != undefined && nodes['campus']['display'] == true){ let campusValR = mkNode('div',{ 'class' : 'fieldRow centerValign' }); campusValR.appendChild(mkNode('label',{ 'innerText' : (nodes.campus.label != null ? nodes.campus.label : 'HVL-Studiestad:'), 'data-english' : 'HVL Campus:', 'data-alt-lang' : (nodes.campus['data-alt-lang'] != null ? nodes.campus['data-alt-lang'] : 'HVL-Studiested:'), 'for' : 'Campus' })); let campusValSelect = mkNode('select',{ 'id' : 'Campus', 'data-oblig' : 'true', 'options' : { '' : '···', 'Bergen' : 'Bergen', 'Førde' : 'Førde', 'Haugesund' : 'Haugesund', 'Sogndal' : 'Sogndal', 'Stord' : 'Stord', 'Campusuavhengig/Fleircampus' : 'Campusuavhengig/Fleircampus' } }); campusValR.appendChild(campusValSelect); nodePack.appendChild(campusValR); } // Fakultet if (nodes['faculty'] != undefined && nodes['faculty']['display'] == true){ let fakultetR = mkNode('div',{ 'class': 'fieldRow topValign' }); fakultetR.appendChild(mkNode('label',{ 'innerText' : (nodes.faculty.label != null ? nodes.faculty.label : 'HVL-Fakultet:'), 'data-english' : 'Faculty at HVL:', 'data-alt-lang' : (nodes.faculty['data-alt-lang'] != null ? nodes.faculty['data-alt-lang'] : 'HVL-Fakultet:'), 'for' : 'FakultetSelect' })); fakultetR.appendChild(mkNode('div',{ 'id' : 'Fakultet' })); let asyncInsert = function(){ tapDataAndInsertIntoForm(formInstanceFieldValues); }; //console.info('Oppdat: JSSKJEMA_ENV: ' + JSON.stringify(JSSKJEMA_ENV,'',' ')); let drawFakultet = mkMultiSelect('#Fakultet','Fakultet[]','https://v.hvl.no/verktyg/org/organisasjon.php?filter=fakultet&orgrole=' + JSSKJEMA_ENV.meta.orgRole + (currLang == 'no' ? '' : '&lang=en'),(currLang == 'no' ? 'Tilknytta fakultet...' : 'Affiliated to faculty...'),{},asyncInsert); nodePack.appendChild(fakultetR); } // Enheter (institutt, avdelinger m.m.) if (nodes['unit'] != undefined && nodes['unit']['display'] == true){ let einingR = mkNode('div',{ 'class': 'fieldRow topValign' }); einingR.appendChild(mkNode('label',{ 'innerText' : (nodes.unit.label != null ? nodes.unit.label : 'HVL-Eining:'), 'data-english' : 'HVL Unit:', 'data-alt-lang' : (nodes.unit['data-alt-lang'] != null ? nodes.unit['data-alt-lang'] : 'HVL-Enhet:'), 'for' : 'EiningSelect' })); einingR.appendChild(mkNode('div',{ 'id' : 'Eining' })); let asyncInsert = function(){ console.info('Enheter run - remove at #1587'); tapDataAndInsertIntoForm(formInstanceFieldValues); //insertFormValues(formInstanceFieldValues,coreVars); }; let drawEining = mkMultiSelect('#Eining','Eining[]','https://v.hvl.no/verktyg/org/organisasjon.php?filter=institutt&appendix=' + (currLang == 'no' ? 'Anna' : 'Other') + (currLang == 'no' ? '' : '&lang=en'),(currLang == 'no' ? 'Tilknytta eining...' : 'Affiliated to unit...'),{},asyncInsert); nodePack.appendChild(einingR); } if (nodes['study'] != undefined && nodes['study']['display'] == true){ // Studiumrad let studiumRad = mkNode('div',{ 'class' : 'fieldRow centerValign' }); studiumRad.appendChild(mkNode('label',{ 'innerText' : (nodes.study.label != null ? nodes.study.label : 'HVL-Studieprogram:'), 'data-english' : 'HVL Study:', 'data-alt-lang' : (nodes.study['data-alt-lang'] != null ? nodes.study['data-alt-lang'] : 'HVL-Studieprogram:'), 'for' : 'Studieprogram' })); studiumRad.appendChild(mkNode('input',{ 'type' : 'text', 'list' : 'studieprogramliste', 'id' : 'Studieprogram', 'placeholder' : 'Søk på del av studieprogram', 'data-alt-lang-placeholder' : 'Søk på del av studieprogramnavn', 'data-oblig' : 'true', })); nodePack.appendChild(studiumRad); let studiumDatalist = mkNode('datalist',{ 'id': 'studieprogramliste', 'data-src' : 'https://v.hvl.no/verktyg/studieinfo/studieprogramliste.php' }); nodePack.appendChild(studiumDatalist); } if (nodes['employee'] != undefined && nodes['employee']['display'] == true){ nodePack.appendChild(mkSelectEmployees(coreVars,currLang)); } return nodePack; } /** * Fetches all inputs that has the attribute data-tag and their value of that attribute. Another * input (which normally is not visible to the user) will receive all these inputs values as a comma * delimited string as its own value. Such a 'tag' input is most commonly used for the first input * of a form, and its value vil be the readable reference for the element in the form list. * @param {string} tag Id of the text/hidden input that is to receive the concated values. * @see Initializing of variable tagValSrc in /includes/admin-view.php. */ function updateTag(tag,reverse = true){ let tagValSrc = document.querySelectorAll('[data-tag="' + tag + '"]'); let vals = []; tagValSrc.forEach(t => { if (t.value.length > 0){ vals.push(t.value); } }); if (reverse){ vals.reverse(); } try{ document.getElementById(tag).value = vals.join(', ') }catch(e){ console.error('Could not find target tag field for "' + tag + '"'); } }; /** * Creates and returns an element containing a group of radio inputs for each statement displayed in a matrix. * @author Ole Brede * @see mkNode * * @param {Object} question An object holding information about the question and answer options. * @param {Object} question.label The question text. * @param {Object[]} question.statements An array of objects holding information about the statements. * @param {string} question.statements[].id Value used as the `name` attribute of the radio input. * @param {string} question.statements[].label The statement text. * @param {Object[]} question.options An array of objects holding information about the answer options. * @param {string} [question.options[].value] If omitted label will be used as value. * @param {string} question.options[].label Visual text for the radio input. * @param {Object} [options] Object to hold additional options. * @param {Object.} [options.attributes] Attributes to be passed to mkNode when creating the radio input. * @param {boolean} [options.displayValues] Display the values on the radio input or use a generic count. * * @returns {HTMLElement} Container for the radio group matrix. */ let mkRadioGroupMatrix = function(question, options = {}) { const radioGroupContainer = mkNode('div', {class: 'radio-group-container'}) const radioGroupMatrix = mkNode('fieldset', {class: 'radio-group-matrix'}) const legend = mkNode('legend', {innerText: question.label || ''}) radioGroupMatrix.appendChild(legend) // Visual headers if value is used for the radio buttons if (question.options.some(option => 'value' in option)) { const container = mkNode('div', { hidden: '', 'aria-hidden': true, }) container.appendChild(mkNode('span')) question.options.forEach(option => { const span = mkNode('span') if ('value' in option) { span.textContent = option.label } container.appendChild(span) }) radioGroupMatrix.appendChild(container) } question.statements.forEach(statement => { const radioGroup = mkNode('fieldset') const legend = mkNode('legend', {innerText: statement.label}) radioGroup.appendChild(legend) question.options.forEach((option, column) => { const attributes = {} if ('attributes' in options) { Object.assign(attributes, options.attributes) } Object.assign(attributes, { type: 'radio', id: `${statement.id}[${column}]`, name: `${statement.id}`, value: 'value' in option ? option.value : option.label, 'aria-label': option.label, }) const radio = mkNode('input', attributes) // Normally labels aren't exposed to screen readers if associated with a radio input, the spans cause the label to be exposed anyway so aria-hidden is used to hide it again const label = mkNode('label', { for: radio.id, 'aria-hidden': true, }) const labelText = mkNode('span', {innerText: option.label}) if ('value' in option) { labelText.textContent = ': ' + labelText.textContent const labelValue = mkNode('span') if ('displayValues' in options && options.displayValues === false) { labelValue.textContent = column + 1 } else { labelValue.textContent = option.value || '?' } label.appendChild(labelValue) } label.appendChild(labelText) radioGroup.appendChild(radio) radioGroup.appendChild(label) }) radioGroupMatrix.appendChild(radioGroup) }) // When the labels are different heights use stretch to make all the labels the same height. if ('ResizeObserver' in window) { new ResizeObserver(() => { const spans = Array.from(radioGroupMatrix.querySelectorAll('fieldset:first-of-type > label > span:first-child')) radioGroupMatrix.classList.toggle('radio-group-matrix-stretch', !spans.every(span => span.clientHeight === spans[0].clientHeight)) }).observe(radioGroupMatrix) } radioGroupContainer.appendChild(radioGroupMatrix) return radioGroupContainer } /** * Creates a matrix of radio inputs, made for anonyme emneevaluering. * @author Ole Brede * @see mkNode * @see mkRadioGroupMatrix * * @param {Object[]} propositions An array of objects holding information about the propositions to be passed to mkRadioGroupMatrix, see questions.statements parameter in mkRadioGroupMatrix. * @param {string} propositions[].id Value used as the `name` attribute of the radio input. * @param {string} propositions[].label The proposition text. * @param {Object} [options] Object to hold additional options, see options parameter in mkRadioGroupMatrix. * @param {Object.} [options.attributes] Attributes to be passed to mkNode when creating the radio input. * @param {boolean} [options.displayValues] Display the values on the radio input or use a generic count. * * @returns {HTMLElement} Container for the radio group matrix. */ let mkEvalAgreeability = function(propositions, options = {}) { return mkRadioGroupMatrix({ label: 'Hvor enig eller uenig er du i følgende utsagn?', statements: propositions, options: [ {value: '1', label: 'Helt uenig'}, {value: '2', label: 'Uenig'}, {value: '3', label: 'Hverken uenig eller enig'}, {value: '4', label: 'Enig'}, {value: '5', label: 'Helt enig'}, {value: '', label: 'Ikke relevant'}, ] }, options) } /** * Creates and returns an element containing a group of radio inputs. * @author Ole Brede * @see mkNode * * @param {Object} question An object holding information about the question and answer options. * @param {string} question.id Value used as the `name` attribute of the radio input. * @param {string} question.label The question text for the radio group. * @param {Object[]} question.options An array of objects holding information about the answer options. * @param {string} [question.options[].value] The value to be submitted, if omitted label will be used as value. * @param {string} question.options[].label Visual text for the radio input. * @param {Object.} [attributes] Attributes to be passed to mkNode when creating the radio input. * * @example mkCheckboxGroup({
      id: 'Nasjoner',
      label: 'Nasjonliste',
      options: [
        {value:'N',label:'Norge'},
        {value:'S',label:'Sverige'},
        {value:'DK',label:'Danmark'}
      ],
    }) * * * @returns {HTMLElement} Container for the radio group. */ function mkRadioGroup(question, attributes = {}) { const radioGroupContainer = mkNode('div', {class: 'radio-group-container'}) const radioGroup = mkNode('fieldset', {class: 'radio-group'}) const radioGroupInputs = mkNode('div', {class: 'radio-group-inputs'}) const legend = mkNode('legend', {innerText: question.label}) radioGroup.appendChild(legend) question.options.forEach((option, row) => { const radio = mkNode('input', Object.assign(attributes, { type: 'radio', id: `${question.id}[${row}]`, name: `${question.id}`, value: 'value' in option ? option.value : option.label, })) const label = mkNode('label', { for: radio.id, innerText: option.label, }) radioGroupInputs.appendChild(radio) radioGroupInputs.appendChild(label) }) radioGroup.appendChild(radioGroupInputs) radioGroupContainer.appendChild(radioGroup) return radioGroupContainer } /** * Creates and returns an element containing a group of checkbox inputs. * @author Ole Brede * @see mkNode * * @param {Object} question An object holding information about the question and answer options. * @param {string} question.id Value used as the `name` attribute of the checkbox input. * @param {string} question.label The question text for the checkbox group. * @param {Object[]} question.options An array of objects holding information about the answer options. * @param {string} [question.options[].value] The value to be submitted, if omitted label will be used as value. * @param {string} question.options[].label Visual text for the checkbox input. * @param {Object.} [attributes] Attributes to be passed to mkNode when creating the checkbox input. * @param {number} [minChecked=0] the minimum amount of checkboxes to be checked. * * @example mkCheckboxGroup({
      id: 'Nasjoner',
      label: 'Nasjonliste',
      options: [
        {value:'N',label:'Norge'},
        {value:'S',label:'Sverige'},
        {value:'DK',label:'Danmark'}
      ],
    }) * * @returns {HTMLElement} Container for the checkbox group. */ function mkCheckboxGroup(question, attributes = {}, minChecked = 0) { const checkboxGroupContainer = mkNode('div', {class: 'checkbox-group-container'}) const checkboxGroup = mkNode('fieldset', {class: 'checkbox-group'}) const checkboxGroupInputs = mkNode('div', {class: 'checkbox-group-inputs'}) const legend = mkNode('legend', {innerText: question.label}) checkboxGroup.appendChild(legend) question.options.forEach((option, row) => { const checkbox = mkNode('input', Object.assign(attributes, { type: 'checkbox', id: `${question.id}[${row}]`, name: `${question.id}`, value: 'value' in option ? option.value : option.label, })) const label = mkNode('label', { for: checkbox.id, innerText: option.label, }) checkboxGroupInputs.appendChild(checkbox) checkboxGroupInputs.appendChild(label) }) checkboxGroup.appendChild(checkboxGroupInputs) checkboxGroupContainer.appendChild(checkboxGroup) function updateRequired() { const checkboxes = [...checkboxGroup.elements] const checkedCheckboxes = checkboxes.filter(checkbox => checkbox.checked).length checkboxes.forEach(checkbox => checkbox.required = checkedCheckboxes < minChecked) } updateRequired() checkboxGroup.addEventListener('change', updateRequired) return checkboxGroupContainer } class FieldStorage { constructor(storageArea, identifier) { this._storage = storageArea this._identifier = identifier this._fields = [] this.persist = false this.timeOutMilliseconds = 15000 document.body.addEventListener('change', event => { if (this._fields.includes(event.target)) { this._storeFieldCollection() } }) document.body.addEventListener('successful-submit', () => { if (!this.persist) { this._deleteFieldCollection() } }) } // wait for field to be added to DOM async waitForField(id) { return new Promise((resolve, reject) => { const observer = new MutationObserver(() => { const field = document.getElementById(id) if (field) { observer.disconnect() resolve(field) } }) observer.observe(document.body, {childList: true, subtree: true}) setTimeout(() => { observer.disconnect() reject(`Timed out after ${this.timeOutMilliseconds / 1000} seconds while waiting for field with id '${id}' to be added to DOM`) }, this.timeOutMilliseconds) }) } async populateFields() { const fieldCollection = await this._getFieldCollection() if (!fieldCollection) {return} fieldCollection.fields.forEach(fieldCollectionField => { function populateField(field) { if (fieldCollectionField.hasOwnProperty('properties')) { for (const [property, value] of Object.entries(fieldCollectionField.properties)) { field[property] = value dispatchChangeEvent(field) } } if (fieldCollectionField.hasOwnProperty('customProperties')) { if (fieldCollectionField.customProperties.hasOwnProperty('toggledOptions')) { fieldCollectionField.customProperties.toggledOptions.forEach(optionIndex => { const option = field[optionIndex] option.selected = !option.defaultSelected }); dispatchChangeEvent(field) } } } const field = this._fields.find(field => field.id === fieldCollectionField.id) if (field) { populateField(field) } else { this.waitForField(fieldCollectionField.id) .then(field => populateField(field)) .catch(errorMsg => console.warn(errorMsg)) } }) } prompt(element) { if (!this._hasFieldCollection()) {return} const container = document.createElement('div') container.classList = 'form-continue' const infoText = document.createElement('p') infoText.textContent = 'Tidligare forlet du skjemaet utan å sende det inn. Vil du fortsette der du slapp?' infoText.dataset.altLang = 'Tidligere forlot du skjemaet uten å sende det inn. Vil du fortsette der du slapp?'; const loadButton = document.createElement('button') loadButton.type = 'button' loadButton.textContent = 'Fortsett der eg slapp' loadButton.dataset.altLang = 'Fortsett der jeg slapp' loadButton.style.marginRight = '1em' const removeButton = document.createElement('button') removeButton.type = 'button' removeButton.textContent = 'Fjern data' container.appendChild(infoText) container.appendChild(loadButton) container.appendChild(removeButton) element.insertAdjacentElement('afterbegin', container) loadButton.addEventListener('click', () => { this.populateFields() container.remove() }, {once: true}) removeButton.addEventListener('click', () => { this._deleteFieldCollection() container.remove() }, {once: true}) } add(...fields) { const addedFields = [] fields.forEach(field => { if (typeof field === 'string') { const fieldElement = document.getElementById(field) if (fieldElement) { field = fieldElement } else { this.waitForField(field) .then(field => { this._fields.push(field) this._checkFieldSupport([field]) }) .catch(errorMsg => console.warn(errorMsg)) } } if (field instanceof HTMLElement && field.id) { addedFields.push(field) } }) this._fields.push(...addedFields) this._checkFieldSupport(addedFields) } remove(...fields) { fields.forEach(field => { if (typeof field === 'string') { field = document.getElementById(field) } this._fields.delete(field) }) } encryptWithSecret(secret) { this._secret = secret } // Encryption based on https://github.com/mdn/dom-examples/blob/main/web-crypto/derive-key/pbkdf2.js async _getKey(salt) { const textEncoder = new TextEncoder const keyMaterial = await window.crypto.subtle.importKey( 'raw', textEncoder.encode(this._secret), 'PBKDF2', false, ['deriveKey'] ) return await window.crypto.subtle.deriveKey( {name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256'}, keyMaterial, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt'] ) } async _encrypt(fields) { const salt = window.crypto.getRandomValues(new Uint8Array(16)) const iv = window.crypto.getRandomValues(new Uint8Array(12)) const key = await this._getKey(salt) const textEncoder = new TextEncoder const ciphertext = await window.crypto.subtle.encrypt( {name: 'AES-GCM', iv}, key, textEncoder.encode(JSON.stringify(fields)) ) return { buffer: Array.from(new Uint8Array(ciphertext)), salt: Array.from(salt), iv: Array.from(iv) } } async _decrypt(encryptedFields) { const ciphertext = new Uint8Array(encryptedFields.buffer).buffer const salt = new Uint8Array(encryptedFields.salt) const iv = new Uint8Array(encryptedFields.iv) const key = await this._getKey(salt) const textDecoder = new TextDecoder const decrypted = await window.crypto.subtle.decrypt( {name: 'AES-GCM', iv}, key, ciphertext ) return JSON.parse(textDecoder.decode(decrypted)) } _getFieldCollectionData() { const fieldsData = [] this._fields.forEach(field => { const fieldData = { id: field.id, properties: {}, customProperties: {}, } // Field types have been hardcoded as there is no way to know how future field types behave and which edge cases they might have. // Field types that are not applicable or left out intentionally: button, reset, submit, image, fieldset, output, password, file. switch (field.type) { case 'color': case 'date': case 'datetime-local': case 'email': case 'month': case 'number': case 'range': case 'search': case 'tel': case 'text': case 'time': case 'url': case 'week': case 'textarea': // No reason to store fieldData if value hasn't changed from default. if (field.value === field.defaultValue) {return} fieldData.properties.value = field.value break case 'hidden': // When setting the value of a hidden input defaultValue is also changed. // No reason to store fieldData if value is not set. if (field.value === '') {return} fieldData.properties.value = field.value break case 'radio': case 'checkbox': if (field.checked === field.defaultChecked) {return} fieldData.properties.checked = field.checked break case 'select-one': // If multiple options are defaultSelected browsers select the last one // If there are no options that are defaultSelected browsers select index 0 const defaultOption = Array.from(field.options).reverse().find(option => option.defaultSelected) || field.options[0] // No reason to store fieldData if selected is defaultOption. if (field.selectedIndex === defaultOption.index) {return} fieldData.properties.selectedIndex = field.selectedIndex break case 'select-multiple': const toggledOptions = Array.from(field.options).filter(option => option.selected !== option.defaultSelected).map(option => option.index) // No reason to store fieldData if no toggledOptions were found if (toggledOptions.length === 0) {return} fieldData.customProperties.toggledOptions = toggledOptions break } if (Object.keys(fieldData.customProperties).length === 0) { delete fieldData.customProperties } if (Object.keys(fieldData.properties).length === 0) { delete fieldData.properties } if (fieldData.hasOwnProperty('properties') || fieldData.hasOwnProperty('customProperties')) { fieldsData.push(fieldData) } }) return fieldsData } _getFieldCollections() { const fieldCollections = this._storage.getItem('jsskjema.fieldCollections') return fieldCollections !== null ? JSON.parse(fieldCollections) : [] } _findFieldCollection(fieldCollection) { return fieldCollection.meta.pathname === location.pathname + location.search && fieldCollection.meta.identifier === this._identifier } _hasFieldCollection() { const fieldCollections = this._getFieldCollections() const fieldCollection = fieldCollections.find(this._findFieldCollection, this) return fieldCollection !== undefined } async _getFieldCollection() { const fieldCollections = this._getFieldCollections() const fieldCollection = fieldCollections.find(this._findFieldCollection, this) if (!fieldCollection) {return} if (this._secret !== undefined) { fieldCollection.fields = await this._decrypt(fieldCollection.fields) } return fieldCollection } async _storeFieldCollection() { const fieldCollectionData = { meta: { pathname: location.pathname + location.search, identifier: this._identifier, storedAt: Date.now(), }, fields: this._getFieldCollectionData() } if (this._secret !== undefined) { fieldCollectionData.fields = await this._encrypt(fieldCollectionData.fields) } const fieldCollections = this._getFieldCollections() if (this._hasFieldCollection()) { const fieldCollectionIndex = fieldCollections.findIndex(this._findFieldCollection, this) fieldCollections[fieldCollectionIndex] = fieldCollectionData } else { fieldCollections.push(fieldCollectionData) } this._storage.setItem('jsskjema.fieldCollections', JSON.stringify(fieldCollections)) } _deleteFieldCollection() { if (!this._hasFieldCollection()) {return} const fieldCollections = this._getFieldCollections() const fieldCollectionIndex = fieldCollections.findIndex(this._findFieldCollection, this) fieldCollections.splice(fieldCollectionIndex, 1) this._storage.setItem('jsskjema.fieldCollections', JSON.stringify(fieldCollections)) } // Check for any known unsupported fields and warn the developer. // Unsupported fields includes: // - SubForms created with mkDynamicSubForm (On loading the inserted subforms wouldn't be displayed or removable) // - MultiSelects created with mkMultiSelect (The select element isn't responsible for storing the value and the nested checkboxes do not have an id) // - File upload created with mkCheckboxWithFileUpload or mkFileUpload (Reference is available but user can't see what they have uploaded) // - Form Associated Custom Elements (There is no standard for retrieving and setting value, would have to add support on a case by case basis to be safe) _checkFieldSupport(fields) { const unsupportedFieldSelector = [ '[id$="SubForm"]', // mkDynamicSubForm '.multiselect > select:scope', // mkMultiSelect '[id$="UploadUI"] input[type="checkbox"]:scope', // mkCheckboxWithFileUpload or mkFileUpload ].join(', ') const unsupportedFields = fields.filter(field => field.matches(unsupportedFieldSelector) || field.constructor.formAssociated // Form Associated Custom Elements ) if (unsupportedFields.length !== 0) { console.warn('Known unsupported fields found: ', unsupportedFields) } } } // Temporary function to make it easier to incrementally update to the newer version of conditionalRequired, should be removed when all calls to old has been replaced. /** * Make the targets required based on a condition. * @param {Object} options * @param {[HTMLElement]} options.targets An array of elements that should be made conditionally required. * @param {[HTMLElement]} options.dependencies An array of elements that condition depends on. * @param {boolean} options.optional A boolean value that when true makes the targets optional instead of hidden when the condition returns false. * @param {function():boolean} options.condition A callback function that returns a boolean value which decides whether or not the targets should be required. * @returns {function} Function that updates the states of targets, should be used when the condition depends on something other than the changed event of the dependencies. * * @example const target = document.getElementById('target')
    const input = document.getElementById('input')

    // if input.value === 'example' then target should be required, otherwise it should be hidden.
    conditionalRequired({
      targets: [target],
      dependencies: [input],
      optional: false,
      condition: () => input.value === 'example',
    }) */ function conditionalRequired(...args) { return args[0] instanceof HTMLElement ? conditionalRequiredOld(...args) : conditionalRequiredNew(...args) } conditionalRequiredNew.targetsData = new Map conditionalRequiredNew.inputsData = new Map conditionalRequiredNew.dependenciesData = new Map conditionalRequiredNew.placeholderElementsData = new Map function conditionalRequiredNew({targets, dependencies, optional, condition}) { const STATE_REQUIRED = 2 const STATE_OPTIONAL = 1 const STATE_HIDDEN = 0 const {targetsData, inputsData, dependenciesData, placeholderElementsData} = conditionalRequiredNew runCondition.result = STATE_REQUIRED function runCondition() { const oldState = runCondition.result const newState = condition() ? STATE_REQUIRED : optional ? STATE_OPTIONAL : STATE_HIDDEN if (oldState !== newState) { runCondition.result = newState targets.forEach(updateTargetState) } } function updateTargetState(target) { const targetData = targetsData.get(target) const {conditions, parentTarget, dependencies} = targetData const oldState = targetData.state const newState = Math.min( parentTarget ? targetsData.get(parentTarget).state : STATE_REQUIRED, // state based on parent ...conditions.map(condition => condition.result), // state based on it's own conditions ...[...dependencies].map(dependency => { // state based on dependencies if (!inputsData.has(dependency)) {return STATE_REQUIRED} const {parentTarget} = inputsData.get(dependency) if (!parentTarget) {return STATE_REQUIRED} return targetsData.get(parentTarget).state >= STATE_OPTIONAL ? STATE_REQUIRED : STATE_HIDDEN }) ) if (oldState !== newState) { const {inputs, targets} = targetData targetData.state = newState updateTarget(target) inputs.forEach(updateInputState) targets.forEach(updateTargetState) } } function updateTarget(target) { const {placeholderElement, state, parentTarget} = targetsData.get(target) const oldHidden = (parentTarget || document).contains(placeholderElement) const newHidden = state === STATE_HIDDEN // Prevent animating when changing from STATE_REQUIRED to STATE_OPTIONAL which are both visible and don't need an animation if (oldHidden !== newHidden) { if (!window.matchMedia(`(prefers-reduced-motion)`).matches) { // const keyframes = {} // keyframes.opacity = state !== STATE_HIDDEN ? [0, 1] : [1, 0] // if (CSS.supports('interpolate-size', 'allow-keywords')) { // target.style.interpolateSize = 'allow-keywords' // keyframes.overflow = ['hidden', 'hidden'] // const {blockSize, paddingBlock, borderBlockWidth, marginBlock, display} = getComputedStyle(target) // keyframes.blockSize = ['0', blockSize] // keyframes.paddingBlock = ['0', paddingBlock] // keyframes.borderBlockWidth = ['0', borderBlockWidth] // keyframes.marginBlock = ['0', marginBlock] // if (state === STATE_HIDDEN) { // keyframes.display = [display, display] // keyframes.blockSize.reverse() // keyframes.paddingBlock.reverse() // keyframes.borderBlockWidth.reverse() // keyframes.marginBlock.reverse() // } // } else { // if (state !== STATE_HIDDEN) { // keyframes.transform = ['translateY(4rem) scale(0)', 'translateY(0) scale(1)'] // } // } // target.animate(keyframes, { // duration: 300, // easing: 'linear', // }) target.animate({ opacity: [0, 1], transform: ['translateY(4rem) scale(0)', 'translateY(0) scale(1)'], }, { duration: 300, easing: 'ease-out', }) } if (newHidden) { target.replaceWith(placeholderElement) } else { placeholderElement.replaceWith(target) } } } function updateInputState(input) { const inputData = inputsData.get(input) const {parentTarget} = inputData const oldState = inputData.state const newState = parentTarget ? targetsData.get(parentTarget).state : STATE_REQUIRED if (oldState !== newState) { inputData.state = newState updateInput(input) if (dependenciesData.has(input)) { const {targets} = dependenciesData.get(input) targets.forEach(updateTargetState) } } } function updateInput(input) { const inputData = inputsData.get(input) const {originalRequired, originalObligCheckbox, state} = inputData const newRequired = state === STATE_REQUIRED // prevent internal changes to required from updating originalRequired inputData.updateOriginalValues = false // has to update both required and data-oblig to prevent syncing of the attributes from updating originalRequired input.required = originalRequired && newRequired if (originalRequired && newRequired) { input.setAttribute('data-oblig', true) } else { input.removeAttribute('data-oblig') } if (originalObligCheckbox !== null) { if (newRequired) { input.setAttribute('data-oblig-checkbox', originalObligCheckbox) } else { input.removeAttribute('data-oblig-checkbox') } } inputData.updateOriginalValues = true } function getParentTarget(element) { do { if (targetsData.has(element)) { return element } } while (element = element.parentElement); } // function getParentTarget(element) { // return element && (targetsData.has(element) ? element : getParentTarget(element.parentElement)) // } function getChildTargets(target) { const elements = [...target.children] const targets = [] for (const element of elements) { if (targetsData.has(element)) { targets.push(element) } else if (placeholderElementsData.has(element)) { targets.push(placeholderElementsData.get(element).target) } else { elements.push(...element.children) } } return targets } // function getChildTargets(element) { // return [...element.children].flatMap(element => targetsData.has(element) ? element : placeholderElementsData.has(element) ? placeholderElementsData.get(element).target : getChildTargets(element)) // } function getChildInputs(target) { const elements = [...target.children] const inputs = [] if (inputsData.has(target)) { inputs.push(target) } for (const element of elements) { if (targetsData.has(element)) {continue} if (inputsData.has(element)) { inputs.push(element) } elements.push(...element.children) } return inputs } function updateOriginalValue(input, name, value) { const inputData = inputsData.get(input) if (!inputData.updateOriginalValues) {return} inputData[name] = value updateInput(input) } function trackTarget(target) { if (targetsData.has(target)) { const {conditions, dependencies} = targetsData.get(target) conditions.push(runCondition) if (!optional) { dependencies.forEach(dependencies.add, dependencies) } } else { const placeholderElement = document.createElement('div') placeholderElement.hidden = true placeholderElementsData.set(placeholderElement, { target, }) targetsData.set(target, { placeholderElement, conditions: [runCondition], dependencies: new Set(!optional ? dependencies : []), state: STATE_REQUIRED, get parentTarget() {return getParentTarget(target.parentElement || placeholderElement)}, get targets() {return getChildTargets(target)}, get inputs() {return getChildInputs(target)}, }) new MutationObserver(mutations => { const addedInputs = new Set const removedInputs = new Set mutations.forEach(mutation => { mutation.addedNodes.forEach(addedNode => { if (addedNode.nodeType !== Node.ELEMENT_NODE) {return} [addedNode, ...addedNode.querySelectorAll('*')].forEach(addedInput => { if (!('required' in addedInput)) {return} if (removedInputs.delete(addedInput)) {return} addedInputs.add(addedInput) }) }) mutation.removedNodes.forEach(removedNode => { if (removedNode.nodeType !== Node.ELEMENT_NODE) {return} [removedNode, ...removedNode.querySelectorAll('*')].forEach(removedInput => { if (!('required' in removedInput)) {return} if (addedInputs.delete(removedInput)) {return} removedInputs.add(removedInput) }) }) }) addedInputs.forEach(trackInput) removedInputs.forEach(updateInputState) }).observe(target, {childList: true, subtree: true}) ;[target, ...target.querySelectorAll('*')].forEach(input => { if (!('required' in input)) {return} trackInput(input) }) } } function trackInput(input) { if (!inputsData.has(input)) { inputsData.set(input, { // has to look for both required & data-oblig as the attributes may not of been synced if the input hasn't been added to DOM yet. originalRequired: input.required || input.hasAttribute('data-oblig'), originalObligCheckbox: input.getAttribute('data-oblig-checkbox'), updateOriginalValues: true, state: STATE_REQUIRED, get parentTarget() {return getParentTarget(input)}, }) // Hack to listen for changes to required/data-oblig attribute. // This hack is required so that other scripts can update the attribute values of tracked inputs without conditonalRequired interfering let propertyDescriptors = {} for (let object = input; object; object = Object.getPrototypeOf(object)) { propertyDescriptors = Object.assign(Object.getOwnPropertyDescriptors(object), propertyDescriptors) } // attributes in HTML are case-insensitive in regards to A-Z while toLowerCase() does all of unicode // this means "required" and "REQUIRED" are equivelant but not "testæøå" and "TESTÆØÅ" // this isn't a problem right now but could be if we blindly added a check for an attribute with an uppercase letter that isn't A-Z Object.defineProperties(input, { required: Object.assign({}, propertyDescriptors.required, { set(value) { propertyDescriptors.required.set.call(input, value) updateOriginalValue(input, 'originalRequired', value) } }), removeAttribute: Object.assign({}, propertyDescriptors.removeAttribute, { value(name, ...args) { propertyDescriptors.removeAttribute.value.call(input, name, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', false) } else if (name.toLowerCase() === 'data-oblig-checkbox') { updateOriginalValue(input, 'originalObligCheckbox', null) } } }), setAttribute: Object.assign({}, propertyDescriptors.setAttribute, { value(name, value, ...args) { propertyDescriptors.setAttribute.value.call(input, name, value, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', true) } else if (name.toLowerCase() === 'data-oblig-checkbox') { updateOriginalValue(input, 'originalObligCheckbox', value) } } }), toggleAttribute: Object.assign({}, propertyDescriptors.toggleAttribute, { value(name, ...args) { const returnValue = propertyDescriptors.toggleAttribute.value.call(input, name, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', returnValue) } } }), dataset: Object.assign({}, propertyDescriptors.dataset, { get() { const dataset = propertyDescriptors.dataset.get.call(input) return new Proxy(dataset, { set(target, property, value) { const returnValue = Reflect.set(target, property, value) if (property === 'oblig') { updateOriginalValue(input, 'originalRequired', true) } else if (property === 'obligCheckbox') { updateOriginalValue(input, 'originalObligCheckbox', value) } return returnValue }, deleteProperty(target, property) { const returnValue = Reflect.deleteProperty(target, property) if (property === 'oblig') { updateOriginalValue(input, 'originalRequired', false) } else if (property === 'obligCheckbox') { updateOriginalValue(input, 'originalObligCheckbox', null) } return returnValue } }) } }), // additional methods that could be used to edit an attribute but we don't currently use anywhere so haven't bothered implementing // setAttributeNS // setAttributeNode // setAttributeNodeNS // removeAttributeNS // removeAttributeNode }) // additional methods that could be used to edit an attribute but we don't currently use anywhere so haven't bothered implementing // Object.defineProperties(input.attributes, { // setNamedItem // setNamedItemNS // removeNamedItem // removeNamedItemNS // }) } updateInputState(input) } function trackDependency(dependency) { // only required depencies are tracked as optional dependencies shouldn't affect state if (!optional) { if (dependenciesData.has(dependency)) { const {targets: targetsSet} = dependenciesData.get(dependency) targets.forEach(targetsSet.add, targetsSet) } else { dependenciesData.set(dependency, { targets: new Set(targets) }) } } dependency.addEventListener('change', runCondition) } targets.forEach(trackTarget) dependencies.forEach(trackDependency) runCondition() return runCondition } // needs to be stored outside of the function to preserve const conditionalInputs = new WeakMap const conditionalTargets = new WeakMap function conditionalRequiredOld(target, checkRequired, affectVisibility = true) { const inputs = new Set function updateRequiredInput(...inputs) { inputs.forEach(input => { const conditionalInput = conditionalInputs.get(input) const {originalRequired, originalObligCheckbox, checkRequireds} = conditionalInput const required = checkRequireds.every(checkRequired => checkRequired()) // prevent internal changes to required from updating originalRequired conditionalInput.updateOriginalValues = false // has to update both required and data-oblig to prevent syncing of the attributes from updating originalRequired input.required = originalRequired && required if (originalRequired && required) { input.setAttribute('data-oblig', true) } else { input.removeAttribute('data-oblig') } if (originalObligCheckbox !== null) { if (required) { input.setAttribute('data-oblig-checkbox', originalObligCheckbox) } else { input.removeAttribute('data-oblig-checkbox') } } conditionalInput.updateOriginalValues = true }) } function updateDisplay() { if (!affectVisibility) {return} if (checkRequired()) { const {originalDisplay} = conditionalTargets.get(target) if (!window.matchMedia(`(prefers-reduced-motion)`).matches && target.style.display !== originalDisplay) { target.animate({ opacity: [0, 1], transform: ['translateY(4rem) scale(0)', 'translateY(0) scale(1)'], }, { duration: 300, easing: 'ease-out', }) } target.style.display = originalDisplay } else { target.style.display = 'none' } } function updateRequired() { updateRequiredInput(...inputs) updateDisplay() } function updateOriginalValue(input, name, value) { const conditionalInput = conditionalInputs.get(input) if (!conditionalInput.updateOriginalValues) {return} conditionalInput[name] = value updateRequiredInput(input) } function addInputToConditionalRequired(input) { inputs.add(input) if (conditionalInputs.has(input)) { conditionalInputs.get(input).checkRequireds.push(checkRequired) } else { conditionalInputs.set(input, { // has to look for both required & data-oblig as the attributes may not of been synced if the input hasn't been added to DOM yet. originalRequired: input.required || input.hasAttribute('data-oblig'), originalObligCheckbox: input.getAttribute('data-oblig-checkbox'), updateOriginalValues: true, checkRequireds: [checkRequired], }) // Hack to listen for changes to required/data-oblig attribute. let object = input let propertyDescriptors = {} do { propertyDescriptors = Object.assign(Object.getOwnPropertyDescriptors(object), propertyDescriptors) } while (object = Object.getPrototypeOf(object)) // note: removeAttribute, setAttribute and toggleAttribute automatically lowercases only ASCII A-Z while toLowerCase() does all of unicode. Object.defineProperties(input, { required: Object.assign({}, propertyDescriptors.required, { set(value) { propertyDescriptors.required.set.call(input, value) updateOriginalValue(input, 'originalRequired', value) } }), removeAttribute: Object.assign({}, propertyDescriptors.removeAttribute, { value(name, ...args) { propertyDescriptors.removeAttribute.value.call(input, name, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', false) } else if (name.toLowerCase() === 'data-oblig-checkbox') { updateOriginalValue(input, 'originalObligCheckbox', null) } } }), setAttribute: Object.assign({}, propertyDescriptors.setAttribute, { value(name, value, ...args) { propertyDescriptors.setAttribute.value.call(input, name, value, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', true) } else if (name.toLowerCase() === 'data-oblig-checkbox') { updateOriginalValue(input, 'originalObligCheckbox', value) } } }), toggleAttribute: Object.assign({}, propertyDescriptors.toggleAttribute, { value(name, ...args) { const returnValue = propertyDescriptors.toggleAttribute.value.call(input, name, ...args) if (['required', 'data-oblig'].includes(name.toLowerCase())) { updateOriginalValue(input, 'originalRequired', returnValue) } } }), dataset: Object.assign({}, propertyDescriptors.dataset, { get() { const dataset = propertyDescriptors.dataset.get.call(input) return new Proxy(dataset, { set(target, property, value) { const returnValue = Reflect.set(target, property, value) if (property === 'oblig') { updateOriginalValue(input, 'originalRequired', true) } else if (property === 'obligCheckbox') { updateOriginalValue(input, 'originalObligCheckbox', value) } return returnValue }, deleteProperty(target, property) { const returnValue = Reflect.deleteProperty(target, property) if (property === 'oblig') { updateOriginalValue(input, 'originalRequired', false) } else if (property === 'obligCheckbox') { updateOriginalValue(input, 'originalObligCheckbox', null) } return returnValue } }) } }), // setAttributeNS // setAttributeNode // setAttributeNodeNS // removeAttributeNS // removeAttributeNode }) // Object.defineProperties(input.attributes, { // setNamedItem // setNamedItemNS // removeNamedItem // removeNamedItemNS // }) } updateRequiredInput(input) } function removeInputFromConditionalRequired(input) { inputs.delete(input) const conditionalInput = conditionalInputs.get(input) const {checkRequireds} = conditionalInput checkRequireds.splice(checkRequireds.indexOf(checkRequired), 1) updateRequiredInput(input) } if (typeof checkRequired !== 'function') { const [field, value] = checkRequired field.addEventListener('change', updateRequired) if (['checkbox', 'radio'].includes(field.type)) { checkRequired = () => field.checked === value } else { checkRequired = () => field.value === value } } [target, ...target.querySelectorAll('*')].forEach(input => { if (!('required' in input)) {return} addInputToConditionalRequired(input) }) if (!conditionalTargets.has(target)) { conditionalTargets.set(target, { originalDisplay: target.style.display, }) new MutationObserver(mutations => { const addedInputs = new Set const removedInputs = new Set mutations.forEach(mutation => { mutation.addedNodes.forEach(addedNode => { if (addedNode.nodeType !== Node.ELEMENT_NODE) {return} [addedNode, ...addedNode.querySelectorAll('*')].forEach(addedInput => { if (!('required' in addedInput)) {return} if (removedInputs.delete(addedInput)) {return} addedInputs.add(addedInput) }) }) mutation.removedNodes.forEach(removedNode => { if (removedNode.nodeType !== Node.ELEMENT_NODE) {return} [removedNode, ...removedNode.querySelectorAll('*')].forEach(removedInput => { if (!('required' in removedInput)) {return} if (addedInputs.delete(removedInput)) {return} removedInputs.add(removedInput) }) }) }) addedInputs.forEach(addInputToConditionalRequired) removedInputs.forEach(removeInputFromConditionalRequired) }).observe(target, {childList: true, subtree: true}) } updateDisplay() return updateRequired } /** * Dispatches input & change event on field, primarily used for when the value of a field is changed programmatically. * Do not call this function if there is a risk of it causing an infinite loop. * Input & change events aren't dispatched by browsers when the value of a field is changed programmatically due to the potentional to cause infinite loops. * @author Ole Brede * * @param {HTMLElement} field A form field that needs to dispatch a input & change event. * * @example field.value = newValue;
    dispatchChangeEvent(field); */ function dispatchChangeEvent(field) { // The types have to be hardcoded as there is no way to programmatically detect if a given type dispatches the input or change event. const types = new Set([ 'color', 'date', 'datetime-local', 'email', 'month', 'number', 'range', 'search', 'tel', 'text', 'time', 'url', 'week', 'textarea', 'radio', 'checkbox', 'select-one', 'select-multiple', ]) if (types.has(field.type)) { // https://html.spec.whatwg.org/multipage/input.html#common-input-element-events // "[...] In all cases, the input event comes before the corresponding change event [...]" const inputEvent = new Event('input', {bubbles: true, composed: true}) const changeEvent = new Event('change', {bubbles: true}) field.dispatchEvent(inputEvent) field.dispatchEvent(changeEvent) } } // End htmlDOMnode