merge master into 23.1

This commit is contained in:
ralf 2023-11-13 10:05:15 +02:00
commit 9c4d28ca63
280 changed files with 18777 additions and 10006 deletions

View File

@ -58,7 +58,6 @@ class addressbook_import_contacts_csv extends importexport_basic_import_csv {
*/
public function import( $_stream, importexport_definition $_definition ) {
parent::import($_stream, $_definition);
if($_definition->plugin_options['empty_addressbook'])
{
$this->empty_addressbook($this->user, $this->ids);
@ -115,6 +114,45 @@ class addressbook_import_contacts_csv extends importexport_basic_import_csv {
$contact_owner = $this->user;
}
$this->user = $contact_owner;
// Special case fast lookup for simple condition "field exists"
// We find ALL matches first to save DB queries. This saves 1 query per row, at the cost of RAM
// Should be 10x faster for large (thousands of rows) files, may be slower for small (tens of rows) files
$this->cached_condition = [];
foreach($definition->plugin_options['conditions'] as $condition)
{
$contacts = array();
$this->cached_condition[$condition['string']] = [];
switch($condition['type'])
{
// exists
case 'exists' :
$searchcondition = $condition['string'][0] == Api\Storage::CF_PREFIX ? [$condition['string']] : [];
// if we use account_id for the condition, we need to set the owner for filtering, as this
// enables Api\Contacts\Storage to decide what backend is to be used
if($condition['string'] == 'account_id')
{
$searchcondition['owner'] = 0;
}
$field = $condition['string'][0] == Api\Storage::CF_PREFIX ? 'contact_value' : $condition['string'];
$contacts = $this->bocontacts->search(
//array( $condition['string'] => $record[$condition['string']],),
'',
['contact_id', 'cat_id', $field],
'', '', '', false, 'AND', false,
$searchcondition
);
foreach($contacts as $contact)
{
if(!isset($this->cached_condition[$condition['string']][$contact[$field]]))
{
$this->cached_condition[$condition['string']][$contact[$field]] = [];
}
$this->cached_condition[$condition['string']][$contact[$field]][] = $contact;
}
}
}
}
/**
@ -215,18 +253,9 @@ class addressbook_import_contacts_csv extends importexport_basic_import_csv {
switch ( $condition['type'] ) {
// exists
case 'exists' :
if($record_array[$condition['string']]) {
$searchcondition = array( $condition['string'] => $record_array[$condition['string']]);
// if we use account_id for the condition, we need to set the owner for filtering, as this
// enables Api\Contacts\Storage to decide what backend is to be used
if ($condition['string']=='account_id') $searchcondition['owner']=0;
$contacts = $this->bocontacts->search(
//array( $condition['string'] => $record[$condition['string']],),
'',
$this->definition->plugin_options['update_cats'] == 'add' ? false : true,
'', '', '', false, 'AND', false,
$searchcondition
);
if($record_array[$condition['string']] && $this->cached_condition[$condition['string']])
{
$contacts = $this->cached_condition[$condition['string']][$record_array[$condition['string']]];
}
if ( is_array( $contacts ) && count( array_keys( $contacts ) ) >= 1 ) {
// apply action to all contacts matching this exists condition

View File

@ -21,18 +21,20 @@ import {fetchAll, nm_action, nm_compare_field} from "../../api/js/etemplate/et2_
import "./CRM";
import {egw} from "../../api/js/jsapi/egw_global";
import {LitElement} from "@lion/core";
import {Et2SelectState} from "../../api/js/etemplate/Et2Select/Et2Select";
import {Et2SelectCountry} from "../../api/js/etemplate/Et2Select/Et2SelectCountry";
import {Et2SelectCountry} from "../../api/js/etemplate/Et2Select/Select/Et2SelectCountry";
import {Et2SelectState} from "../../api/js/etemplate/Et2Select/Select/Et2SelectState";
/**
* Object to call app.addressbook.openCRMview with
*/
export interface CrmParams {
contact_id: number|string;
crm_list?: "infolog"|"tracker"|"infolog-organisation"; // default: use preference
title?: string; // default: link-title of contact_id
icon?: string; // default: avatar for contact_id
index?: number;
export interface CrmParams
{
contact_id : number | string;
crm_list? : "infolog" | "tracker" | "infolog-organisation"; // default: use preference
title? : string; // default: link-title of contact_id
icon? : string; // default: avatar for contact_id
index? : number;
}
/**

View File

@ -3,36 +3,55 @@
* EGroupware - Admin - DB backup and restore
*
* @link http://www.egroupware.org
* @author Ralf Becker <RalfBecker@outdoor-training.de>
* @author Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package admin
* @version $Id$
*/
use EGroupware\Api;
use EGroupware\Stylite\Vfs\S3;
class admin_db_backup
{
var $public_functions = array(
/**
* @var true[]
*/
public $public_functions = array(
'index' => true,
);
var $db_backup;
/**
* @var Api\Db\Backup
*/
protected $db_backup;
/**
* Method for sheduled backups, called via asynservice
* Method for scheduled backups, called via asynservice
*/
function do_backup()
{
$this->db_backup = new Api\Db\Backup();
if (class_exists(S3\Backup::class) && S3\Backup::available())
{
$this->db_backup = new S3\Backup();
}
else
{
$this->db_backup = new Api\Db\Backup();
}
if (($f = $this->db_backup->fopen_backup()))
{
try {
$f = $this->db_backup->fopen_backup();
$this->db_backup->backup($f);
if(is_resource($f))
if (is_resource($f))
{
fclose($f);
}
/* Remove old backups. */
$this->db_backup->housekeeping();
}
catch (\Exception $e) {
// log error
_egw_log_exception($e);
}
}
/**
@ -50,4 +69,4 @@ class admin_db_backup
unset($tpl_root, $self);
echo $GLOBALS['egw']->framework->footer();
}
}
}

View File

@ -16,9 +16,9 @@
<rows>
<row>
<nextmatch-sortheader label="ID" id="token_id"/>
<et2-nextmatch-header-account id="account_id" emptyLabel="User" accountType="user">
<option value="0">All users</option>
</et2-nextmatch-header-account>
<et2-nextmatch-header-account id="account_id" emptyLabel="User" accountType="accounts">
<option value="0">All users</option>
</et2-nextmatch-header-account>
<nextmatch-header id="token_apps" label="Applications"/>
<nextmatch-sortheader label="Expiration" id="token_valid_until"/>
<et2-vbox>

View File

@ -44,12 +44,47 @@ export class EgwDragActionImplementation implements EgwActionImplementation {
const pseudoNumRows = (_selected[0]?._context?._selectionMgr?._selectAll) ?
_selected[0]._context?._selectionMgr?._total : _selected.length;
for (const egwActionObject of _selected) {
const row: Node = (egwActionObject.iface.getDOMNode()).cloneNode(true);
if (row) {
rows.push(row);
table.append(row);
}
// Clone nodes but use copy webComponent properties
const carefulClone = (node) =>
{
// Don't clone text nodes, it causes duplication in et2-description
if(node.nodeType == node.TEXT_NODE)
{
return;
}
let clone = node.cloneNode();
let widget_class = window.customElements.get(clone.localName);
let properties = widget_class ? widget_class.properties : [];
for(let key in properties)
{
clone[key] = node[key];
}
// Children
node.childNodes.forEach(c =>
{
const child = carefulClone(c)
if(child)
{
clone.appendChild(child);
}
})
if(widget_class)
{
clone.requestUpdate();
}
return clone;
}
for(const egwActionObject of _selected)
{
const row : Node = carefulClone(egwActionObject.iface.getDOMNode());
if(row)
{
rows.push(row);
table.append(row);
}
index++;
if (index == maxRows) {
// Label to show number of items
@ -195,9 +230,33 @@ export class EgwDragActionImplementation implements EgwActionImplementation {
event.dataTransfer.setData('application/json', JSON.stringify(data))
event.dataTransfer.setDragImage(ai.helper, 12, 12);
// Wait for any webComponents to finish
let wait = [];
const webComponents = [];
const check = (element) =>
{
if(typeof element.updateComplete !== "undefined")
{
webComponents.push(element)
element.requestUpdate();
wait.push(element.updateComplete);
}
element.childNodes.forEach(child => check(child));
}
check(ai.helper);
// Clumsily force widget update, since we can't do it async
Promise.all(wait).then(() =>
{
wait = [];
webComponents.forEach(e => wait.push(e.updateComplete));
Promise.all(wait).then(() =>
{
event.dataTransfer.setDragImage(ai.helper, 12, 12);
debugger;
});
});
this.setAttribute('data-egwActionObjID', JSON.stringify(data.selected));
this.setAttribute('data-egwActionObjID', JSON.stringify(data.selected));
};
const dragend = (_) => {

View File

@ -1,4 +1,6 @@
import {Directive, directive, html, repeat} from "@lion/core";
import {html} from "lit";
import {Directive, directive} from "lit/directive.js";
import {repeat} from "lit/directives/repeat.js";
import {et2_activateLinks} from "./et2_core_common";
/**

View File

@ -121,6 +121,15 @@ class multi_video extends HTMLElement {
}
}
/**
* Calls load method for all its sub videos
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/load
*/
load()
{
this._videos.forEach(_item =>{_item.node.load()});
}
/**
* init/update video tags
* @param _value
@ -130,15 +139,24 @@ class multi_video extends HTMLElement {
{
let value = _value.split(',');
let video = null;
let duration = 0;
for (let i=0;i<value.length;i++)
{
video = document.createElement('video');
if (value[i].match(/&duration=/))
{
// get duration from url duration param which is necessary for setting duration time of webm file
let params = new URLSearchParams(value[i]);
duration = parseInt(params.get('duration'));
value[i] = value[i].replace(/&duration.*/, '');
}
video.src = value[i];
this._videos[i] = {
node:this._wrapper.appendChild(video),
loadedmetadata: false,
timeupdate: false,
duration: 0,
duration: duration ? duration : 0,
previousDurations: 0,
currentTime: 0,
active: false,
@ -201,7 +219,7 @@ class multi_video extends HTMLElement {
});
if (allReady) {
this._videos.forEach(_item => {
_item.duration = _item.node.duration;
_item.duration = _item.duration ? _item.duration : _item.node.duration;
_item.previousDurations = _item.index > 0 ? this._videos[_item.index-1]['duration'] + this._videos[_item.index-1]['previousDurations'] : 0;
});
this.duration = this.__duration();

View File

@ -9,7 +9,7 @@
*/
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {css, SlotMixin} from "@lion/core";
import {css} from "lit";
import {SlAvatar} from "@shoelace-style/shoelace";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
import {egw} from "../../jsapi/egw_global";
@ -18,7 +18,7 @@ import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import "../../../../vendor/bower-asset/cropper/dist/cropper.min.js";
import {cropperStyles} from "./cropperStyles";
export class Et2Avatar extends Et2Widget(SlotMixin(SlAvatar)) implements et2_IDetachedDOM
export class Et2Avatar extends Et2Widget(SlAvatar) implements et2_IDetachedDOM
{
private _contactId;
private _delBtn: HTMLElement;
@ -91,6 +91,10 @@ export class Et2Avatar extends Et2Widget(SlotMixin(SlAvatar)) implements et2_IDe
crop: {type: Boolean},
/**
* Explicitly specify the avatar size.
* Better to set the --size CSS variable in app.css, since it allows inheritance and overriding
*/
size: {type: String}
}
}
@ -103,7 +107,6 @@ export class Et2Avatar extends Et2Widget(SlotMixin(SlAvatar)) implements et2_IDe
this.contactId = "";
this.editable = false;
this.crop = false;
this.size = "2.7em";
this.icon = "";
this.shape = "rounded";
}
@ -246,10 +249,10 @@ export class Et2Avatar extends Et2Widget(SlotMixin(SlAvatar)) implements et2_IDe
{
let self = this;
this._editBtn = document.createElement('et2-button-icon');
this._editBtn.setAttribute('name', 'pencil');
this._editBtn.setAttribute('image', 'pencil');
this._editBtn.setAttribute('part', 'edit');
this._delBtn = document.createElement('et2-button-icon');
this._delBtn.setAttribute('name', 'trash');
this._delBtn.setAttribute('image', 'delete');
this._delBtn.setAttribute('part', 'edit');
this._baseNode.append(this._editBtn);
this._baseNode.append(this._delBtn);
@ -434,7 +437,8 @@ export class Et2Avatar extends Et2Widget(SlotMixin(SlAvatar)) implements et2_IDe
}
}
}
customElements.define("et2-avatar", Et2Avatar as any);
customElements.define("et2-avatar", Et2Avatar);
// make et2_avatar publicly available as we need to call it from templates
{
window['et2_avatar'] = Et2Avatar;

View File

@ -1,5 +1,6 @@
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {css, html, LitElement, repeat} from "@lion/core";
import {css, html, LitElement} from "lit";
import {repeat} from "lit/directives/repeat.js";
import shoelace from "../Styles/shoelace";
/**

View File

@ -10,7 +10,7 @@
import {Et2Avatar} from "./Et2Avatar";
import shoelace from "../Styles/shoelace";
import {css} from "@lion/core";
import {css} from "lit";
export class Et2LAvatar extends Et2Avatar
{
@ -126,4 +126,5 @@ export class Et2LAvatar extends Et2Avatar
return {background: bg, initials: text};
}
}
customElements.define("et2-lavatar", Et2LAvatar as any);
customElements.define("et2-lavatar", Et2LAvatar);

View File

@ -1,7 +1,7 @@
/**
* Cropper styles constant
*/
import {css} from "@lion/core";
import {css} from "lit";
/*!
* Cropper.js v1.5.12

View File

@ -9,9 +9,10 @@
*/
import {css, LitElement, PropertyValues} from "@lion/core";
import {css, LitElement, PropertyValues} from "lit";
import '../Et2Image/Et2Image';
import shoelace from "../Styles/shoelace";
import {egw_registerGlobalShortcut} from "../../egw_action/egw_keymanager";
type Constructor<T = LitElement> = new (...args : any[]) => T;
export const ButtonMixin = <T extends Constructor>(superclass : T) => class extends superclass
@ -49,6 +50,12 @@ export const ButtonMixin = <T extends Constructor>(superclass : T) => class exte
et2_button_delete: /delete(&|\]|$)/ // red
};
static readonly default_keys : object = {
//egw_shortcutIdx : id regex
_83_C: /save(&|\]|$)/, // CTRL+S
_27_: /cancel(&|\]|$)/, // Esc
};
static get styles()
{
return [
@ -323,6 +330,38 @@ export const ButtonMixin = <T extends Constructor>(superclass : T) => class exte
return "";
}
/**
* If button ID has a default keyboard shortcut (eg: Save: Ctrl+S), register with egw_keymanager
*
* @param {string} check_id
*/
_register_default_keyhandler(check_id : string)
{
if(!check_id)
{
return;
}
// @ts-ignore
for(const keyindex in this.constructor.default_keys)
{
// @ts-ignore
if(check_id.match(this.constructor.default_keys[keyindex]))
{
let [keycode, modifiers] = keyindex.substring(1).split("_");
egw_registerGlobalShortcut(
parseInt(keycode),
modifiers.includes("S"), modifiers.includes("C"), modifiers.includes("A"),
() =>
{
this.dispatchEvent(new MouseEvent("click"));
return true;
},
this
)
}
}
}
/**
* Get a default class for the button based on ID
*

View File

@ -13,7 +13,7 @@ import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import '../Et2Image/Et2Image';
import {SlButton} from "@shoelace-style/shoelace";
import {ButtonMixin} from "./ButtonMixin";
import {PropertyValues} from "@lion/core";
import {PropertyValues} from "lit";
export class Et2Button extends ButtonMixin(Et2InputWidget(SlButton))
@ -22,7 +22,7 @@ export class Et2Button extends ButtonMixin(Et2InputWidget(SlButton))
{
return {
...super.properties,
label: {type: String}
label: {type: String, noAccessor: true}
}
}
@ -30,6 +30,9 @@ export class Et2Button extends ButtonMixin(Et2InputWidget(SlButton))
{
super.firstUpdated(_changedProperties);
// Register default keyboard shortcut, if applicable
this._register_default_keyhandler(this.id);
if(!this.label && this.__image)
{
/*

View File

@ -14,7 +14,7 @@ import '../Et2Image/Et2Image';
import {SlIconButton} from "@shoelace-style/shoelace";
import {ButtonMixin} from "./ButtonMixin";
import shoelace from "../Styles/shoelace";
import {css} from "@lion/core";
import {css} from "lit";
export class Et2ButtonIcon extends ButtonMixin(Et2InputWidget(SlIconButton))
@ -45,7 +45,7 @@ export class Et2ButtonIcon extends ButtonMixin(Et2InputWidget(SlIconButton))
}
if(new_image && !this.src)
{
this.name = new_image;
this.__name = new_image;
}
}
@ -53,6 +53,16 @@ export class Et2ButtonIcon extends ButtonMixin(Et2InputWidget(SlIconButton))
{
return this.src || this.name;
}
set name(name)
{
// No - use image to avoid conflicts between our icons & SlIconButton's image/url loading
}
get name()
{
return super.name;
}
}
customElements.define("et2-button-icon", Et2ButtonIcon);

View File

@ -8,7 +8,7 @@
* @author Nathan Gray
*/
import {css, html, LitElement} from "@lion/core";
import {css, html, LitElement} from "lit";
import {ButtonMixin} from "./ButtonMixin";
/**
@ -87,14 +87,14 @@ export class Et2ButtonScroll extends ButtonMixin(LitElement)
<et2-button-icon
noSubmit
data-direction="1"
name="chevron-up"
image="chevron-up"
part="button"
>
</et2-button-icon>
<et2-button-icon
noSubmit
data-direction="-1"
name="chevron-down"
image="chevron-down"
part="button"
>
</et2-button-icon>

View File

@ -9,7 +9,7 @@
*/
import {css} from "@lion/core";
import {css} from "lit";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import '../Et2Image/Et2Image';
import {SlCheckbox} from "@shoelace-style/shoelace";

View File

@ -1,7 +1,8 @@
import {et2_IDetachedDOM} from "../et2_core_interfaces";
import {et2_checkbox} from "../et2_widget_checkbox";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {classMap, css, html, LitElement} from "@lion/core";
import {css, html, LitElement} from "lit";
import {classMap} from "lit/directives/class-map.js"
import shoelace from "../Styles/shoelace";
/**

View File

@ -9,7 +9,7 @@
*/
import {css, html, PropertyValues, render} from "@lion/core";
import {css, html, PropertyValues, render} from "lit";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {SlColorPicker} from "@shoelace-style/shoelace";
import shoelace from "../Styles/shoelace";

View File

@ -2,7 +2,7 @@
* Sharable date styles constant
*/
import {css} from "@lion/core";
import {css} from "lit";
import {colorsDefStyles} from "../Styles/colorsDefStyles";
import {cssImage} from "../Et2Widget/Et2Widget";

View File

@ -9,7 +9,7 @@
*/
import {css, html} from "@lion/core";
import {css, html} from "lit";
import 'lit-flatpickr';
import {dateStyles} from "./DateStyles";
import type {Instance} from 'flatpickr/dist/types/instance';
@ -19,15 +19,11 @@ import flatpickr from "flatpickr";
import {egw} from "../../jsapi/egw_global";
import type {HTMLElementWithValue} from "@lion/form-core/types/FormControlMixinTypes";
import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
import {Et2ButtonIcon} from "../Et2Button/Et2ButtonIcon";
import {FormControlMixin} from "@lion/form-core";
import {LitFlatpickr} from "lit-flatpickr";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import shoelace from "../Styles/shoelace";
const textbox = new Et2Textbox();
const button = new Et2ButtonIcon();
// list of existing localizations from node_modules/flatpicker/dist/l10n directory:
const l10n = [
'ar', 'at', 'az', 'be', 'bg', 'bn', 'bs', 'cat', 'cs', 'cy', 'da', 'de', 'eo', 'es', 'et', 'fa', 'fi', 'fo',
@ -1033,13 +1029,13 @@ export class Et2Date extends Et2InputWidget(FormControlMixin(LitFlatpickr))
<div class="et2-date-time__scrollbuttons" part="scrollbuttons" @click=${this.handleScroll}>
<et2-button-icon
noSubmit
name="chevron-up"
image="chevron-up"
data-direction="1"
>
</et2-button-icon>
<et2-button-icon
noSubmit
name="chevron-down"
image="chevron-down"
data-direction="-1"
>
</et2-button-icon>

View File

@ -9,7 +9,8 @@
*/
import {classMap, css, html, LitElement} from "@lion/core";
import {css, html, LitElement} from "lit";
import {classMap} from "lit/directives/class-map.js";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {sprintf} from "../../egw_action/egw_action_common";
import {dateStyles} from "./DateStyles";
@ -50,7 +51,7 @@ export function formatDuration(value : number | string, options : formatOptions)
for(let i = 0; i < options.displayFormat.length; ++i)
{
let unit = options.displayFormat[i];
let val = this._unit_from_value(value, unit, i === 0);
let val = this._unit_from_value(value, unit, i === 0, options);
if(unit === 's' || unit === 'm' || unit === 'h' && options.displayFormat[0] === 'd')
{
vals.push(sprintf('%02d', val));
@ -132,6 +133,7 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
}
.input-group__after {
display: contents;
margin-inline-start: var(--sl-input-spacing-medium);
}
@ -274,6 +276,16 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
this.formatter = formatDuration;
}
async getUpdateComplete()
{
const result = await super.getUpdateComplete();
// Format select does not start with value, needs an update
this._formatNode?.requestUpdate("value");
return result;
}
transformAttributes(attrs)
{
// Clean formats, but avoid things that need to be expanded like $cont[displayFormat]
@ -432,7 +444,10 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
for(let i = 0; i < this.displayFormat.length; ++i)
{
let unit = this.displayFormat[i];
let val = this._unit_from_value(_value, unit, i === 0);
let val = this._unit_from_value(_value, unit, i === 0, {
hoursPerDay: this.hoursPerDay,
dataFormat: this.dataFormat
});
if(unit === 's' || unit === 'm' || unit === 'h' && this.displayFormat[0] === 'd')
{
vals.push(sprintf('%02d', val));
@ -505,9 +520,9 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
}
}
private _unit_from_value(_value, _unit, _highest)
private _unit_from_value(_value, _unit, _highest, options)
{
_value *= this._unit2seconds(this.dataFormat);
_value *= this._unit2seconds(options.dataFormat);
// get value for given _unit
switch(_unit)
{
@ -518,9 +533,9 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
return _highest ? _value : _value % 60;
case 'h':
_value = Math.floor(_value / 3600);
return _highest ? _value : _value % this.hoursPerDay;
return _highest ? _value : _value % options.hoursPerDay;
case 'd':
return Math.floor(_value / 3600 / this.hoursPerDay);
return Math.floor(_value / 3600 / options.hoursPerDay);
}
}
@ -605,15 +620,19 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
s: this.shortLabels ? this.egw().lang("s") : this.egw().lang("Seconds")
};
// It would be nice to use an et2-select here, but something goes weird with the styling
const current = this._display.unit || this.displayFormat[0];
return html`
<et2-select value="${this._display.unit || this.displayFormat[0]}">
<sl-select value="${current}">
${[...this.displayFormat].map((format : string) =>
html`
<sl-menu-item value=${format} ?checked=${this._display.unit === format}>
<sl-option
value=${format}
.selected=${(format == current)}
>
${this.time_formats[format]}
</sl-menu-item>`
</sl-option>`
)}
</et2-select>
</sl-select>
`;
}
@ -631,7 +650,7 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
*/
get _formatNode() : HTMLSelectElement
{
return this.shadowRoot ? this.shadowRoot.querySelector("et2-select") : null;
return this.shadowRoot ? this.shadowRoot.querySelector("sl-select") : null;
}
}

View File

@ -8,7 +8,7 @@
*/
import {css, html} from "@lion/core";
import {css, html} from "lit";
import {Et2DateDuration, formatOptions} from "./Et2DateDuration";
import {dateStyles} from "./DateStyles";

View File

@ -1,11 +1,11 @@
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {FormControlMixin} from "@lion/form-core";
import {classMap, css, html, ifDefined, LitElement, TemplateResult} from "@lion/core";
import {css, html, LitElement, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js";
import {ifDefined} from "lit/directives/if-defined.js";
import shoelace from "../Styles/shoelace";
import {dateStyles} from "./DateStyles";
import flatpickr from "flatpickr";
import {default as rangePlugin} from "flatpickr/dist/plugins/rangePlugin";
import {Et2Date, formatDate, parseDate} from "./Et2Date";
import {formatDate, parseDate} from "./Et2Date";
import {egw} from "../../jsapi/egw_global";
/**

View File

@ -8,7 +8,7 @@
*/
import {html, LitElement} from "@lion/core";
import {html, LitElement} from "lit";
import {formatDate, parseDate} from "./Et2Date";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
import {Et2Widget} from "../Et2Widget/Et2Widget";

View File

@ -8,7 +8,7 @@
*/
import {html} from "@lion/core";
import {html} from "lit";
import {parseDate, parseDateTime} from "./Et2Date";
import {Et2DateReadonly} from "./Et2DateReadonly";

View File

@ -9,7 +9,7 @@
*/
import {css} from "@lion/core";
import {css} from "lit";
import {Et2Date, formatDate, formatDateTime} from "./Et2Date";
import type {Instance} from "flatpickr/dist/types/instance";
import {default as ShortcutButtonsPlugin} from "shortcut-buttons-flatpickr/dist/shortcut-buttons-flatpickr";

View File

@ -8,7 +8,7 @@
*/
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {css, html, LitElement, render} from "@lion/core";
import {css, html, LitElement, render} from "lit";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
import {activateLinks} from "../ActivateLinksDirective";
import {et2_csvSplit} from "../et2_core_common";
@ -144,14 +144,14 @@ export class Et2Description extends Et2Widget(LitElement) implements et2_IDetach
this.requestUpdate('value', oldValue);
}
requestUpdate(attribute, oldValue)
updated(changedProperties)
{
super.requestUpdate(...arguments);
super.updated(changedProperties);
// Due to how we do the rendering into the light DOM (not sure it's right) we need this after
// value change or it won't actually show up
if(["value", "href", "activateLinks"].indexOf(attribute) != -1 && this.parentNode)
if((changedProperties.has("value") || changedProperties.has("href") || changedProperties.has("activateLinks")) && this.parentNode)
{
this.updateComplete.then(() => render(this._renderContent(), <HTMLElement><unknown>this));
render(this._renderContent(), <HTMLElement><unknown>this);
}
}

View File

@ -12,7 +12,12 @@
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {et2_button} from "../et2_widget_button";
import {et2_widget} from "../et2_core_widget";
import {classMap, css, html, ifDefined, LitElement, render, repeat, SlotMixin, styleMap} from "@lion/core";
import {css, html, LitElement, render} from "lit";
import {classMap} from "lit/directives/class-map.js";
import {ifDefined} from "lit/directives/if-defined.js";
import {repeat} from "lit/directives/repeat.js";
import {styleMap} from "lit/directives/style-map.js";
import {SlotMixin} from "@lion/core";
import {et2_template} from "../et2_widget_template";
import {etemplate2} from "../etemplate2";
import {egw, IegwAppLocal} from "../../jsapi/egw_global";
@ -35,8 +40,6 @@ export interface DialogButton
}
/**
* Et2Dialog widget
*
* A common dialog widget that makes it easy to inform users or prompt for information.
*
* It is possible to have a custom dialog by using a template, but you can also use
@ -47,7 +50,7 @@ export interface DialogButton
*
* Or a more complete example:
* ```js
* let callback = function (button_id)
* let callback = function (button_id)
* {
* if(button_id == Et2Dialog.YES_BUTTON)
* {
@ -78,22 +81,22 @@ export interface DialogButton
* ```
*
* The parameters for the above are all optional, except callback (which can be null) and message:
* callback - function called when the dialog closes, or false/null.
* - callback - function called when the dialog closes, or false/null.
* The ID of the button will be passed. Button ID will be one of the Et2Dialog.*_BUTTON constants.
* The callback is _not_ called if the user closes the dialog with the X in the corner, or presses ESC.
* message - (plain) text to display
* title - Dialog title
* value (for prompt)
* buttons - Et2Dialog BUTTONS_* constant, or an array of button settings. Use DialogButton interface.
* dialog_type - Et2Dialog *_MESSAGE constant
* icon - URL of icon
* - message - (plain) text to display
* - title - Dialog title
* - value (for prompt)
* - buttons - Et2Dialog BUTTONS_* constant, or an array of button settings. Use DialogButton interface.
* - dialog_type - Et2Dialog *_MESSAGE constant
* - icon - URL of icon
*
* Note that these methods will _not_ block program flow while waiting for user input unless you use "await" on getComplete().
* The user's input will be provided to the callback.
*
* You can also create a custom dialog using an etemplate, even setting all the buttons yourself.
* ```ts
* // Pass egw in the constructor
* // Pass egw in the constructor
* let dialog = new Et2Dialog(my_egw_reference);
*
* // Set attributes. They can be set in any way, but this is convenient.
@ -131,6 +134,7 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
*
* @type {IegwAppLocal}
* @protected
* @internal
*/
protected __egw : IegwAppLocal
@ -140,6 +144,7 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
*
* @type {et2_template | null}
* @protected
* @internal
*/
protected _template_widget : etemplate2 | null;
protected _template_promise : Promise<boolean>;
@ -148,6 +153,7 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
* Treat the dialog as an atomic operation, and use this promise to notify when
* "done" instead of (or in addition to) using the callback function.
* It gives the button ID and the dialog value.
* @internal
*/
protected _complete_promise : Promise<[number, Object]>;
@ -166,6 +172,7 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
*
* @type {number|null}
* @protected
* @internal
*/
protected _button_id : number | null;
@ -233,6 +240,10 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
margin-top: 0.5em;
}
.dialog_content {
height: var(--height, auto);
}
/* Non-modal dialogs don't have an overlay */
:host(:not([ismodal])) .dialog, :host(:not([isModal])) .dialog__overlay {
@ -877,7 +888,7 @@ export class Et2Dialog extends Et2Widget(SlotMixin(SlDialog))
}
if(this.height)
{
styles.height = "--height: " + this.height;
styles["--height"] = this.height;
}
return html`

View File

@ -9,11 +9,11 @@
*/
import {Et2Button} from "../Et2Button/Et2Button";
import {SlButtonGroup, SlDropdown} from "@shoelace-style/shoelace";
import {css, html, TemplateResult} from "@lion/core";
import {Et2widgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin";
import {css, html, LitElement, TemplateResult} from "lit";
import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin";
import {SelectOption} from "../Et2Select/FindSelectOptions";
import shoelace from "../Styles/shoelace";
/**
* A split button - a button with a dropdown list
@ -28,13 +28,14 @@ import {SelectOption} from "../Et2Select/FindSelectOptions";
* as for a select box, but the title can also be full HTML if needed.
*
*/
export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button)
export class Et2DropdownButton extends Et2WidgetWithSelectMixin(LitElement)
{
static get styles()
{
return [
...super.styles,
shoelace,
css`
:host {
/* Avoid unwanted style overlap from button */
@ -98,6 +99,7 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button)
// We have our own render, so we can handle it internally
}
render() : TemplateResult
{
if(this.readonly)

View File

@ -10,7 +10,7 @@
*/
import {Et2DropdownButton} from "../Et2DropdownButton/Et2DropdownButton";
import {css, html, PropertyValues, TemplateResult} from "@lion/core";
import {css, html, PropertyValues, TemplateResult} from "lit";
import {SelectOption} from "../Et2Select/FindSelectOptions";
import {et2_INextmatchHeader, et2_nextmatch} from "../et2_extension_nextmatch";
import {Et2Image} from "../Et2Image/Et2Image";
@ -76,24 +76,24 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
min-width: 15em;
}
sl-menu-item:hover et2-image[src="trash"] {
sl-option:hover et2-image[src="trash"] {
display: initial;
}
/* Add star icons - radio button is already in prefix */
sl-menu-item::part(base) {
sl-option::part(base) {
background-image: ${cssImage("fav_filter")};
background-repeat: no-repeat;
background-size: 16px 16px;
background-position: 5px center;
}
sl-menu-item[checked]::part(base) {
sl-option[checked]::part(base) {
background-image: ${cssImage("favorites")};
}
sl-menu-item:last-child::part(base) {
sl-option:last-child::part(base) {
background-image: none;
}
`,
@ -185,11 +185,11 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea
statustext="${this.egw().lang("Delete")}"></et2-image>`;
return html`
<sl-menu-item value="${option.value}" ?checked="${option.value == this._preferred}">
<sl-option value="${option.value}" ?checked="${option.value == this._preferred}">
${option.value !== Et2Favorites.ADD_VALUE ? radio : ""}
${icon}
${option.label}
</sl-menu-item>`;
</sl-option>`;
}

View File

@ -9,7 +9,8 @@
*/
import {css, html, LitElement, SlotMixin} from "@lion/core";
import {css, html, LitElement} from "lit";
import {SlotMixin} from "@lion/core";
import {Et2Widget} from "../Et2Widget/Et2Widget";
export class Et2Iframe extends Et2Widget(SlotMixin(LitElement))

View File

@ -8,8 +8,8 @@
* @author Nathan Gray
*/
import {css, html, LitElement, render, SlotMixin} from "@lion/core";
import {css, html, LitElement, render} from "lit";
import {SlotMixin} from "@lion/core";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
@ -248,4 +248,4 @@ export class Et2Image extends Et2Widget(SlotMixin(LitElement)) implements et2_ID
}
}
customElements.define("et2-image", Et2Image as any, {extends: 'img'});
customElements.define("et2-image", Et2Image, {extends: 'img'});

View File

@ -1,10 +1,11 @@
import {et2_IInput, et2_IInputNode, et2_ISubmitListener} from "../et2_core_interfaces";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {css, dedupeMixin, LitElement, PropertyValues} from "@lion/core";
import {css, LitElement, PropertyValues} from "lit";
import {Required} from "../Validators/Required";
import {ManualMessage} from "../Validators/ManualMessage";
import {LionValidationFeedback, Validator} from "@lion/form-core";
import {et2_csvSplit} from "../et2_core_common";
import {dedupeMixin} from "@lion/core";
/**
* This mixin will allow any LitElement to become an Et2InputWidget
@ -486,6 +487,15 @@ const Et2InputWidgetMixin = <T extends Constructor<LitElement>>(superclass : T)
{
super.transformAttributes(attrs);
// Set attributes for the form / autofill. It's the individual widget's
// responsibility to do something appropriate with these properties.
if(this.autocomplete == "on" && window.customElements.get(this.localName).getPropertyOptions("name") != "undefined" &&
this.getArrayMgr("content") !== null
)
{
this.name = this.getArrayMgr("content").explodeKey(this.id).pop();
}
// Check whether an validation error entry exists
if(this.id && this.getArrayMgr("validation_errors"))
{
@ -512,7 +522,7 @@ const Et2InputWidgetMixin = <T extends Constructor<LitElement>>(superclass : T)
*/
async validate(skipManual = false)
{
if(this.readonly)
if(this.readonly || this.disabled)
{
// Don't validate if the widget is read-only, there's nothing the user can do about it
return Promise.resolve();

View File

@ -82,11 +82,21 @@ export function inputBasicTests(before : Function, test_value : string, value_se
{
element = await before();
});
it("no value gives empty string", () =>
it("no value gives empty string", async() =>
{
element.set_value("");
await elementUpdated(element);
// Shows as empty / no value
let value = (<Element><unknown>element).querySelector(value_selector) || (<Element><unknown>element).shadowRoot.querySelector(value_selector);
assert.isDefined(value, "Bad value selector '" + value_selector + "'");
debugger;
assert.equal(value.textContent.trim(), "", "Displaying something when there is no value");
if(element.multiple)
{
assert.isEmpty(element.get_value());
return;
}
// Gives no value
assert.equal(element.get_value(), "", "Value mismatch");
});
@ -94,7 +104,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se
it("value out matches value in", async() =>
{
element.set_value(test_value);
debugger;
// wait for asychronous changes to the DOM
await elementUpdated(<Element><unknown>element);

View File

@ -11,7 +11,7 @@
import {ExposeMixin, ExposeValue} from "../Expose/ExposeMixin";
import {css, html, LitElement, TemplateResult} from "@lion/core";
import {css, html, LitElement, TemplateResult} from "lit";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {et2_IDetachedDOM} from "../et2_core_interfaces";

View File

@ -1,6 +1,7 @@
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {css, html, LitElement, PropertyValues} from "lit";
import {FormControlMixin, ValidateMixin} from "@lion/form-core";
import {css, html, LitElement, PropertyValues, SlotMixin} from "@lion/core";
import {SlotMixin} from "@lion/core";
import {Et2LinkAppSelect} from "./Et2LinkAppSelect";
import {LinkInfo} from "./Et2Link";
import {Et2Button} from "../Et2Button/Et2Button";

View File

@ -1,9 +1,9 @@
import {cleanSelectOptions, SelectOption} from "../Et2Select/FindSelectOptions";
import {css, html, SlotMixin, TemplateResult} from "@lion/core";
import {css, html, TemplateResult} from "lit";
import {Et2Select} from "../Et2Select/Et2Select";
export class Et2LinkAppSelect extends SlotMixin(Et2Select)
export class Et2LinkAppSelect extends Et2Select
{
static get styles()
{
@ -49,22 +49,11 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
}
};
get slots()
{
return {
...super.slots,
"": () =>
{
/*
icon.style.width = "var(--icon-width)";
icon.style.height = "var(--icon-width)";
const icon = document.createElement("et2-image");
icon.setAttribute("slot", "prefix");
icon.setAttribute("src", "api/navbar");
icon.style.width = "var(--icon-width)";
icon.style.height = "var(--icon-width)";
return icon;
}
}
}
*/
protected __applicationList : string[];
protected __onlyApp : string;
@ -104,9 +93,6 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
{
super.connectedCallback();
// Set icon
this.querySelector(":scope > [slot='prefix']").setAttribute("src", this.egw().link_get_registry(this.value, 'icon') ?? this.value + "/navbar");
if(!this.value)
{
// use preference
@ -118,7 +104,7 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
this.value = this.egw().preference('link_app', appname || this.egw().app_name());
}
// Register to
this.addEventListener("change", this._handleChange);
this.addEventListener("sl-change", this._handleChange);
if(this.__onlyApp)
{
@ -129,7 +115,7 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
disconnectedCallback()
{
super.disconnectedCallback();
this.removeEventListener("change", this._handleChange);
this.removeEventListener("sl-change", this._handleChange);
}
/**
@ -173,10 +159,9 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
super.value = new_value;
}
_handleChange(e)
handleValueChange(e)
{
// Set icon
this.querySelector(":scope > [slot='prefix']").setAttribute("src", this.egw().link_get_registry(this.value, 'icon'));
super.handleValueChange(e);
// update preference
let appname = "";
@ -198,13 +183,21 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
// Limit to one app
if(this.onlyApp)
{
select_options.push({value: this.onlyApp, label: this.egw().lang(this.onlyApp)});
select_options.push({
value: this.onlyApp,
label: this.egw().lang(this.onlyApp),
icon: this.egw().link_get_registry(this.onlyApp, 'icon') ?? this.onlyApp + "/navbar"
});
}
else if(this.applicationList.length > 0)
{
select_options = this.applicationList.map((app) =>
{
return {value: app, label: this.egw().lang(app)};
return {
value: app,
label: this.egw().lang(app),
icon: this.egw().link_get_registry(app, 'icon') ?? app + "/navbar"
};
});
}
else
@ -215,29 +208,27 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
{
delete select_options['addressbook-email'];
}
select_options = cleanSelectOptions(select_options);
select_options.map((option) =>
{
option.icon = this.egw().link_get_registry(option.value, 'icon') ?? option.value + "/navbar"
});
}
if (!this.value)
{
this.value = <string>this.egw().preference('link_app', this.egw().app_name());
}
this.select_options = cleanSelectOptions(select_options);
this.select_options = select_options;
}
_optionTemplate(option : SelectOption) : TemplateResult
{
return html`
<sl-menu-item value="${option.value}" title="${option.title}">
<sl-option value="${option.value}" title="${option.title}">
${this.appIcons ? "" : option.label}
${this._iconTemplate(option.value)}
</sl-menu-item>`;
}
_iconTemplate(appname)
{
let url = appname ? this.egw().link_get_registry(appname, 'icon') : "";
return html`
<et2-image style="width: var(--icon-width)" slot="prefix" src="${url}"></et2-image>`;
${this._iconTemplate(option)}
</sl-option>`;
}
}

View File

@ -6,8 +6,8 @@
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {css, html, LitElement, PropertyValues, SlotMixin} from "@lion/core";
import {css, html, LitElement, PropertyValues} from "lit";
import {SlotMixin} from "@lion/core";
import {Et2LinkAppSelect} from "./Et2LinkAppSelect";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {FormControlMixin} from "@lion/form-core";

View File

@ -10,8 +10,9 @@
*/
import {css, html, repeat, TemplateResult} from "@lion/core";
import {Et2Link, LinkInfo} from "./Et2Link";
import {css, html, TemplateResult} from "lit";
import {repeat} from "lit/directives/repeat.js";
import {LinkInfo} from "./Et2Link";
import {egw} from "../../jsapi/egw_global";
import {Et2LinkString} from "./Et2LinkString";
import {egwMenu} from "../../egw_action/egw_menu";

View File

@ -7,7 +7,7 @@
* @author Nathan Gray
*/
import {css} from "@lion/core";
import {css} from "lit";
import {Et2Select} from "../Et2Select/Et2Select";
import {Et2LinkAppSelect} from "./Et2LinkAppSelect";
import {Et2Link} from "./Et2Link";
@ -86,9 +86,9 @@ export class Et2LinkSearch extends Et2Select
super.updated(changedProperties);
// Set a value we don't have as an option? That's OK, we'll just add it
if(changedProperties.has("value") && this.value && (
this.menuItems && this.menuItems.length == 0 ||
this.menuItems?.filter && this.menuItems.filter(item => this.value.includes(item.value)).length == 0
if(changedProperties.has("value") && this.value && this.value.length > 0 && (
this.select_options.length == 0 ||
this.select_options.filter && this.select_options.filter(item => this.getValueAsArray().includes(item.value)).length == 0
))
{
this._missingOption(this.value)
@ -120,19 +120,16 @@ export class Et2LinkSearch extends Et2Select
option.label = title || Et2Link.MISSING_TITLE;
option.class = "";
// It's probably already been rendered, find the item
let item = this.menuItems.find(i => i.value === option.value);
let item = this.getAllOptions().find(i => i.value === option.value);
if(item)
{
item.textContent = title;
item.classList.remove("loading");
this.syncItemsFromValue();
}
else
{
// Not already rendered, update the select option
this.requestUpdate("select_options");
// update the displayed text
this.updateComplete.then(() => this.syncItemsFromValue());
}
});
}

View File

@ -10,7 +10,8 @@
*/
import {css, html, LitElement, PropertyValues, render, TemplateResult, until} from "@lion/core";
import {css, html, LitElement, PropertyValues, render, TemplateResult} from "lit";
import {until} from "lit/directives/until.js";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {Et2Link, LinkInfo} from "./Et2Link";
import {et2_IDetachedDOM} from "../et2_core_interfaces";

View File

@ -12,7 +12,8 @@
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {FormControlMixin, ValidateMixin} from "@lion/form-core";
import {css, html, LitElement, ScopedElementsMixin} from "@lion/core";
import {css, html, LitElement} from "lit";
import {ScopedElementsMixin} from "@lion/core";
import {et2_createWidget, et2_widget} from "../et2_core_widget";
import {et2_file} from "../et2_widget_file";
import {Et2Button} from "../Et2Button/Et2Button";
@ -140,7 +141,7 @@ export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMix
<et2-link-entry .onlyApp="${this.onlyApp}"
.applicationList="${this.applicationList}"
.readonly=${this.readonly}
@sl-select=${this.handleEntrySelected}
@sl-change=${this.handleEntrySelected}
@sl-clear="${this.handleEntryCleared}">
</et2-link-entry>
<et2-button id="link_button" label="Link" class="link" .noSubmit=${true}
@ -357,6 +358,8 @@ export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMix
// Clear link entry
this.select.value = {app: this.select.app, id: ""};
this.select._searchNode.clearSearch();
this.select._searchNode.select_options = [];
}
/**
@ -404,6 +407,7 @@ export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMix
if(event.target == this.select._searchNode)
{
this.classList.add("can_link");
this.link_button.focus();
}
}

View File

@ -1,7 +1,9 @@
/**
* Column selector for nextmatch
*/
import {classMap, css, html, LitElement, repeat, TemplateResult} from "@lion/core";
import {css, html, LitElement, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js";
import {repeat} from "lit/directives/repeat.js";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {et2_nextmatch_customfields} from "../et2_extension_nextmatch";
import shoelace from "../Styles/shoelace";
@ -43,6 +45,10 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
background-repeat: no-repeat;
cursor: grab;
}
sl-menu-item::part(label), sl-menu-item::part(submenu-icon) {
cursor: initial;
}
/* Change vertical alignment of CF checkbox line to up with title, not middle */
.custom_fields::part(base) {
align-items: baseline;
@ -73,14 +79,12 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
{
super(...args);
this.columnClickHandler = this.columnClickHandler.bind(this);
this.handleSelectAll = this.handleSelectAll.bind(this);
}
connectedCallback()
{
super.connectedCallback();
this.updateComplete.then(() =>
{
this.sort = Sortable.create(this.shadowRoot.querySelector('sl-menu'), {
@ -99,7 +103,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
<sl-icon slot="header" name="check-all" @click=${this.handleSelectAll}
title="${this.egw().lang("Select all")}"
style="font-size:24px"></sl-icon>
<sl-menu @sl-select="${this.columnClickHandler}" part="columns" slot="content">
<sl-menu part="columns" slot="content">
${repeat(this.__columns, (column) => column.id, (column) => this.rowTemplate(column))}
</sl-menu>`;
}
@ -132,13 +136,20 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
*/
protected rowTemplate(column) : TemplateResult
{
let isCustom = column.widget?.instanceOf(et2_nextmatch_customfields) || false;
/* ?disabled=${column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_DISABLED} */
const isCustom = column.widget?.instanceOf(et2_nextmatch_customfields) || false;
const alwaysOn = [et2_dataview_column.ET2_COL_VISIBILITY_ALWAYS, et2_dataview_column.ET2_COL_VISIBILITY_ALWAYS_NOSELECT].indexOf(column.visibility) !== -1;
// Don't show disabled columns
if(column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_DISABLED)
{
return html``;
}
return html`
<sl-menu-item
value="${column.id}"
?checked=${column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_VISIBLE}
value="${column.id.replaceAll(" ", "___")}"
type="checkbox"
?checked=${alwaysOn || column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_VISIBLE}
?disabled=${alwaysOn}
title="${column.title}"
class="${classMap({
select_row: true,
@ -182,14 +193,6 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
<sl-divider></sl-divider>`;
}
columnClickHandler(event)
{
const item = event.detail.item;
// Toggle checked state
item.checked = !item.checked;
}
handleSelectAll(event)
{
let checked = (<SlMenuItem>this.shadowRoot.querySelector("sl-menu-item")).checked || false;
@ -220,7 +223,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
{
menuItem.querySelectorAll("[value][checked]").forEach((cf : SlMenuItem) =>
{
value.push(cf.value);
value.push(cf.value.replaceAll("___", " "));
})
}
}

View File

@ -1,4 +1,4 @@
import {Et2SelectAccount} from "../../Et2Select/Et2SelectAccount";
import {Et2SelectAccount} from "../../Et2Select/Select/Et2SelectAccount";
import {et2_INextmatchHeader} from "../../et2_extension_nextmatch";
import {FilterMixin} from "./FilterMixin";

View File

@ -2,7 +2,8 @@ import {loadWebComponent} from "../../Et2Widget/Et2Widget";
import {Et2Select} from "../../Et2Select/Et2Select";
import {Et2InputWidget, Et2InputWidgetInterface} from "../../Et2InputWidget/Et2InputWidget";
import {FilterMixin} from "./FilterMixin";
import {html, LitElement} from "@lion/core";
import {html, LitElement} from "lit";
import {cleanSelectOptions} from "../../Et2Select/FindSelectOptions";
/**
* Filter by some other type of widget
@ -84,6 +85,20 @@ export class Et2CustomFilterHeader extends FilterMixin(Et2InputWidget(LitElement
}
}
/**
* New filter options from server
* @param new_options
*/
set_select_options(new_options)
{
const widget_class = window.customElements.get(this.filter_node?.localName);
const property = widget_class.getPropertyOptions('select_options');
if(this.filter_node && property)
{
this.filter_node.select_options = cleanSelectOptions(new_options);
}
}
render()
{
return html`

View File

@ -1,6 +1,6 @@
import {egw} from "../../../jsapi/egw_global";
import {et2_INextmatchHeader, et2_nextmatch} from "../../et2_extension_nextmatch";
import {LitElement} from "@lion/core";
import {LitElement} from "lit";
// Export the Interface for TypeScript
type Constructor<T = LitElement> = new (...args : any[]) => T;

View File

@ -15,8 +15,9 @@ import {SlCard} from "@shoelace-style/shoelace";
import interact from "@interactjs/interactjs";
import type {InteractEvent} from "@interactjs/core/InteractEvent";
import {egw} from "../../jsapi/egw_global";
import {classMap, css, html, TemplateResult} from "@lion/core";
import {HasSlotController} from "@shoelace-style/shoelace/dist/internal/slot";
import {css, html, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js";
import type {HasSlotController} from "../../../../node_modules/@shoelace-style/shoelace/dist/internal/slot";
import shoelace from "../Styles/shoelace";
import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {et2_IResizeable} from "../et2_core_interfaces";
@ -92,11 +93,11 @@ export class Et2Portlet extends Et2Widget(SlCard)
user-select: none;
}
.portlet__header et2-button-icon {
.portlet__header .portlet__settings-icon {
display: none;
}
.portlet__header:hover et2-button-icon {
.portlet__header:hover .portlet__settings-icon {
display: initial;
}
@ -577,8 +578,9 @@ export class Et2Portlet extends Et2Widget(SlCard)
<header class="portlet__header">
<slot name="header" part="header" class="card__header">${this.headerTemplate()}</slot>
<et2-button-icon id="settings" name="gear" label="Settings" noSubmit=true
@click="${() => this.edit_settings()}"></et2-button-icon>
<sl-icon-button id="settings" name="gear" label="Settings" class="portlet__settings-icon"
@click="${() => this.edit_settings()}">
</sl-icon-button>
</header>
<slot part="body" class="card__body">${this.bodyTemplate()}</slot>
<slot name="footer" part="footer" class="card__footer">${this.footerTemplate()}</slot>

View File

@ -1,8 +1,8 @@
import {SlMenu} from "@shoelace-style/shoelace";
import {Et2widgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
import {Et2WidgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
import {RowLimitedMixin} from "../Layout/RowLimitedMixin";
import shoelace from "../Styles/shoelace";
import {css, html, TemplateResult} from "@lion/core";
import {css, html, TemplateResult} from "lit";
import {SelectOption} from "./FindSelectOptions";
/**
@ -12,7 +12,7 @@ import {SelectOption} from "./FindSelectOptions";
*
* Use Et2Selectbox in most cases, it's better.
*/
export class Et2Listbox extends RowLimitedMixin(Et2widgetWithSelectMixin(SlMenu))
export class Et2Listbox extends RowLimitedMixin(Et2WidgetWithSelectMixin(SlMenu))
{
static get styles()
@ -40,7 +40,8 @@ export class Et2Listbox extends RowLimitedMixin(Et2widgetWithSelectMixin(SlMenu)
overflow-x: clip;
}
/* Ellipsis when too small */
sl-menu-item.menu-item__label {
sl-option.option__label {
display: block;
text-overflow: ellipsis;
/* This is usually not used due to flex, but is the basis for ellipsis calculation */
@ -153,15 +154,15 @@ export class Et2Listbox extends RowLimitedMixin(Et2widgetWithSelectMixin(SlMenu)
// Tag used must match this.optionTag, but you can't use the variable directly.
// Pass option along so SearchMixin can grab it if needed
return html`
<sl-menu-item
<sl-option
value="${option.value}"
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
class="${option.class}" .option=${option}
?checked=${checked}
.selected=${checked}
>
${icon}
${this.noLang ? option.label : this.egw().lang(option.label)}
</sl-menu-item>`;
</sl-option>`;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,177 +0,0 @@
/**
* EGroupware eTemplate2 - Select Category 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 {css, PropertyValues} from "@lion/core";
import {Et2Select} from "./Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "./StaticOptions";
import {cleanSelectOptions} from "./FindSelectOptions";
/**
* Customised Select widget for categories
* This widget gives us category colors and icons in the options and selected value.
*/
export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
{
static get styles()
{
return [
...super.styles,
css`
/* Category color on options */
::slotted(*) {
border-left: 6px solid var(--category-color, transparent);
}
/* Border on the (single) selected value */
:host(.hasValue:not([multiple])) .select--standard .select__control {
border-left: 6px solid var(--sl-input-border-color);
}
`
]
}
static get properties()
{
return {
...super.properties,
/**
* Include global categories
*/
globalCategories: {type: Boolean},
/**
* Show categories from this application. If not set, will be the current application
*/
application: {type: String},
/**
* Show categories below this parent category
*/
parentCat: {type: Number}
}
}
constructor()
{
super();
// we should not translate categories name
this.noLang = true;
}
async connectedCallback()
{
super.connectedCallback();
if(typeof this.application == 'undefined')
{
this.application =
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
(this.getInstanceManager() && this.getInstanceManager().app) ||
this.egw().app_name();
}
// If app passes options (addressbook index) we'll use those instead.
// They will be found automatically by update() after ID is set.
await this.updateComplete;
if(this.select_options.length == 0)
{
}
}
willUpdate(changedProperties : PropertyValues)
{
super.willUpdate(changedProperties);
if(changedProperties.has("global_categories") || changedProperties.has("application") || changedProperties.has("parentCat"))
{
this.fetchComplete = so.cat(this).then(options =>
{
this.static_options = cleanSelectOptions(options);
this.requestUpdate("select_options");
});
}
if(changedProperties.has("value") || changedProperties.has('select_options'))
{
this.doLabelChange()
}
}
/**
* Override from parent (SlSelect) to customise display of the current value.
* Here's where we add the icon & color border
*/
doLabelChange()
{
// Update the display label when checked menu item's label changes
if(this.multiple)
{
return;
}
const checkedItem = this.menuItems.find(item => item.value === this.value);
this.displayLabel = checkedItem ? checkedItem.textContent : '';
this.querySelector("[slot=prefix].tag_image")?.remove();
if(checkedItem)
{
let image = this._createImage(checkedItem)
if(image)
{
this.append(image);
}
this.dropdown.querySelector(".select__control").style.borderColor =
getComputedStyle(checkedItem).getPropertyValue("--category-color") || "";
}
}
/**
* Render select_options as child DOM Nodes
*
* Overridden here so we can re-do the displayed label after first load of select options.
* Initial load order / lifecycle does not have all the options at the right time
* @protected
*/
protected _renderOptions()
{
// @ts-ignore Doesn't know about Et2WidgetWithSelectMixin._renderOptions()
return super._renderOptions().then(() =>
{
// @ts-ignore Doesn't know about SlSelect.menuItems
if(this.menuItems.length > 0)
{
this.doLabelChange();
}
});
}
/**
* Use a custom tag for when multiple=true
*
* @returns {string}
*/
get tagTag() : string
{
return "et2-category-tag";
}
/**
* Customise how tags are rendered.
* This overrides parent to set application
*
* @param item
* @protected
*/
protected _createTagNode(item)
{
let tag = super._createTagNode(item);
tag.application = this.application;
return tag;
}
}
customElements.define("et2-select-cat", Et2SelectCategory);

View File

@ -1,59 +0,0 @@
/**
* EGroupware eTemplate2 - Select Country 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 {Et2Select} from "./Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "./StaticOptions";
import {egw} from "../../jsapi/egw_global";
import {SelectOption} from "./FindSelectOptions";
/**
* Customised Select widget for countries
* This widget uses CSS from api/templates/default/css/flags.css to set flags
*/
egw(window).includeCSS("api/templates/default/css/flags.css")
export class Et2SelectCountry extends Et2StaticSelectMixin(Et2Select)
{
static get properties()
{
return {
...super.properties,
/* Reflect the value so we can use CSS selectors */
value: {type: String, reflect: true}
}
}
constructor()
{
super();
this.search = true;
(<Promise<SelectOption[]>>so.country(this, {}, true)).then(options =>
{
this.static_options = options
this.requestUpdate("select_options");
});
}
connectedCallback()
{
super.connectedCallback();
// Add element for current value flag
this.querySelector("[slot=prefix].tag_image")?.remove();
let image = document.createElement("span");
image.slot = "prefix";
image.classList.add("tag_image", "flag");
this.appendChild(image);
}
}
customElements.define("et2-select-country", Et2SelectCountry);

View File

@ -8,7 +8,8 @@
*/
import {Et2InputWidget, Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
import {html, LitElement, PropertyValues, render, TemplateResult} from "@lion/core";
import {html, LitElement, PropertyValues, render, TemplateResult} from "lit";
import {property} from "lit/decorators/property.js";
import {et2_readAttrWithDefault} from "../et2_core_xml";
import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions";
import {SearchMixinInterface} from "./SearchMixin";
@ -17,7 +18,7 @@ import {SearchMixinInterface} from "./SearchMixin";
* 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().
* As with most other widgets that extend Shoelace components, do not override render() without good reason.
* 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.
@ -46,46 +47,54 @@ import {SearchMixinInterface} from "./SearchMixin";
* 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 the Interface for TypeScript
type Constructor<T = {}> = new (...args : any[]) => T;
export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(superclass : T) =>
export const Et2WidgetWithSelectMixin = <T extends Constructor<LitElement>>(superclass : T) =>
{
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 ''
*/
emptyLabel: 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, noAccessor: true},
/**
* Limit size
*/
rows: {type: Number, noAccessor: true, reflect: true}
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value attribute will be a space-delimited list of values based on the options selected, and the value property will
* be an array.
*
@property({
noAccessor: true,
converter: {
fromAttribute: (value : string) => value.split(',')
}
}
})
value : string | string[] = "";
*/
/**
* Textual label for first row, eg: 'All' or 'None'. It's value will be ''
*/
@property({type: String})
emptyLabel : String = "";
/**
* Limit size
*/
@property({type: Number, noAccessor: true, reflect: true})
/**
* Internal list of possible select options
*
* This is where we keep options sent from the server. This is not always the complete list, as extending
* classes may have their own options to add in. For example, static options are kept separate, as are search
* results. The select_options getter should give the complete list.
*/
private __select_options : SelectOption[] = [];
/**
* When we create the select option elements, it takes a while.
* If we don't wait for them, it causes issues in SlSelect
*/
protected _optionRenderPromise : Promise<void> = Promise.resolve();
/**
* Options found in the XML when reading the template
@ -101,6 +110,13 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
this.__select_options = <SelectOption[]>[];
}
async getUpdateComplete() : Promise<boolean>
{
const result = await super.getUpdateComplete();
await this._optionRenderPromise;
return result;
}
/** @param {import('@lion/core').PropertyValues } changedProperties */
updated(changedProperties : PropertyValues)
{
@ -116,26 +132,48 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
}
}
}
willUpdate(changedProperties : PropertyValues<this>)
{
// Add in actual option tags to the DOM based on the new select_options
if(changedProperties.has('select_options') || changedProperties.has("emptyLabel"))
{
// Add in options as children to the target node
this._renderOptions();
const optionPromise = this._renderOptions();
// This is needed to display initial load value in some cases, like infolog nm header filters
if(this.handleMenuSlotChange && !this.hasUpdated)
if(typeof this.selectionChanged !== "undefined")
{
this.handleMenuSlotChange();
optionPromise.then(async() =>
{
await this.updateComplete;
this.selectionChanged();
});
}
}
}
public getValueAsArray()
{
if(Array.isArray(this.value))
{
return this.value;
}
if(this.value == "null" || this.value == null || typeof this.value == "undefined" || !this.emptyLabel && this.value == "")
{
return [];
}
return [this.value];
}
/**
* Render select_options as child DOM Nodes
* @protected
*/
protected _renderOptions()
{
return Promise.resolve();
// Add in options as children to the target node
if(!this._optionTargetNode)
{
@ -156,7 +194,7 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
.map(this._groupTemplate.bind(this))}`;
render(options, temp_target);
return Promise.all(([...temp_target.querySelectorAll(":scope > *")].map(item => item.render)))
this._optionRenderPromise = Promise.all(([...temp_target.querySelectorAll(":scope > *")].map(item => item.render)))
.then(() =>
{
this._optionTargetNode.replaceChildren(
@ -168,23 +206,7 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
this.handleMenuSlotChange();
}
});
}
/**
* 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);
return this._optionRenderPromise;
}
/**
@ -212,6 +234,13 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
this.select_options = <SelectOption[]>new_options;
}
/**
* 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[]
*/
@property({type: Object})
get select_options() : SelectOption[]
{
return this.__select_options;
@ -262,7 +291,7 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
* @param {SelectOption} option
* @returns {TemplateResult}
*/
_optionTemplate(option : SelectOption) : TemplateResult
protected _optionTemplate(option : SelectOption) : TemplateResult
{
return html`
<span>Override _optionTemplate(). ${option.value} => ${option.label}</span>`;
@ -276,7 +305,7 @@ export const Et2widgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
}
return html`
<sl-menu-label>${this.noLang ? option.label : this.egw().lang(option.label)}</sl-menu-label>
<small>${this.noLang ? option.label : this.egw().lang(option.label)}</small>
${option.value.map(this._optionTemplate.bind(this))}
<sl-divider></sl-divider>
`;

View File

@ -18,6 +18,9 @@ export interface SelectOption
// Show the option, but it is not selectable.
// If multiple=true and the option is in the value, it is not removable.
disabled? : boolean;
// If a search is in progress, does this option match.
// Automatically changed.
isMatch? : boolean;
}
/**

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,13 @@
* @author Ralf Becker <rb@egroupware.org>
*/
import {Et2Select} from "./Et2Select";
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
import {SelectAccountMixin} from "./SelectAccountMixin";
import {Et2StaticSelectMixin} from "./StaticOptions";
import {html, nothing} from "@lion/core";
import {Et2Select} from "../Et2Select";
import {cleanSelectOptions, SelectOption} from "../FindSelectOptions";
import {SelectAccountMixin} from "../SelectAccountMixin";
import {Et2StaticSelectMixin} from "../StaticOptions";
import {html, nothing} from "lit";
export type AccountType = 'accounts'|'groups'|'both'|'owngroups';
export type AccountType = 'accounts' | 'groups' | 'both' | 'owngroups';
/**
* @customElement et2-select-account
@ -51,32 +51,47 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
super.connectedCallback();
// Start fetch of select_options
this.fetchComplete = this._getAccounts();
}
/**
* Pre-fill the account list according to type & preferences
*
* @protected
* @internal
*/
protected _getAccounts()
{
const type = this.egw().preference('account_selection', 'common');
let fetch = [];
let process = (options) =>
{
// Shallow copy to avoid re-using the same object.
// Uses more memory, but otherwise multiple selectboxes get "tied" together
let cleaned = cleanSelectOptions(options)
// slice to avoid problems with lots of accounts
.slice(0, /* Et2WidgetWithSearch.RESULT_LIMIT */ 100);
this.account_options = this.account_options.concat(cleaned);
};
// for primary_group we only display owngroups == own memberships, not other groups
if(type === 'primary_group' && this.accountType !== 'accounts')
{
if(this.accountType === 'both')
{
fetch.push(this.egw().accounts('accounts').then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
fetch.push(this.egw().accounts('accounts').then(process));
}
fetch.push(this.egw().accounts('owngroups').then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
fetch.push(this.egw().accounts('owngroups').then(process));
}
else if(["primary_group", "groupmembers"].includes(type))
else if(type !== "none")
{
fetch.push(this.egw().accounts(this.accountType).then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
fetch.push(this.egw().accounts(this.accountType).then(process));
}
this.fetchComplete = Promise.all(fetch)
.then(() => this._renderOptions());
}
firstUpdated(changedProperties?)
{
super.firstUpdated(changedProperties);
// Due to the different way Et2SelectAccount handles options, we call this explicitly
this._renderOptions();
return Promise.all(fetch).then(() =>
{
this.requestUpdate("select_options");
});
}
set accountType(type : AccountType)
@ -102,12 +117,7 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
{
return [];
}
let select_options : Array<SelectOption> = [...(this.static_options || []), ...super.select_options];
return select_options.filter((value, index, self) =>
{
return self.findIndex(v => v.value === value.value) === index;
});
return super.select_options;
}
set select_options(new_options : SelectOption[])

View File

@ -0,0 +1,17 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
import {cleanSelectOptions} from "../FindSelectOptions";
export class Et2SelectApp extends Et2StaticSelectMixin(Et2Select)
{
public connectedCallback()
{
super.connectedCallback()
this.fetchComplete = so.app(this, {}).then((options) =>
{
this.set_static_options(cleanSelectOptions(options));
})
}
}
customElements.define("et2-select-app", Et2SelectApp);

View File

@ -0,0 +1,26 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin} from "../StaticOptions";
export class Et2SelectBitwise extends Et2StaticSelectMixin(Et2Select)
{
/* currently handled server-side */
/*
set value(new_value)
{
let oldValue = this._value;
let expanded_value = [];
let options = this.select_options;
for(let index in options)
{
let right = parseInt(options[index].value);
if(!!(new_value & right))
{
expanded_value.push(right);
}
}
super.value = expanded_value;
}
*/
}
customElements.define("et2-select-bitwise", Et2SelectBitwise);

View File

@ -0,0 +1,28 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
export class Et2SelectBool extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.bool(this);
}
get value()
{
return super.value;
}
/**
* Boolean option values are "0" and "1", so change boolean to those
* @param {string | string[]} new_value
*/
set value(new_value)
{
super.value = new_value ? "1" : "0";
}
}
customElements.define("et2-select-bool", Et2SelectBool);

View File

@ -0,0 +1,145 @@
/**
* EGroupware eTemplate2 - Select Category 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 {css, html, nothing, PropertyValues, TemplateResult, unsafeCSS} from "lit";
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
import {cleanSelectOptions} from "../FindSelectOptions";
import {StaticValue} from "lit/development/static-html";
import {literal} from "lit/static-html.js";
import {repeat} from "lit/directives/repeat.js";
/**
* Customised Select widget for categories
* This widget gives us category colors and icons in the options and selected value.
*/
export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
{
static get styles()
{
return [
...super.styles,
css`
/* Category color on options */
sl-option {
border-left: 6px solid var(--category-color, transparent);
}
/* Border on the (single) selected value */
:host(:not([multiple]))::part(combobox) {
border-left: 6px solid var(--category-color, var(--sl-input-border-color));
}
`
]
}
static get properties()
{
return {
...super.properties,
/**
* Include global categories
*/
globalCategories: {type: Boolean},
/**
* Show categories from this application. If not set, will be the current application
*/
application: {type: String},
/**
* Show categories below this parent category
*/
parentCat: {type: Number}
}
}
constructor()
{
super();
// we should not translate categories name
this.noLang = true;
}
async connectedCallback()
{
super.connectedCallback();
if(typeof this.application == 'undefined')
{
this.application =
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
(this.getInstanceManager() && this.getInstanceManager().app) ||
this.egw().app_name();
}
}
willUpdate(changedProperties : PropertyValues)
{
super.willUpdate(changedProperties);
if(changedProperties.has("global_categories") || changedProperties.has("application") || changedProperties.has("parentCat"))
{
this.fetchComplete = so.cat(this).then(options =>
{
this._static_options = cleanSelectOptions(options);
this.requestUpdate("select_options");
});
}
}
protected handleValueChange(e)
{
super.handleValueChange(e);
// Just re-draw to get the colors & icon
this.requestUpdate();
}
/**
* Custom, dynamic styling
*
* CSS variables are not making it through to options, re-declaring them here works
*
* @returns {TemplateResult}
* @protected
*/
protected _styleTemplate() : TemplateResult
{
return html`
<style>
${repeat(this.select_options, (option) =>
{
if(typeof option.color == "undefined" || !option.color)
{
return nothing;
}
return unsafeCSS(
(this.getValueAsArray().includes(option.value) ? "::part(combobox) { --category-color: " + option.color + ";}" : "") +
".cat_" + option.value + " {--category-color: " + option.color + ";}"
);
})}
</style>
`;
}
/**
* Use a custom tag for when multiple=true
*
* @returns {string}
*/
public get tagTag() : StaticValue
{
return literal`et2-category-tag`;
}
}
customElements.define("et2-select-cat", Et2SelectCategory);

View File

@ -0,0 +1,97 @@
/**
* EGroupware eTemplate2 - Select Country 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 {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
import {egw} from "../../../jsapi/egw_global";
import {SelectOption} from "../FindSelectOptions";
import {html} from "lit";
/**
* Customised Select widget for countries
* This widget uses CSS from api/templates/default/css/flags.css to set flags
*/
egw(window).includeCSS("api/templates/default/css/flags.css")
export class Et2SelectCountry extends Et2StaticSelectMixin(Et2Select)
{
static get properties()
{
return {
...super.properties,
/* Reflect the value so we can use CSS selectors */
value: {type: String, reflect: true}
}
}
constructor()
{
super();
this.search = true;
(<Promise<SelectOption[]>>so.country(this, {}, true)).then(options =>
{
this._static_options = options
this.requestUpdate("select_options");
});
}
connectedCallback()
{
super.connectedCallback();
}
/**
* Get the element for the flag
*
* @param option
* @protected
*/
protected _iconTemplate(option)
{
return html`
<span slot="prefix" part="flag country_${option.value}_flag"
style="width: var(--icon-width)">
</span>`;
}
/**
* Used to render each option into the select
* Override to get flags in
*
* @param {SelectOption} option
* @returns {TemplateResult}
*
protected _optionTemplate(option : SelectOption) : TemplateResult
{
// Exclude non-matches when searching
if(typeof option.isMatch == "boolean" && !option.isMatch)
{
return html``;
}
return html`
<sl-option
part="option"
value="${value}"
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
class="${option.class}" .option=${option}
.selected=${this.getValueAsArray().some(v => v == value)}
?disabled=${option.disabled}
>
${this._iconTemplate(option)}
${this.noLang ? option.label : this.egw().lang(option.label)}
</sl-option>`;
}
*/
}
customElements.define("et2-select-country", Et2SelectCountry);

View File

@ -0,0 +1,14 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
export class Et2SelectDay extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = so.day(this, {});
}
}
customElements.define("et2-select-day", Et2SelectDay);

View File

@ -0,0 +1,53 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
import {cleanSelectOptions} from "../FindSelectOptions";
export class Et2SelectDayOfWeek extends Et2StaticSelectMixin(Et2Select)
{
connectedCallback()
{
super.connectedCallback();
// Wait for connected instead of constructor because attributes make a difference in
// which options are offered
this.fetchComplete = StaticOptions.dow(this, {other: this.other || []}).then(options =>
{
this.set_static_options(cleanSelectOptions(options));
});
}
set value(new_value)
{
let expanded_value = typeof new_value == "object" ? new_value : [];
if(new_value && (typeof new_value == "string" || typeof new_value == "number"))
{
let int_value = parseInt(new_value);
this.updateComplete.then(() =>
{
this.fetchComplete.then(() =>
{
let options = this.select_options;
for(let index in options)
{
let right = parseInt(options[index].value);
if((int_value & right) == right)
{
expanded_value.push("" + right);
}
}
super.value = expanded_value;
})
});
return;
}
super.value = expanded_value;
}
get value()
{
return super.value;
}
}
customElements.define("et2-select-dow", Et2SelectDayOfWeek);

View File

@ -7,11 +7,12 @@
* @author Nathan Gray
*/
import {Et2Select} from "./Et2Select";
import {css, html, nothing, PropertyValues} from "@lion/core";
import {IsEmail} from "../Validators/IsEmail";
import {Et2Select} from "../Et2Select";
import {css, html, nothing, PropertyValues} from "lit";
import {IsEmail} from "../../Validators/IsEmail";
import interact from "@interactjs/interact";
import {Validator} from "@lion/form-core";
import {classMap} from "lit/directives/class-map.js";
/**
* Select email address(es)
@ -100,6 +101,7 @@ export class Et2SelectEmail extends Et2Select
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
}
/** @param {import('@lion/core').PropertyValues } changedProperties */
willUpdate(changedProperties : PropertyValues)
{
@ -112,9 +114,38 @@ export class Et2SelectEmail extends Et2Select
}
}
connectedCallback()
updated(changedProperties : Map<string, any>)
{
super.connectedCallback();
// Make tags draggable
if(!this.readonly && this.allowFreeEntries && this.allowDragAndDrop)
{
let dragTranslate = {x: 0, y: 0};
const tags = this.shadowRoot.querySelectorAll(".select__tags [part='tag']");
let draggable = interact(tags).draggable({
startAxis: 'xy',
listeners: {
start: function(e)
{
let dragPosition = {x: e.page.x, y: e.page.y};
dragTranslate = {x: 0, y: 0};
e.target.setAttribute('style', `width:${e.target.clientWidth}px !important`);
e.target.style.position = 'fixed';
e.target.style.zIndex = 10;
e.target.style.transform =
`translate(${dragPosition.x}px, ${dragPosition.y}px)`;
},
move: function(e)
{
dragTranslate.x += e.delta.x;
dragTranslate.y += e.delta.y;
e.target.style.transform =
`translate(${dragTranslate.x}px, ${dragTranslate.y}px)`;
}
}
});
// set parent_node with widget context in order to make it accessible after drop
draggable.parent_node = this;
}
}
protected _bindListeners()
@ -145,7 +176,7 @@ export class Et2SelectEmail extends Et2Select
}
});
}
/**
* Handle keypresses inside the search input
* Overridden from parent to also skip the hidden selected options, which other selects do not do
@ -187,55 +218,27 @@ export class Et2SelectEmail extends Et2Select
*
* @returns {string}
*/
get tagTag() : string
_tagTemplate(option, index)
{
return "et2-email-tag";
}
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
const isEditable = this.editModeEnabled && !readonly;
/**
* override tag creation in order to add DND functionality
* @param item
* @protected
*/
protected _createTagNode(item)
{
let tag = super._createTagNode(item);
tag.fullEmail = this.fullEmail;
tag.onlyEmail = this.onlyEmail;
// Re-set after setting fullEmail as that can change what we show
tag.textContent = item.getTextLabel().trim();
if(!this.readonly && this.allowFreeEntries && this.allowDragAndDrop)
{
let dragTranslate = {x: 0, y: 0};
tag.class = item.classList.value + " et2-select-draggable";
let draggable = interact(tag).draggable({
startAxis: 'xy',
listeners: {
start: function(e)
{
let dragPosition = {x:e.page.x, y:e.page.y};
e.target.setAttribute('style', `width:${e.target.clientWidth}px !important`);
e.target.style.position = 'fixed';
e.target.style.zIndex = 10;
e.target.style.transform =
`translate(${dragPosition.x}px, ${dragPosition.y}px)`;
},
move : function(e)
{
dragTranslate.x += e.delta.x;
dragTranslate.y += e.delta.y;
e.target.style.transform =
`translate(${dragTranslate.x}px, ${dragTranslate.y}px)`;
}
}
});
// set parent_node with widget context in order to make it accessible after drop
draggable.parent_node = this;
}
return tag;
return html`
<et2-email-tag
class=${classMap({
...option.classList,
"et2-select-draggable": !this.readonly && this.allowFreeEntries && this.allowDragAndDrop
})}
.fullEmail=${this.fullEmail}
.onlyEmail=${this.onlyEmail}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
.value=${option.value.replaceAll("___", " ")}
>
${option.getTextLabel().trim()}
</et2-email-tag>
`;
}
/**
@ -258,16 +261,6 @@ export class Et2SelectEmail extends Et2Select
</et2-lavatar>`;
}
/**
* Override image to skip it, we add images in Et2EmailTag using CSS
* @param item
* @protected
*/
protected _createImage(item)
{
return this.multiple ? "" : super._createImage(item);
}
/**
* Overwritten to NOT split RFC822 addresses containing a comma in quoted name part
*

View File

@ -0,0 +1,14 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
export class Et2SelectHour extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.hour(this, {other: this.other || []});
}
}
customElements.define("et2-select-hour", Et2SelectHour);

View File

@ -0,0 +1,14 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
export class Et2SelectLang extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.lang(this, {other: this.other || []});
}
}
customElements.define("et2-select-lang", Et2SelectLang);

View File

@ -0,0 +1,14 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
export class Et2SelectMonth extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.month(this);
}
}
customElements.define("et2-select-month", Et2SelectMonth);

View File

@ -0,0 +1,52 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
import {PropertyValues} from 'lit';
export class Et2SelectNumber extends Et2StaticSelectMixin(Et2Select)
{
static get properties()
{
return {
...super.properties,
/**
* Step between numbers
*/
interval: {type: Number},
min: {type: Number},
max: {type: Number},
/**
* Add one or more leading zeros
* Set to how many zeros you want (000)
*/
leading_zero: {type: String},
/**
* Appended after every number
*/
suffix: {type: String}
}
}
constructor()
{
super();
this.min = 1;
this.max = 10;
this.interval = 1;
this.leading_zero = "";
this.suffix = "";
}
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
if(changedProperties.has('min') || changedProperties.has('max') || changedProperties.has('interval') || changedProperties.has('suffix'))
{
this._static_options = StaticOptions.number(this);
this.requestUpdate("select_options");
}
}
}
customElements.define("et2-select-number", Et2SelectNumber);

View File

@ -0,0 +1,15 @@
import {Et2SelectNumber} from "./Et2SelectNumber";
export class Et2SelectPercent extends Et2SelectNumber
{
constructor()
{
super();
this.min = 0;
this.max = 100;
this.interval = 10;
this.suffix = "%%";
}
}
customElements.define("et2-select-percent", Et2SelectPercent);

View File

@ -0,0 +1,14 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
export class Et2SelectPriority extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.priority(this);
}
}
customElements.define("et2-select-priority", Et2SelectPriority);

View File

@ -8,12 +8,13 @@
*/
import {css, html, LitElement, repeat, TemplateResult} from "@lion/core";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {Et2StaticSelectMixin, StaticOptions, StaticOptions as so} from "./StaticOptions";
import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions";
import {SelectAccountMixin} from "./SelectAccountMixin";
import {css, html, LitElement, TemplateResult} from "lit";
import {repeat} from "lit/directives/repeat.js";
import {et2_IDetachedDOM} from "../../et2_core_interfaces";
import {Et2Widget} from "../../Et2Widget/Et2Widget";
import {Et2StaticSelectMixin, StaticOptions, StaticOptions as so} from "../StaticOptions";
import {cleanSelectOptions, find_select_options, SelectOption} from "../FindSelectOptions";
import {SelectAccountMixin} from "../SelectAccountMixin";
/**
* This is a stripped-down read-only widget used in nextmatch
@ -143,6 +144,11 @@ li {
return this.value;
}
getValueAsArray()
{
return (Array.isArray(this.value) ? this.value : [this.value]);
}
set value(new_value : string | string[])
{
// Split anything that is still a CSV
@ -206,10 +212,11 @@ li {
render()
{
const value = this.getValueAsArray();
return html`
<ul>
${repeat(
(Array.isArray(this.value) ? this.value : [this.value]),
this.getValueAsArray(),
(val : string) => val, (val) =>
{
let option = (<SelectOption[]>this.select_options).find(option => option.value == val);
@ -282,14 +289,16 @@ customElements.define("et2-select-app_ro", Et2SelectAppReadonly);
export class Et2SelectBitwiseReadonly extends Et2SelectReadonly
{
/* Currently handled server side, we get an array
render()
{
let new_value = [];
let int_value = parseInt(this.value);
for(let index in this.select_options)
{
let option = this.select_options[index];
let right = parseInt(option && option.value ? option.value : index);
if(!!(this.value & right))
if(!!(int_value & right))
{
new_value.push(right);
}
@ -307,6 +316,8 @@ export class Et2SelectBitwiseReadonly extends Et2SelectReadonly
})}
</ul>`;
}
*/
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
@ -349,7 +360,6 @@ export class Et2SelectPercentReadonly extends Et2SelectReadonly
constructor()
{
super(...arguments);
this.suffix = "%%";
this.select_options = so.percent(this);
}
}
@ -391,6 +401,23 @@ export class Et2SelectDayOfWeekReadonly extends Et2StaticSelectMixin(Et2SelectRe
this.set_static_options(cleanSelectOptions(options));
});
}
getValueAsArray()
{
let expanded_value = [];
let int_value = parseInt(this.value);
let options = this.select_options;
for(let index in options)
{
let right = parseInt(<string>options[index].value);
if((int_value & right) == right)
{
expanded_value.push("" + right);
}
}
return expanded_value;
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
@ -422,7 +449,7 @@ export class Et2SelectNumberReadonly extends Et2StaticSelectMixin(Et2SelectReado
{
protected find_select_options(_attrs)
{
this.static_options = so.number(this, _attrs);
this._static_options = so.number(this, _attrs);
}
}

View File

@ -0,0 +1,45 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
import {SelectOption} from "../FindSelectOptions";
export class Et2SelectState extends Et2StaticSelectMixin(Et2Select)
{
/**
* Two-letter ISO country code
*/
protected __countryCode;
static get properties()
{
return {
...super.properties,
countryCode: String,
}
}
constructor()
{
super();
this.countryCode = 'DE';
}
get countryCode()
{
return this.__countryCode;
}
set countryCode(code : string)
{
this.__countryCode = code;
this._static_options = <SelectOption[]>StaticOptions.state(this, {country_code: code});
this.requestUpdate("select_options");
}
set_country_code(code)
{
this.countryCode = code;
}
}
customElements.define("et2-select-state", Et2SelectState);

View File

@ -0,0 +1,61 @@
import {Et2SelectApp} from "./Et2SelectApp";
import {SelectOption} from "../FindSelectOptions";
export class Et2SelectTab extends Et2SelectApp
{
constructor()
{
super();
this.allowFreeEntries = true;
}
set value(new_value)
{
if(!new_value)
{
super.value = new_value;
return;
}
const values = Array.isArray(new_value) ? new_value : [new_value];
const options = this.select_options;
values.forEach(value =>
{
if(!options.filter(option => option.value == value).length)
{
const matches = value.match(/^([a-z0-9]+)\-/i);
let option : SelectOption = {value: value, label: value};
if(matches)
{
option = options.filter(option => option.value == matches[1])[0] || {
value: value,
label: this.egw().lang(matches[1])
};
option.value = value;
option.label += ' ' + this.egw().lang('Tab');
}
try
{
const app = opener?.framework.getApplicationByName(value);
if(app && app.displayName)
{
option.label = app.displayName;
}
}
catch(e)
{
// ignore security exception, if opener is not accessible
}
this.select_options.concat(option);
}
})
super.value = new_value;
}
get value()
{
return super.value;
}
}
customElements.define("et2-select-tab", Et2SelectTab);

View File

@ -7,9 +7,9 @@
* @author Nathan Gray
*/
import {Et2Select} from "./Et2Select";
import {css} from "@lion/core";
import {SelectOption} from "./FindSelectOptions";
import {Et2Select} from "../Et2Select";
import {css} from "lit";
import {SelectOption} from "../FindSelectOptions";
export class Et2SelectThumbnail extends Et2Select
{

View File

@ -0,0 +1,17 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
import {cleanSelectOptions} from "../FindSelectOptions";
export class Et2SelectTimezone extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this.fetchComplete = StaticOptions.timezone(this, {other: this.other ?? []}).then((options) =>
{
this.set_static_options(cleanSelectOptions(options));
})
}
}
customElements.define("et2-select-timezone", Et2SelectTimezone);

View File

@ -0,0 +1,25 @@
import {Et2SelectNumber} from "./Et2SelectNumber";
import {PropertyValues} from "lit";
import {StaticOptions} from "../StaticOptions";
export class Et2SelectYear extends Et2SelectNumber
{
constructor()
{
super();
this.min = -3;
this.max = 2;
}
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
if(changedProperties.has('min') || changedProperties.has('max') || changedProperties.has('interval') || changedProperties.has('suffix'))
{
this._static_options = StaticOptions.year(this);
}
}
}
customElements.define("et2-select-year", Et2SelectYear);

View File

@ -1,5 +1,5 @@
import {SelectOption} from "./FindSelectOptions";
import {LitElement} from "@lion/core";
import {LitElement} from "lit";
/**
* EGroupware eTemplate2 - SelectAccountMixin
@ -110,7 +110,8 @@ export const SelectAccountMixin = <T extends Constructor<LitElement>>(superclass
get select_options()
{
return [...(this.account_options || []), ...super.select_options];
return [...new Map([...this.account_options, ...(super.select_options || [])].map(item =>
[item.value, item])).values()];
}
set select_options(value : SelectOption[])

View File

@ -0,0 +1,25 @@
/**
* Import all our sub-types
*/
import './Select/Et2SelectAccount';
import './Select/Et2SelectApp';
import './Select/Et2SelectBitwise';
import './Select/Et2SelectBool';
import './Select/Et2SelectCategory';
import './Select/Et2SelectCountry';
import './Select/Et2SelectDay';
import './Select/Et2SelectDayOfWeek';
import './Select/Et2SelectEmail';
import './Select/Et2SelectHour';
import './Select/Et2SelectLang';
import './Select/Et2SelectMonth';
import './Select/Et2SelectNumber';
import './Select/Et2SelectPercent';
import './Select/Et2SelectPriority';
import './Select/Et2SelectReadonly';
import './Select/Et2SelectState';
import './Select/Et2SelectTab';
import './Select/Et2SelectThumbnail';
import './Select/Et2SelectTimezone';
import './Select/Et2SelectYear';

View File

@ -8,11 +8,18 @@
* @param {type} widget
*/
import {sprintf} from "../../egw_action/egw_action_common";
import {Et2SelectReadonly} from "./Et2SelectReadonly";
import {Et2SelectReadonly} from "./Select/Et2SelectReadonly";
import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions";
import {Et2Select, Et2WidgetWithSelect} from "./Et2Select";
import {state} from "lit/decorators/state.js";
export type Et2SelectWidgets = Et2Select | Et2WidgetWithSelect | Et2SelectReadonly;
type NumberOptions = {
min? : number,
max? : number,
interval? : number,
format? : string
};
// Export the Interface for TypeScript
type Constructor<T = {}> = new (...args : any[]) => T;
@ -31,27 +38,25 @@ export const Et2StaticSelectMixin = <T extends Constructor<Et2WidgetWithSelect>>
// Hold the static widget options separately so other options (like sent from server in sel_options) won't
// conflict or be wiped out
protected static_options : SelectOption[];
@state()
protected _static_options : SelectOption[] = [];
// If widget needs to fetch options from server, we might want to wait for them
protected fetchComplete : Promise<SelectOption[] | void>;
@state()
protected fetchComplete : Promise<SelectOption[] | void> = Promise.resolve();
constructor(...args)
async getUpdateComplete() : Promise<boolean>
{
super(...args);
this.static_options = [];
this.fetchComplete = Promise.resolve();
// Trigger the options to get rendered into the DOM
this.requestUpdate("select_options");
const result = await super.getUpdateComplete();
await this.fetchComplete;
return result;
}
get select_options() : SelectOption[]
{
// @ts-ignore
const options = super.select_options || [];
const statics = this.static_options || [];
const statics = this._static_options || [];
if(options.length == 0)
{
@ -62,7 +67,7 @@ export const Et2StaticSelectMixin = <T extends Constructor<Et2WidgetWithSelect>>
return options;
}
// Merge & make sure result is unique
return [...new Map([...options, ...statics].map(item =>
return [...new Map([...options, ...(this._static_options || [])].map(item =>
[item.value, item])).values()];
}
@ -75,7 +80,7 @@ export const Et2StaticSelectMixin = <T extends Constructor<Et2WidgetWithSelect>>
set_static_options(new_static_options)
{
this.static_options = new_static_options;
this._static_options = new_static_options;
this.requestUpdate("select_options");
}
@ -273,19 +278,14 @@ export const StaticOptions = new class StaticOptionsType
];
}
number(widget : Et2SelectWidgets, attrs = {
min: undefined,
max: undefined,
interval: undefined,
format: undefined
}) : SelectOption[]
number(widget : Et2SelectWidgets, attrs : NumberOptions = {}) : SelectOption[]
{
var options = [];
var min = parseFloat(attrs.min ?? widget.min ?? 1);
var max = parseFloat(attrs.max ?? widget.max ?? 10);
var interval = parseFloat(attrs.interval ?? widget.interval ?? 1);
var format = attrs.format ?? '%d';
const options = [];
const min = parseFloat(attrs.min ?? widget.min ?? 1);
const max = parseFloat(attrs.max ?? widget.max ?? 10);
let interval = parseFloat(attrs.interval ?? widget.interval ?? 1);
let format = attrs.format ?? '%d';
// leading zero specified in interval
if(widget.leading_zero && widget.leading_zero[0] == '0')
@ -313,7 +313,7 @@ export const StaticOptions = new class StaticOptionsType
percent(widget : Et2SelectWidgets) : SelectOption[]
{
return this.number(widget, {min: 0, max: 100, interval: 10, format: undefined});
return this.number(widget, {min: 0, max: 100, interval: 10, format: "%d%%"});
}
year(widget : Et2SelectWidgets, attrs?) : SelectOption[]
@ -323,15 +323,14 @@ export const StaticOptions = new class StaticOptionsType
attrs = {}
}
var t = new Date();
attrs.min = t.getFullYear() + parseInt(widget.min);
attrs.max = t.getFullYear() + parseInt(widget.max);
attrs.min = t.getFullYear() + parseInt(attrs.min ?? widget.min ?? -3);
attrs.max = t.getFullYear() + parseInt(attrs.max ?? widget.max ?? 2);
return this.number(widget, attrs);
}
day(widget : Et2SelectWidgets, attrs) : SelectOption[]
{
attrs.other = [1, 31, 1];
return this.number(widget, attrs);
return this.number(widget, {min: 1, max: 31, interval: 1});
}
hour(widget : Et2SelectWidgets, attrs) : SelectOption[]
@ -394,9 +393,9 @@ export const StaticOptions = new class StaticOptionsType
return this.cached_server_side(widget, 'select-lang', options);
}
timezone(widget : Et2SelectWidgets, attrs) : SelectOption[] | Promise<SelectOption[]>
timezone(widget : Et2SelectWidgets, attrs) : Promise<SelectOption[]>
{
var options = ',' + (attrs.other || []).join(',');
return this.cached_server_side(widget, 'select-timezone', options);
return <Promise<SelectOption[]>>this.cached_server_side(widget, 'select-timezone', options, true);
}
}

View File

@ -6,7 +6,7 @@
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {css, html, TemplateResult} from "@lion/core";
import {css, html, TemplateResult} from "lit";
import shoelace from "../../Styles/shoelace";
import {Et2Tag} from "./Et2Tag";

View File

@ -6,7 +6,8 @@
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {classMap, css, html, nothing, PropertyValues, TemplateResult} from "@lion/core";
import {css, html, nothing, PropertyValues, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js";
import shoelace from "../../Styles/shoelace";
import {Et2Tag} from "./Et2Tag";
@ -56,6 +57,15 @@ export class Et2EmailTag extends Et2Tag
.tag__remove {
order: 3;
}
/* Shoelace disabled gives a not-allowed cursor, but we also set disabled for read-only.
* We don't want the not-allowed cursor, since you can always click the email address
*/
:host([readonly]) {
cursor: pointer !important;
}
`];
}
@ -95,8 +105,8 @@ export class Et2EmailTag extends Et2Tag
this.onlyEmail = false;
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleContactClick = this.handleContactClick.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleContactMouseDown = this.handleContactMouseDown.bind(this);
}
connectedCallback()
@ -166,7 +176,7 @@ export class Et2EmailTag extends Et2Tag
this.shadowRoot.querySelector(".tag").classList.remove("contact_plus");
}
handleClick(e : MouseEvent)
handleMouseDown(e : MouseEvent)
{
e.stopPropagation();
@ -177,7 +187,7 @@ export class Et2EmailTag extends Et2Tag
this.egw().open('', 'addressbook', 'add', extra);
}
handleContactClick(e : MouseEvent)
handleContactMouseDown(e : MouseEvent)
{
e.stopPropagation();
this.checkContact(this.value).then((result) =>
@ -217,15 +227,15 @@ export class Et2EmailTag extends Et2Tag
{
let content = this.value;
// If there's a name, just show the name, otherwise show the email
if(!this.onlyEmail && Et2EmailTag.email_cache[this.value])
if(!this.onlyEmail && Et2EmailTag.email_cache[content])
{
// Append current value as email, data may have work & home email in it
content = (Et2EmailTag.email_cache[this.value]?.n_fn || "") + " <" + (Et2EmailTag.splitEmail(this.value)?.email || this.value) + ">"
content = (Et2EmailTag.email_cache[content]?.n_fn || "") + " <" + (Et2EmailTag.splitEmail(content)?.email || content) + ">"
}
if (this.onlyEmail)
{
const split = Et2EmailTag.splitEmail(content);
content = split.email || this.value;
content = split.email || content;
}
else if(!this.fullEmail)
{
@ -255,7 +265,7 @@ export class Et2EmailTag extends Et2Tag
button_or_avatar = html`
<et2-lavatar slot="prefix" part="icon"
@click=${this.handleContactClick}
@mousedown=${this.handleContactMouseDown}
.size=${style.getPropertyValue("--icon-width")}
lname=${option.lname || nothing}
fname=${option.fname || nothing}
@ -269,7 +279,7 @@ export class Et2EmailTag extends Et2Tag
// Show a button to add as new contact
classes['tag__has_plus'] = true;
button_or_avatar = html`
<et2-button-icon image="add" @click=${this.handleClick}
<et2-button-icon image="add" @mousedown=${this.handleMouseDown}
label="${this.egw().lang("Add a new contact")}"
statustext="${this.egw().lang("Add a new contact")}">
</et2-button-icon>`;

View File

@ -8,7 +8,8 @@
*/
import {Et2Widget} from "../../Et2Widget/Et2Widget";
import {SlTag} from "@shoelace-style/shoelace";
import {classMap, css, html, TemplateResult} from "@lion/core";
import {css, html, TemplateResult} from "lit";
import {classMap} from "lit/directives/class-map.js";
import shoelace from "../../Styles/shoelace";
/**
@ -23,7 +24,6 @@ export class Et2Tag extends Et2Widget(SlTag)
shoelace, css`
:host {
flex: 1 1 auto;
overflow: hidden;
}
.tag--pill {
@ -35,6 +35,9 @@ export class Et2Tag extends Et2Widget(SlTag)
width: 20px;
}
.tag__prefix {
line-height: normal;
}
.tag__content {
padding: 0px 0.2rem;
flex: 1 2 auto;
@ -113,11 +116,12 @@ export class Et2Tag extends Et2Widget(SlTag)
<sl-icon-button
part="remove-button"
exportparts="base:remove-button__base"
name="x"
name="x-lg"
library="system"
label=${this.egw().lang('remove')}
class="tag__remove"
@click=${this.handleRemoveClick}
tabindex="-1"
></sl-icon-button>
`
: ''}

View File

@ -6,7 +6,7 @@
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {css} from "@lion/core";
import {css} from "lit";
import shoelace from "../../Styles/shoelace";
import {Et2Tag} from "./Et2Tag";

View File

@ -13,6 +13,7 @@ window.egw = {
};
let element : Et2Select;
const tag_name = "et2-tag";
async function before(editable = true)
{
@ -20,16 +21,19 @@ async function before(editable = true)
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" value="one" multiple="true" .editModeEnabled=${editable}>
<sl-menu-item value="one">One</sl-menu-item>
<sl-menu-item value="two">Two</sl-menu-item>
<option value="one">One</option>
<option value="two">Two</option>
</et2-select>
`);
// Need to call loadFromXML() explicitly to read the options
element.loadFromXML(element);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await element.updateComplete;
let tags = [];
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tags.push(t.updateComplete));
element.shadowRoot.querySelectorAll(tag_name).forEach((t : Et2Tag) => tags.push(t.updateComplete));
await Promise.all(tags);
return element;
@ -48,30 +52,29 @@ describe("Editable tag", () =>
it("Tag editable matches editModeEnabled", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
let tag = element.select.combobox.querySelectorAll(tag_name);
assert.isAbove(tag.length, 0, "No tags found");
assert.isTrue(tag[0].editable);
// Change it to false & force immediate update
element.editModeEnabled = false;
element.syncItemsFromValue();
element.requestUpdate();
await element.updateComplete;
tag = element.shadowRoot.querySelectorAll(element.tagTag);
tag = element.select.combobox.querySelectorAll(tag_name);
assert.isAbove(tag.length, 0, "No tags found");
assert.isFalse(tag[0].editable);
});
it("Has edit button when editable ", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
let tag = element.select.combobox.querySelectorAll(tag_name);
assert.isAbove(tag.length, 0, "No tags found");
assert.exists(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "No edit button");
});
it("Shows input when edit button is clicked", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag)[0];
let tag = element.select.combobox.querySelectorAll(tag_name)[0];
let edit_button = tag.shadowRoot.querySelector("et2-button-icon");
edit_button.click();
@ -81,7 +84,7 @@ describe("Editable tag", () =>
});
it("Changes value when edited", async() =>
{
let tag = <Et2Tag>element.shadowRoot.querySelectorAll(element.tagTag)[0];
let tag = <Et2Tag>element.select.combobox.querySelectorAll(tag_name)[0];
tag.isEditing = true;
tag.requestUpdate();
await tag.updateComplete;
@ -119,7 +122,7 @@ describe("Editable tag", () =>
await listener2;
assert.equal(tag.value, "change select too");
// Haven't turned on allow free entries, so no change here
// Have turned on allow free entries, so it should change here
assert.equal(element.value, "change select too", "Tag change did not cause value change in parent select (allowFreeEntries was on)");
});
@ -129,7 +132,7 @@ describe("Editable tag", () =>
element.readonly = true;
await element.updateComplete;
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
let tag = element.select.combobox.querySelectorAll(tag_name);
assert.isAbove(tag.length, 0, "No tags found");
let wait = [];
@ -146,7 +149,7 @@ describe("Select is not editable", () =>
it("Does not have edit button when not editable", async() =>
{
let tag = element.shadowRoot.querySelectorAll(element.tagTag);
let tag = element.select.combobox.querySelectorAll(tag_name);
assert.isAbove(tag.length, 0, "No tags found");
assert.isNull(tag[0].shadowRoot.querySelector("et2-button-icon[label='edit*']"), "Unexpected edit button");

View File

@ -0,0 +1,104 @@
/**
* EGroupware eTemplate2 - Email Tag WebComponent tests
*
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package api
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {assert, fixture, html} from '@open-wc/testing';
import {Et2EmailTag} from "../Tag/Et2EmailTag";
import * as sinon from 'sinon';
// Stub global egw
// @ts-ignore
window.egw = {
tooltipUnbind: () => {},
lang: i => i + "*",
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
webserverUrl: "",
app: (_app) => _app,
jsonq: () => Promise.resolve({})
};
describe('Et2EmailTag', () =>
{
let component : Et2EmailTag;
beforeEach(async() =>
{
component = await fixture<Et2EmailTag>(html`
<et2-email-tag value="test@example.com"></et2-email-tag>`);
// Stub egw()
// @ts-ignore
sinon.stub(component, "egw").returns(window.egw);
await component.updateComplete;
// Asserting this instanceOf forces class loading
assert.instanceOf(component, Et2EmailTag);
});
it('should be defined', () =>
{
assert.isDefined(component);
});
it('should have a value property', () =>
{
assert.equal(component.value, 'test@example.com');
});
it('should have a contactPlus property', () =>
{
assert.isTrue(component.contactPlus);
});
it('should have an onlyEmail property', () =>
{
assert.isFalse(component.onlyEmail);
});
it('should have a fullEmail property', () =>
{
assert.isFalse(component.fullEmail);
});
it('should open addressbook with email preset on (+) click', () =>
{
window.egw.open = () =>
{
open: (url, app, mode, extra) =>
{
assert.equal(url, '');
assert.equal(app, 'addressbook');
assert.equal(mode, 'add');
assert.equal(extra['presets[email]'], 'test@example.com');
}
};
component.handleMouseDown(new MouseEvent('click'));
});
it('should open addressbook CRM on avatar click', async() =>
{
// Fake data to test against
const contact = {
id: '123',
n_fn: 'Test User',
photo: 'test.jpg'
};
component.value = 'test@example.com';
component.checkContact = async(email) => contact;
component.egw.open = () =>
{
open: (id, app, mode, extra) =>
{
assert.equal(id, contact.id);
assert.equal(app, 'addressbook');
assert.equal(mode, 'view');
assert.deepEqual(extra, {title: contact.n_fn, icon: contact.photo});
}
};
await component.handleContactMouseDown(new MouseEvent('click'));
});
});

View File

@ -25,11 +25,13 @@ async function before()
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select"/>
<et2-select label="I'm a select">
</et2-select>
`);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await elementUpdated(element);
return element;
}
@ -48,7 +50,6 @@ describe("Select widget basics", () =>
it('has a label', async() =>
{
element.set_label("Label set");
// @ts-ignore TypeScript doesn't recognize widgets as Elements
await elementUpdated(element);
assert.equal(element.querySelector("[slot='label']").textContent, "Label set");
@ -59,6 +60,36 @@ describe("Select widget basics", () =>
assert.notExists(element.querySelector("option"), "Static option not found in DOM");
assert.deepEqual(element.select_options, [], "Unexpected option(s)");
})
it("closes when losing focus", async() =>
{
// WIP
const blurSpy = sinon.spy();
element.addEventListener('sl-hide', blurSpy);
const showPromise = new Promise(resolve =>
{
element.addEventListener("sl-after-show", resolve);
});
const hidePromise = new Promise(resolve =>
{
element.addEventListener("sl-hide", resolve);
});
await elementUpdated(element);
element.focus();
await showPromise;
await elementUpdated(element);
element.blur();
await elementUpdated(element);
await hidePromise;
sinon.assert.calledOnce(blurSpy);
// Check that it actually closed dropdown
assert.isFalse(element.select?.hasAttribute("open"));
})
});
describe("Multiple", () =>
@ -69,10 +100,11 @@ describe("Multiple", () =>
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" multiple="true">
<sl-menu-item value="one">One</sl-menu-item>
<sl-menu-item value="two">Two</sl-menu-item>
<option value="one">One</option>
<option value="two">Two</option>
</et2-select>
`);
element.loadFromXML(element);
element.set_value("one,two");
// Stub egw()
@ -83,14 +115,14 @@ describe("Multiple", () =>
it("Can remove tags", async() =>
{
assert.equal(element.querySelectorAll("sl-menu-item").length, 2, "Did not find options");
assert.equal(element.select.querySelectorAll("sl-option").length, 2, "Did not find options");
assert.sameMembers(element.value, ["one", "two"]);
let tags = element.shadowRoot.querySelectorAll('.select__tags > *');
let tags = element.select.combobox.querySelectorAll('.select__tags et2-tag');
// Await tags to render
let tag_updates = []
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
element.select.combobox.querySelectorAll("et2-tag").forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
await Promise.all(tag_updates);
assert.equal(tags.length, 2);
@ -110,15 +142,21 @@ describe("Multiple", () =>
// Wait for widget to update
await element.updateComplete;
tag_updates = []
element.shadowRoot.querySelectorAll(element.tagTag).forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
element.select.combobox.querySelectorAll('et2-tag').forEach((t : Et2Tag) => tag_updates.push(t.updateComplete));
await Promise.all(tag_updates);
// Check
assert.sameMembers(element.value, ["two"], "Removing tag did not remove value");
tags = element.shadowRoot.querySelectorAll('.select__tags > *');
tags = element.select.combobox.querySelectorAll('.select__tags et2-tag');
assert.equal(tags.length, 1, "Removed tag is still there");
});
});
inputBasicTests(before, "", "select");
inputBasicTests(async() =>
{
const element = await before();
element.noLang = true;
element.select_options = [{value: "", label: ""}];
return element
}, "", "sl-select");

View File

@ -1,8 +1,11 @@
import {assert, elementUpdated, fixture, html} from '@open-wc/testing';
import {Et2Box} from "../../Layout/Et2Box/Et2Box";
import {Et2Select, SelectOption} from "../Et2Select";
import {Et2Select} from "../Et2Select";
import * as sinon from "sinon";
import {et2_arrayMgr} from "../../et2_core_arrayMgr";
import {SelectOption} from "../FindSelectOptions";
import '../Select/Et2SelectNumber';
import {Et2SelectNumber} from "../Select/Et2SelectNumber";
let parser = new window.DOMParser();
@ -30,7 +33,6 @@ describe("Select widget", () =>
beforeEach(async() =>
{
// This stuff because otherwise Et2Select isn't actually loaded when testing
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
element = await fixture<Et2Select>(html`
<et2-select></et2-select>
`);
@ -39,7 +41,6 @@ describe("Select widget", () =>
assert.instanceOf(element, Et2Select);
element.remove();
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
container = await fixture<Et2Box>(html`
<et2-box/>
`);
@ -51,7 +52,7 @@ describe("Select widget", () =>
describe("Finds options", () =>
{
it("static", async() =>
it("from DOM/Template", async() =>
{
/** SETUP **/
// Create an element to test with, and wait until it's ready
@ -60,13 +61,12 @@ describe("Select widget", () =>
container.loadFromXML(parser.parseFromString(node, "text/xml"));
// wait for asychronous changes to the DOM
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
await elementUpdated(container);
element = <Et2Select>container.getWidgetById('select');
await element.updateComplete;
/** TESTING **/
assert.isNotNull(element.querySelector("[value='option']"), "Missing static option");
assert.isNotNull(element.select.querySelector("[value='option']"), "Missing template option");
});
it("directly in sel_options", async() =>
@ -80,16 +80,15 @@ describe("Select widget", () =>
container.loadFromXML(parser.parseFromString(node, "text/xml"));
// wait for asychronous changes to the DOM
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
await elementUpdated(container);
element = <Et2Select>container.getWidgetById('select');
await element.updateComplete;
/** TESTING **/
assert.equal(element.querySelectorAll("sl-menu-item").length, 2);
assert.equal(element.select.querySelectorAll("sl-option").length, 2);
});
it("merges static options with sel_options", async() =>
it("merges template options with sel_options", async() =>
{
/** SETUP **/
@ -101,19 +100,115 @@ describe("Select widget", () =>
container.loadFromXML(parser.parseFromString(node, "text/xml"));
// wait for asychronous changes to the DOM
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
await elementUpdated(container);
element = <Et2Select>container.getWidgetById('select');
await element.updateComplete;
/** TESTING **/
// @ts-ignore o.value isn't known by TypeScript, but it's there
let option_keys = Object.values(element.querySelectorAll("sl-menu-item")).map(o => o.value);
assert.include(option_keys, "option", "Static option missing");
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
assert.include(option_keys, "option", "Template option missing");
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
assert.equal(option_keys.length, 3);
});
it("static options (number)", async() =>
{
/** SETUP **/
// Create an element to test with, and wait until it's ready
// Default number options are 1-10
let element = await fixture<Et2SelectNumber>(html`
<et2-select-number></et2-select-number>
`);
// wait for asychronous changes to the DOM
await elementUpdated(element);
await element.updateComplete;
/** TESTING **/
assert.equal(element.select.querySelectorAll("sl-option").length, 10);
});
it("merges static options with sel_options", async() =>
{
/** SETUP **/
let options = [
<SelectOption>{value: "one", label: "Option 1"},
<SelectOption>{value: "two", label: "Option 2"}
];
// Create an element to test with, and wait until it's ready
let node = '<et2-select-number id="select" label="I am a select" max="2"></et2-select-number>';
container.setArrayMgr("sel_options", new et2_arrayMgr({
select: options
}));
container.loadFromXML(parser.parseFromString(node, "text/xml"));
// wait for asychronous changes to the DOM
await elementUpdated(container);
element = <Et2Select>container.getWidgetById('select');
await element.updateComplete;
/** TESTING **/
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
assert.includeMembers(option_keys, ["1", "2", "one", "two"], "Option mis-match");
assert.equal(option_keys.length, 4);
});
it("merges static options with template options", async() =>
{
/** SETUP **/
// Create an element to test with, and wait until it's ready
// Default number options are 1-10
let element = await fixture<Et2SelectNumber>(html`
<et2-select-number>
<option value="option">option label</option>
</et2-select-number>
`);
// wait for asychronous changes to the DOM
element.loadFromXML(element);
await elementUpdated(element);
await element.updateComplete;
/** TESTING **/
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
assert.include(option_keys, "option", "Template option missing");
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
assert.equal(option_keys.length, 11);
});
it("actually shows the options", async() =>
{
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select"></et2-select>
`);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
element.select_options = [
{value: "one", label: "one"},
{value: "two", label: "two"},
{value: "three", label: "three"},
{value: "four", label: "four"},
{value: "five", label: "five"}
];
await element.updateComplete;
await elementUpdated(element);
await element.show();
// Not actually testing if the browser renders, just if they show where expected
const options = element.select.querySelectorAll("sl-option");
assert.equal(options.length, 5, "Wrong number of options");
// Still not checking if they're _really_ visible, just that they have the correct display
options.forEach(o =>
{
assert.equal(getComputedStyle(o).display, "block", "Wrong style.display");
})
});
});
describe("Value tests", () =>

View File

@ -3,19 +3,22 @@
* Currently just checking to make sure onchange is only called once.
*/
import {SelectOption} from "../FindSelectOptions";
import {assert, elementUpdated, fixture, html} from '@open-wc/testing';
import {assert, elementUpdated, fixture, html, oneEvent} from '@open-wc/testing';
import * as sinon from 'sinon';
import {Et2Box} from "../../Layout/Et2Box/Et2Box";
import {Et2Select} from "../Et2Select";
import {Et2Textbox} from "../../Et2Textbox/Et2Textbox";
let keep_import : Et2Textbox = new Et2Textbox();
let keep_import : Et2Textbox = null;
// Stub global egw for cssImage to find
// @ts-ignore
window.egw = {
ajaxUrl: url => url,
decodePath: url => url,
//image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
lang: i => i + "*",
link: l => l,
tooltipUnbind: () => {},
webserverUrl: "",
window: window
@ -65,14 +68,17 @@ describe("Search actions", () =>
'</et2-select>';
container.loadFromXML(parser.parseFromString(node, "text/xml"));
await elementUpdated(container);
const change = sinon.spy();
let element = <Et2Select>container.getWidgetById('select');
element.onchange = change;
await elementUpdated(element);
element.value = "two";
const option = element.select.querySelector("[value='two']");
const listener = oneEvent(option, "mouseup");
option.dispatchEvent(new Event("mouseup", {bubbles: true}));
await listener;
await elementUpdated(element);
@ -96,14 +102,14 @@ describe("Trigger search", () =>
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" search=true>
<sl-menu-item value="one">One</sl-menu-item>
<sl-menu-item value="two">Two</sl-menu-item>
<sl-menu-item value="three">Three</sl-menu-item>
<sl-menu-item value="four">Four</sl-menu-item>
<sl-menu-item value="five">Five</sl-menu-item>
<sl-menu-item value="six">Six</sl-menu-item>
<sl-menu-item value="seven">Seven</sl-menu-item>
<et2-select label="I'm a select" search>
<sl-option value="one">One</sl-option>
<sl-option value="two">Two</sl-option>
<sl-option value="three">Three</sl-option>
<sl-option value="four">Four</sl-option>
<sl-option value="five">Five</sl-option>
<sl-option value="six">Six</sl-option>
<sl-option value="seven">Seven</sl-option>
</et2-select>
`);
// Stub egw()
@ -111,6 +117,7 @@ describe("Trigger search", () =>
await element.updateComplete;
await element._searchInputNode.updateComplete;
await elementUpdated(element);
});
afterEach(() =>
@ -125,11 +132,11 @@ describe("Trigger search", () =>
let searchSpy = sinon.spy(element, "startSearch");
// Send two keypresses, but we need to explicitly set the value
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
element._searchInputNode.value = "o";
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
assert(searchSpy.notCalled);
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
element._searchInputNode.value = "on";
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "n"}));
assert(searchSpy.notCalled);
// Skip the timeout
@ -145,8 +152,8 @@ describe("Trigger search", () =>
let searchSpy = sinon.spy(element, "startSearch");
// Send two keypresses, but we need to explicitly set the value
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
element._searchInputNode.value = "t";
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "o"}));
assert(searchSpy.notCalled);
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Enter"}));
@ -161,11 +168,207 @@ describe("Trigger search", () =>
let searchSpy = sinon.spy(element, "startSearch");
// Send two keypresses, but we need to explicitly set the value
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
element._searchInputNode.value = "t";
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "t"}));
element._searchInputNode.dispatchEvent(new KeyboardEvent("keydown", {"key": "Escape"}));
assert(searchSpy.notCalled, "startSearch() was called");
assert(abortSpy.calledOnce, "_handleSearchAbort() was not called");
})
});
async function doSearch(element, search)
{
// we need to explicitly set the value
element._searchInputNode.value = search;
await element.startSearch();
await elementUpdated(element)
};
describe("Search results", () =>
{
let element : Et2Select;
const remote_results = [
{value: "remote_one", label: "remote_one"},
{value: "remote_two", label: "remote_two"}
];
let clickOption = (value) =>
{
const option = element.select.querySelector("[value='" + value + "']");
let listener = oneEvent(option, "mouseup");
option.dispatchEvent(new Event("mouseup", {bubbles: true}));
return listener;
}
// Setup run before each test
beforeEach(async() =>
{
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" search>
<option value="one">One</option>
<option value="two">Two</option>
<option value="three">Three</option>
<option value="four">Four</option>
<option value="five">Five</option>
<option value="six">Six</option>
<option value="seven">Seven</option>
</et2-select>
`);
element.loadFromXML(element);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await element.updateComplete;
await element._searchInputNode.updateComplete;
await elementUpdated(element);
});
it("Correct local results", async() =>
{
// Search
await doSearch(element, "one")
// Check the result is offered
const option = element.select.querySelector("[value='one']")
assert.isNotNull(option, "Did not find option in result");
// _only_ that one?
assert.sameMembers(Array.from(element.select.querySelectorAll("sl-option")).map(e => e.value), ["one"], "Unexpected search results");
});
it("Correct remote results", async() =>
{
// Enable searching
element.searchUrl = "test";
// Fake remote search
window.egw.request = sinon.fake
.returns(Promise.resolve([remote_results[0]]));
// Search
await doSearch(element, "remote_one")
// Check the result is offered
const option = element.select.querySelector("[value='remote_one']")
assert.isNotNull(option, "Did not find option in result");
// _only_ that one?
// N.B. that "one" will stay, since that's the current value
assert.sameMembers(Array.from(element.select.querySelectorAll("sl-option.remote")).map(e => e.value), ["remote_one"], "Unexpected search results");
});
it("Correct local and remote together", async() =>
{
// Enable searching
element.searchUrl = "test";
// Fake remote search
window.egw.request = sinon.fake
.returns(Promise.resolve([remote_results[0]]));
// Search
await doSearch(element, "one")
// Check the result is offered
const local_option = element.select.querySelector("[value='one']")
assert.isNotNull(local_option, "Did not find local option in result");
const remote_option = element.select.querySelector("[value='remote_one']")
assert.isNotNull(remote_option, "Did not find remote option in result");
// _only_ that one?
assert.sameMembers(Array.from(element.select.querySelectorAll("sl-option")).map(e => e.value), ["one", "remote_one"], "Unexpected search results");
});
it("Selected local result is in value", async() =>
{
// Search
await doSearch(element, "one")
// "Click" that one
await clickOption("one");
await element.updateComplete;
assert.equal(element.value, "one", "Selected search result was not in value");
});
it("Selected remote result in value", async() =>
{
// Enable searching
element.searchUrl = "test";
// Fake remote search
window.egw.request = sinon.fake
.returns(Promise.resolve([remote_results[0]]));
// Search
await doSearch(element, "remote_one")
// Click
await clickOption("remote_one");
await element.updateComplete;
assert.equal(element.value, "remote_one", "Selected search result was not in value");
});
it("Selected multiple remote results in value", async() =>
{
// Enable multiple
element.multiple = true;
// Clear auto-selected value
element.value = "";
// Enable searching
element.searchUrl = "test";
// Fake remote search
window.egw.request = sinon.fake
.returns(Promise.resolve(remote_results));
// Search
await doSearch(element, "doesn't matter, we're faking it")
// Click
const values = ["remote_one", "remote_two"];
let listener;
values.forEach(value =>
{
listener = clickOption(value);
});
await listener;
await element.updateComplete;
assert.deepEqual(element.value, values, "Selected search results were not in value");
});
it("Adding (multiple) remote keeps value", async() =>
{
const values = ["remote_one", "remote_two"];
// Enable multiple
element.multiple = true;
// Clear value ("one" was selected automatically)
element.value = "";
await element.updateComplete;
// Enable searching
element.searchUrl = "test";
// Fake remote search
window.egw.request = sinon.fake
.returns(Promise.resolve(remote_results));
// Search
await doSearch(element, "doesn't matter, we're faking it")
debugger;
// Select the first one
await clickOption("remote_one");
await element.updateComplete;
// Search & select another one
await doSearch(element, "doesn't matter, we're faking it");
await clickOption("remote_two");
await element.updateComplete;
assert.deepEqual(element.value, values, "Selected search results were not in value");
});
});

View File

@ -11,7 +11,7 @@
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {SlSpinner} from "@shoelace-style/shoelace";
import shoelace from "../Styles/shoelace";
import {css} from "@lion/core";
import {css} from "lit";
export class Et2Spinner extends Et2Widget(SlSpinner)
{

View File

@ -8,8 +8,8 @@
* @author Hadi Nategh
*/
import {css, html, SlotMixin} from "@lion/core";
import {css, html} from "lit";
import {SlotMixin} from "@lion/core";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import '../Et2Image/Et2Image';
import {SlSwitch} from "@shoelace-style/shoelace";
@ -151,14 +151,13 @@ export class Et2Switch extends Et2InputWidget(SlotMixin(SlSwitch))
if(new_value)
{
this._labelNode?.classList.add('on');
this.checked = true;
}
else
{
this._labelNode?.classList.remove('on');
this.checked = false;
}
}
this.checked = !!new_value;
return;
}
@ -174,8 +173,9 @@ export class Et2Switch extends Et2InputWidget(SlotMixin(SlSwitch))
labelTemplate()
{
const labelClass = this.checked ? "label on" : "label";
return html`
<span class="label" aria-label="${this.label}">
<span class=${labelClass} aria-label="${this.label}">
<span class="on">${this.toggleOn}</span>
<span class="off">${this.toggleOff}</span>
</span>

View File

@ -9,7 +9,7 @@
*/
import {css} from "@lion/core";
import {css} from "lit";
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {SlTextarea} from "@shoelace-style/shoelace";
import shoelace from "../Styles/shoelace";
@ -29,14 +29,16 @@ export class Et2Textarea extends Et2InputWidget(SlTextarea)
width: 100%;
height: 100%;
}
.textarea--resize-vertical .textarea__control {
.textarea--resize-vertical {
height: 100%;
}
:host::part(form-control) {
height: 100%;
align-items: stretch !important;
}
:host::part(base) {
:host::part(form-control-input), :host::part(textarea) {
height: 100%;
}
`,

View File

@ -9,7 +9,7 @@
*/
import {Et2Textbox} from "./Et2Textbox";
import {css, html, render} from "@lion/core";
import {css, html, render} from "lit";
export class Et2Number extends Et2Textbox
{

View File

@ -11,7 +11,9 @@
import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin";
import {Et2Textbox} from "./Et2Textbox";
import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {classMap, html, ifDefined} from "@lion/core";
import {html} from "lit";
import {classMap} from "lit/directives/class-map.js";
import {ifDefined} from "lit/directives/if-defined.js";
import {egw} from "../../jsapi/egw_global";
const isChromium = navigator.userAgentData?.brands.some(b => b.brand.includes('Chromium'));
@ -68,14 +70,14 @@ export class Et2Password extends Et2InvokerMixin(Et2Textbox)
if(typeof attrs.viewable !== "undefined")
{
attrs['passwordToggle'] = attrs.viewable;
attrs['togglePassword'] = attrs.viewable;
delete attrs.viewable;
}
if(typeof attrs.passwordToggle !== "undefined" && !attrs.passwordToggle
|| typeof attrs.passwordToggle == "string" && !this.getArrayMgr("content").parseBoolExpression(attrs.passwordToggle))
if(typeof attrs.togglePassword !== "undefined" && !attrs.togglePassword
|| typeof attrs.togglePassword == "string" && !this.getArrayMgr("content").parseBoolExpression(attrs.togglePassword))
{
// Unset passwordToggle if its false. It's from parent, and it doesn't handle string "false" = false
delete attrs.passwordToggle;
// Unset togglePassword if its false. It's from parent, and it doesn't handle string "false" = false
delete attrs.togglePassword;
}
super.transformAttributes(attrs);
@ -299,7 +301,7 @@ export class Et2Password extends Et2InvokerMixin(Et2Textbox)
: ''
}
${
this.passwordToggle && !this.disabled
this.togglePassword && !this.disabled
? html`
<button
part="password-toggle-button"

View File

@ -9,7 +9,7 @@
*/
import {css, PropertyValues} from "@lion/core";
import {css, PropertyValues} from "lit";
import {Regex} from "../Validators/Regex";
import {SlInput} from "@shoelace-style/shoelace";
import shoelace from "../Styles/shoelace";

View File

@ -0,0 +1,352 @@
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
import {SlTree} from "@shoelace-style/shoelace";
import {Et2Link} from "../Et2Link/Et2Link";
import {et2_no_init} from "../et2_core_common";
import {egw, framework} from "../../jsapi/egw_global";
import {SelectOption, find_select_options, cleanSelectOptions} from "../Et2Select/FindSelectOptions";
import {egwIsMobile} from "../../egw_action/egw_action_common";
import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin";
import {LitElement, css, TemplateResult, html} from "lit";
import {repeat} from "lit/directives/repeat.js";
import shoelace from "../Styles/shoelace";
export type TreeItem = {
child: Boolean | 1,
data?: Object,//{sieve:true,...} or {acl:true} or other
id: String,
im0: String,
im1: String,
im2: String,
item: TreeItem[],
checked?: Boolean,
nocheckbox: number | Boolean,
open: 0 | 1,
parent: String,
text: String,
tooltip: String
}
export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement)
{
static get styles()
{
return [
shoelace,
// @ts-ignore
...super.styles,
css`
`
]
}
private currentItem: TreeItem
private input: any = null;
private autoloading_url: any;
private selectOptions: TreeItem[] = [];
private needsLazyLoading: Boolean = true;
/**
* Limit server searches to 100 results, matches Link::DEFAULT_NUM_ROWS
* @type {number}
*/
static RESULT_LIMIT: number = 100;
constructor()
{
super();
}
public loadFromXML()
{
//if(this.id)
this.selectOptions = <TreeItem[]><unknown>find_select_options(this)[1]
}
static get properties()
{
return {
...super.properties,
multiple: {
name: "",
type: Boolean,
default: false,
description: "Allow selecting multiple options"
},
selectOptions: {
type: "any",
name: "Select options",
default: {},
description: "Used to set the tree options."
},
onClick: {
name: "onClick",
type: "js",
description: "JS code which gets executed when clicks on text of a node"
},
onSelect: {
name: "onSelect",
type: "js",
default: et2_no_init,
description: "Javascript executed when user selects a node"
},
onCheck: {
name: "onCheck",
type: "js",
default: et2_no_init,
description: "Javascript executed when user checks a node"
},
// TODO do this : --> onChange event is mapped depending on multiple to onCheck or onSelect
onOpenStart: {
name: "onOpenStart",
type: "js",
default: et2_no_init,
description: "Javascript function executed when user opens a node: function(_id, _widget, _hasChildren) returning true to allow opening!"
},
onOpenEnd: {
name: "onOpenEnd",
type: "js",
default: et2_no_init,
description: "Javascript function executed when opening a node is finished: function(_id, _widget, _hasChildren)"
},
imagePath: {
name: "Image directory",
type: String,
default: egw().webserverUrl + "/api/templates/default/images/dhtmlxtree/",//TODO we will need a different path here! maybe just rename the path?
description: "Directory for tree structure images, set on server-side to 'dhtmlx' subdir of templates image-directory"
},
value: {
type: "any",
default: {}
},
actions: {
name: "Actions array",
type: "any",
default: et2_no_init,
description: "List of egw actions that can be done on the tree. This includes context menu, drag and drop. TODO: Link to action documentation"
},
autoLoading: {
name: "Auto loading",
type: String,
default: "",
description: "JSON URL or menuaction to be called for nodes marked with child=1, but not having children, GET parameter selected contains node-id"
},
stdImages: {
name: "Standard images",
type: String,
default: "",
description: "comma-separated names of icons for a leaf, closed and opened folder (default: leaf.png,folderClosed.png,folderOpen.png), images with extension get loaded from imagePath, just 'image' or 'appname/image' are allowed too"
},
multiMarking: {
name: "multi marking",
type: "any",
default: false,
description: "Allow marking multiple nodes, default is false which means disabled multiselection, true or 'strict' activates it and 'strict' makes it strict to only same level marking"
},
highlighting: {
name: "highlighting",
type: Boolean,
default: false,
description: "Add highlighting class on hovered over item, highlighting is disabled by default"
},
}
};
public set onOpenStart(_handler: Function)
{
this.installHandler("onOpenStart", _handler)
}
public set onChange(_handler: Function)
{
this.installHandler("onChange", _handler)
}
public set onClick(_handler: Function)
{
this.installHandler("onClick", _handler)
}
public set onSelect(_handler: Function)
{
this.installHandler("onSelect", _handler)
}
public set onOpenEnd(_handler: Function)
{
this.installHandler("onOpenEnd", _handler)
}
/**
* @deprecated assign to onOpenStart
* @param _handler
*/
public set_onopenstart(_handler: Function)
{
this.installHandler("onOpenStart", _handler)
}
/**
* @deprecated assign to onChange
* @param _handler
*/
public set_onchange(_handler: Function)
{
this.installHandler('onchange', _handler);
}
/**
* @deprecated assign to onClick
* @param _handler
*/
public set_onclick(_handler: Function)
{
this.installHandler('onclick', _handler);
}
/**
* @deprecated assign to onSelect
* @param _handler
*/
public set_onselect(_handler: Function)
{
this.installHandler('onselect', _handler);
}
/**
* @deprecated assign to onOpenEnd
* @param _handler
*/
public set_onopenend(_handler: Function)
{
this.installHandler('onOpenEnd', _handler);
}
private installHandler(_name: String, _handler: Function)
{
if (this.input == null) this.createTree();
// automatic convert onChange event to oncheck or onSelect depending on multiple is used or not
// if (_name == "onchange") {
// _name = this.options.multiple ? "oncheck" : "onselect"
// }
// let handler = _handler;
// let widget = this;
// this.input.attachEvent(_name, function(_id){
// let args = jQuery.makeArray(arguments);
// // splice in widget as 2. parameter, 1. is new node-id, now 3. is old node id
// args.splice(1, 0, widget);
// // try to close mobile sidemenu after clicking on node
// if (egwIsMobile() && typeof args[2] == 'string') framework.toggleMenu('on');
// return handler.apply(this, args);
// });
}
private createTree()
{
// widget.input = document.querySelector("et2-tree");
// // Allow controlling icon size by CSS
// widget.input.def_img_x = "";
// widget.input.def_img_y = "";
//
// // to allow "," in value, eg. folder-names, IF value is specified as array
// widget.input.dlmtr = ':}-*(';
// @ts-ignore from static get properties
if (this.autoLoading)
{
// @ts-ignore from static get properties
let url = this.autoLoading;
if (url.charAt(0) != '/' && url.substr(0, 4) != 'http')
{
url = '/json.php?menuaction=' + url;
}
this.autoloading_url = url;
}
}
private handleLazyLoading(_item: TreeItem)
{
let sendOptions = {
num_rows: Et2Tree.RESULT_LIMIT,
}
return egw().request(egw().link(egw().ajaxUrl(egw().decodePath(this.autoloading_url)),
{
id: _item.id
}), [sendOptions])
.then((results) => {
// If results have a total included, pull it out.
// It will cause errors if left in the results
// this._total_result_count = results.length;
// if(typeof results.total !== "undefined")
// {
// this._total_result_count = results.total;
// delete results.total;
// }
// let entries = cleanSelectOptions(results);
// this.processRemoteResults(entries);
// return entries;
return results
});
}
//this.selectOptions = find_select_options(this)[1];
_optionTemplate(selectOption: TreeItem)
{
// @ts-ignore
//slot = expanded/collapsed instead of expand/collapse like it is in documentation
//selectOption.child === 1
return html`
<sl-tree-item
.currentItem=${selectOption}
?lazy=${this.needsLazyLoading}
@sl-lazy-load=${() => this.handleLazyLoading(selectOption)}
>
${selectOption.text}
${repeat(selectOption.item, this._optionTemplate.bind(this))}
</sl-tree-item>`
}
public render(): unknown
{
return html`
<sl-tree
>
${repeat(this.selectOptions, this._optionTemplate.bind(this))}
</sl-tree>
`;
}
protected remoteQuery(search: string, options: object): Promise<SelectOption[]>
{
// Include a limit, even if options don't, to avoid massive lists breaking the UI
let sendOptions = {
num_rows: Et2Tree.RESULT_LIMIT,
...options
}
return this.egw().request(this.egw().link(this.egw().ajaxUrl(this.egw().decodePath(this.searchUrl)),
{query: search, ...sendOptions}), [search, sendOptions]).then((results) => {
// If results have a total included, pull it out.
// It will cause errors if left in the results
this._total_result_count = results.length;
if (typeof results.total !== "undefined")
{
this._total_result_count = results.total;
delete results.total;
}
let entries = cleanSelectOptions(results);
this.processRemoteResults(entries);
return entries;
});
}
}
customElements.define("et2-tree", Et2Tree);

View File

@ -0,0 +1,12 @@
import {Et2Tree} from "./Et2Tree";
export function initMailTree(): Et2Tree {
const changeFunction = () => {
console.log("change"+tree)
}
const tree: Et2Tree = document.querySelector("et2-tree");
tree.selection = "single";
tree.addEventListener("sl-selection-change", (event)=>{console.log(event)})
return tree;
}

View File

@ -0,0 +1,7 @@
const selectionMode = document.querySelector('#selection-mode');
const tree = document.querySelector('.tree-selectable');
selectionMode.addEventListener('sl-change', () => {
tree.querySelectorAll('sl-tree-item').forEach(item => (item.selected = false));
tree.selection = selectionMode.value;
});

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TestSite</title>
</head>
<body>
<sl-tree class="tree-selectable">
<sl-tree-item>
Item 1
<sl-tree-item>
Item A
<sl-tree-item>Item Z</sl-tree-item>
<sl-tree-item>Item Y</sl-tree-item>
<sl-tree-item>Item X</sl-tree-item>
</sl-tree-item>
<sl-tree-item>Item B</sl-tree-item>
<sl-tree-item>Item C</sl-tree-item>
</sl-tree-item>
<sl-tree-item>Item 2</sl-tree-item>
<sl-tree-item>Item 3</sl-tree-item>
</sl-tree>
<script src="TreeTest.js"></script>
</body>
</html>

View File

@ -8,7 +8,8 @@
*/
/* eslint-disable import/no-extraneous-dependencies */
import {css, dedupeMixin, html, LitElement, SlotMixin} from '@lion/core';
import {css, html, LitElement} from 'lit';
import {dedupeMixin, SlotMixin} from '@lion/core';
import {Et2InputWidget, Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
import {colorsDefStyles} from "../Styles/colorsDefStyles";

View File

@ -11,7 +11,7 @@
import {Et2InvokerMixin} from "./Et2InvokerMixin";
import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
import {colorsDefStyles} from "../Styles/colorsDefStyles";
import {css} from "@lion/core";
import {css} from "lit";
import {egw} from "../../jsapi/egw_global";
/**

View File

@ -12,7 +12,7 @@ import {Et2InvokerMixin} from "./Et2InvokerMixin";
import {IsEmail} from "../Validators/IsEmail";
import {Et2Textbox} from "../Et2Textbox/Et2Textbox";
import {colorsDefStyles} from "../Styles/colorsDefStyles";
import {css} from "@lion/core";
import {css} from "lit";
import {egw} from "../../jsapi/egw_global";
/**

Some files were not shown because too many files have changed in this diff Show More