'),u=this.input.getAttribute("class")||"",d=s.mode;var h;W(r,s.wrapperClass,u,d),W(a,s.controlClass),Ot(r,a),W(l,s.dropdownClass,d),s.copyClassesToDropdown&&W(l,u),W(c,s.dropdownContentClass),Ot(l,c),B(s.dropdownParent||r).appendChild(l),R(s.controlInput)?(h=B(s.controlInput),j(["autocorrect","autocapitalize","autocomplete"],(t=>{i.getAttribute(t)&&et(h,{[t]:i.getAttribute(t)})})),h.tabIndex=-1,a.appendChild(h),this.focus_node=h):s.controlInput?(h=B(s.controlInput),this.focus_node=h):(h=B("
"),this.focus_node=a),this.wrapper=r,this.dropdown=l,this.dropdown_content=c,this.control=a,this.control_input=h,this.setup()}setup(){const t=this,e=t.settings,n=t.control_input,i=t.dropdown,s=t.dropdown_content,o=t.wrapper,r=t.control,l=t.input,c=t.focus_node,u={passive:!0},d=t.inputId+"-ts-dropdown";et(s,{id:d}),et(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":d});const h=Dt(c,t.inputId+"-ts-control"),p="label[for='"+z(t.inputId)+"']",m=document.querySelector(p),f=t.focus.bind(t);if(m){Tt(m,"click",f),et(m,{for:h});const e=Dt(m,t.inputId+"-ts-label");et(c,{"aria-labelledby":e}),et(s,{"aria-labelledby":e})}if(o.style.width=l.style.width,t.plugins.names.length){const e="plugin-"+t.plugins.names.join(" plugin-");W([o,i],e)}(null===e.maxItems||e.maxItems>1)&&t.is_select_tag&&et(l,{multiple:"multiple"}),e.placeholder&&et(n,{placeholder:e.placeholder}),!e.splitOn&&e.delimiter&&(e.splitOn=new RegExp("\\s*"+a(e.delimiter)+"+\\s*")),e.load&&e.loadThrottle&&(e.load=_t(e.load,e.loadThrottle)),t.control_input.type=l.type,Tt(i,"mousemove",(()=>{t.ignoreHover=!1})),Tt(i,"mouseenter",(e=>{var n=G(e.target,"[data-selectable]",i);n&&t.onOptionHover(e,n)}),{capture:!0}),Tt(i,"click",(e=>{const n=G(e.target,"[data-selectable]");n&&(t.onOptionSelect(e,n),xt(e,!0))})),Tt(r,"click",(e=>{var i=G(e.target,"[data-ts-item]",r);i&&t.onItemSelect(e,i)?xt(e,!0):""==n.value&&(t.onClick(),xt(e,!0))})),Tt(c,"keydown",(e=>t.onKeyDown(e))),Tt(n,"keypress",(e=>t.onKeyPress(e))),Tt(n,"input",(e=>t.onInput(e))),Tt(c,"blur",(e=>t.onBlur(e))),Tt(c,"focus",(e=>t.onFocus(e))),Tt(n,"paste",(e=>t.onPaste(e)));const g=e=>{const s=e.composedPath()[0];if(!o.contains(s)&&!i.contains(s))return t.isFocused&&t.blur(),void t.inputState();s==n&&t.isOpen?e.stopPropagation():xt(e,!0)},v=()=>{t.isOpen&&t.positionDropdown()};Tt(document,"mousedown",g),Tt(window,"scroll",v,u),Tt(window,"resize",v,u),this._destroy=()=>{document.removeEventListener("mousedown",g),window.removeEventListener("scroll",v),window.removeEventListener("resize",v),m&&m.removeEventListener("click",f)},this.revertSettings={innerHTML:l.innerHTML,tabIndex:l.tabIndex},l.tabIndex=-1,l.insertAdjacentElement("afterend",t.wrapper),t.sync(!1),e.items=[],delete e.optgroups,delete e.options,Tt(l,"invalid",(()=>{t.isValid&&(t.isValid=!1,t.isInvalid=!0,t.refreshState())})),t.updateOriginalInput(),t.refreshItems(),t.close(!1),t.inputState(),t.isSetup=!0,l.disabled?t.disable():t.enable(),t.on("change",this.onChange),W(l,"tomselected","ts-hidden-accessible"),t.trigger("initialize"),!0===e.preload&&t.preload()}setupOptions(t=[],e=[]){this.addOptions(t),j(e,(t=>{this.registerOptionGroup(t)}))}setupTemplates(){var t=this,e=t.settings.labelField,n=t.settings.optgroupLabelField,i={optgroup:t=>{let e=document.createElement("div");return e.className="optgroup",e.appendChild(t.options),e},optgroup_header:(t,e)=>'",option:(t,n)=>"
"+n(t[e])+"
",item:(t,n)=>"
"+n(t[e])+"
",option_create:(t,e)=>'
Add '+e(t.input)+" …
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"};t.settings.render=Object.assign({},i,t.settings.render)}setupCallbacks(){var t,e,n={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"};for(t in n)(e=this.settings[n[t]])&&this.on(t,e)}sync(t=!0){const e=this,n=t?Ct(e.input,{delimiter:e.settings.delimiter}):e.settings;e.setupOptions(n.options,n.optgroups),e.setValue(n.items||[],!0),e.lastQuery=null}onClick(){var t=this;if(t.activeItems.length>0)return t.clearActiveItems(),void t.focus();t.isFocused&&t.isOpen?t.blur():t.focus()}onMouseDown(){}onChange(){Y(this.input,"input"),Y(this.input,"change")}onPaste(t){var e=this;e.isInputHidden||e.isLocked?xt(t):e.settings.splitOn&&setTimeout((()=>{var t=e.inputValue();if(t.match(e.settings.splitOn)){var n=t.trim().split(e.settings.splitOn);j(n,(t=>{vt(t)&&(this.options[t]?e.addItem(t):e.createItem(t))}))}}),0)}onKeyPress(t){var e=this;if(!e.isLocked){var n=String.fromCharCode(t.keyCode||t.which);return e.settings.create&&"multi"===e.settings.mode&&n===e.settings.delimiter?(e.createItem(),void xt(t)):void 0}xt(t)}onKeyDown(t){var e=this;if(e.ignoreHover=!0,e.isLocked)t.keyCode!==mt&&xt(t);else{switch(t.keyCode){case ot:if(Et(ft,t)&&""==e.control_input.value)return xt(t),void e.selectAll();break;case at:return e.isOpen&&(xt(t,!0),e.close()),void e.clearActiveItems();case dt:if(!e.isOpen&&e.hasOptions)e.open();else if(e.activeOption){let t=e.getAdjacent(e.activeOption,1);t&&e.setActiveOption(t)}return void xt(t);case ct:if(e.activeOption){let t=e.getAdjacent(e.activeOption,-1);t&&e.setActiveOption(t)}return void xt(t);case rt:return void(e.canSelect(e.activeOption)?(e.onOptionSelect(t,e.activeOption),xt(t)):(e.settings.create&&e.createItem()||document.activeElement==e.control_input&&e.isOpen)&&xt(t));case lt:return void e.advanceSelection(-1,t);case ut:return void e.advanceSelection(1,t);case mt:return void(e.settings.selectOnTab&&(e.canSelect(e.activeOption)&&(e.onOptionSelect(t,e.activeOption),xt(t)),e.settings.create&&e.createItem()&&xt(t)));case ht:case pt:return void e.deleteSelection(t)}e.isInputHidden&&!Et(ft,t)&&xt(t)}}onInput(t){var e=this;if(!e.isLocked){var n=e.inputValue();e.lastValue!==n&&(e.lastValue=n,e.settings.shouldLoad.call(e,n)&&e.load(n),e.refreshOptions(),e.trigger("type",n))}}onOptionHover(t,e){this.ignoreHover||this.setActiveOption(e,!1)}onFocus(t){var e=this,n=e.isFocused;if(e.isDisabled)return e.blur(),void xt(t);e.ignoreFocus||(e.isFocused=!0,"focus"===e.settings.preload&&e.preload(),n||e.trigger("focus"),e.activeItems.length||(e.showInput(),e.refreshOptions(!!e.settings.openOnFocus)),e.refreshState())}onBlur(t){if(!1!==document.hasFocus()){var e=this;if(e.isFocused){e.isFocused=!1,e.ignoreFocus=!1;var n=()=>{e.close(),e.setActiveItem(),e.setCaret(e.items.length),e.trigger("blur")};e.settings.create&&e.settings.createOnBlur?e.createItem(null,n):n()}}}onOptionSelect(t,e){var n,i=this;e.parentElement&&e.parentElement.matches("[data-disabled]")||(e.classList.contains("create")?i.createItem(null,(()=>{i.settings.closeAfterSelect&&i.close()})):void 0!==(n=e.dataset.value)&&(i.lastQuery=null,i.addItem(n),i.settings.closeAfterSelect&&i.close(),!i.settings.hideSelected&&t.type&&/click/.test(t.type)&&i.setActiveOption(e)))}canSelect(t){return!!(this.isOpen&&t&&this.dropdown_content.contains(t))}onItemSelect(t,e){var n=this;return!n.isLocked&&"multi"===n.settings.mode&&(xt(t),n.setActiveItem(e,t),!0)}canLoad(t){return!!this.settings.load&&!this.loadedSearches.hasOwnProperty(t)}load(t){const e=this;if(!e.canLoad(t))return;W(e.wrapper,e.settings.loadingClass),e.loading++;const n=e.loadCallback.bind(e);e.settings.load.call(e,t,n)}loadCallback(t,e){const n=this;n.loading=Math.max(n.loading-1,0),n.lastQuery=null,n.clearActiveOption(),n.setupOptions(t,e),n.refreshOptions(n.isFocused&&!n.isInputHidden),n.loading||Z(n.wrapper,n.settings.loadingClass),n.trigger("load",t,e)}preload(){var t=this.wrapper.classList;t.contains("preloaded")||(t.add("preloaded"),this.load(""))}setTextboxValue(t=""){var e=this.control_input;e.value!==t&&(e.value=t,Y(e,"update"),this.lastValue=t)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(t,e){wt(this,e?[]:["change"],(()=>{this.clear(e),this.addItems(t,e)}))}setMaxItems(t){0===t&&(t=null),this.settings.maxItems=t,this.refreshState()}setActiveItem(t,e){var n,i,s,o,r,a,l=this;if("single"!==l.settings.mode){if(!t)return l.clearActiveItems(),void(l.isFocused&&l.showInput());if("click"===(n=e&&e.type.toLowerCase())&&Et("shiftKey",e)&&l.activeItems.length){for(a=l.getLastActive(),(s=Array.prototype.indexOf.call(l.control.children,a))>(o=Array.prototype.indexOf.call(l.control.children,t))&&(r=s,s=o,o=r),i=s;i<=o;i++)t=l.control.children[i],-1===l.activeItems.indexOf(t)&&l.setActiveItemClass(t);xt(e)}else"click"===n&&Et(ft,e)||"keydown"===n&&Et("shiftKey",e)?t.classList.contains("active")?l.removeActiveItem(t):l.setActiveItemClass(t):(l.clearActiveItems(),l.setActiveItemClass(t));l.hideInput(),l.isFocused||l.focus()}}setActiveItemClass(t){const e=this,n=e.control.querySelector(".last-active");n&&Z(n,"last-active"),W(t,"active last-active"),e.trigger("item_select",t),-1==e.activeItems.indexOf(t)&&e.activeItems.push(t)}removeActiveItem(t){var e=this.activeItems.indexOf(t);this.activeItems.splice(e,1),Z(t,"active")}clearActiveItems(){Z(this.activeItems,"active"),this.activeItems=[]}setActiveOption(t,e=!0){t!==this.activeOption&&(this.clearActiveOption(),t&&(this.activeOption=t,et(this.focus_node,{"aria-activedescendant":t.getAttribute("id")}),et(t,{"aria-selected":"true"}),W(t,"active"),e&&this.scrollToOption(t)))}scrollToOption(t,e){if(!t)return;const n=this.dropdown_content,i=n.clientHeight,s=n.scrollTop||0,o=t.offsetHeight,r=t.getBoundingClientRect().top-n.getBoundingClientRect().top+s;r+o>i+s?this.scroll(r-i+o,e):r
{t.setActiveItemClass(e)})))}inputState(){var t=this;t.control.contains(t.control_input)&&(et(t.control_input,{placeholder:t.settings.placeholder}),t.activeItems.length>0||!t.isFocused&&t.settings.hidePlaceholder&&t.items.length>0?(t.setTextboxValue(),t.isInputHidden=!0):(t.settings.hidePlaceholder&&t.items.length>0&&et(t.control_input,{placeholder:""}),t.isInputHidden=!1),t.wrapper.classList.toggle("input-hidden",t.isInputHidden))}hideInput(){this.inputState()}showInput(){this.inputState()}inputValue(){return this.control_input.value.trim()}focus(){var t=this;t.isDisabled||(t.ignoreFocus=!0,t.control_input.offsetWidth?t.control_input.focus():t.focus_node.focus(),setTimeout((()=>{t.ignoreFocus=!1,t.onFocus()}),0))}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(t){return this.sifter.getScoreFunction(t,this.getSearchOptions())}getSearchOptions(){var t=this.settings,e=t.sortField;return"string"==typeof t.sortField&&(e=[{field:t.sortField}]),{fields:t.searchField,conjunction:t.searchConjunction,sort:e,nesting:t.nesting}}search(t){var e,n,i=this,s=this.getSearchOptions();if(i.settings.score&&"function"!=typeof(n=i.settings.score.call(i,t)))throw new Error('Tom Select "score" setting must be a function that returns a function');return t!==i.lastQuery?(i.lastQuery=t,e=i.sifter.search(t,Object.assign(s,{score:n})),i.currentResults=e):e=Object.assign({},i.currentResults),i.settings.hideSelected&&(e.items=e.items.filter((t=>{let e=vt(t.id);return!(e&&-1!==i.items.indexOf(e))}))),e}refreshOptions(t=!0){var e,n,i,s,o,r,a,l,c,u;const d={},h=[];var p=this,m=p.inputValue();const f=m===p.lastQuery||""==m&&null==p.lastQuery;var g=p.search(m),v=null,y=p.settings.shouldOpen||!1,b=p.dropdown_content;for(f&&(v=p.activeOption)&&(c=v.closest("[data-group]")),s=g.items.length,"number"==typeof p.settings.maxOptions&&(s=Math.min(s,p.settings.maxOptions)),s>0&&(y=!0),e=0;e0&&(u=u.cloneNode(!0),et(u,{id:a.$id+"-clone-"+n,"aria-selected":null}),u.classList.add("ts-cloned"),Z(u,"active"),p.activeOption&&p.activeOption.dataset.value==s&&c&&c.dataset.group===o.toString()&&(v=u)),t.appendChild(u),d[o]=t}}p.settings.lockOptgroupOrder&&h.sort(((t,e)=>{const n=p.optgroups[t],i=p.optgroups[e];return(n&&n.$order||0)-(i&&i.$order||0)})),a=document.createDocumentFragment(),j(h,(t=>{let e=d[t];if(!e||!e.children.length)return;let n=p.optgroups[t];if(void 0!==n){let t=document.createDocumentFragment(),i=p.render("optgroup_header",n);Ot(t,i),Ot(t,e);let s=p.render("optgroup",{group:n,options:t});Ot(a,s)}else Ot(a,e)})),b.innerHTML="",Ot(b,a),p.settings.highlight&&(st(b),g.query.length&&g.tokens.length&&j(g.tokens,(t=>{it(b,t.regex)})));var _=t=>{let e=p.render(t,{input:m});return e&&(y=!0,b.insertBefore(e,b.firstChild)),e};if(p.loading?_("loading"):p.settings.shouldLoad.call(p,m)?0===g.items.length&&_("no_results"):_("not_loading"),(l=p.canCreate(m))&&(u=_("option_create")),p.hasOptions=g.items.length>0||l,y){if(g.items.length>0){if(v||"single"!==p.settings.mode||null==p.items[0]||(v=p.getOption(p.items[0])),!b.contains(v)){let t=0;u&&!p.settings.addPrecedence&&(t=1),v=p.selectable()[t]}}else u&&(v=u);t&&!p.isOpen&&(p.open(),p.scrollToOption(v,"auto")),p.setActiveOption(v)}else p.clearActiveOption(),t&&p.isOpen&&p.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(t,e=!1){const n=this;if(Array.isArray(t))return n.addOptions(t,e),!1;const i=vt(t[n.settings.valueField]);return null!==i&&!n.options.hasOwnProperty(i)&&(t.$order=t.$order||++n.order,t.$id=n.inputId+"-opt-"+t.$order,n.options[i]=t,n.lastQuery=null,e&&(n.userOptions[i]=e,n.trigger("option_add",i,t)),i)}addOptions(t,e=!1){j(t,(t=>{this.addOption(t,e)}))}registerOption(t){return this.addOption(t)}registerOptionGroup(t){var e=vt(t[this.settings.optgroupValueField]);return null!==e&&(t.$order=t.$order||++this.order,this.optgroups[e]=t,e)}addOptionGroup(t,e){var n;e[this.settings.optgroupValueField]=t,(n=this.registerOptionGroup(e))&&this.trigger("optgroup_add",n,e)}removeOptionGroup(t){this.optgroups.hasOwnProperty(t)&&(delete this.optgroups[t],this.clearCache(),this.trigger("optgroup_remove",t))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(t,e){const n=this;var i,s;const o=vt(t),r=vt(e[n.settings.valueField]);if(null===o)return;const a=n.options[o];if(null==a)return;if("string"!=typeof r)throw new Error("Value must be set in option data");const l=n.getOption(o),c=n.getItem(o);if(e.$order=e.$order||a.$order,delete n.options[o],n.uncacheValue(r),n.options[r]=e,l){if(n.dropdown_content.contains(l)){const t=n._render("option",e);nt(l,t),n.activeOption===l&&n.setActiveOption(t)}l.remove()}c&&(-1!==(s=n.items.indexOf(o))&&n.items.splice(s,1,r),i=n._render("item",e),c.classList.contains("active")&&W(i,"active"),nt(c,i)),n.lastQuery=null}removeOption(t,e){const n=this;t=yt(t),n.uncacheValue(t),delete n.userOptions[t],delete n.options[t],n.lastQuery=null,n.trigger("option_remove",t),n.removeItem(t,e)}clearOptions(t){const e=(t||this.clearFilter).bind(this);this.loadedSearches={},this.userOptions={},this.clearCache();const n={};j(this.options,((t,i)=>{e(t,i)&&(n[i]=t)})),this.options=this.sifter.items=n,this.lastQuery=null,this.trigger("option_clear")}clearFilter(t,e){return this.items.indexOf(e)>=0}getOption(t,e=!1){const n=vt(t);if(null===n)return null;const i=this.options[n];if(null!=i){if(i.$div)return i.$div;if(e)return this._render("option",i)}return null}getAdjacent(t,e,n="option"){var i,s=this;if(!t)return null;i="item"==n?s.controlChildren():s.dropdown_content.querySelectorAll("[data-selectable]");for(let n=0;n0?i[n+1]:i[n-1];return null}getItem(t){if("object"==typeof t)return t;var e=vt(t);return null!==e?this.control.querySelector(`[data-value="${St(e)}"]`):null}addItems(t,e){var n=this,i=Array.isArray(t)?t:[t];const s=(i=i.filter((t=>-1===n.items.indexOf(t))))[i.length-1];i.forEach((t=>{n.isPending=t!==s,n.addItem(t,e)}))}addItem(t,e){wt(this,e?[]:["change","dropdown_close"],(()=>{var n,i;const s=this,o=s.settings.mode,r=vt(t);if((!r||-1===s.items.indexOf(r)||("single"===o&&s.close(),"single"!==o&&s.settings.duplicates))&&null!==r&&s.options.hasOwnProperty(r)&&("single"===o&&s.clear(e),"multi"!==o||!s.isFull())){if(n=s._render("item",s.options[r]),s.control.contains(n)&&(n=n.cloneNode(!0)),i=s.isFull(),s.items.splice(s.caretPos,0,r),s.insertAtCaret(n),s.isSetup){if(!s.isPending&&s.settings.hideSelected){let t=s.getOption(r),e=s.getAdjacent(t,1);e&&s.setActiveOption(e)}s.isPending||s.settings.closeAfterSelect||s.refreshOptions(s.isFocused&&"single"!==o),0!=s.settings.closeAfterSelect&&s.isFull()?s.close():s.isPending||s.positionDropdown(),s.trigger("item_add",r,n),s.isPending||s.updateOriginalInput({silent:e})}(!s.isPending||!i&&s.isFull())&&(s.inputState(),s.refreshState())}}))}removeItem(t=null,e){const n=this;if(!(t=n.getItem(t)))return;var i,s;const o=t.dataset.value;i=tt(t),t.remove(),t.classList.contains("active")&&(s=n.activeItems.indexOf(t),n.activeItems.splice(s,1),Z(t,"active")),n.items.splice(i,1),n.lastQuery=null,!n.settings.persist&&n.userOptions.hasOwnProperty(o)&&n.removeOption(o,e),i{})){3===arguments.length&&(e=arguments[2]),"function"!=typeof e&&(e=()=>{});var n,i=this,s=i.caretPos;if(t=t||i.inputValue(),!i.canCreate(t))return e(),!1;i.lock();var o=!1,r=t=>{if(i.unlock(),!t||"object"!=typeof t)return e();var n=vt(t[i.settings.valueField]);if("string"!=typeof n)return e();i.setTextboxValue(),i.addOption(t,!0),i.setCaret(s),i.addItem(n),e(t),o=!0};return n="function"==typeof i.settings.create?i.settings.create.call(this,t,r):{[i.settings.labelField]:t,[i.settings.valueField]:t},o||r(n),!0}refreshItems(){var t=this;t.lastQuery=null,t.isSetup&&t.addItems(t.items),t.updateOriginalInput(),t.refreshState()}refreshState(){const t=this;t.refreshValidityState();const e=t.isFull(),n=t.isLocked;t.wrapper.classList.toggle("rtl",t.rtl);const i=t.wrapper.classList;i.toggle("focus",t.isFocused),i.toggle("disabled",t.isDisabled),i.toggle("required",t.isRequired),i.toggle("invalid",!t.isValid),i.toggle("locked",n),i.toggle("full",e),i.toggle("input-active",t.isFocused&&!t.isInputHidden),i.toggle("dropdown-active",t.isOpen),i.toggle("has-options",X(t.options)),i.toggle("has-items",t.items.length>0)}refreshValidityState(){var t=this;t.input.validity&&(t.isValid=t.input.validity.valid,t.isInvalid=!t.isValid)}isFull(){return null!==this.settings.maxItems&&this.items.length>=this.settings.maxItems}updateOriginalInput(t={}){const e=this;var n,i;const s=e.input.querySelector('option[value=""]');if(e.is_select_tag){const o=[],r=e.input.querySelectorAll("option:checked").length;function a(t,n,i){return t||(t=B(''+bt(i)+" ")),t!=s&&e.input.append(t),o.push(t),(t!=s||r>0)&&(t.selected=!0),t}e.input.querySelectorAll("option:checked").forEach((t=>{t.selected=!1})),0==e.items.length&&"single"==e.settings.mode?a(s,"",""):e.items.forEach((t=>{n=e.options[t],i=n[e.settings.labelField]||"",o.includes(n.$option)?a(e.input.querySelector(`option[value="${St(t)}"]:not(:checked)`),t,i):n.$option=a(n.$option,t,i)}))}else e.input.value=e.getValue();e.isSetup&&(t.silent||e.trigger("change",e.getValue()))}open(){var t=this;t.isLocked||t.isOpen||"multi"===t.settings.mode&&t.isFull()||(t.isOpen=!0,et(t.focus_node,{"aria-expanded":"true"}),t.refreshState(),U(t.dropdown,{visibility:"hidden",display:"block"}),t.positionDropdown(),U(t.dropdown,{visibility:"visible",display:"block"}),t.focus(),t.trigger("dropdown_open",t.dropdown))}close(t=!0){var e=this,n=e.isOpen;t&&(e.setTextboxValue(),"single"===e.settings.mode&&e.items.length&&e.hideInput()),e.isOpen=!1,et(e.focus_node,{"aria-expanded":"false"}),U(e.dropdown,{display:"none"}),e.settings.hideSelected&&e.clearActiveOption(),e.refreshState(),n&&e.trigger("dropdown_close",e.dropdown)}positionDropdown(){if("body"===this.settings.dropdownParent){var t=this.control,e=t.getBoundingClientRect(),n=t.offsetHeight+e.top+window.scrollY,i=e.left+window.scrollX;U(this.dropdown,{width:e.width+"px",top:n+"px",left:i+"px"})}}clear(t){var e=this;if(e.items.length){var n=e.controlChildren();j(n,(t=>{e.removeItem(t,!0)})),e.showInput(),t||e.updateOriginalInput(),e.trigger("clear")}}insertAtCaret(t){const e=this,n=e.caretPos,i=e.control;i.insertBefore(t,i.children[n]||null),e.setCaret(n+1)}deleteSelection(t){var e,n,i,s,o=this;e=t&&t.keyCode===ht?-1:1,n=kt(o.control_input);const r=[];if(o.activeItems.length)s=Q(o.activeItems,e),i=tt(s),e>0&&i++,j(o.activeItems,(t=>r.push(t)));else if((o.isFocused||"single"===o.settings.mode)&&o.items.length){const t=o.controlChildren();let i;e<0&&0===n.start&&0===n.length?i=t[o.caretPos-1]:e>0&&n.start===o.inputValue().length&&(i=t[o.caretPos]),void 0!==i&&r.push(i)}if(!o.shouldDelete(r,t))return!1;for(xt(t,!0),void 0!==i&&o.setCaret(i);r.length;)o.removeItem(r.pop());return o.showInput(),o.positionDropdown(),o.refreshOptions(!1),!0}shouldDelete(t,e){const n=t.map((t=>t.dataset.value));return!(!n.length||"function"==typeof this.settings.onDelete&&!1===this.settings.onDelete(n,e))}advanceSelection(t,e){var n,i,s=this;s.rtl&&(t*=-1),s.inputValue().length||(Et(ft,e)||Et("shiftKey",e)?(i=(n=s.getLastActive(t))?n.classList.contains("active")?s.getAdjacent(n,t,"item"):n:t>0?s.control_input.nextElementSibling:s.control_input.previousElementSibling)&&(i.classList.contains("active")&&s.removeActiveItem(n),s.setActiveItemClass(i)):s.moveCaret(t))}moveCaret(t){}getLastActive(t){let e=this.control.querySelector(".last-active");if(e)return e;var n=this.control.querySelectorAll(".active");return n?Q(n,t):void 0}setCaret(t){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.isLocked=!0,this.refreshState()}unlock(){this.isLocked=!1,this.refreshState()}disable(){var t=this;t.input.disabled=!0,t.control_input.disabled=!0,t.focus_node.tabIndex=-1,t.isDisabled=!0,this.close(),t.lock()}enable(){var t=this;t.input.disabled=!1,t.control_input.disabled=!1,t.focus_node.tabIndex=t.tabIndex,t.isDisabled=!1,t.unlock()}destroy(){var t=this,e=t.revertSettings;t.trigger("destroy"),t.off(),t.wrapper.remove(),t.dropdown.remove(),t.input.innerHTML=e.innerHTML,t.input.tabIndex=e.tabIndex,Z(t.input,"tomselected","ts-hidden-accessible"),t._destroy(),delete t.input.tomselect}render(t,e){var n,i;const s=this;if("function"!=typeof this.settings.render[t])return null;if(!(i=s.settings.render[t].call(this,e,bt)))return null;if(i=B(i),"option"===t||"option_create"===t?e[s.settings.disabledField]?et(i,{"aria-disabled":"true"}):et(i,{"data-selectable":""}):"optgroup"===t&&(n=e.group[s.settings.optgroupValueField],et(i,{"data-group":n}),e.group[s.settings.disabledField]&&et(i,{"data-disabled":""})),"option"===t||"item"===t){const n=yt(e[s.settings.valueField]);et(i,{"data-value":n}),"item"===t?(W(i,s.settings.itemClass),et(i,{"data-ts-item":""})):(W(i,s.settings.optionClass),et(i,{role:"option",id:e.$id}),e.$div=i,s.options[n]=e)}return i}_render(t,e){const n=this.render(t,e);if(null==n)throw"HTMLElement expected";return n}clearCache(){j(this.options,(t=>{t.$div&&(t.$div.remove(),delete t.$div)}))}uncacheValue(t){const e=this.getOption(t);e&&e.remove()}canCreate(t){return this.settings.create&&t.length>0&&this.settings.createFilter.call(this,t)}hook(t,e,n){var i=this,s=i[e];i[e]=function(){var e,o;return"after"===t&&(e=s.apply(i,arguments)),o=n.apply(i,arguments),"instead"===t?o:("before"===t&&(e=s.apply(i,arguments)),e)}}}function At(){Tt(this.input,"change",(()=>{this.sync()}))}function It(){var t=this,e=t.onOptionSelect;t.settings.hideSelected=!1;var n=function(t){setTimeout((()=>{var e=t.querySelector("input");e instanceof HTMLInputElement&&(t.classList.contains("selected")?e.checked=!0:e.checked=!1)}),1)};t.hook("after","setupTemplates",(()=>{var e=t.settings.render.option;t.settings.render.option=(n,i)=>{var s=B(e.call(t,n,i)),o=document.createElement("input");o.addEventListener("click",(function(t){xt(t)})),o.type="checkbox";const r=vt(n[t.settings.valueField]);return r&&t.items.indexOf(r)>-1&&(o.checked=!0),s.prepend(o),s}})),t.on("item_remove",(e=>{var i=t.getOption(e);i&&(i.classList.remove("selected"),n(i))})),t.on("item_add",(e=>{var i=t.getOption(e);i&&n(i)})),t.hook("instead","onOptionSelect",((i,s)=>{if(s.classList.contains("selected"))return s.classList.remove("selected"),t.removeItem(s.dataset.value),t.refreshOptions(),void xt(i,!0);e.call(t,i,s),n(s)}))}function Nt(t){const e=this,n=Object.assign({className:"clear-button",title:"Clear All",html:t=>`⨯
`},t);e.on("initialize",(()=>{var t=B(n.html(n));t.addEventListener("click",(t=>{e.isDisabled||(e.clear(),"single"===e.settings.mode&&e.settings.allowEmptyOption&&e.addItem(""),t.preventDefault(),t.stopPropagation())})),e.control.appendChild(t)}))}function Ft(){var t=this;if(!$.fn.sortable)throw new Error('The "drag_drop" plugin requires jQuery UI "sortable".');if("multi"===t.settings.mode){var e=t.lock,n=t.unlock;t.hook("instead","lock",(()=>{var n=$(t.control).data("sortable");return n&&n.disable(),e.call(t)})),t.hook("instead","unlock",(()=>{var e=$(t.control).data("sortable");return e&&e.enable(),n.call(t)})),t.on("initialize",(()=>{var e=$(t.control).sortable({items:"[data-value]",forcePlaceholderSize:!0,disabled:t.isLocked,start:(t,n)=>{n.placeholder.css("width",n.helper.css("width")),e.css({overflow:"visible"})},stop:()=>{e.css({overflow:"hidden"});var n=[];e.children("[data-value]").each((function(){this.dataset.value&&n.push(this.dataset.value)})),t.setValue(n)}})}))}}function Pt(t){const e=this,n=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:t=>''},t);e.on("initialize",(()=>{var t=B(n.html(n)),i=t.querySelector("."+n.closeClass);i&&i.addEventListener("click",(t=>{xt(t,!0),e.close()})),e.dropdown.insertBefore(t,e.dropdown.firstChild)}))}function jt(){var t=this;t.hook("instead","setCaret",(e=>{"single"!==t.settings.mode&&t.control.contains(t.control_input)?(e=Math.max(0,Math.min(t.items.length,e)))==t.caretPos||t.isPending||t.controlChildren().forEach(((n,i)=>{i{if(!t.isFocused)return;const n=t.getLastActive(e);if(n){const i=tt(n);t.setCaret(e>0?i+1:i),t.setActiveItem(),Z(n,"last-active")}else t.setCaret(t.caretPos+e)}))}function qt(){const t=this;t.settings.shouldOpen=!0,t.hook("before","setup",(()=>{t.focus_node=t.control,W(t.control_input,"dropdown-input");const e=B('');e.append(t.control_input),t.dropdown.insertBefore(e,t.dropdown.firstChild);const n=B('
');n.placeholder=t.settings.placeholder||"",t.control.append(n)})),t.on("initialize",(()=>{t.control_input.addEventListener("keydown",(e=>{switch(e.keyCode){case at:return t.isOpen&&(xt(e,!0),t.close()),void t.clearActiveItems();case mt:t.focus_node.tabIndex=-1}return t.onKeyDown.call(t,e)})),t.on("blur",(()=>{t.focus_node.tabIndex=t.isDisabled?-1:t.tabIndex})),t.on("dropdown_open",(()=>{t.control_input.focus()}));const e=t.onBlur;t.hook("instead","onBlur",(n=>{if(!n||n.relatedTarget!=t.control_input)return e.call(t)})),Tt(t.control_input,"blur",(()=>t.onBlur())),t.hook("before","close",(()=>{t.isOpen&&t.focus_node.focus({preventScroll:!0})}))}))}function Ht(){var t=this;t.on("initialize",(()=>{var e=document.createElement("span"),n=t.control_input;e.style.cssText="position:absolute; top:-99999px; left:-99999px; width:auto; padding:0; white-space:pre; ",t.wrapper.appendChild(e);var i=["letterSpacing","fontSize","fontFamily","fontWeight","textTransform"];for(const t of i)e.style[t]=n.style[t];var s=()=>{e.textContent=n.value,n.style.width=e.clientWidth+"px"};s(),t.on("update item_add item_remove",s),Tt(n,"input",s),Tt(n,"keyup",s),Tt(n,"blur",s),Tt(n,"update",s)}))}function Vt(){var t=this,e=t.deleteSelection;this.hook("instead","deleteSelection",(n=>!!t.activeItems.length&&e.call(t,n)))}function $t(){this.hook("instead","setActiveItem",(()=>{})),this.hook("instead","selectAll",(()=>{}))}function Bt(){var t=this,e=t.onKeyDown;t.hook("instead","onKeyDown",(n=>{var i,s,o,r;if(!t.isOpen||n.keyCode!==lt&&n.keyCode!==ut)return e.call(t,n);t.ignoreHover=!0,r=G(t.activeOption,"[data-group]"),i=tt(t.activeOption,"[data-selectable]"),r&&(r=n.keyCode===lt?r.previousSibling:r.nextSibling)&&(s=(o=r.querySelectorAll("[data-selectable]"))[Math.min(o.length-1,i)])&&t.setActiveOption(s)}))}function Rt(t){const e=Object.assign({label:"×",title:"Remove",className:"remove",append:!0},t);var n=this;if(e.append){var i='
'+e.label+" ";n.hook("after","setupTemplates",(()=>{var t=n.settings.render.item;n.settings.render.item=(e,s)=>{var o=B(t.call(n,e,s)),r=B(i);return o.appendChild(r),Tt(r,"mousedown",(t=>{xt(t,!0)})),Tt(r,"click",(t=>{xt(t,!0),n.isLocked||n.shouldDelete([o],t)&&(n.removeItem(o),n.refreshOptions(!1),n.inputState())})),o}}))}}function zt(t){const e=this,n=Object.assign({text:t=>t[e.settings.labelField]},t);e.on("item_remove",(function(t){if(e.isFocused&&""===e.control_input.value.trim()){var i=e.options[t];i&&e.setTextboxValue(n.text.call(e,i))}}))}function Yt(){const t=this,e=t.canLoad,n=t.clearActiveOption,i=t.loadCallback;var s,o,r={},a=!1,l=[];if(t.settings.shouldLoadMore||(t.settings.shouldLoadMore=()=>{if(s.clientHeight/(s.scrollHeight-s.scrollTop)>.9)return!0;if(t.activeOption){var e=t.selectable();if(Array.from(e).indexOf(t.activeOption)>=e.length-2)return!0}return!1}),!t.settings.firstUrl)throw"virtual_scroll plugin requires a firstUrl() method";t.settings.sortField=[{field:"$order"},{field:"$score"}];const c=e=>!("number"==typeof t.settings.maxOptions&&s.children.length>=t.settings.maxOptions||!(e in r)||!r[e]),u=(e,n)=>t.items.indexOf(n)>=0||l.indexOf(n)>=0;t.setNextUrl=(t,e)=>{r[t]=e},t.getUrl=e=>{if(e in r){const t=r[e];return r[e]=!1,t}return r={},t.settings.firstUrl.call(t,e)},t.hook("instead","clearActiveOption",(()=>{if(!a)return n.call(t)})),t.hook("instead","canLoad",(n=>n in r?c(n):e.call(t,n))),t.hook("instead","loadCallback",((e,n)=>{if(a){if(o){const n=e[0];void 0!==n&&(o.dataset.value=n[t.settings.valueField])}}else t.clearOptions(u);i.call(t,e,n),a=!1})),t.hook("after","refreshOptions",(()=>{const e=t.lastValue;var n;c(e)?(n=t.render("loading_more",{query:e}))&&(n.setAttribute("data-selectable",""),o=n):e in r&&!s.querySelector(".no-results")&&(n=t.render("no_more_results",{query:e})),n&&(W(n,t.settings.optionClass),s.append(n))})),t.on("initialize",(()=>{l=Object.keys(t.options),s=t.dropdown_content,t.settings.render=Object.assign({},{loading_more:()=>'
Loading more results ...
',no_more_results:()=>'
No more results
'},t.settings.render),s.addEventListener("scroll",(()=>{t.settings.shouldLoadMore.call(t)&&c(t.lastValue)&&(a||(a=!0,t.load.call(t,t.lastValue)))}))}))}return Mt.define("change_listener",At),Mt.define("checkbox_options",It),Mt.define("clear_button",Nt),Mt.define("drag_drop",Ft),Mt.define("dropdown_header",Pt),Mt.define("caret_position",jt),Mt.define("dropdown_input",qt),Mt.define("input_autogrow",Ht),Mt.define("no_backspace_delete",Vt),Mt.define("no_active_items",$t),Mt.define("optgroup_columns",Bt),Mt.define("remove_button",Rt),Mt.define("restore_on_backspace",zt),Mt.define("virtual_scroll",Yt),Mt}()}},function(t){var e;e=4028,t(t.s=e)}]);
\ No newline at end of file
diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json
index ff114e5a..d1738bb4 100644
--- a/public/build/entrypoints.json
+++ b/public/build/entrypoints.json
@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.74179306.js",
- "/build/app.cde83469.js"
+ "/build/app.41121fe4.js"
],
"css": [
"/build/app.ee4f7e19.css"
@@ -72,7 +72,7 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
- "/build/app.cde83469.js": "sha384-829jYMX4FgmjcB0IV2HWYYOEviCyDD55lOX4fXXWUmUgDuDXXFZMyPFEEcK93dWR",
+ "/build/app.41121fe4.js": "sha384-4v3lVToJIcpMJ++4WaIZ5r9rXR5Vb4waSoHcEzx3vOfcLL+1+TN70di7XPzYSW7I",
"/build/app.ee4f7e19.css": "sha384-7E3ujVsOCkGatHu6khaWWqYhPkc85k22zuXZOwOVo1MlWcPSEBhS65Y0qbeWXdAp",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl",
diff --git a/public/build/manifest.json b/public/build/manifest.json
index 95008b7e..971413a9 100644
--- a/public/build/manifest.json
+++ b/public/build/manifest.json
@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.ee4f7e19.css",
- "build/app.js": "/build/app.cde83469.js",
+ "build/app.js": "/build/app.41121fe4.js",
"build/app-rtl.css": "/build/app-rtl.db588f2b.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
diff --git a/src/API/Authentication/ApiRequestMatcher.php b/src/API/Authentication/ApiRequestMatcher.php
index 02c39c2d..5febb943 100644
--- a/src/API/Authentication/ApiRequestMatcher.php
+++ b/src/API/Authentication/ApiRequestMatcher.php
@@ -26,6 +26,22 @@ final class ApiRequestMatcher implements RequestMatcherInterface
return false;
}
+ // ------------------------------------------------------------------------------------
+ // the next two checks are primarily here to make sure to return proper error messages
+
+ // let's use this firewall if a Bearer token is set in the header
+ // other cases like "bearer" are rejected earlier
+ if (($auth = $request->headers->get('Authorization')) !== null && str_starts_with($auth, 'Bearer ')) {
+ return true;
+ }
+
+ // let's use this firewall if the deprecated username & token combination is available
+ if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
+ $request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
+ return true;
+ }
+ // ------------------------------------------------------------------------------------
+
// checking for a previous session allows us to skip the API firewall and token access handler
// we simply re-use the existing session when doing API calls from the frontend.
// it is not necessary to check headers. if there is no valid session, we should always use this firewall
diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php
index c0c16900..a414b7d5 100644
--- a/src/API/ConfigurationController.php
+++ b/src/API/ConfigurationController.php
@@ -31,7 +31,7 @@ final class ConfigurationController extends BaseApiController
* Returns the timesheet configuration
*/
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
- #[Route(methods: ['GET'], path: '/config/timesheet')]
+ #[Route(path: '/config/timesheet', methods: ['GET'])]
public function timesheetConfigAction(SystemConfiguration $configuration): Response
{
$model = new TimesheetConfig();
@@ -46,4 +46,17 @@ final class ConfigurationController extends BaseApiController
return $this->viewHandler->handle($view);
}
+
+ /**
+ * Returns the configured color codes and names
+ */
+ #[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
+ #[Route(path: '/config/colors', methods: ['GET'])]
+ public function colorConfigAction(SystemConfiguration $configuration): Response
+ {
+ $view = new View($configuration->getThemeColors(), 200);
+ $view->getContext()->setGroups(['Default']);
+
+ return $this->viewHandler->handle($view);
+ }
}
diff --git a/src/API/TagController.php b/src/API/TagController.php
index 333ce49a..f3f713a2 100644
--- a/src/API/TagController.php
+++ b/src/API/TagController.php
@@ -39,7 +39,7 @@ final class TagController extends BaseApiController
}
/**
- * Fetch all existing tags
+ * Deprecated: Fetch tags by filter as string collection
*/
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
#[Route(methods: ['GET'], name: 'get_tags')]
@@ -56,6 +56,27 @@ final class TagController extends BaseApiController
return $this->viewHandler->handle($view);
}
+ /**
+ * Fetch tags by filter as entities
+ */
+ #[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
+ #[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
+ #[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
+ public function findTags(ParamFetcherInterface $paramFetcher): Response
+ {
+ $filter = $paramFetcher->get('name');
+
+ $data = [];
+ if (\is_string($filter)) {
+ $data = $this->repository->findAllTags($filter);
+ }
+
+ $view = new View($data, 200);
+ $view->getContext()->setGroups(self::GROUPS_COLLECTION);
+
+ return $this->viewHandler->handle($view);
+ }
+
/**
* Creates a new tag
*/
diff --git a/src/API/UserController.php b/src/API/UserController.php
index 10f45988..a9cf50f1 100644
--- a/src/API/UserController.php
+++ b/src/API/UserController.php
@@ -9,7 +9,6 @@
namespace App\API;
-use App\Configuration\SystemConfiguration;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Event\PrepareUserEvent;
@@ -18,6 +17,7 @@ use App\Form\API\UserApiEditForm;
use App\Repository\AccessTokenRepository;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
+use App\User\UserService;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -27,8 +27,6 @@ use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
-use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -45,8 +43,6 @@ final class UserController extends BaseApiController
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly UserRepository $repository,
- private readonly UserPasswordHasherInterface $passwordHasher,
- private readonly SystemConfiguration $configuration
) {
}
@@ -139,13 +135,9 @@ final class UserController extends BaseApiController
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
#[Route(methods: ['POST'], path: '', name: 'post_user')]
- public function postAction(Request $request): Response
+ public function postAction(Request $request, UserService $userService): Response
{
- $user = new User();
- $user->setEnabled(true);
- $user->setRoles([User::DEFAULT_ROLE]);
- $user->setTimezone($this->configuration->getUserDefaultTimezone());
- $user->setLanguage($this->configuration->getUserDefaultLanguage());
+ $user = $userService->createNewUser();
$form = $this->createForm(UserApiCreateForm::class, $user, [
'include_roles' => $this->isGranted('roles', $user),
@@ -156,24 +148,11 @@ final class UserController extends BaseApiController
$form->submit($request->request->all());
if ($form->isValid()) {
- $plainPassword = $user->getPlainPassword();
- if ($plainPassword === null) {
- throw new BadRequestHttpException('Password cannot be empty');
- }
- $password = $this->passwordHasher->hashPassword($user, $plainPassword);
- $user->setPassword($password);
-
- if ($user->getPlainApiToken() !== null) {
- $user->setApiToken($this->passwordHasher->hashPassword($user, $user->getPlainApiToken()));
- }
-
- $this->repository->saveUser($user);
+ $user = $userService->saveNewUser($user);
$view = new View($user, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
- $user->eraseCredentials();
-
return $this->viewHandler->handle($view);
}
diff --git a/src/Command/UserLoginLinkCommand.php b/src/Command/UserLoginLinkCommand.php
index e9b75456..25d3788a 100644
--- a/src/Command/UserLoginLinkCommand.php
+++ b/src/Command/UserLoginLinkCommand.php
@@ -21,10 +21,10 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
-#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
/**
* @CloudRequired
*/
+#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
final class UserLoginLinkCommand extends Command
{
public function __construct(
diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php
index ea16ae5c..24f9c804 100644
--- a/src/Configuration/SystemConfiguration.php
+++ b/src/Configuration/SystemConfiguration.php
@@ -9,6 +9,8 @@
namespace App\Configuration;
+use App\Constants;
+
final class SystemConfiguration
{
private bool $initialized = false;
@@ -479,6 +481,9 @@ final class SystemConfiguration
return (bool) $this->find('theme.avatar_url');
}
+ /**
+ * @internal will be made private soon after 2.18.0 - do ot access this method directly, but through getThemeColors()
+ */
public function getThemeColorChoices(): string
{
$config = $this->find('theme.color_choices');
@@ -489,6 +494,40 @@ final class SystemConfiguration
return 'Silver|#c0c0c0';
}
+ /**
+ * @return array
+ */
+ public function getThemeColors(): array
+ {
+ $config = explode(',', $this->getThemeColorChoices());
+
+ $colors = [];
+ foreach ($config as $item) {
+ if (empty($item)) {
+ continue;
+ }
+ $item = explode('|', $item);
+ $key = $item[0];
+ $value = $key;
+
+ if (\count($item) > 1) {
+ $value = $item[1];
+ }
+
+ if (empty($key)) {
+ $key = $value;
+ }
+
+ if ($value === Constants::DEFAULT_COLOR) {
+ continue;
+ }
+
+ $colors[$key] = $value;
+ }
+
+ return array_unique($colors);
+ }
+
// ========== Projects ==========
public function isProjectCopyTeamsOnCreate(): bool
diff --git a/src/Constants.php b/src/Constants.php
index 24368626..b3543a2c 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
- public const VERSION = '2.17.0';
+ public const VERSION = '2.18.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
- public const VERSION_ID = 21700;
+ public const VERSION_ID = 21800;
/**
* The software name
*/
diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php
index a8fd4ce0..40cd30a2 100644
--- a/src/Controller/ActivityController.php
+++ b/src/Controller/ActivityController.php
@@ -456,19 +456,22 @@ final class ActivityController extends AbstractController
}
/**
- * @param Activity $activity
* @return FormInterface
*/
private function createEditForm(Activity $activity): FormInterface
{
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
+ if ($activity->getProject()?->getId() !== null) {
+ $url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);
+ }
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
- if (null !== $activity->getProject()) {
- $currency = $activity->getProject()->getCustomer()->getCurrency();
- }
+ }
+
+ if (null !== $activity->getProject()) {
+ $currency = $activity->getProject()->getCustomer()->getCurrency();
}
return $this->createForm(ActivityEditForm::class, $activity, [
diff --git a/src/Controller/Auth/SamlController.php b/src/Controller/Auth/SamlController.php
index 8c51c222..e8c636f7 100644
--- a/src/Controller/Auth/SamlController.php
+++ b/src/Controller/Auth/SamlController.php
@@ -47,7 +47,7 @@ final class SamlController extends AbstractController
$session->remove($authErrorKey);
}
- if ($error) {
+ if ($error !== null) {
if (\is_object($error) && method_exists($error, 'getMessage')) {
$error = $error->getMessage();
}
@@ -60,13 +60,12 @@ final class SamlController extends AbstractController
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}
- $url = $this->authFactory->create()->login($redirectTarget);
+ $url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}
- // this line is not (yet) reached, as the previous call will exit
return $this->redirect($url);
}
diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php
index b6107091..a47e7146 100644
--- a/src/Entity/Tag.php
+++ b/src/Entity/Tag.php
@@ -9,6 +9,7 @@
namespace App\Entity;
+use App\Utils\Color;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -22,6 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]
+#[Serializer\VirtualProperty('ColorSafe', exp: 'object.getColorSafe()', options: [new Serializer\SerializedName('color-safe'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Default'])])]
class Tag
{
/**
@@ -95,4 +97,9 @@ class Tag
{
return $this->getName();
}
+
+ public function getColorSafe(): string
+ {
+ return $this->getColor() ?? (new Color())->getRandom($this->getName());
+ }
}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index d82b14c4..56f31251 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -298,6 +298,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
+ /**
+ * @deprecated since 2.15
+ */
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('apiToken')]
#[Serializer\Groups(['Default'])]
diff --git a/src/Form/DataTransformer/TagArrayToStringTransformer.php b/src/Form/DataTransformer/TagArrayToStringTransformer.php
index 77576fe0..633929a1 100644
--- a/src/Form/DataTransformer/TagArrayToStringTransformer.php
+++ b/src/Form/DataTransformer/TagArrayToStringTransformer.php
@@ -37,7 +37,22 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
return '';
}
- return implode(', ', $value);
+ if (!\is_array($value)) {
+ return '';
+ }
+
+ $result = [];
+ foreach ($value as $item) {
+ if ($item instanceof Tag) {
+ $result[] = $item->getName();
+ } elseif (\is_string($item)) {
+ $result[] = $item;
+ } else {
+ throw new TransformationFailedException('Tags must only contain a Tag or a string.');
+ }
+ }
+
+ return implode(',', $result);
}
/**
@@ -55,6 +70,7 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
if ('' === $value || null === $value) {
return [];
}
+
if (!\is_array($value)) {
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
} else {
@@ -68,18 +84,12 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
}
$tagName = trim($tagName);
- $tag = null;
- if (is_numeric($tagName)) {
- $tag = $this->tagRepository->find($tagName);
- }
-
- if ($tag === null) {
- $tag = $this->tagRepository->findTagByName($tagName);
- }
+ // do not check for numeric values as ID, this form type only submits tag names
+ $tag = $this->tagRepository->findTagByName($tagName);
// get the current tags and find the new ones that should be created
- if ($this->create && $tag === null) {
+ if ($tag === null && $this->create) {
$tag = new Tag();
$tag->setName(mb_substr($tagName, 0, 100));
$this->tagRepository->saveTag($tag);
diff --git a/src/Form/Type/ColorChoiceType.php b/src/Form/Type/ColorChoiceType.php
index 1d1f41a9..f35db414 100644
--- a/src/Form/Type/ColorChoiceType.php
+++ b/src/Form/Type/ColorChoiceType.php
@@ -10,7 +10,6 @@
namespace App\Form\Type;
use App\Configuration\SystemConfiguration;
-use App\Constants;
use App\Utils\Color;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataTransformerInterface;
@@ -22,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
{
- public function __construct(private SystemConfiguration $systemConfiguration)
+ public function __construct(private readonly SystemConfiguration $systemConfiguration)
{
}
@@ -36,7 +35,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
$options = [
'documentation' => [
'type' => 'string',
- 'description' => sprintf('The hexadecimal color code (default: %s)', Constants::DEFAULT_COLOR),
+ 'description' => 'The hexadecimal color code (default: auto-calculated by name)',
],
'label' => 'color',
'empty_data' => null,
@@ -50,7 +49,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
];
$choices = [];
- $colors = $this->convertStringToColorArray($this->systemConfiguration->getThemeColorChoices());
+ $colors = $this->systemConfiguration->getThemeColors();
foreach ($colors as $name => $color) {
$choices[$name] = $color;
@@ -69,44 +68,9 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
]);
}
- /**
- * @param string $config
- * @return array
- */
- private function convertStringToColorArray(string $config): array
+ public function transform(mixed $value): mixed
{
- $config = explode(',', $config);
-
- $colors = [];
- foreach ($config as $item) {
- if (empty($item)) {
- continue;
- }
- $item = explode('|', $item);
- $key = $item[0];
- $value = $key;
-
- if (\count($item) > 1) {
- $value = $item[1];
- }
-
- if (empty($key)) {
- $key = $value;
- }
-
- if ($value === Constants::DEFAULT_COLOR) {
- continue;
- }
-
- $colors[$key] = $value;
- }
-
- return array_unique($colors);
- }
-
- public function transform(mixed $data): mixed
- {
- return $data;
+ return $value;
}
public function reverseTransform(mixed $value): mixed
diff --git a/src/Form/Type/TagsInputType.php b/src/Form/Type/TagsInputType.php
index 2f7bfa40..4d4a0531 100644
--- a/src/Form/Type/TagsInputType.php
+++ b/src/Form/Type/TagsInputType.php
@@ -54,11 +54,12 @@ final class TagsInputType extends AbstractType
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['attr'] = array_merge($view->vars['attr'], [
- 'data-autocomplete-url' => $this->router->generate('get_tags'),
+ 'data-autocomplete-url' => $this->router->generate('get_tags_full'),
'data-minimum-character' => 3,
'class' => 'form-select',
'autocomplete' => 'off',
- 'data-form-widget' => 'autocomplete'
+ 'data-form-widget' => 'tags',
+ 'data-renderer' => 'color',
]);
if ($options['allow_create']) {
diff --git a/src/Form/Type/TagsSelectType.php b/src/Form/Type/TagsSelectType.php
index c4835e25..215905c6 100644
--- a/src/Form/Type/TagsSelectType.php
+++ b/src/Form/Type/TagsSelectType.php
@@ -12,7 +12,6 @@ namespace App\Form\Type;
use App\Entity\Tag;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\TagRepository;
-use App\Utils\Color;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -87,17 +86,11 @@ final class TagsSelectType extends AbstractType
return $tag->getId();
},
'choice_attr' => function (Tag $tag) {
- $color = $tag->getColor();
- if ($color === null) {
- $color = (new Color())->getRandom($tag->getName());
- }
-
- return ['data-color' => $color];
+ return ['data-color' => $tag->getColorSafe()];
},
'choice_label' => function (Tag $tag) {
return $tag->getName();
},
- 'attr' => ['data-renderer' => 'color'],
]);
$resolver->setDefault('query_builder', function (Options $options) {
@@ -119,6 +112,10 @@ final class TagsSelectType extends AbstractType
'data-create' => 'post_tag',
]);
}
+
+ $view->vars['attr'] = array_merge($view->vars['attr'], [
+ 'data-renderer' => 'color',
+ ]);
}
public function getParent(): string
diff --git a/src/Form/Type/TagsType.php b/src/Form/Type/TagsType.php
index 5619bcdd..2ca3e895 100644
--- a/src/Form/Type/TagsType.php
+++ b/src/Form/Type/TagsType.php
@@ -16,6 +16,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TagsType extends AbstractType
{
+ /**
+ * See KimaiFormSelect.js (maxOptions) as well.
+ */
+ public const MAX_AMOUNT_SELECT = 500;
+
private ?int $count = null;
public function __construct(
@@ -37,7 +42,7 @@ final class TagsType extends AbstractType
$this->count = $this->repository->count([]);
}
- if ($this->count > TagRepository::MAX_AMOUNT_SELECT) {
+ if ($this->count > self::MAX_AMOUNT_SELECT) {
return TagsInputType::class;
}
diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php
index 92f391c8..0aa55e58 100644
--- a/src/Repository/TagRepository.php
+++ b/src/Repository/TagRepository.php
@@ -19,15 +19,10 @@ use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
- * @extends \Doctrine\ORM\EntityRepository
+ * @extends EntityRepository
*/
class TagRepository extends EntityRepository
{
- /**
- * See KimaiFormSelect.js (maxOptions) as well.
- */
- public const MAX_AMOUNT_SELECT = 500;
-
public function saveTag(Tag $tag): void
{
$entityManager = $this->getEntityManager();
@@ -64,18 +59,11 @@ class TagRepository extends EntityRepository
return $this->findOneBy(['name' => $tagName, 'visible' => $visible]);
}
- /**
- * Find all visible tag names in alphabetical order.
- *
- * @return array
- */
- public function findAllTagNames(?string $filter = null): array
+ private function findAllTagsQuery(?string $filter = null): QueryBuilder
{
$qb = $this->createQueryBuilder('t');
- $qb
- ->select('t.name')
- ->addOrderBy('t.name', 'ASC');
+ $qb->addOrderBy('t.name', 'ASC');
$qb->andWhere($qb->expr()->eq('t.visible', ':visible'));
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
@@ -85,7 +73,27 @@ class TagRepository extends EntityRepository
$qb->setParameter('filter', '%' . $filter . '%');
}
- return array_column($qb->getQuery()->getScalarResult(), 'name');
+ return $qb;
+ }
+
+ /**
+ * Find all visible tag names in alphabetical order.
+ *
+ * @return array
+ */
+ public function findAllTags(?string $filter = null): array
+ {
+ return $this->findAllTagsQuery($filter)->getQuery()->getResult();
+ }
+
+ /**
+ * Find all visible tag names in alphabetical order.
+ *
+ * @return array
+ */
+ public function findAllTagNames(?string $filter = null): array
+ {
+ return array_column($this->findAllTagsQuery($filter)->select('t.name')->getQuery()->getScalarResult(), 'name');
}
/**
diff --git a/src/Voter/ApiVoter.php b/src/Voter/ApiVoter.php
index d78d8692..632b0016 100644
--- a/src/Voter/ApiVoter.php
+++ b/src/Voter/ApiVoter.php
@@ -48,6 +48,10 @@ final class ApiVoter extends Voter
return false;
}
- return $this->permissionManager->hasRolePermission($user, 'api_access');
+ if ($token->hasAttribute('api-token')) {
+ return $this->permissionManager->hasRolePermission($user, 'api_access');
+ }
+
+ return true;
}
}
diff --git a/templates/quick-entry/index.html.twig b/templates/quick-entry/index.html.twig
index 4f7128ef..8690bb85 100644
--- a/templates/quick-entry/index.html.twig
+++ b/templates/quick-entry/index.html.twig
@@ -6,15 +6,21 @@
{% embed '@theme/embeds/card.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="quick_entry_box"{% endblock %}
+ {% block box_class %}mt-2{% endblock %}
{% block box_before %}
{{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }}
{{ form_widget(form._token) }}
- {% endblock %}
- {% block box_after %}{{ form_end(form) }}{% endblock %}
- {% block box_title %}
{{ form_widget(form.date) }}
{{ form_errors(form) }}
{% endblock %}
+ {% block box_after %}
+
+
+ {{ icon('create', true) }}
+ {{ 'action.add'|trans }}
+
+ {{ form_end(form) }}
+ {% endblock %}
{# "table-responsive" does not work, because that would render dropdowns at the bottom behind the container #}
{% block box_body_class %}p-0 maybe-table-responsive{% endblock %}
{% block box_body %}
@@ -46,13 +52,6 @@
{% endblock %}
- {% block box_footer %}
-
-
- {{ icon('create', true) }}
- {{ 'action.add'|trans }}
-
- {% endblock %}
{% endembed %}
{% endblock %}
diff --git a/templates/widget/widget-paginatedworkingtimechart.html.twig b/templates/widget/widget-paginatedworkingtimechart.html.twig
index 106979c5..1ceaef6f 100644
--- a/templates/widget/widget-paginatedworkingtimechart.html.twig
+++ b/templates/widget/widget-paginatedworkingtimechart.html.twig
@@ -31,35 +31,35 @@
{% endblock %}
{% block box_footer %}
-
+
-
{{ data.day|duration }}
+ {{ data.day|duration }}
{{ 'stats.workingTimeToday'|trans({'%day%': 'now'|date_short}) }}
-
+
-
{{ data.week|duration }}
+ {{ data.week|duration }}
{{ 'stats.workingTimeWeek'|trans({'%week%': data.begin|date_format('W')}) }}
-
+
-
{{ data.month|duration }}
+ {{ data.month|duration }}
{{ 'stats.workingTimeMonth'|trans({'%month%': data.thisMonth|month_name, '%year%': data.thisMonth|date_format('Y')}) }}
{% if data.financial is not null %}
-
+
-
{{ data.financial|duration }}
+ {{ data.financial|duration }}
{{ 'stats.workingTimeFinancialYear'|trans }}
{% else %}
-
+
-
{{ data.year|duration }}
+ {{ data.year|duration }}
{{ 'stats.workingTimeYear'|trans({'%year%': data.thisMonth|date_format('Y')}) }}
diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php
index 304e90c0..fcf78879 100644
--- a/tests/API/APIControllerBaseTest.php
+++ b/tests/API/APIControllerBaseTest.php
@@ -287,6 +287,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'id' => 'int',
'name' => 'string',
'color' => '@string',
+ 'color-safe' => 'string',
'visible' => 'bool',
];
diff --git a/tests/API/ApiDocControllerTest.php b/tests/API/ApiDocControllerTest.php
index 6bcbfffb..6c929d17 100644
--- a/tests/API/ApiDocControllerTest.php
+++ b/tests/API/ApiDocControllerTest.php
@@ -64,6 +64,7 @@ class ApiDocControllerTest extends ControllerBaseTest
'/api/activities/{id}/rates',
'/api/activities/{id}/rates/{rateId}',
'/api/config/timesheet',
+ '/api/config/colors',
'/api/customers',
'/api/customers/{id}',
'/api/customers/{id}/meta',
@@ -78,6 +79,7 @@ class ApiDocControllerTest extends ControllerBaseTest
'/api/version',
'/api/plugins',
'/api/tags',
+ '/api/tags/find',
'/api/tags/{id}',
'/api/teams',
'/api/teams/{id}',
diff --git a/tests/API/ConfigurationControllerTest.php b/tests/API/ConfigurationControllerTest.php
index eced66ea..f852a654 100644
--- a/tests/API/ConfigurationControllerTest.php
+++ b/tests/API/ConfigurationControllerTest.php
@@ -25,7 +25,9 @@ class ConfigurationControllerTest extends APIControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/config/timesheet', 'GET');
- $result = json_decode($client->getResponse()->getContent(), true);
+ $content = $client->getResponse()->getContent();
+ $this->assertIsString($content);
+ $result = json_decode($content, true);
$this->assertIsArray($result);
$this->assertNotEmpty($result);
@@ -37,4 +39,53 @@ class ConfigurationControllerTest extends APIControllerBaseTest
$this->assertEquals($expectedKeys, $actual, 'Config structure does not match');
}
+
+ public function testIsColorsSecure(): void
+ {
+ $this->assertUrlIsSecured('/api/config/colors');
+ }
+
+ public function testGetColors(): void
+ {
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
+ $this->assertAccessIsGranted($client, '/api/config/colors', 'GET');
+ $content = $client->getResponse()->getContent();
+ $this->assertIsString($content);
+ $actual = json_decode($content, true);
+
+ $this->assertIsArray($actual);
+ $this->assertNotEmpty($actual);
+ $expected = [
+ 'Silver' => '#c0c0c0',
+ 'Gray' => '#808080',
+ 'Black' => '#000000',
+ 'Maroon' => '#800000',
+ 'Brown' => '#a52a2a',
+ 'Red' => '#ff0000',
+ 'Orange' => '#ffa500',
+ 'Gold' => '#ffd700',
+ 'Yellow' => '#ffff00',
+ 'Peach' => '#ffdab9',
+ 'Khaki' => '#f0e68c',
+ 'Olive' => '#808000',
+ 'Lime' => '#00ff00',
+ 'Jelly' => '#9acd32',
+ 'Green' => '#008000',
+ 'Teal' => '#008080',
+ 'Aqua' => '#00ffff',
+ 'LightBlue' => '#add8e6',
+ 'DeepSky' => '#00bfff',
+ 'Dodger' => '#1e90ff',
+ 'Blue' => '#0000ff',
+ 'Navy' => '#000080',
+ 'Purple' => '#800080',
+ 'Fuchsia' => '#ff00ff',
+ 'Violet' => '#ee82ee',
+ 'Rose' => '#ffe4e1',
+ 'Lavender' => '#E6E6FA',
+ ];
+ $this->assertCount(\count($expected), $actual);
+
+ $this->assertEquals($expected, $actual, 'Color structure does not match');
+ }
}
diff --git a/tests/API/TagControllerTest.php b/tests/API/TagControllerTest.php
index 1ff36f79..7bc00e92 100644
--- a/tests/API/TagControllerTest.php
+++ b/tests/API/TagControllerTest.php
@@ -54,6 +54,21 @@ class TagControllerTest extends APIControllerBaseTest
$this->assertEquals('Test', $result[9]);
}
+ public function testFindCollection(): void
+ {
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
+ $this->importTagFixtures();
+ $this->assertAccessIsGranted($client, '/api/tags/find', 'GET', ['name' => '2018']);
+ $content = $client->getResponse()->getContent();
+ $this->assertNotFalse($content);
+ $result = json_decode($content, true);
+
+ $this->assertIsArray($result);
+ $this->assertNotEmpty($result);
+ $this->assertEquals(3, \count($result));
+ self::assertApiResponseTypeStructure('TagEntity', $result[0]);
+ }
+
public function testEmptyCollection(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
diff --git a/tests/Configuration/SystemConfigurationTest.php b/tests/Configuration/SystemConfigurationTest.php
index f2460ef6..9de6fae0 100644
--- a/tests/Configuration/SystemConfigurationTest.php
+++ b/tests/Configuration/SystemConfigurationTest.php
@@ -137,6 +137,7 @@ class SystemConfigurationTest extends TestCase
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
$this->assertEquals(99, $sut->find('timesheet.active_entries.hard_limit'));
$this->assertEquals('Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,#ffffff,,|#000000', $sut->getThemeColorChoices());
+ $this->assertEquals(['Maroon' => '#800000', 'Brown' => '#a52a2a', 'Red' => '#ff0000', 'Orange' => '#ffa500', '#ffffff' => '#ffffff', '#000000' => '#000000'], $sut->getThemeColors());
}
public function testDefaultWithLoader(): void
@@ -160,6 +161,7 @@ class SystemConfigurationTest extends TestCase
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
$this->assertTrue($sut->isSamlActive());
$this->assertEquals('Silver|#c0c0c0', $sut->getThemeColorChoices());
+ $this->assertEquals(['Silver' => '#c0c0c0'], $sut->getThemeColors());
$this->assertEquals('2020-03-27', $sut->getFinancialYearStart());
}
diff --git a/tests/Controller/TimesheetControllerTest.php b/tests/Controller/TimesheetControllerTest.php
index ccda19a2..461abd06 100644
--- a/tests/Controller/TimesheetControllerTest.php
+++ b/tests/Controller/TimesheetControllerTest.php
@@ -14,7 +14,7 @@ use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
-use App\Repository\TagRepository;
+use App\Form\Type\TagsType;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\TagFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
@@ -51,7 +51,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$start = new \DateTime('first day of this month');
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$fixture->addTagNameToCreate('bar');
$this->importFixture($fixture);
@@ -531,7 +531,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$fixture->addTagNameToCreate('two');
$this->importFixture($fixture);
@@ -636,7 +636,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$id = $timesheets[0]->getId();
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$this->importFixture($fixture);
$this->request($client, '/timesheet/' . $id . '/edit');
@@ -717,7 +717,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->importFixture($fixture);
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$this->importFixture($fixture);
$this->assertAccessIsGranted($client, '/timesheet/');
diff --git a/tests/Controller/TimesheetTeamControllerTest.php b/tests/Controller/TimesheetTeamControllerTest.php
index 01844d72..5d0bba1e 100644
--- a/tests/Controller/TimesheetTeamControllerTest.php
+++ b/tests/Controller/TimesheetTeamControllerTest.php
@@ -12,7 +12,7 @@ namespace App\Tests\Controller;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
-use App\Repository\TagRepository;
+use App\Form\Type\TagsType;
use App\Tests\DataFixtures\TagFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Timesheet\DateTimeFactory;
@@ -194,7 +194,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$this->importFixture($fixture);
$this->request($client, '/team/timesheet/create_mu');
@@ -272,7 +272,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$id = $timesheets[0]->getId();
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$this->importFixture($fixture);
$this->request($client, '/team/timesheet/' . $id . '/edit');
@@ -355,7 +355,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->importFixture($fixture);
$fixture = new TagFixtures();
- $fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
+ $fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
$this->importFixture($fixture);
$this->assertAccessIsGranted($client, '/team/timesheet/');
diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php
index 9cd3c24a..1f139539 100644
--- a/tests/Entity/TagTest.php
+++ b/tests/Entity/TagTest.php
@@ -35,8 +35,11 @@ class TagTest extends TestCase
$sut->setName(null);
$this->assertNull($sut->getName());
+ $this->assertNull($sut->getColor());
+ $this->assertIsString($sut->getColorSafe());
$sut->setColor('#fffccc');
$this->assertEquals('#fffccc', $sut->getColor());
+ $this->assertEquals('#fffccc', $sut->getColorSafe());
}
}
diff --git a/tests/Form/DataTransformer/TagArrayToStringTransformerTest.php b/tests/Form/DataTransformer/TagArrayToStringTransformerTest.php
index 4a3558ab..53f08dc2 100644
--- a/tests/Form/DataTransformer/TagArrayToStringTransformerTest.php
+++ b/tests/Form/DataTransformer/TagArrayToStringTransformerTest.php
@@ -13,6 +13,7 @@ use App\Entity\Tag;
use App\Form\DataTransformer\TagArrayToStringTransformer;
use App\Repository\TagRepository;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* @covers \App\Form\DataTransformer\TagArrayToStringTransformer
@@ -23,6 +24,7 @@ class TagArrayToStringTransformerTest extends TestCase
{
$results = [
(new Tag())->setName('foo'),
+ 'test',
(new Tag())->setName('bar'),
];
@@ -32,10 +34,26 @@ class TagArrayToStringTransformerTest extends TestCase
$this->assertEquals('', $sut->transform([]));
$this->assertEquals('', $sut->transform(null));
+ $this->assertEquals('', $sut->transform(new \stdClass())); // @phpstan-ignore-line
- $actual = $sut->transform($results);
+ $actual = $sut->transform($results); // @phpstan-ignore-line
- $this->assertEquals('foo, bar', $actual);
+ $this->assertEquals('foo,test,bar', $actual);
+ }
+
+ public function testTransformFails(): void
+ {
+ $this->expectException(TransformationFailedException::class);
+ $results = [
+ (new Tag())->setName('foo'),
+ new \stdClass(),
+ (new Tag())->setName('bar'),
+ ];
+
+ $repository = $this->getMockBuilder(TagRepository::class)->disableOriginalConstructor()->getMock();
+
+ $sut = new TagArrayToStringTransformer($repository, true);
+ $sut->transform($results); // @phpstan-ignore-line
}
public function testReverseTransform(): void
@@ -55,7 +73,7 @@ class TagArrayToStringTransformerTest extends TestCase
$this->assertEquals([], $sut->reverseTransform(''));
$this->assertEquals([], $sut->reverseTransform(null));
- $actual = $sut->reverseTransform('foo, bar, hello');
+ $actual = $sut->reverseTransform('foo, bar , hello ');
$this->assertEquals(array_merge($results, [(new Tag())->setName('hello')]), $actual);
}
diff --git a/tests/phpstan.neon b/tests/phpstan.neon
index 34f0630a..3737a9b1 100644
--- a/tests/phpstan.neon
+++ b/tests/phpstan.neon
@@ -243,11 +243,6 @@ parameters:
count: 1
path: API/ApiDocControllerTest.php
- -
- message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
- count: 1
- path: API/ConfigurationControllerTest.php
-
-
message: "#^Cannot call method addSubscriber\\(\\) on object\\|null\\.$#"
count: 1
@@ -1473,11 +1468,6 @@ parameters:
count: 1
path: DataFixtures/TestFixture.php
- -
- message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
- count: 1
- path: DataFixtures/TimesheetFixtures.php
-
-
message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) has parameter \\$tagArray with no value type specified in iterable type array\\.$#"
count: 1
diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf
index 4d8c1d7c..7392ba09 100644
--- a/translations/messages.de.xlf
+++ b/translations/messages.de.xlf
@@ -1756,6 +1756,10 @@
absence_comment_mandatory
Abwesenheit: Kommentar ist Pflichtfeld
+
+ booking_allow_only_work_days
+ Erlaube Zeiteinträge nur an Tagen, für die im Arbeitsvertrag Sollstunden hinterlegt sind
+
Expiry date
Ablaufdatum
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index cf0ae655..db6f23f9 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -1756,6 +1756,10 @@
absence_comment_mandatory
Absence: Comment is a mandatory field
+
+ booking_allow_only_work_days
+ Allow time entries only for days for which expected hours are defined in the employment contract
+
Expiry date
Expiry date
diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf
index 04ff9449..38efd822 100644
--- a/translations/validators.de.xlf
+++ b/translations/validators.de.xlf
@@ -150,6 +150,10 @@
The period of absence must not extend beyond the turn of the year.
Der Zeitraum der Abwesenheit darf nicht über einen Jahreswechsel hinweg gehen.
+
+ The CSRF token is invalid. Please try to resubmit the form.
+ Bitte senden Sie das Formular erneut ab. Sollte das Problem weiterhin bestehen, laden Sie die bitte die Seite neu.
+