egroupware/api/js/etemplate/Et2Select/Et2WidgetWithSelectMixin.ts
ralf a9e180a9fb fix mixup of this.value, Lion this.modelValue and old get/set_value
causing eg. numeric values not to be cast to string and therefore not selecting their option
2022-06-02 16:12:38 +02:00

260 lines
7.9 KiB
TypeScript

/**
* EGroupware eTemplate2 - WidgetWithSelectMixin
*
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package api
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {StaticOptions} from "./StaticOptions";
import {dedupeMixin, html, PropertyValues, render, repeat, TemplateResult} from "@lion/core";
import {et2_readAttrWithDefault} from "../et2_core_xml";
import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions";
/**
* Base class for things that do selectbox type behaviour, to avoid putting too much or copying into read-only
* selectboxes, also for common handling of properties for more special selectboxes.
*
* As with most other widgets that extend Lion components, do not override render().
* To extend this mixin, override:
* - _optionTargetNode(): Return the HTMLElement where the "options" go.
* - _optionTemplate(option:SelectOption): Renders the option. To use a special widget, use its tag in render.
* Select option:
* ```js
* return html`
* <option value="${option.value}" title="${option.title}" ?selected=${option.value == this.modelValue}>
* ${option.label}
* </option>`;
* ```
*
*
* or pass it off to a different WebComponent:
*
* ```js
* _optionTemplate(option:SelectOption) : TemplateResult
* {
* return html`
* <special-option-tag .value=${option}></special-option-tag>`;
* }
* ```
*
* Optionally, you can override:
* - _emptyLabelTemplate(): How to render the empty label
* - slots(): Most Lion components have an input slot where the <input> tag is created.
* You can specify something else, or return {} to do your own thing. This is a little more complicated. You should
* also override _inputGroupInputTemplate() to do what you normally would in render().
*
*
* Technical note:
* LionSelect (and any other LionField) use slots to wrap a real DOM node. ET2 doesn't expect this,
* so we have to create the input node (via slots()) and respect that it is _external_ to the Web Component.
* This complicates things like adding the options, since we can't just override _inputGroupInputTemplate()
* and include them when rendering - the parent expects to find the <select> added via a slot, render() would
* put it inside the shadowDOM. That's fine, but then it doesn't get created until render(), and the parent
* (LionField) can't find it when it looks for it before then.
*
*/
export const Et2widgetWithSelectMixin = dedupeMixin((superclass) =>
{
class Et2WidgetWithSelect extends Et2InputWidget(superclass)
{
static get properties()
{
return {
...super.properties,
/**
* Textual label for first row, eg: 'All' or 'None'. It's value will be ''
*/
empty_label: String,
/**
* Select box options
*
* Will be found automatically based on ID and type, or can be set explicitly in the template using
* <option/> children, or using widget.select_options = SelectOption[]
*/
select_options: {type: Object},
}
}
constructor()
{
super();
this.__select_options = <StaticOptions[]>[];
}
/** @param {import('@lion/core').PropertyValues } changedProperties */
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
// If the ID changed (or was just set) find the select options
if(changedProperties.has("id"))
{
const options = find_select_options(this);
if (options.length) this.select_options = options;
}
// Add in actual option tags to the DOM based on the new select_options
if(changedProperties.has('select_options'))
{
// Add in options as children to the target node
if(this._optionTargetNode)
{
render(html`${this._emptyLabelTemplate()}
${repeat(<SelectOption[]>this.select_options, (option : SelectOption) => option.value, this._optionTemplate.bind(this))}`,
this._optionTargetNode
);
}
}
}
/**
* Overwritten as sometimes called before this._inputNode is available
*
* @param {*} v - modelValue: can be an Object, Number, String depending on the
* input type(date, number, email etc)
* @returns {string} formattedValue
*/
formatter(v)
{
if (!this._inputNode)
{
return v;
}
return super.formatter(v);
}
/**
* Set the select options
*
* @param new_options
*/
set select_options(new_options : SelectOption[])
{
const old_options = this.__select_options;
this.__select_options = cleanSelectOptions(new_options);
this.requestUpdate("select_options", old_options);
// if single selection and value does not match an option, use the first option
if (!this.multiple && !this.empty_label && !this.__select_options.filter(option => option.value === this.value).length)
{
this.value = this.__select_options[0].value;
}
}
/**
* Set select options
*
* @deprecated assign to select_options
* @param new_options
*/
set_select_options(new_options : SelectOption[] | { [key : string] : string }[])
{
this.select_options = <SelectOption[]>new_options;
}
get select_options() : SelectOption[]
{
return this.__select_options;
}
/**
* Get the node where we're putting the options
*
* If this were a normal selectbox, this would be just the <select> tag (this._inputNode) but in a more
* complicated widget, this could be anything.
*
* @overridable
* @returns {HTMLElement}
*/
get _optionTargetNode() : HTMLElement
{
return <HTMLElement><unknown>this;
}
/**
* Render the "empty label", used when the selectbox does not currently have a value
*
* @overridable
* @returns {TemplateResult}
*/
_emptyLabelTemplate() : TemplateResult
{
return html`${this.empty_label}`;
}
/**
* Render a single option
*
* Override this method to specify how to render each option.
* In a normal selectbox, this would be something like:
*```
* <option value="${option.value}" title="${option.title}" ?selected=${option.value == this.modelValue}>
* ${option.label}
* </option>`;
* ```
* but you can do whatever you need. To use a different WebComponent, just use its tag instead of "option".
* We should even be able to pass the whole SelectOption across
* ```
* <special-option .value=${option}></special-option>
* ```
*
* @overridable
* @param {SelectOption} option
* @returns {TemplateResult}
*/
_optionTemplate(option : SelectOption) : TemplateResult
{
return html`
<span>Override _optionTemplate(). ${option.value} => ${option.label}</span>`;
}
/**
* Load extra stuff from the template node. In particular, we're looking for any <option/> tags added.
*
* @param {Element} _node
*/
loadFromXML(_node : Element)
{
let new_options = [];
// Read the option-tags, but if not rendered there won't be any yet so check existing options
let options = _node.querySelectorAll("option");
for(let 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", "")
});
}
if(options.length == 0 && this.__select_options.length)
{
// Start with any existing options, (static options from type)
// Use a copy since we'll probably be modifying it, and we don't want to change for any other
// widget of the same static type
new_options = [...this.__select_options];
}
if(this.id)
{
new_options = find_select_options(this, {}, new_options);
}
if(new_options.length)
{
this.select_options = new_options;
}
}
}
return Et2WidgetWithSelect;
});