Change et2_template to Et2Template webComponent (#169)

* Change template to webcomponent
This commit is contained in:
Nathan Gray 2025-01-16 13:34:27 -07:00 committed by GitHub
parent e5b6a8edc3
commit d86d26cc24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1479 additions and 467 deletions

View File

@ -17,7 +17,7 @@ const ADD_ET2_PREFIX_REGEXP = '#<((/?)([vh]?box)|vfs-select)(/?|\s[^>]*)>#m';
const ADD_ET2_PREFIX_LAST_GROUP = 4;
// unconditional of legacy add et2- prefix to this widgets
const ADD_ET2_PREFIX_LEGACY_REGEXP = '#<((/?)(tabbox|description|searchbox|textbox|label|avatar|lavatar|image|appicon|colorpicker|checkbox|url(-email|-phone|-fax)?|vfs-mime|vfs-uid|vfs-gid|vfs-select|vfs-name|link|link-[a-z]+|favorites))(/?|\s[^>]*)>#m';
const ADD_ET2_PREFIX_LEGACY_REGEXP = '#<((/?)(template|tabbox|description|searchbox|textbox|label|avatar|lavatar|image|appicon|colorpicker|checkbox|url(-email|-phone|-fax)?|vfs-mime|vfs-uid|vfs-gid|vfs-select|vfs-name|link|link-[a-z]+|favorites))(/?|\s[^>]*)>#m';
const ADD_ET2_PREFIX_LEGACY_LAST_GROUP = 5;
// switch evtl. set output-compression off, as we can't calculate a Content-Length header with transparent compression

View File

@ -0,0 +1,81 @@
import {EgwActionObjectInterface} from "./EgwActionObjectInterface";
import {Element} from "parse5";
import {Function} from "estree";
import {EGW_AO_STATE_NORMAL, EGW_AO_STATE_VISIBLE} from "./egw_action_constants";
import {Et2Widget} from "../etemplate/Et2Widget/Et2Widget";
/**
* Generic interface object so any webComponent can participate in action system.
* This interface can be extended if special handling is needed, but it should work
* for any widget.
*/
export class EgwEt2WidgetObject implements EgwActionObjectInterface
{
node = null;
constructor(node)
{
this.node = node;
}
_state : number = EGW_AO_STATE_NORMAL || EGW_AO_STATE_VISIBLE;
handlers : { [p : string] : any };
reconnectActionsCallback(p0)
{
}
reconnectActionsContext : any;
stateChangeCallback(p0)
{
}
stateChangeContext : any;
// @ts-ignore
getDOMNode() : Element
{
return this.node;
}
getWidget() : typeof Et2Widget
{
return this.node
}
getState() : number
{
return this._state;
}
makeVisible() : void
{
}
reconnectActions() : void
{
}
setReconnectActionsCallback(_callback : Function, _context : any) : void
{
}
setState(_state : any) : void
{
this._state = _state
}
setStateChangeCallback(_callback : Function, _context : any) : void
{
}
triggerEvent(_event : any, _data : any) : boolean
{
return false;
}
updateState(_stateBit : number, _set : boolean, _shiftState : boolean) : void
{
}
}

View File

@ -746,7 +746,7 @@ export class Et2Dialog extends Et2Widget(SlDialog)
}
this._template_widget = new etemplate2(this._contentNode);
// Fire an event so consumers can do their thing - etemplate will fire its own load event when its done
// Fire an event so consumers can do their thing - etemplate will fire its own load event when it's done
if(!this.dispatchEvent(new CustomEvent("before-load", {
bubbles: true,
cancelable: true,
@ -756,38 +756,14 @@ export class Et2Dialog extends Et2Widget(SlDialog)
return;
}
if(this.__template.indexOf('.xet') > 0)
{
let template = this.__template;
// inject preprocessor, if not already in template-url
const webserverUrl = this.egw().webserverUrl;
if(!template.match(new RegExp(webserverUrl + '/api/etemplate.php')))
{
template = template.replace(new RegExp(webserverUrl), webserverUrl + '/api/etemplate.php');
}
// if we have no cache-buster, reload daily
if(template.indexOf('?') === -1)
{
template += '?' + ((new Date).valueOf() / 86400 | 0).toString();
}
// File name provided, fetch from server
this._template_widget.load("", template, this.__value || {content: {}},)
.then(() =>
{
this._templateResolver(true);
});
}
else
{
// Just template name, it better be loaded already
this._template_widget.load(this.__template, '', this.__value || {},
this._template_widget.load(this.__template, '', this.__value || {},
// true: do NOT call et2_ready, as it would overwrite this.et2 in app.js
undefined, undefined, true)
.then(() =>
{
this._templateResolver(true);
});
}
// Don't let dialog closing destroy the parent session
if(this._template_widget.etemplate_exec_id && this._template_widget.app)

View File

@ -0,0 +1,102 @@
The template displays a loader while it is loading the file, and is replaced with the actual content once all widgets
are ready.
```html:preview
<style>
et2-template {
min-height: 5em;
}
</style>
<et2-template template="template"></et2-template>
```
## Loading
:::tip
Since template files are auto-fetched from the server, actual examples here would not work.
:::
### Template
Use `template` attribute with `<app>.<template>` format to specify which template to load.
This will fetch
`/<app>/templates/<interface>/<template>.xet`, where `<interface>` is the user's current interface, or default.
```xml
<et2-template template="infolog.edit"></et2-template>
```
### Sub-templates
If the template file contains more than one template definition, you can load any of the other templates defined after
the file has been loaded using either their full ID or a shortened form. This is useful for breaking a template into
smaller parts.
multiple.xml:
```xml
<overlay>
<template id="multiple.one" class="one">...</template>
<template id="multiple.two" class="two">...</template>
<template id="multiple" class="multiple">
...
<et2-template template="multiple.one"></et2-template>
...
<et2-template template="two"></et2-template>
</template>
</overlay>
```
### URL
If you need to bypass the autoloading based on template ID, you can specify the full URL to the template file. If there
are multiple templates defined in the file and you did not specify `template`, the last template in the file will be
loaded.
## Content
When loading `Et2Template` will use its array managers (content, select_options, readonlys & modification) to set the
child widget attributes as it loads them.
Use `content` to create a namespace, loading the template using only a sub-section of the content arrays.
Content data:
```json
{
"address_one": {
"street": "123 example street",
"city": "Testville"
},
"address_two": {
"street": "321 Industrial Ave",
"city": "Testville"
}
}
```
client/default/view.xet:
```xml
<overlay>
<template id="address">
<et2-textbox id="street" label="Street"></et2-textbox>
<et2-textbox id="city" label="City"></et2-textbox>
...
</template>
<template id="client.view">
...
<et2-template template="address" content="address_one"></et2-template>
<et2-template template="address" content="address_two"></et2-template>
...
</template>
</overlay>
```
Result:
![Content example](/assets/components/template_example_content.png)

View File

@ -0,0 +1,29 @@
import {css} from 'lit';
export default css`
:host {
display: block;
position: relative;
}
.template--loading {
position: absolute;
width: 100%;
height: 100%;
min-height: 5rem;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--sl-panel-background-color);
color: var(--application-color, var(--primary-color));
z-index: var(--sl-z-index-dialog);
font-size: 5rem;
}
.template {
height: 100%;
}
`;

View File

@ -0,0 +1,628 @@
/**
* EGroupware eTemplate2 - Email WebComponent
*
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package api
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {html, LitElement, nothing, PropertyValues, render} from "lit";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import shoelace from "../Styles/shoelace";
import styles from "./Et2Template.styles";
import {property} from "lit/decorators/property.js";
import {customElement} from "lit/decorators/custom-element.js";
import {et2_loadXMLFromURL} from "../et2_core_xml";
import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
import type {IegwAppLocal} from "../../jsapi/egw_global";
import {until} from "lit/directives/until.js";
import {classMap} from "lit/directives/class-map.js";
// @ts-ignore
/**
* @summary Load & populate a template (.xet file) into the DOM
*
* @slot - The template's contents
* @event load - Emitted when all elements are loaded
*
* @csspart template - Wrapper around template content
* @csspart loader - Displayed while the template contents are being loaded
*/
@customElement("et2-template")
export class Et2Template extends Et2Widget(LitElement)
{
static get styles()
{
return [
shoelace,
super.styles,
styles
];
}
/**
* Name / ID of template.
* It can be full [app].[template_file].[template] form or just the last part. Templates will be loaded from /[app]/templates/_interface_/[template_file].xet
* To use the short form, the file must already be loaded.
*/
@property()
template : string;
/**
* A full URL to load template, optionally including cache-buster ('?'+filemtime of template on server)
*/
@property()
url : string;
/**
* Used as index into content array for passing in specific content to the template other than what it would get by its ID
*/
@property()
content : string;
/**
* Cache of known templates
* @type {{[name : string] : Element}}
*/
public static templateCache : { [name : string] : Element } = {};
protected loading : Promise<void>;
private __egw : IegwAppLocal = null;
// Internal flag to indicate loading is in progress, since we can't monitor a promise
private __isLoading = false;
constructor(egw? : IegwAppLocal)
{
super();
if(egw)
{
this.__egw = egw;
}
this.loading = Promise.resolve();
}
connectedCallback() : void
{
super.connectedCallback();
this.addEventListener("load", this.handleLoad);
// If we can, start loading immediately
if(this.template || this.id || this.url)
{
this.load();
}
}
disconnectedCallback() : void
{
super.disconnectedCallback();
this.removeEventListener("load", this.handleLoad);
}
async getUpdateComplete() : Promise<boolean>
{
const result = await super.getUpdateComplete();
await this.loading;
return result;
}
willUpdate(changedProperties : PropertyValues)
{
// If content index was changed, re-check / create namespace
if(changedProperties.has("content"))
{
this.checkCreateNamespace();
}
// Load if template (template, id or URL) or content index changed
// (as long as we're not currently already loading, to prevent loops if load changes an attribute)
if(!this.__isLoading && ["template", "id", "url", "content"].filter(v => changedProperties.has(v)).length > 0)
{
this.load();
}
}
/**
* Searches for a DOM widget by id in the tree, descending into the child levels.
*
* @param _id is the id you're searching for
*/
getDOMWidgetById(_id) : typeof Et2Widget | null
{
let widget = this.getWidgetById(_id);
if(widget && (widget instanceof HTMLElement || widget.instanceOf(Et2Widget)))
{
return <typeof Et2Widget>widget;
}
return null
}
/**
* Searches for a Value widget by id in the tree, descending into the child levels.
*
* @param _id is the id you're searching for
*/
getInputWidgetById(_id) : Et2InputWidgetInterface | null
{
let widget = <any>this.getWidgetById(_id);
// instead of checking widget to be an instance of valueWidget (which would create a circular dependency)
// we check for the interface/methods of valueWidget
if(widget && typeof widget.get_value === 'function' && typeof widget.set_value === 'function')
{
return <Et2InputWidgetInterface>widget;
}
return null
}
/**
* Set the value for a child widget, specified by the given ID
*
* @param id string The ID you're searching for
* @param value Value for the widget
*
* @return Returns the result of widget's set_value(), though this is usually undefined
*
* @throws Error If the widget cannot be found or it does not have a set_value() function
*/
setValueById(id : string, value) : any
{
let widget = this.getWidgetById(id);
if(!widget)
{
throw 'Could not find widget ' + id;
}
// Don't care about what class it is, just that it has the function
// @ts-ignore
if(typeof widget.set_value !== 'function')
{
throw 'Widget ' + id + ' does not have a set_value() function';
}
// @ts-ignore
return widget.set_value(value);
}
/**
* Get the current value of a child widget, specified by the given ID
*
* This is the current value of the widget, which may be different from the original value given in content
*
* @param id string The ID you're searching for
* @throws Error If the widget cannot be found or it does not have a set_value() function
*/
getValueById(id : string)
{
let widget = this.getWidgetById(id);
if(!widget)
{
throw 'Could not find widget ' + id;
}
// Don't care about what class it is, just that it has the function
// @ts-ignore
if(typeof widget.get_value !== 'function' && typeof widget.value == "undefined")
{
throw 'Widget ' + id + ' does not have a get_value() function';
}
// @ts-ignore
return typeof widget.get_value == "function" ? widget.get_value() : widget.value;
}
/**
* Set the value for a child widget, specified by the given ID
*
* @param id string The ID you're searching for
* @param value new value to set
* @throws Error If the widget cannot be found, or it does not have a set_value() function
*/
setDisabledById(id : string, value : boolean)
{
let widget = this.getWidgetById(id);
if(!widget)
{
throw 'Could not find widget ' + id;
}
// Don't care about what class it is, just that it has the function
// @ts-ignore
if(typeof widget.set_disabled !== 'function')
{
throw 'Widget ' + id + ' does not have a set_disabled() function';
}
// @ts-ignore
return widget.set_disabled(value);
}
public egw() : IegwAppLocal
{
if(this.__egw)
{
return this.__egw;
}
else
{
return super.egw();
}
}
/**
* Get the template XML and create widgets from it
*
* Asks the server if we don't have that template on the client yet, then takes the template
* node and goes through it, creating widgets. This is normally called automatically when the
* template is added to the DOM, but if you want to re-load or not put it in the DOM you need to call load() yourself.
*
* If you need to set more than just content (select options, readonly or modifications), set it in the array manager
* before calling load:
* ```
* template.setArrayMgr("readonlys", template.getArrayMgr("readonlys").openPerspective(template, newReadonlys));
* ```
*
* @returns {Promise<void>}
* @protected
*/
public async load(newContent? : object)
{
// @ts-ignore can't find disabled, it's in Et2Widget
if(this.disabled)
{
this.loading = Promise.resolve();
return this.loading;
}
if(typeof newContent != "undefined")
{
// @ts-ignore ArrayMgr still expects et2_widgets
this.setArrayMgr("content", this.getArrayMgr("content").openPerspective(this, newContent));
}
this.__isLoading = true;
this.loading = new Promise(async(resolve, reject) =>
{
// Empty in case load was called again
this.clear();
// Get template XML
let xml : Element;
try
{
xml = await this.findTemplate();
}
catch(e)
{
reject(e);
return;
}
// Read the XML structure of the requested template
if(typeof xml != 'undefined')
{
// Get any template attributes from XML template node
const attrs = {};
xml.getAttributeNames().forEach(attribute =>
{
attrs[attribute] = xml.getAttribute(attribute);
});
// Don't change ID, keep what we've got
delete attrs["id"];
this.transformAttributes(attrs);
// Load children into template
this.loadFromXML(xml);
}
else
{
reject("Could not find template");
return;
}
// Wait for widgets to be complete
await this.loadFinished();
console.groupEnd();
this.__isLoading = false;
// Resolve promise, this.updateComplete now resolved
resolve();
// Yield to give anything else a chance to run
setTimeout(() =>
{
// Notification event
this.dispatchEvent(new CustomEvent("load", {
bubbles: true,
composed: true,
detail: this
}));
}, 0);
}).catch(reason =>
{
this.loadFailed(reason);
});
return this.loading;
}
/**
* Find the template XML node, either from the local cache or the server
*
* @returns {Promise<any>}
* @protected
*/
protected async findTemplate() : Promise<Element>
{
// Find template name
const parts = (this.template || this.id).split('?');
const cache_buster = parts.length > 1 ? parts.pop() : null;
let template_name = parts.pop();
// Check to see if the template is already known / loaded into global ETemplate cache
let xml = Et2Template.templateCache[template_name];
// Check to see if ID is short form --> prepend parent/top-level name
if(!xml && template_name.indexOf('.') < 0)
{
const root = this.getRoot();
const top_name = root && root.getInstanceManager() ? root.getInstanceManager().name : null;
if(top_name && template_name.indexOf('.') < 0)
{
template_name = top_name + '.' + template_name
xml = Et2Template.templateCache[template_name];
}
}
// Ask the server for the template
if(!xml)
{
const url = this.getUrl();
let templates = <Element>{};
try
{
templates = await this.loadFromFile(url);
if(!templates)
{
throw new Error("No templates found in template file " + url);
}
}
catch(e)
{
throw new Error("Could not load template file " + url);
}
// Scan the file for templates and store them
let fallback;
for(let i = 0; i < templates.childNodes?.length; i++)
{
const template = <Element>templates.childNodes[i];
if(!["template", "et2-template"].includes(template.nodeName.toLowerCase()))
{
continue;
}
Et2Template.templateCache[template.getAttribute("id")] = <Element>template;
if(template.getAttribute("id") == template_name)
{
xml = template;
}
fallback = template;
}
// Take last template in the file if we had no better match
if(!xml)
{
xml = fallback;
}
}
return xml;
}
/**
* Load the xml from the given file
*
* Broken out here so it can be stubbed for testing
*
* @param path
* @returns {Promise<Element | void>}
* @protected
*/
protected loadFromFile(path)
{
return et2_loadXMLFromURL(path, null, this);
}
/**
* The template has been loaded, wait for child widgets to be complete.
*
* For webComponents, we wait for the widget's updateComplete.
* For legacy widgets, we let them finish and wait for their doLoadingFinished Promise
*
* @protected
*/
protected loadFinished()
{
// List of Promises from widgets that are not quite fully loaded
let deferred = [];
// Inform the widget tree that it has been successfully loaded.
super.loadingFinished(deferred);
// Don't wait for ourselves, it will never happen
deferred = deferred.filter((d) => { return d.widget !== this});
let ready = false;
// Wait for everything to be loaded, then finish it up. Use timeout to give anything else a chance
// to run.
return Promise.race([
Promise.all(deferred).then(() => ready = true),
// If loading takes too long, give some feedback so we can try to track down why
new Promise((resolve) =>
{
setTimeout(() =>
{
if(ready)
{
return;
}
this.loadFailed("Load timeout");
console.debug("This is the deferred widget list. Look for widgets still pending to find the problem", deferred);
resolve();
}, 10000
);
})
]);
}
protected clear()
{
// Clear
while(this.firstChild) this.removeChild(this.lastChild);
}
loadFailed(reason? : any)
{
const message = (this.templateName) + " @ " + this.getUrl() + (reason ? " \n" + reason : "");
render(this.errorTemplate(message), this);
this.egw().debug("warn", "Loading failed: " + message);
}
protected getUrl()
{
if(this.url)
{
return this.url;
}
let url = "";
const parts = (this.template || this.id).split('?');
const cache_buster = parts.length > 1 ? parts.pop() : ((new Date).valueOf() / 86400 | 0).toString();
let template_name = this.templateName;
// Full URL passed as template?
if(template_name.startsWith(this.egw().webserverUrl) && template_name.endsWith("xet"))
{
url = template_name;
}
else
{
const splitted = template_name.split('.');
const app = splitted.shift();
url = this.egw().link(
'/' + app + "/templates/default/" + splitted.join('.') + ".xet",
{download: cache_buster}
);
}
// if we have no cache-buster, reload daily
if(url.indexOf('?') === -1)
{
url += '?download=' + cache_buster;
}
return url;
}
public get app()
{
const parts = (this.template || this.id).split('?');
const cache_buster = parts.length > 1 ? parts.pop() : null;
let template_name = parts.pop();
const splitted = template_name.split('.');
return splitted.shift() || "";
}
public get templateName()
{
const parts = (this.template || this.id).split('?');
const cache_buster = parts.length > 1 ? parts.pop() : null;
let template_name = parts.pop() || "";
return template_name;
}
/**
* Override parent to support content attribute
* Templates always have ID set, but seldom do we want them to
* create a namespace based on their ID.
*/
checkCreateNamespace()
{
if(this.content)
{
const old_id = this.id;
this._widget_id = this.content;
super.checkCreateNamespace.apply(this, arguments);
this._widget_id = old_id;
}
}
_createNamespace() : boolean
{
return this.content && this.content != this.id;
}
handleLoad(event)
{
if(this.onload && typeof this.onload == "function")
{
// Make sure function gets a reference to the widget
let args = Array.prototype.slice.call(arguments);
if(args.indexOf(this) == -1)
{
args.push(this);
}
return this.onload.apply(this, args);
}
}
loadingTemplate()
{
let loading = html`
<sl-spinner></sl-spinner>`;
return html`
<div part="loader" class="template--loading">${loading}</div>`;
}
errorTemplate(errorMessage = "")
{
return html`
<sl-alert variant="warning" open>
<sl-icon slot="icon" name="exclamation-triangle"></sl-icon>
<strong>${this.egw().lang("Loading failed")}</strong><br/>
${errorMessage}
</sl-alert>`
}
render()
{
const classes = {
template: true,
'template--disabled': this.disabled,
'template--readonly': this.readonly
};
if(this.app)
{
classes["template--app-" + this.app] = true;
}
if(this.layout != "none")
{
classes["layout-" + this.layout] = true;
classes["template--layout-" + this.layout] = true;
}
return html`
<div
part="base"
class=${classMap(classes)}
>
${until(this.loading.then(() => nothing), this.loadingTemplate())}
<slot></slot>
</div>`
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -0,0 +1,225 @@
import {assert, elementUpdated, fixture, html, nextFrame, oneEvent} from "@open-wc/testing";
import * as sinon from "sinon";
import {Et2Template} from "../Et2Template";
import {Et2Description} from "../../Et2Description/Et2Description";
/**
* Test file for Template webComponent
*
* In here we test just basics and simple loading to avoid as few additional dependencies as possible.
*/
// Stub global egw
// @ts-ignore
window.egw = {
debug: () => {},
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
lang: i => i + "*",
link: i => i,
tooltipUnbind: () => { },
webserverUrl: ""
};
let element : Et2Template;
let keepImport : Et2Description = new Et2Description();
async function before()
{
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture(html`
<et2-template>
</et2-template>
`);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await elementUpdated(element);
return element;
}
function fakedTemplate(template_text)
{
const parser = new window.DOMParser();
return parser.parseFromString(template_text, "text/xml").children[0];
}
const SIMPLE_EMPTY = `<overlay><template id="simple.empty"></template></overlay>`;
const SIMPLE = `<overlay><template id="simple">
<et2-description id="static" value="Static value"></et2-description>
<et2-description id="test"></et2-description>
</template></overlay>`;
const TEMPLATE_ATTRIBUTES = `<overlay><template id="attributes" class="gotClass" slot="gotSlot"></template></overlay>`;
const MULTIPLE = `<overlay>
<template id="multiple.one" class="one"/>
<template id="multiple.two" class="two"/>
<template id="multiple" class="multiple"></template>
</overlay>`;
const INVALID = `<overlay><template id="invalid"><overlay>`;
// Pre-fill cache
Et2Template.templateCache["simple.empty"] = <Element>fakedTemplate(SIMPLE_EMPTY).childNodes.item(0);
Et2Template.templateCache["simple"] = <Element>fakedTemplate(SIMPLE).childNodes.item(0);
Et2Template.templateCache["attributes"] = <Element>fakedTemplate(TEMPLATE_ATTRIBUTES).childNodes.item(0);
describe("Template widget basics", () =>
{
// Setup run before each test
beforeEach(before);
// Make sure it works
it('is defined', () =>
{
assert.instanceOf(element, Et2Template);
});
it("starts empty", () =>
{
assert.notExists(element.querySelectorAll("*"), "Not-loaded template has content. It should be empty.");
});
});
describe("Loading", () =>
{
beforeEach(before);
it("loads from file", async() =>
{
// Stub the url to point to the fixture
let xml = fakedTemplate(SIMPLE_EMPTY);
// @ts-ignore
sinon.stub(element, "loadFromFile").returns(xml);
const listener = oneEvent(element, "load");
// Set the template to start load
element.template = "simple.empty";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
assert.exists(loadEvent);
})
it("loads from cache", async() =>
{
// Cache was pre-filled above
const listener = oneEvent(element, "load");
// Set the template to start load
element.template = "simple.empty";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
assert.exists(loadEvent);
});
it("loads with short name (from cache)", async() =>
{
// Cache was pre-filled above
const listener = oneEvent(element, "load");
// @ts-ignore
sinon.stub(element, "getRoot").returns({
getInstanceManager: () => {return {name: "simple"}}
});
// Set the template to start load, but use "short" name
element.template = "empty";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
assert.exists(loadEvent);
});
it("takes template attributes", async() =>
{
// Set the template to start load
element.template = "attributes";
// Wait for load
await element.updateComplete;
assert.isTrue(element.classList.contains("gotClass"), "Did not get class from template");
assert.isTrue(element.hasAttribute("slot"), "Did not get slot from template");
assert.equal(element.getAttribute("slot"), "gotSlot", "Did not get slot from template");
});
it("loads last template in file when it has no template otherwise", async() =>
{
// Stub the url to point to the fixture
let xml = fakedTemplate(MULTIPLE);
// @ts-ignore
sinon.stub(element, "loadFromFile").returns(xml);
// We don't set the template, just give the URL
element.url = "load a file that has several template"
// Wait for load
await element.updateComplete;
assert.isTrue(element.classList.contains("multiple"));
});
it("shows loader while loading", async() =>
{
// @ts-ignore
sinon.stub(element, "findTemplate").returns(new Promise((resolve) =>
{
// It's not good to wait in the test, but...
setTimeout(() => resolve(Et2Template.templateCache["simple.empty"]), 100);
}));
// Set the template to start load
element.template = "simple.empty";
// Wait for load to start
await nextFrame();
// Check for loader
let loader = element.shadowRoot.querySelector(".template--loading");
assert.isNotNull(loader, "Loader (shown while loading) not found")
// Wait for load, check the loader is gone
await element.updateComplete;
loader = element.shadowRoot.querySelector(".template--loading");
assert.isNull(loader, "Loader still there after load");
});
it("actually creates children", async() =>
{
// Set the template to start load
element.template = "simple";
// Wait for load
await element.updateComplete;
// Should be not be empty
assert.isNotEmpty(element.querySelectorAll("*"));
assert.isNotNull(element.querySelector("#static"), "Missing template element");
assert.isNotNull(element.querySelector("#test"), "Missing template element");
})
it("does not load when disabled", async() =>
{
// Disable
// @ts-ignore can't find disabled attribute, though it's inherited from Et2Widget
element.disabled = true;
// Set the template to start load
element.template = "simple";
// Wait for load
await element.updateComplete;
// Should be empty
assert.isEmpty(element.querySelectorAll("*"));
});
it("shows a message when it can't find the template", async() =>
{
// Set the template to start load
element.template = "fail";
// Wait for load
await element.updateComplete;
// Should be not be empty, it has some error text
assert.isNotEmpty(element.querySelectorAll("*"));
assert.isTrue(element.innerText.includes("failed"));
})
});

View File

@ -0,0 +1,144 @@
import {Et2Template} from "../Et2Template";
import {Et2Description} from "../../Et2Description/Et2Description";
import {assert, elementUpdated, fixture, html, oneEvent} from "@open-wc/testing";
import * as sinon from "sinon";
import {et2_arrayMgr} from "../../et2_core_arrayMgr";
/**
* Test file for Template webComponent
*
* In here we test just basics and simple loading to avoid as few additional dependencies as possible.
*/
// Stub global egw
// @ts-ignore
window.egw = {
image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=",
lang: i => i + "",
link: i => i,
tooltipUnbind: () => { },
webserverUrl: ""
};
let element : Et2Template;
let keepImport : Et2Description = new Et2Description();
async function before()
{
// Create an element to test with, and wait until it's ready
// @ts-ignore
element = await fixture(html`
<et2-template>
</et2-template>
`);
// Stub egw()
sinon.stub(element, "egw").returns(window.egw);
await elementUpdated(element);
return element;
}
function fakedTemplate(template_text)
{
const parser = new window.DOMParser();
return parser.parseFromString(template_text, "text/xml").children[0];
}
const SIMPLE = `<overlay><template id="simple">
<et2-description id="static" value="Static value"></et2-description>
<et2-description id="test"></et2-description>
</template></overlay>`;
// Pre-fill cache
Et2Template.templateCache["simple"] = <Element>fakedTemplate(SIMPLE).childNodes.item(0);
describe("Namespaces", () =>
{
// Setup run before each test
beforeEach(before);
it("Does not create a namespace with no 'content' attribute", async() =>
{
const listener = oneEvent(element, "load");
element.setArrayMgr("content", new et2_arrayMgr({
test: "Test"
}));
// Set the template to start load
element.template = "simple";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
const staticElement : HTMLElement = element.querySelector(":scope > *:first-of-type");
assert.isNotNull(staticElement, "Did not find test element");
assert.equal(staticElement.getAttribute("id"), "static", "Static child ID was wrong");
assert.equal(staticElement.innerText, "Static value");
const dynamicElement : HTMLElement = element.querySelector(":scope > *:last-of-type");
assert.isNotNull(dynamicElement, "Did not find test element");
assert.equal(dynamicElement.getAttribute("id"), "test", "Dynamic child ID was wrong");
assert.equal(dynamicElement.innerText, "Test");
});
/**
* Test creating a namespace on the template.
* This means we expect all child elements to include the template ID as part of their ID,
* and they should be given the correct part of the content array.
*/
it("Creates a namespace when content is set", async() =>
{
const listener = oneEvent(element, "load");
element.setArrayMgr("content", new et2_arrayMgr({
test: "Top level",
sub: {
test: "Namespaced"
}
}));
element.content = "sub";
// Set the template to start load
element.template = "simple";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
const staticElement : HTMLElement = element.querySelector(":scope > *:first-of-type");
assert.isNotNull(staticElement, "Did not find test element");
assert.equal(staticElement.getAttribute("id"), "sub_static", "Child ID was not namespaced");
assert.equal(staticElement.innerText, "Static value");
const dynamicElement : HTMLElement = element.querySelector(":scope > *:last-of-type");
assert.isNotNull(dynamicElement, "Did not find test element");
assert.notEqual(dynamicElement.getAttribute("id"), "Top level");
assert.equal(dynamicElement.innerText, "Namespaced");
});
it("Can replace data when loading", async() =>
{
const listener = oneEvent(element, "load");
element.setArrayMgr("content", new et2_arrayMgr({
test: "Test"
}));
// Set the template to start load
element.template = "simple";
// Wait for load & load event
await element.updateComplete;
const loadEvent = await listener;
const staticElement : HTMLElement = element.querySelector(":scope > *:first-of-type");
assert.isNotNull(staticElement, "Did not find test element");
assert.equal(staticElement.innerText, "Static value");
let dynamicElement : HTMLElement = element.querySelector(":scope > *:last-of-type");
assert.isNotNull(dynamicElement, "Did not find test element");
assert.equal(dynamicElement.innerText, "Test");
// Now load new data
await element.load({test: "Success"});
// Old element was destroyed, get the new one
dynamicElement = element.querySelector(":scope > *:last-of-type");
assert.equal(dynamicElement.innerText, "Success", "Element did not get new value when template was loaded with new data");
});
});

View File

@ -13,6 +13,12 @@ import {dedupeMixin} from "@open-wc/dedupe-mixin";
import type {et2_container} from "../et2_core_baseWidget";
import type {et2_DOMWidget} from "../et2_core_DOMWidget";
import bootstrapIcons from "../Styles/bootstrap-icons";
import {EgwAction} from "../../egw_action/EgwAction";
import {property} from "lit/decorators/property.js";
import {egw_getActionManager, egw_getAppObjectManager} from "../../egw_action/egw_action";
import {EgwEt2WidgetObject} from "../../egw_action/EgwEt2WidgetObject";
import {EgwActionObject} from "../../egw_action/EgwActionObject";
import {EgwActionObjectInterface} from "../../egw_action/EgwActionObjectInterface";
/**
* This mixin will allow any LitElement to become an Et2Widget
@ -101,6 +107,8 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
*/
protected _deferred_properties : { [key : string] : string } = {};
protected _actionManager : EgwAction = null;
/** WebComponent **/
static get styles()
@ -267,6 +275,10 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
data: {
type: String,
reflect: false
},
actions: {
type: Object
}
};
}
@ -497,6 +509,156 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
return data.join(",");
}
/**
* Set Actions on the widget
*
* Each action is defined as an object:
*
* move: {
* type: "drop",
* acceptedTypes: "mail",
* icon: "move",
* caption: "Move to"
* onExecute: javascript:mail_move"
* }
*
* This will turn the widget into a drop target for "mail" drag types. When "mail" drag types are dropped,
* the global function mail_move(egwAction action, egwActionObject sender) will be called. The ID of the
* dragged "mail" will be in sender.id, some information about the sender will be in sender.context. The
* etemplate2 widget involved can typically be found in action.parent.data.widget, so your handler
* can operate in the widget context easily. The location varies depending on your action though. It
* might be action.parent.parent.data.widget
*
* To customise how the actions are handled for a particular widget, override _link_actions(). It handles
* the more widget-specific parts.
*
* @param {object} actions {ID: {attributes..}+} map of egw action information
* @see api/src/Etemplate/Widget/Nextmatch.php egw_actions() method
*/
@property({type: Object})
set actions(actions : EgwAction[] | { [id : string] : object })
{
if(!(Array.isArray(actions) && actions.length > 0 || Object.entries(actions).length > 0))
{
// Not trying to clear actions, just called automatic
if(!this._actionManager)
{
return;
}
}
if(this.id == "" || typeof this.id == "undefined")
{
this.egw().debug("warn", "Widget should have an ID if you want actions", this);
return;
}
// Initialize the action manager and add some actions to it
if(this._actionManager == null)
{
// Find the apropriate parent action manager
let parent_am = null;
let widget = <Et2WidgetClass | et2_widget>this;
while(widget.getParent() && !parent_am)
{
// @ts-ignore
if(widget._actionManager)
{
// @ts-ignore
parent_am = widget._actionManager;
}
widget = widget.getParent();
}
if(!parent_am)
{
// Only look 1 level deep
parent_am = egw_getActionManager(this.egw().appName, true, 1);
}
if(parent_am.getActionById(this.getInstanceManager().uniqueId, 1) !== null)
{
parent_am = parent_am.getActionById(this.getInstanceManager().uniqueId, 1);
}
if(parent_am.getActionById(this.id, 1) != null)
{
this._actionManager = parent_am.getActionById(this.id, 1);
}
else
{
this._actionManager = parent_am.addAction("actionManager", this.id);
}
}
this._actionManager.updateActions(actions, this.egw().appName);
// Put a reference to the widget into the action stuff, so we can
// easily get back to widget context from the action handler
this._actionManager.data = {widget: this};
// Link the actions to the DOM
this._link_actions(actions);
}
get actions()
{
return this._actionManager?.children || {};
}
/**
* Get all action-links / id's of 1.-level actions from a given action object
*
* This can be overwritten to not allow all actions, by not returning them here.
*
* @param actions
* @returns {Array}
*/
protected _get_action_links(actions)
{
const action_links = [];
for(let i in actions)
{
let action = actions[i];
action_links.push(typeof action.id != 'undefined' ? action.id : i);
}
return action_links;
}
/**
* Link the actions to the DOM nodes / widget bits.
*
* @param {object} actions {ID: {attributes..}+} map of egw action information
*/
protected _link_actions(actions)
{
// Get the top level element for the tree
let objectManager = egw_getAppObjectManager(true);
let widget_object = objectManager.getObjectById(this.id);
if(widget_object == null)
{
// Add a new container to the object manager which will hold the widget
// objects
widget_object = objectManager.insertObject(false, new EgwActionObject(
this.id, objectManager, this.createWidgetObjectInterface(),
this._actionManager || objectManager.manager.getActionById(this.id) || objectManager.manager
));
}
else
{
widget_object.setAOI(this.createWidgetObjectInterface());
}
// Delete all old objects
widget_object.clear();
widget_object.unregisterActions();
// Go over the widget & add links - this is where we decide which actions are
// 'allowed' for this widget at this time
widget_object.updateActionLinks(this._get_action_links(actions));
}
protected createWidgetObjectInterface() : EgwActionObjectInterface
{
return <EgwActionObjectInterface><unknown>(new EgwEt2WidgetObject(this));
}
/**
* A property has changed, and we want to make adjustments to other things
* based on that
@ -1010,7 +1172,7 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
if(!this._parent_node && this.getParent() instanceof et2_widget && (<et2_DOMWidget>this.getParent()).getDOMNode(this) != this.parentNode)
{
// @ts-ignore this is not an et2_widget, and Et2Widget is not a Node
(<et2_DOMWidget>this.getParent()).getDOMNode(this).append(this);
(<et2_DOMWidget>this.getParent()).getDOMNode(this)?.append(this);
}
// An empty text node causes problems with legacy widget children
@ -1589,6 +1751,11 @@ function transformAttributes(widget, mgr : et2_arrayMgr, attributes)
widget.style.setProperty("flex", "0 0 auto");
delete attributes.width;
}
if(attributes.height)
{
widget.style.setProperty("height", attributes.height);
delete attributes.height;
}
// Apply any set attributes - widget will do its own coercion
for(let attribute in attributes)
@ -1712,7 +1879,10 @@ function transformAttributes(widget, mgr : et2_arrayMgr, attributes)
continue;
}
// Set as property
const old_value = widget[attribute];
widget[attribute] = attrValue;
// Due to reactive properties not updating properly, make sure to trigger an update
widget.requestUpdate(attribute, old_value);
}
if(widget_class.getPropertyOptions("value") && widget.set_value)

View File

@ -12,10 +12,10 @@ import {loadWebComponent} from "../../Et2Widget/Et2Widget";
import {et2_directChildrenByTagName, et2_filteredNodeIterator, et2_readAttrWithDefault} from "../../et2_core_xml";
import {css, PropertyValues} from "lit";
import shoelace from "../../Styles/shoelace";
import {et2_createWidget} from "../../et2_core_widget";
import {colorsDefStyles} from "../../Styles/colorsDefStyles";
import {Et2InputWidget} from "../../Et2InputWidget/Et2InputWidget";
import {et2_IResizeable} from "../../et2_core_interfaces";
import {Et2Template} from "../../Et2Template/Et2Template";
export class Et2Tabs extends Et2InputWidget(SlTabGroup) implements et2_IResizeable
@ -465,7 +465,7 @@ export class Et2Tabs extends Et2InputWidget(SlTabGroup) implements et2_IResizeab
}
else
{
et2_createWidget('template', tab.widget_options, tab.contentDiv);
<Et2Template>loadWebComponent('et2-template', tab.widget_options, tab.contentDiv);
}
return tab.contentDiv;

View File

@ -360,7 +360,8 @@ export abstract class et2_DOMWidget extends et2_widget implements et2_IDOMNode
do {
template = template.getParent();
// @ts-ignore
} while (template !== tabbox && template.getType() !== 'template');
}
while(template !== tabbox && ['template', 'ET2-TEMPLATE'].indexOf(template.getType()) == -1);
for (var i = tabbox.tabData.length - 1; i >= 0; i--)
{
if(template && template.id &&

View File

@ -76,6 +76,8 @@ import {Et2AccountFilterHeader} from "./Et2Nextmatch/Headers/AccountFilterHeader
import {Et2SelectCategory} from "./Et2Select/Select/Et2SelectCategory";
import {Et2Searchbox} from "./Et2Textbox/Et2Searchbox";
import type {LitElement} from "lit";
import {Et2Template} from "./Et2Template/Et2Template";
import {waitForEvent} from "./Et2Widget/event";
//import {et2_selectAccount} from "./et2_widget_SelectAccount";
let keep_import : Et2AccountFilterHeader
@ -1304,9 +1306,12 @@ export class et2_nextmatch extends et2_DOMWidget implements et2_IResizeable, et2
// Bind a resize while we're here
if(tab.flagDiv)
{
tab.flagDiv.addEventListener("click", (e) =>
tab.flagDiv.addEventListener("click", async(e) =>
{
window.setTimeout(() => this.resize(), 1);
// Wait for the tab to be done being shown
await waitForEvent(tab.flagDiv.parentElement, "sl-tab-show");
// then resize
this.resize();
});
}
return new et2_dynheight(tab.contentDiv, this.innerDiv, 100);
@ -2413,7 +2418,7 @@ export class et2_nextmatch extends et2_DOMWidget implements et2_IResizeable, et2
*/
set_template(template_name : string)
{
const template = et2_createWidget("template", {"id": template_name}, this);
const template = <Et2Template>loadWebComponent("et2-template", {"id": template_name, class: "hideme"}, this);
if(this.template)
{
// Stop early to prevent unneeded processing, and prevent infinite
@ -2482,10 +2487,11 @@ export class et2_nextmatch extends et2_DOMWidget implements et2_IResizeable, et2
return;
}
// Free the template again, but don't remove it
// Free the template and remove it
setTimeout(function()
{
template.destroy();
template.remove();
}, 1);
// Call the "setNextmatch" function of all registered
@ -2525,13 +2531,9 @@ export class et2_nextmatch extends et2_DOMWidget implements et2_IResizeable, et2
this._set_autorefresh(this._get_autorefresh());
};
// Template might not be loaded yet, defer parsing
const promise = [];
template.loadingFinished(promise);
// Wait until template (& children) are done
// Keep promise so we can return it from doLoadingFinished
this.template_promise = Promise.all(promise).then(() =>
this.template_promise = template.updateComplete.then(() =>
{
parse.call(this, template);
if(!this.dynheight)
@ -2558,6 +2560,12 @@ export class et2_nextmatch extends et2_DOMWidget implements et2_IResizeable, et2
});
}
).finally(() => this.template_promise = null);
this.template_promise.widget = this;
// Explictly add template to DOM since it won't happen otherwise, and webComponents need to be in the DOM
// to complete
this.div.append(template);
template.load();
return this.template_promise;
}
@ -3650,47 +3658,37 @@ export class et2_nextmatch_header_bar extends et2_DOMWidget implements et2_INext
// Load the template
const self = this;
const header = <et2_template>et2_createWidget("template", {"id": template_name}, this);
const header = <Et2Template>loadWebComponent("et2-template", {"id": template_name}, this);
this.headers[id] = header;
const deferred = [];
header.loadingFinished(deferred);
// fix order in DOM by reattaching templates in correct position
switch(id)
{
case 0: // header_left: prepend
jQuery(header.getDOMNode()).prependTo(self.header_div);
break;
case 1: // header_right: before favorites and count
window.setTimeout(() =>
jQuery(header.getDOMNode()).prependTo(self.header_div.find('div.header_row_right')));
break;
case 2: // header_row: after search
window.setTimeout(function()
{ // otherwise we might end up after filters
jQuery(header.getDOMNode()).insertAfter(self.header_div.find('div.search'));
}, 1);
break;
case 3: // header_row2: below everything
window.setTimeout(function()
{ // otherwise we might end up after filters
jQuery(header.getDOMNode()).insertAfter(self.header_div);
}, 1);
break;
}
// Wait until all child widgets are loaded, then bind
Promise.all(deferred).then(() =>
header.updateComplete.then(() =>
{
// fix order in DOM by reattaching templates in correct position
switch(id)
{
case 0: // header_left: prepend
jQuery(header.getDOMNode()).prependTo(self.header_div);
break;
case 1: // header_right: before favorites and count
window.setTimeout(() =>
jQuery(header.getDOMNode()).prependTo(self.header_div.find('div.header_row_right')));
break;
case 2: // header_row: after search
window.setTimeout(function()
{ // otherwise we might end up after filters
jQuery(header.getDOMNode()).insertAfter(self.header_div.find('div.search'));
}, 1);
break;
case 3: // header_row2: below everything
window.setTimeout(function()
{ // otherwise we might end up after filters
jQuery(header.getDOMNode()).insertAfter(self.header_div);
}, 1);
break;
}
// Give child templates a chance to load before we bind inputs
let children = [];
header.iterateOver((_widget) =>
{
children.push(_widget.loading);
}, this, et2_template);
Promise.all(children).then(() =>
{
self._bindHeaderInput(header);
});
self._bindHeaderInput(header);
});
}

View File

@ -8,266 +8,12 @@
* @author Andreas Stöckel
*/
/*egw:uses
et2_core_xml;
et2_core_DOMWidget;
*/
import './et2_core_interfaces';
import {et2_DOMWidget} from './et2_core_DOMWidget';
import {ClassWithAttributes} from "./et2_core_inheritance";
import {et2_register_widget, WidgetConfig} from "./et2_core_widget";
import {etemplate2} from "./etemplate2";
import {et2_cloneObject, et2_no_init} from "./et2_core_common";
import {et2_loadXMLFromURL} from "./et2_core_xml";
import {egw} from "../jsapi/egw_global";
import {Et2Template} from "./Et2Template/Et2Template";
/**
* Class which implements the "template" XET-Tag. When the id parameter is set,
* the template class checks whether another template with this id already
* exists. If yes, this template is removed from the DOM tree, copied and
* inserted in place of this template.
* @deprecated use Et2Template
*/
export class et2_template extends et2_DOMWidget
export class et2_template extends Et2Template
{
static readonly _attributes : any = {
"template": {
"name": "Template",
"type": "string",
"description": "Name / ID of template with optional cache-buster ('?'+filemtime of template on server)",
"default": et2_no_init
},
"group": {
// TODO: Not implemented
"name": "Group",
"description":"Not implemented",
//"default": 0
"default": et2_no_init
},
"version": {
"name": "Version",
"type": "string",
"description": "Version of the template"
},
"lang": {
"name": "Language",
"type": "string",
"description": "Language the template is written in"
},
"content": {
"name": "Content index",
"default": et2_no_init,
"description": "Used for passing in specific content to the template other than what it would get by ID."
},
url: {
name: "URL of template",
type: "string",
description: "full URL to load template incl. cache-buster"
},
"onload": {
"name": "onload",
"type": "js",
"default": et2_no_init,
"description": "JS code which is executed after the template is loaded."
}
};
content : string;
div : HTMLDivElement;
loading : Promise<any>;
/**
* Constructor
*/
constructor(_parent, _attrs? : WidgetConfig, _child? : object)
{
// Call the inherited constructor
super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_template._attributes, _child || {}));
// Set this early, so it's available for creating namespace
if(_attrs.content)
{
this.content = _attrs.content;
}
// constructor was called here before!
this.div = document.createElement("div");
// Deferred object so we can load via AJAX
this.loading = new Promise<any>((resolve, reject) =>
{
// run transformAttributes now, to get server-side modifications (url!)
if(_attrs.template)
{
this.id = _attrs.template;
this.transformAttributes(_attrs);
this.options = et2_cloneObject(_attrs);
_attrs = {};
}
if((this.id != "" || this.options.template) && !this.options.disabled)
{
var parts = (this.options.template || this.id).split('?');
var cache_buster = parts.length > 1 ? parts.pop() : null;
var template_name = parts.pop();
// Check to see if XML is known
var xml = null;
var templates = etemplate2.templates; // use global eTemplate cache
if(!(xml = templates[template_name]))
{
// Check to see if ID is short form --> prepend parent/top-level name
if(template_name.indexOf('.') < 0)
{
var root = _parent ? _parent.getRoot() : null;
var top_name = root && root._inst ? root._inst.name : null;
if(top_name && template_name.indexOf('.') < 0)
{
template_name = top_name + '.' + template_name;
}
}
xml = templates[template_name];
if(!xml)
{
// Ask server
var url = this.options.url;
if(!this.options.url)
{
var splitted = template_name.split('.');
var app = splitted.shift();
url = egw.link('/'+ app + "/templates/default/" +
splitted.join('.')+ ".xet", {download:cache_buster? cache_buster :(new Date).valueOf()});
}
// if server did not give a cache-buster, fall back to current time
if (url.indexOf('?') == -1) url += '?download='+(new Date).valueOf();
if(this.options.url || splitted.length)
{
var fetch_url_callback = function(_xmldoc)
{
// Scan for templates and store them
for(var i = 0; i < _xmldoc.childNodes.length; i++)
{
var template = _xmldoc.childNodes[i];
if(template.nodeName.toLowerCase() != "template")
{
continue;
}
templates[template.getAttribute("id")] = template;
}
// Read the XML structure of the requested template
if(typeof templates[template_name] != 'undefined')
{
this.loadFromXML(templates[template_name]);
}
// Update flag
resolve();
}.bind(this);
et2_loadXMLFromURL(url, fetch_url_callback, this, function( error) {
url = egw.link('/'+ app + "/templates/default/" +
splitted.join('.')+ ".xet", {download:cache_buster? cache_buster :(new Date).valueOf()});
et2_loadXMLFromURL(url, fetch_url_callback, this);
});
}
return;
}
}
if(xml !== null && typeof xml !== "undefined")
{
this.egw().debug("log", "Loading template from XML: ", template_name);
this.loadFromXML(xml);
// Don't call this here - done by caller, or on whole widget tree
//this.loadingFinished();
// But resolve the promise
resolve();
}
else
{
this.egw().debug("warn", "Unable to find XML for ", template_name);
reject("Unable to find XML for " + template_name);
}
}
else
{
// No actual template - not an error, just nothing to do
resolve();
}
});
}
/**
* Override parent to support content attribute
* Templates always have ID set, but seldom do we want them to
* create a namespace based on their ID.
*/
checkCreateNamespace(_attrs)
{
if(_attrs.content)
{
var old_id = _attrs.id;
this.id = _attrs.content;
super.checkCreateNamespace.apply(this, arguments);
this.id = old_id;
}
}
_createNamespace() : boolean
{
return true;
}
getDOMNode()
{
return this.div;
}
attachToDOM()
{
if (this.div)
{
jQuery(this.div)
.off('.et2_template')
.bind("load.et2_template", this, function(e) {
e.data.load.call(e.data, this);
});
}
return super.attachToDOM();
}
/**
* Called after the template is fully loaded to handle any onload handlers
*/
load()
{
if(typeof this.options.onload == 'function')
{
// Make sure function gets a reference to the widget
var args = Array.prototype.slice.call(arguments);
if(args.indexOf(this) == -1) args.push(this);
return this.options.onload.apply(this, args);
}
}
/**
* Override to return the promise for deferred loading
*/
doLoadingFinished()
{
// Apply parent now, which actually puts into the DOM
super.doLoadingFinished();
// Fire load event when done loading
this.loading.then(function() {jQuery(this).trigger("load");}.bind(this.div));
// Not done yet, but widget will let you know
return this.loading;
}
}
et2_register_widget(et2_template, ["template"]);

View File

@ -16,9 +16,6 @@ import {EgwApp} from "../jsapi/egw_app";
import {et2_IInput, et2_IPrint, et2_IResizeable, et2_ISubmitListener} from "./et2_core_interfaces";
import {egw} from "../jsapi/egw_global";
import {et2_arrayMgr, et2_readonlysArrayMgr} from "./et2_core_arrayMgr";
import {et2_checkType} from "./et2_core_common";
import {et2_compileLegacyJS} from "./et2_core_legacyJSFunctions";
import {et2_loadXMLFromURL} from "./et2_core_xml";
import {et2_nextmatch, et2_nextmatch_header_bar} from "./et2_extension_nextmatch";
import '../jsapi/egw_json.js';
import {egwIsMobile} from "../egw_action/egw_action_common";
@ -88,6 +85,7 @@ import './Et2Select/Tag/Et2ThumbnailTag';
import './Et2Spinner/Et2Spinner';
import './Et2Switch/Et2Switch';
import './Et2Switch/Et2SwitchIcon';
import './Et2Template/Et2Template';
import './Et2Textarea/Et2Textarea';
import './Et2Textarea/Et2TextareaReadonly';
import './Et2Textbox/Et2Textbox';
@ -158,6 +156,7 @@ import './et2_extension_nextmatch';
import './et2_extension_customfields';
import {Et2Tabs} from "./Layout/Et2Tabs/Et2Tabs";
import {Et2Dialog} from "./Et2Dialog/Et2Dialog";
import {Et2Template} from "./Et2Template/Et2Template";
/**
@ -185,7 +184,7 @@ export class etemplate2
private uniqueId : void | string;
private template_base_url : string;
private _widgetContainer : et2_container;
private _widgetContainer : Et2Template;
private _DOMContainer : HTMLElement;
private resize_timeout : number | boolean;
@ -218,7 +217,6 @@ export class etemplate2
/**
* Preset the object variable
* @type {et2_container}
*/
this._widgetContainer = null;
@ -227,15 +225,15 @@ export class etemplate2
// We share list of templates with iframes and popups
try
{
if(opener && opener.etemplate2)
if(opener && opener.Et2Template)
{
etemplate2.templates = opener.etemplate2.templates;
Et2Template.templateCache = opener.Et2Template.templateCache;
}
// @ts-ignore
else if(top.etemplate2)
else if(top.Et2Template)
{
// @ts-ignore
etemplate2.templates = top.etemplate2.templates;
Et2Template.templateCache = top.Et2Template.templateCache;
}
}
catch(e)
@ -357,7 +355,7 @@ export class etemplate2
jQuery(this._DOMContainer).empty();
// Remove self from the index
for(const name in etemplate2.templates)
for(const name in Et2Template.templateCache)
{
if(typeof etemplate2._byTemplate[name] == "undefined")
{
@ -677,10 +675,17 @@ export class etemplate2
}
// Create the basic widget container and attach it to the DOM
this._widgetContainer = new et2_container(null);
this._widgetContainer.setApiInstance(egw(currentapp, egw.elemWindow(this._DOMContainer)));
this._widgetContainer = new Et2Template(egw(currentapp, egw.elemWindow(this._DOMContainer)));
this._widgetContainer.setInstanceManager(this);
this._widgetContainer.setParentDOMNode(this._DOMContainer);
this._widgetContainer.template = this.name;
if(_url)
{
this._widgetContainer.url = _url;
}
// Set array managers first, or errors will happen
this._widgetContainer.setArrayMgrs(this._createArrayManagers(_data));
// Template starts loading when added
this.DOMContainer.append(this._widgetContainer);
// store the id to submit it back to server
if(_data)
@ -698,14 +703,6 @@ export class etemplate2
const _load = function()
{
egw.debug("log", "Loading template...");
if(egw.debug_level() >= 4 && console.timeStamp)
{
console.timeStamp("Begin rendering template");
console.time("Template load");
console.time("loadFromXML");
}
// Add into indexed list - do this before, so anything looking can find it,
// even if it's not loaded
if(typeof etemplate2._byTemplate[_name] == "undefined")
@ -714,22 +711,6 @@ export class etemplate2
}
etemplate2._byTemplate[_name].push(this);
// Read the XML structure of the requested template
if(etemplate2.templates[this.name].hasAttribute("slot"))
{
this.DOMContainer.setAttribute("slot", etemplate2.templates[this.name].getAttribute("slot"));
}
this._widgetContainer.loadFromXML(etemplate2.templates[this.name]);
console.timeEnd("loadFromXML");
console.time("deferred");
// List of Promises from widgets that are not quite fully loaded
const deferred = [];
// Inform the widget tree that it has been successfully loaded.
this._widgetContainer.loadingFinished(deferred);
// Connect to the window resize event
jQuery(window).on("resize." + this.uniqueId, this, function(e)
{
@ -749,27 +730,8 @@ export class etemplate2
// to run.
setTimeout(() =>
{
Promise.race([Promise.all(deferred),
// If loading takes too long, give some feedback so we can try to track down why
new Promise((resolve) =>
{
setTimeout(() =>
{
if(this.ready)
{
return;
}
egw.debug("error", "Loading timeout");
console.debug("Deferred widget list, look for widgets still pending.", deferred);
resolve()
}, 10000
);
})
]).then(() =>
this._widgetContainer.updateComplete.then(() =>
{
console.timeEnd("deferred");
console.timeStamp("Deferred done");
// Clear dirty now that it's all loaded
this.widgetContainer.iterateOver((_widget) =>
{
@ -786,7 +748,7 @@ export class etemplate2
this.resize();
// Automatically set focus to first visible input for popups
if(this._widgetContainer._egw.is_popup() && jQuery('[autofocus]', this._DOMContainer).focus().length == 0)
if(this._widgetContainer.egw().is_popup() && jQuery('[autofocus]', this._DOMContainer).focus().length == 0)
{
this.focusOnFirstInput();
}
@ -817,16 +779,6 @@ export class etemplate2
detail: this
}));
if(etemplate2.templates[this.name].attributes.onload)
{
let onload = et2_checkType(etemplate2.templates[this.name].attributes.onload.value, 'js', 'onload', {});
if(typeof onload === 'string')
{
onload = et2_compileLegacyJS(onload, this, this._widgetContainer);
}
onload.call(this._widgetContainer);
}
// Profiling
if(egw.debug_level() >= 4)
{
@ -851,58 +803,9 @@ export class etemplate2
});
};
// Load & process
try
{
if(etemplate2.templates[_name])
{
// Set array managers first, or errors will happen
this._widgetContainer.setArrayMgrs(this._createArrayManagers(_data));
// Already have it
_load.apply(this, []);
return;
}
}
catch(e)
{
// weird security exception in IE denying access to template cache in opener
if(e.message == 'Permission denied')
{
etemplate2.templates = {};
}
// other error eg. in app.js et2_ready or event handlers --> rethrow it
else
{
throw e;
}
}
// Split the given data into array manager objects and pass those to the
// widget container - do this here because file is loaded async
this._widgetContainer.setArrayMgrs(this._createArrayManagers(_data));
// Asynchronously load the XET file
return et2_loadXMLFromURL(_url, function(_xmldoc)
{
// Scan for templates and store them
for(let i = 0; i < _xmldoc.childNodes.length; i++)
{
const template = _xmldoc.childNodes[i];
if(template.nodeName.toLowerCase() != "template")
{
continue;
}
etemplate2.templates[template.getAttribute("id")] = template;
if(!_name)
{
this.name = template.getAttribute("id");
}
}
_load.apply(this, []);
}, this);
});
_load.apply(this, []);
}).then(async() => await this._widgetContainer.updateComplete);
}
public focusOnFirstInput()

View File

@ -318,7 +318,7 @@ window.app = {classes: {}};
{
var $main_div = jQuery('#popupMainDiv');
let $et2 = jQuery('.et2_container');
let $layoutTable = jQuery(".et2_container > div > table", $main_div);
let $layoutTable = jQuery(".et2_container > * > table", $main_div);
if ($layoutTable.length && $et2.width() < $layoutTable.width())
{
// Still using a layout table, and it's bigger.
@ -344,7 +344,7 @@ window.app = {classes: {}};
delta_height = 0;
}
if((delta_width != 0 || delta_height != 0) &&
(delta_width >2 || delta_height >2 || delta_width<-2 || delta_height < -2) && (scrollHeight>0 || scrollWidth>0))
(delta_width > 2 || delta_height > 2 || delta_width < -2 || delta_height < -2))
{
if (window.framework && typeof window.framework.resize_popup != 'undefined')
@ -376,7 +376,14 @@ window.app = {classes: {}};
const data = JSON.parse(node.getAttribute('data-etemplate')) || {};
if (popup || window.opener && !egwIsMobile()) {
// Resize popup when et2 load is done
jQuery(node).on('load', () => window.setTimeout(resize_popup, 50));
jQuery(node).on('load', (event) =>
{
// Only the top-level template, not any sub-templates
if (event.target == node)
{
window.setTimeout(resize_popup, 50)
}
});
}
const et2 = new etemplate2(node, data.data.menuaction);
et2.load(data.name, data.url, data.data);

View File

@ -67,11 +67,11 @@
max-width: 100%
}
.et2_container > div:not([class]) {
.et2_container > *:not([class]) {
height: 100%;
}
.et2_container > div:not([class]) > table {
.et2_container > *:not([class]) > table {
table-layout: fixed;
width: 100%;
}
@ -2044,7 +2044,7 @@ egw-validation-feedback[type] {
background-color: transparent;
}
.et2_nextmatch .nextmatch_header_row > div {
.et2_nextmatch .nextmatch_header_row > div, .et2_nextmatch .nextmatch_header_row et2-template::part(base) {
display: inline-flex;
flex-direction: row;
gap: 1ex;

View File

@ -132,7 +132,7 @@
#calendar-toolbar {
height: auto;
}
#calendar-toolbar > div {
#calendar-toolbar > et2-template::part(base) {
display: flex;
align-items: center;
}
@ -178,7 +178,7 @@
left: 0px;
right: 0px;
}
form#calendar-toolbar > div {
form#calendar-toolbar > * {
column-gap: 1ex;
}
#calendar-todo {

View File

@ -143,7 +143,7 @@
#calendar-toolbar {
height: auto;
}
#calendar-toolbar > div {
#calendar-toolbar > et2-template::part(base) {
display: flex;
align-items: center;
}
@ -190,7 +190,7 @@
left: 0px;
right: 0px;
}
form#calendar-toolbar > div {
form#calendar-toolbar > * {
column-gap: 1ex;
}
#calendar-todo {

View File

@ -132,7 +132,7 @@
#calendar-toolbar {
height: auto;
}
#calendar-toolbar > div {
#calendar-toolbar > et2-template::part(base) {
display: flex;
align-items: center;
}
@ -179,7 +179,7 @@
left: 0px;
right: 0px;
}
form#calendar-toolbar > div {
form#calendar-toolbar > * {
column-gap: 1ex;
}
#calendar-todo {

View File

@ -70,6 +70,7 @@ module.exports = function (eleventyConfig)
eleventyConfig.addPassthroughCopy({
"../../vendor/bower-asset/jquery/dist/jquery.min.js": "assets/scripts/vendor/bower-asset/jquery/dist/jquery.min.js",
"../../vendor/bower-asset/cropper/dist/cropper.min.js": "assets/scripts/vendor/bower-asset/cropper/dist/cropper.min.js",
"../../vendor/bower-asset/diff2html/dist/diff2html.min.js": "assets/scripts/vendor/bower-asset/diff2html/dist/diff2html.min.js",
"../../vendor/tinymce/tinymce/tinymce.min.js": "assets/scripts/vendor/tinymce/tinymce/tinymce.min.js",
})
@ -77,6 +78,7 @@ module.exports = function (eleventyConfig)
eleventyConfig.addPassthroughCopy({"../../chunks": "assets/scripts/chunks"});
eleventyConfig.addPassthroughCopy({"../../api/js/etemplate/etemplate2.js": "assets/scripts/sub/dir/etemplate/etemplate2.js"});
eleventyConfig.addPassthroughCopy({"../../node_modules/bootstrap-icons/font/bootstrap-icons.min.css": "assets/styles/bootstrap-icons.min.css"});
eleventyConfig.addPassthroughCopy({"../../api/js/etemplate/*/doc/*": "assets/components/"});
eleventyConfig.addPassthroughCopy({"../../node_modules/diff2html/bundles/css/diff2html.min.css": "assets/styles/diff2html.min.css"});
//eleventyConfig.addPassthroughCopy({"../../vendor/**/*min.js": "assets/scripts/vendor/"});