diff --git a/api/js/etemplate/Et2Date/DateStyles.ts b/api/js/etemplate/Et2Date/DateStyles.ts index 2e656c4d25..ee55eed78c 100644 --- a/api/js/etemplate/Et2Date/DateStyles.ts +++ b/api/js/etemplate/Et2Date/DateStyles.ts @@ -1,3 +1,7 @@ +/** + * Sharable date styles constant + */ + import {css} from "@lion/core"; export const dateStyles = css` diff --git a/api/js/etemplate/Et2Select/Et2Select.ts b/api/js/etemplate/Et2Select/Et2Select.ts new file mode 100644 index 0000000000..90cdd9ce82 --- /dev/null +++ b/api/js/etemplate/Et2Select/Et2Select.ts @@ -0,0 +1,447 @@ +/** + * EGroupware eTemplate2 - Description WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + + +import {LionSelect} from "@lion/select"; +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import {et2_readAttrWithDefault} from "../et2_core_xml"; +import {css, html, repeat} from "@lion/core"; +import arrow_down from "../../../../pixelegg/images/arrow_down.svg"; + +export interface SelectOption +{ + value : String; + label : String; + // Hover help text + title? : String; +}; + + +export class Et2Select extends Et2InputWidget(LionSelect) +{ + + protected _value : string = ""; + protected _options : SelectOption[] = []; + + static get styles() + { + return [ + ...super.styles, + css` + :host { + display: inline-block; + } + select { + color: var(--input-text-color, #26537c); + border-radius: 3px; + flex: 1 0 auto; + padding-top: 4px; + padding-bottom: 4px; + padding-right: 20px; + border-width: 1px; + border-style: solid; + border-color: #e6e6e6; + -webkit-appearance: none; + -moz-appearance: none; + margin: 0; + background: #fff no-repeat center right; + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8P3htbC1zdHlsZXNoZWV0IHR5cGU9InRleHQvY3NzIiBocmVmPSIuLi9sZXNzL3N2Zy5jc3MiID8+CjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0icGl4ZWxlZ2dfYXJyb3dfZG93biIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHdpZHRoPSIzMnB4IiBoZWlnaHQ9IjMycHgiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMzIgMzIiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iIzY5Njk2OSIgcG9pbnRzPSIxLjc1LDUgMS43NDksNSAxNiwyOC40OSAzMC4yNTEsNSAiLz4NCjwvc3ZnPg0K'); + background-size: 8px auto; + background-position-x: calc(100% - 8px); + text-indent: 5px; + } + + select:hover { + box-shadow: 1px 1px 1px rgb(0 0 0 / 60%); + }` + ]; + } + + static get properties() + { + return { + ...super.properties, + /** + * Textual label for first row, eg: 'All' or 'None'. It's value will be '' + */ + empty_label: String, + + select_options: Object + } + } + + constructor() + { + super(); + + // Initialize properties + this.value = ""; + } + + + /** + * Set the ID of the widget + * + * Overridden from parent to update select options + * + * @param {string} value + */ + set id(value) + { + let oldValue = this.id; + super.id = value; + if(value !== oldValue) + { + this.set_select_options(find_select_options(this)); + } + } + + /** + * Get the ID of the widget + * Overridden from parent because we overrode setter + * + * @returns {string} + */ + get id() + { + return this._widget_id; + } + + set_value(value) + { + this.value = value; + } + + get value() + { + return this._value; + } + + set value(_value : string) + { + let oldValue = this.value; + + this._value = _value; + this.requestUpdate('value', oldValue); + } + + /** + * Set the select options + * + * @param {SelectOption[]} new_options + */ + set_select_options(new_options : SelectOption[]) + { + if(!Array.isArray(new_options)) + { + let fixed_options = []; + for(let key in new_options) + { + fixed_options.push({value: key, label: new_options[key]}); + } + this._options = fixed_options; + return; + } + this._options = new_options; + } + + get_select_options() + { + return this._options; + } + + get slots() + { + return { + ...super.slots, + input: () => + { + return document.createElement("select"); + } + } + } + + /** + * @return {TemplateResult} + * @protected + */ + // eslint-disable-next-line class-methods-use-this + _inputGroupInputTemplate() + { + return html` +
+ +
+ `; + } + + _emptyLabelTemplate() + { + if(!this.empty_label) + { + return html``; + } + return html` + `; + } + + _optionTemplate(option : SelectOption) + { + return html` + `; + } + + loadFromXML(_node : Element) + { + // Read the option-tags + let options = _node.querySelectorAll("option"); + let new_options = this._options; + for(var i = 0; i < options.length; i++) + { + new_options.push({ + value: et2_readAttrWithDefault(options[i], "value", options[i].textContent), + // allow options to contain multiple translated sub-strings eg: {Firstname}.{Lastname} + label: options[i].textContent.replace(/{([^}]+)}/g, function(str, p1) + { + return this.egw().lang(p1); + }), + title: et2_readAttrWithDefault(options[i], "title", "") + }); + } + + this.set_select_options(new_options); + } + + transformAttributes(_attrs) + { + super.transformAttributes(_attrs); + + // If select_options are already known, skip the rest + if(this._options && this._options.length > 0 || + _attrs.select_options && Object.keys(_attrs.select_options).length > 0 || + // Allow children to skip select_options - check to make sure default got set to something (should be {}) + typeof _attrs.select_options == 'undefined' || _attrs.select_options === null + ) + { + // do not return inside nextmatch, as get_rows data might have changed select_options + // for performance reasons we only do it for first row, which should have id "0[...]" + if(this.getParent() && this.getParent().getType() != 'rowWidget' || !_attrs.id || _attrs.id[0] != '0') + { + return; + } + } + + let sel_options = find_select_options(this, _attrs['select_options'], _attrs); + if(sel_options.length > 0) + { + this.set_select_options(sel_options); + } + } + +} + + +/** + * Find the select options for a widget, out of the many places they could be. + * @param {Et2Widget} widget to check for. Should be some sort of select widget. + * @param {object} attr_options Select options in attributes array + * @param {object} attrs Widget attributes + * @return {object} Select options, or empty object + */ +export function find_select_options(widget, attr_options?, attrs?) +{ + let name_parts = widget.id.replace(/[/g, '[').replace(/]|]/g, '').split('['); + + let type_options : SelectOption[] = []; + let content_options : SelectOption[] = []; + + // First check type, there may be static options. + // TODO + /* + var type = widget._type; + var type_function = type.replace('select-', '').replace('taglist-', '').replace('_ro', '') + '_options'; + if(typeof this[type_function] == 'function') + { + var old_type = widget._type; + widget._type = type.replace('taglist-', 'select-'); + if(typeof attrs.other == 'string') + { + attrs.other = attrs.other.split(','); + } + // Copy, to avoid accidental modification + // + // type options used to use jQuery.extend deep copy to get a clone object of options + // but as jQuery.extend deep copy is very expensive operation in MSIE (in this case almost 400ms) + // we use JSON parsing instead to copy the options object + type_options = this[type_function].call(this, widget, attrs); + try + { + type_options = JSON.parse(JSON.stringify(type_options)); + } + catch(e) + { + egw.debug(e); + } + + widget._type = old_type; + } + + */ + + // Try to find the options inside the "sel-options" + if(widget.getArrayMgr("sel_options")) + { + // Try first according to ID + content_options = widget.getArrayMgr("sel_options").getEntry(widget.id); + // ID can get set to an array with 0 => ' ' - not useful + if(content_options && (content_options.length == 1 && typeof content_options[0] == 'string' && content_options[0].trim() == '' || + // eg. autorepeated id "cat[3]" would pick array element 3 from cat + typeof content_options.value != 'undefined' && typeof content_options.label != 'undefined' && widget.id.match(/\[\d+\]$/))) + { + content_options = null; + } + // We could wind up too far inside options if label,title are defined + if(content_options && !isNaN(name_parts[name_parts.length - 1]) && content_options.label && content_options.title) + { + name_parts.pop(); + content_options = widget.getArrayMgr("sel_options").getEntry(name_parts.join('[')); + delete content_options["$row"]; + } + + // Select options tend to be defined once, at the top level, so try that + if(!content_options || content_options.length == 0) + { + content_options = widget.getArrayMgr("sel_options").getRoot().getEntry(name_parts[name_parts.length - 1]); + } + + // Try in correct namespace (inside a grid or something) + if(!content_options || content_options.length == 0) + { + content_options = widget.getArrayMgr("sel_options").getEntry(name_parts[name_parts.length - 1]); + } + + // Try name like widget[$row] + if(name_parts.length > 1 && (!content_options || content_options.length == 0)) + { + let pop_that = JSON.parse(JSON.stringify(name_parts)); + while(pop_that.length > 1 && (!content_options || content_options.length == 0)) + { + var last = pop_that.pop(); + content_options = widget.getArrayMgr('sel_options').getEntry(pop_that.join('[')); + + // Double check, might have found a normal parent namespace ( eg subgrid in subgrid[selectbox] ) + // with an empty entry for the selectbox. If there were valid options here, + // we would have found them already, and keeping this would result in the ID as an option + if(content_options && !Array.isArray(content_options) && typeof content_options[last] != 'undefined' && content_options[last]) + { + content_options = content_options[last]; + } + else if(content_options) + { + // Check for real values + for(var key in content_options) + { + if(!(isNaN(key) && typeof content_options[key] === 'string' || + !isNaN(key) && typeof content_options[key] === 'object' && typeof content_options[key]['value'] !== 'undefined')) + { + // Found a parent of some other namespace + content_options = undefined; + break; + } + } + } + } + } + + // Maybe in a row, and options got stuck in ${row} instead of top level + // not sure this code is still needed, as server-side no longer creates ${row} or {$row} for select-options + var row_stuck = ['${row}', '{$row}']; + for(let i = 0; i < row_stuck.length && (!content_options || content_options.length == 0); i++) + { + // perspectiveData.row in nm, data["${row}"] in an auto-repeat grid + if(widget.getArrayMgr("sel_options").perspectiveData.row || widget.getArrayMgr("sel_options").data[row_stuck[i]]) + { + var row_id = widget.id.replace(/[0-9]+/, row_stuck[i]); + content_options = widget.getArrayMgr("sel_options").getEntry(row_id); + if(!content_options || content_options.length == 0) + { + content_options = widget.getArrayMgr("sel_options").getEntry(row_stuck[i] + '[' + widget.id + ']'); + } + } + } + if(attr_options && Object.keys(attr_options).length > 0 && content_options) + { + //content_options = jQuery.extend(true, {}, attr_options, content_options); + content_options = [...attr_options, ...content_options]; + } + } + + // Check whether the options entry was found, if not read it from the + // content array. + if(content_options && content_options.length > 0 && widget.getArrayMgr('content') != null) + { + if(content_options) + { + attr_options = content_options; + } + var content_mgr = widget.getArrayMgr('content'); + if(content_mgr) + { + // If that didn't work, check according to ID + if(!content_options) + { + content_options = content_mgr.getEntry("options-" + widget.id); + } + // Again, try last name part at top level + if(!content_options) + { + content_options = content_mgr.getRoot().getEntry("options-" + name_parts[name_parts.length - 1]); + } + } + } + + // Default to an empty object + if(content_options == null) + { + content_options = []; + } + + // Include type options, preferring any content options + if(type_options.length || Object.keys(type_options).length > 0) + { + for(let i in content_options) + { + var value = typeof content_options[i] == 'object' && typeof content_options[i].value !== 'undefined' ? content_options[i].value : i; + var added = false; + + // Override any existing + for(var j in type_options) + { + if('' + type_options[j].value === '' + value) + { + added = true; + type_options[j] = content_options[i]; + break; + } + } + if(!added) + { + type_options.splice(i, 0, content_options[i]); + } + } + content_options = type_options; + } + return content_options; +} + +// @ts-ignore TypeScript is not recognizing that this widget is a LitElement +customElements.define("et2-select", Et2Select); \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/Et2SelectReadonly.ts b/api/js/etemplate/Et2Select/Et2SelectReadonly.ts new file mode 100644 index 0000000000..6c0e9c29aa --- /dev/null +++ b/api/js/etemplate/Et2Select/Et2SelectReadonly.ts @@ -0,0 +1,115 @@ +/** + * EGroupware eTemplate2 - Readonly select WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + + +import {html, LitElement} from "@lion/core"; +import {et2_IDetachedDOM} from "../et2_core_interfaces"; +import {Et2Widget} from "../Et2Widget/Et2Widget"; +import {find_select_options, SelectOption} from "./Et2Select"; + +/** + * This is a stripped-down read-only widget used in nextmatch + * (and other read-only usages) + */ +export class Et2SelectReadonly extends Et2Widget(LitElement) implements et2_IDetachedDOM +{ + protected value : any; + protected _options : SelectOption[] = []; + + static get styles() + { + return [ + ...super.styles + ]; + } + + static get properties() + { + return { + ...super.properties, + value: String, + select_options: Object + } + } + + constructor() + { + super(); + } + + transformAttributes(_attrs) + { + super.transformAttributes(_attrs); + + let sel_options = find_select_options(this, _attrs['select_options'], _attrs); + if(sel_options.length > 0) + { + this.set_select_options(sel_options); + } + } + + set_value(value) + { + this.value = value; + } + + /** + * Set the select options + * + * @param {SelectOption[]} new_options + */ + set_select_options(new_options : SelectOption[] | { [key : string] : string }) + { + if(!Array.isArray(new_options)) + { + let fixed_options : SelectOption[] = []; + for(let key in new_options) + { + fixed_options.push({value: key, label: new_options[key]}); + } + console.warn(this.id + " passed a key => value map instead of array"); + this._options = fixed_options; + return; + } + this._options = new_options; + } + + render() + { + let current = this._options.find(option => option.value == this.value); + let label = current ? current.label : ""; + + return html` + ${label} + `; + } + + getDetachedAttributes(attrs) + { + attrs.push("id", "value", "class"); + } + + getDetachedNodes() : HTMLElement[] + { + return [this]; + } + + setDetachedAttributes(_nodes : HTMLElement[], _values : object, _data? : any) : void + { + // Do nothing, since we can't actually stop being a DOM node... + } + + loadFromXML() + { + // nope + } +} + +// @ts-ignore TypeScript is not recognizing that this widget is a LitElement +customElements.define("et2-select_ro", Et2SelectReadonly); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 73969b1a95..1913817cd7 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -30,6 +30,8 @@ import './Et2Date/Et2DateReadonly' import './Et2Date/Et2DateTime'; import './Et2Date/Et2DateTimeReadonly'; import './Et2Description/Et2Description'; +import './Et2Select/Et2Select'; +import './Et2Select/Et2SelectReadonly'; import './Et2Textarea/Et2Textarea'; import './Et2Textbox/Et2Textbox'; import './Et2Colorpicker/Et2Colorpicker'; diff --git a/package.json b/package.json index 9185a375c6..249b6fdaa6 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@lion/input": "^0.15.4", "@lion/input-date": "^0.12.6", "@lion/input-datepicker": "^0.23.6", + "@lion/select": "^0.14.7", "@lion/textarea": "^0.13.4", "jquery-ui-timepicker-addon": "^1.6.3", "sortablejs": "^1.14.0"