Merge remote-tracking branch 'origin/master'

This commit is contained in:
Milan 2023-09-27 12:59:53 +02:00
commit e3ca0bfacd
95 changed files with 14477 additions and 8659 deletions

View File

@ -20,6 +20,7 @@ import {egwAction, egwActionObject} from '../../api/js/egw_action/egw_action';
import {LitElement} from "@lion/core";
import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch";
import {et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget";
import {Et2SelectAccount} from "../../api/js/etemplate/Et2Select/Et2SelectAccount";
/**
* UI for Admin
@ -710,8 +711,7 @@ class AdminApp extends EgwApp
sel_options.acl_appname = [];
for(let app in acl_rights)
{
sel_options.acl_appname.push({value: app, label: this.egw.lang(
<string>this.egw.link_get_registry(app, 'entries') || app)});
sel_options.acl_appname.push({value: app, label: app});
}
// Sort list
sel_options.acl_appname.sort(function(a,b) {
@ -1367,6 +1367,28 @@ class AdminApp extends EgwApp
if (use_default) use_default.set_value(false);
}
/**
* onchange callback for mail account account_id (valid for)
*
* @param {object} _event
* @param {et2_widget} _widget
*/
warnMailAccountForAllChanged(_event : Event, _widget : Et2SelectAccount)
{
const account_id = _widget.value;
const old_account_id = this.et2.getArrayMgr('content').getEntry('account_id');
// this is (no longer) an account for all
if ((Array.isArray(account_id) ? account_id.length : account_id) &&
// but this was an account for all
!(Array.isArray(old_account_id) ? old_account_id.length : old_account_id))
{
_widget.blur();
Et2Dialog.alert(this.egw.lang('By selecting a user or group you effectively delete the mail account for all other users!\n\nAre you really sure you want to do that?'),
this.egw.lang('This is a mail account for ALL users!'), Et2Dialog.WARNING_MESSAGE);
}
}
/**
* default onExecute for admin actions
*

View File

@ -188,6 +188,7 @@ blocking after wrong password admin de Blockierung nach falschem Passwort
bottom admin de unten
bulk password reset admin de Rücksetzen mehrerer Passwörter
by admin de Von
by selecting a user or group you effectively delete the mail account for all other users!\n\nare you really sure you want to do that? admin de Durch die Auswahl eines Benutzer oder Gruppe löschen Sie das Mailkonto für alle anderen Benutzer!\n\nSind Sie wirklich sicher, dass Sie das wollen?
calculate next run admin de nächste Ausführung berechnen
calendar recurrence horizont in days (default 1000) admin de Kalender Wiederholungs-Bereich in Tagen (Vorgabe sind 1000)
can be used by application admin de Kann von folgender Anwendung verwendet werden
@ -920,6 +921,7 @@ this application is current admin de Diese Anwendung ist aktuell
this application requires an upgrade admin de Diese Anwendung benötigt ein Upgrade
this category is currently being used by applications as a parent category admin de Diese Kategorie wird gegenwärtig als übergeordnete Kategorie benutzt.
this controls exports and merging. admin de Steuert den Export und den Merge Print von Dokumenten
this is a mail account for all users! admin de Das ist ein Mailkonto für ALLE Benutzer!
this is not a personal mail account!\n\naccount will be deleted for all users!\n\nare you really sure you want to do that? admin de Das ist KEIN persönliches Mail-Konto!\n\nDas Konto wird für ALLE Benutzer gelöscht!\n\nSind Sie wirklich sicher, dass Sie das wollen?
this key used in the html code your site serves to users. admin de Dieser Schlüssel steht im HTML Code den Ihre Website ausliefert.
this php has no imap support compiled in!! admin de Dieses PHP hat keine IMAP Unterstützung!!!

View File

@ -188,6 +188,7 @@ blocking after wrong password admin en Blocking after wrong password
bottom admin en Bottom
bulk password reset admin en Bulk password reset
by admin en By
by selecting a user or group you effectively delete the mail account for all other users!\n\nare you really sure you want to do that? admin en By selecting a user or group you effectively delete the mail account for all other users!\n\nAre you really sure you want to do that?
calculate next run admin en Calculate next run
calendar recurrence horizont in days (default 1000) admin en Calendar recurrence horizon in days. Default = 1000.
can be used by application admin en Can be used by application
@ -923,6 +924,7 @@ this application is current admin en This application is current.
this application requires an upgrade admin en This application requires an upgrade.
this category is currently being used by applications as a parent category admin en This category is currently being used by applications as a parent category.
this controls exports and merging. admin en This controls exports and merge prints.
this is a mail account for all users! admin en This is a mail account for ALL users!
this is not a personal mail account!\n\naccount will be deleted for all users!\n\nare you really sure you want to do that? admin en This is NOT a personal mail account!\n\nAccount will be deleted for ALL users!\n\nAre you really sure you want to do that?
this key used in the html code your site serves to users. admin en This key used in the HTML code your site serves to users.
this php has no imap support compiled in!! admin en This PHP has no IMAP support compiled in!!

View File

@ -341,7 +341,7 @@
<row class="emailadmin_no_user dialogHeader2">
<et2-description for="account_id" value="Valid for"></et2-description>
<et2-hbox span="all">
<et2-select-account id="account_id" placeholder="Everyone" multiple="true" accountType="both" rows="1"></et2-select-account>
<et2-select-account id="account_id" placeholder="Everyone" multiple="true" accountType="both" rows="1" onchange="app.admin.warnMailAccountForAllChanged"></et2-select-account>
<et2-checkbox label="account editable by user" id="acc_user_editable"></et2-checkbox>
</et2-hbox>
</row>

View File

@ -611,14 +611,14 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
};
// It would be nice to use an et2-select here, but something goes weird with the styling
return html`
<et2-select value="${this._display.unit || this.displayFormat[0]}">
<sl-select value="${this._display.unit || this.displayFormat[0]}">
${[...this.displayFormat].map((format : string) =>
html`
<sl-option value=${format}>
${this.time_formats[format]}
</sl-option>`
)}
</et2-select>
</sl-select>
`;
}

View File

@ -131,10 +131,10 @@ export class Et2DropdownButton extends Et2WidgetWithSelectMixin(LitElement)
<et2-image slot="prefix" src=${option.icon} icon></et2-image>` : '';
return html`
<sl-option value="${option.value}">
<sl-menu-item value="${option.value}">
${icon}
${this.noLang ? option.label : this.egw().lang(option.label)}
</sl-option>`;
</sl-menu-item>`;
}
protected _handleSelect(ev)

View File

@ -489,7 +489,9 @@ const Et2InputWidgetMixin = <T extends Constructor<LitElement>>(superclass : T)
// 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")
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();
}

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

@ -87,8 +87,8 @@ export class Et2LinkSearch extends Et2Select
// 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.value.length > 0 && (
this.getAllOptions().length == 0 ||
this.getAllOptions().filter && this.getAllOptions().filter(item => this.getValueAsArray().includes(item.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)

View File

@ -141,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}
@ -405,6 +405,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

@ -45,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;
@ -75,19 +79,17 @@ 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'), {
ghostClass: 'ui-fav-sortable-placeholder',
draggable: 'sl-option.column',
draggable: 'sl-menu-item.column',
dataIdAttr: 'value',
direction: 'vertical',
delay: 25
@ -101,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>`;
}
@ -143,11 +145,11 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
return html``;
}
return html`
<sl-option
value="${column.id}"
<sl-menu-item
value="${column.id.replaceAll(" ", "___")}"
type="checkbox"
?checked=${alwaysOn || column.visibility == et2_dataview_column.ET2_COL_VISIBILITY_VISIBLE}
?disabled=${alwaysOn}
.selected="${this.value.some(v => v == column.id)}"
title="${column.title}"
class="${classMap({
select_row: true,
@ -157,7 +159,7 @@ export class Et2ColumnSelection extends Et2InputWidget(LitElement)
${column.caption}
<!-- Custom fields get listed separately -->
${isCustom ? this.customFieldsTemplate(column) : ''}
</sl-option>`;
</sl-menu-item>`;
}
/**
@ -191,18 +193,10 @@ 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-option")).checked || false;
this.shadowRoot.querySelectorAll('sl-option').forEach((item) => {item.checked = !checked});
let checked = (<SlMenuItem>this.shadowRoot.querySelector("sl-menu-item")).checked || false;
this.shadowRoot.querySelectorAll('sl-menu-item').forEach((item) => {item.checked = !checked});
}
set columns(new_columns)
@ -229,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

@ -14,10 +14,9 @@ import {Et2WidgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
import {SelectOption} from "./FindSelectOptions";
import shoelace from "../Styles/shoelace";
import {RowLimitedMixin} from "../Layout/RowLimitedMixin";
import {Et2Tag} from "./Tag/Et2Tag";
import {Et2WithSearchMixin} from "./SearchMixin";
import {property} from "lit/decorators/property.js";
import {SlChangeEvent, SlSelect} from "@shoelace-style/shoelace";
import {SlChangeEvent, SlOption, SlSelect} from "@shoelace-style/shoelace";
import {repeat} from "lit/directives/repeat.js";
// export Et2WidgetWithSelect which is used as type in other modules
@ -144,13 +143,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
/* Hide dropdown trigger when multiple & readonly */
:host([readonly][multiple]) .select__expand-icon {
:host([readonly][multiple])::part(expand-icon) {
display: none;
}
/* Style for tag count if rows=1 */
:host([readonly][multiple][rows]) .select__tags sl-tag {
:host([readonly][multiple][rows])::part(tags) {
position: absolute;
right: 0px;
top: 1px;
@ -196,6 +195,11 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
max-height: 8em;
overflow-y: auto;
}
:host([readonly])::part(combobox) {
background: none;
opacity: 1;
border: none;
}
`
];
}
@ -293,19 +297,88 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
_triggerChange(e)
{
if(super._triggerChange(e) && !this._block_change_event)
if(super._triggerChange(e))
{
this.dispatchEvent(new Event("change", {bubbles: true}));
}
if(this._block_change_event)
}
/**
* Handle the case where there is no value set, or the value provided is not an option.
* If this happens, we choose the first option or empty label.
*
* Careful when this is called. We change the value here, so an infinite loop is possible if the widget has
* onchange.
*
*/
protected fix_bad_value()
{
// Stop if there are no options
if(!Array.isArray(this.select_options) || this.select_options.length == 0)
{
this.updateComplete.then(() => this._block_change_event = false);
// Nothing to do here
return;
}
// emptyLabel is fine
if(this.value === "" && this.emptyLabel)
{
return;
}
let valueArray = this.getValueAsArray();
// Check for value using missing options (deleted or otherwise not allowed)
let filtered = this.filterOutMissingOptions(valueArray);
if(filtered.length != valueArray.length)
{
this.value = filtered;
return;
}
// Multiple is allowed to be empty, and if we don't have an emptyLabel or options nothing to do
if(this.multiple || (!this.emptyLabel && this.select_options.length === 0))
{
return;
}
// See if parent (search / free entry) is OK with it
if(super.fix_bad_value())
{
return;
}
// If somebody gave '' as a select_option, let it be
if(this.value === '' && this.select_options.filter((option) => this.value === option.value).length == 1)
{
return;
}
// If no value is set, choose the first option
// Only do this on once during initial setup, or it can be impossible to clear the value
// value not in options --> use emptyLabel, if exists, or first option otherwise
if(this.select_options.filter((option) => valueArray.find(val => val == option.value) ||
Array.isArray(option.value) && option.value.filter(o => valueArray.find(val => val == o.value))).length === 0)
{
let oldValue = this.value;
this.value = this.emptyLabel ? "" : "" + this.select_options[0]?.value;
// ""+ to cast value of 0 to "0", to not replace with ""
this.requestUpdate("value", oldValue);
}
}
@property()
get value()
{
// Handle a bunch of non-values, if it's multiple we want an array
if(this.multiple && (this.__value == "null" || this.__value == null || typeof this.__value == "undefined" ||
!this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == "")))
{
return [];
}
if(!this.multiple && !this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == ""))
{
return null;
}
return this.multiple ?
this.__value ?? [] :
this.__value ?? "";
@ -384,10 +457,17 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
return filteredArray;
}
// Empty is allowed, if there's an emptyLabel
if(value.toString() == "" && this.emptyLabel)
{
return value;
}
const missing = filterBySelectOptions(value, this.select_options);
if(missing.length > 0)
{
console.warn("Invalid option '" + missing.join(", ") + " ' removed");
debugger;
console.warn("Invalid option '" + missing.join(", ") + "' removed from " + this.id, this);
value = value.filter(item => missing.indexOf(item) == -1);
}
}
@ -395,9 +475,9 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
}
/**
* Add an option for the "empty label" option, used if there's no value
* Additional customisations from the XET node
*
* @returns {TemplateResult}
* @param {Element} _node
*/
loadFromXML(_node : Element)
{
@ -484,59 +564,6 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
}
*/
_emptyLabelTemplate() : TemplateResult
{
if(!this.emptyLabel || this.multiple)
{
return html``;
}
return html`
<sl-option value=""
.selected=${this.getValueAsArray().some(v => v == "")}
>
${this.emptyLabel}
</sl-option>`;
}
protected _optionsTemplate() : TemplateResult
{
return html`${repeat(this.select_options
// Filter out empty values if we have empty label to avoid duplicates
.filter(o => this.emptyLabel ? o.value !== '' : o), this._groupTemplate.bind(this))
}`;
}
/**
* Used to render each option into the select
*
* @param {SelectOption} option
* @returns {TemplateResult}
*/
protected _optionTemplate(option : SelectOption) : TemplateResult
{
// Exclude non-matches when searching
if(typeof option.isMatch == "boolean" && !option.isMatch)
{
return html``;
}
// Tag used must match this.optionTag, but you can't use the variable directly.
// Pass option along so SearchMixin can grab it if needed
const value = (<string>option.value).replaceAll(" ", "___");
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>`;
}
/**
* Tag used for rendering tags when multiple=true
* Used for creating, finding & filtering options.
@ -548,116 +575,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
return literal`et2-tag`;
}
/**
* Custom tag
* @param {Et2Option} option
* @param {number} index
* @returns {TemplateResult}
* @protected
*/
protected _tagTemplate(option : Et2Option, index : number) : TemplateResult
{
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
const isEditable = this.editModeEnabled && !readonly;
const image = this._createImage(option);
const tagName = this.tagTag;
return html`
<${tagName}
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base,
icon:icon
"
class=${"search_tag " + option.classList.value}
?pill=${this.pill}
size=${this.size}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
value=${option.value}
@dblclick=${this._handleDoubleClick}
@click=${typeof this.onTagClick == "function" ? (e) => this.onTagClick(e, e.target) : nothing}
>
${image ?? nothing}
${option.getTextLabel().trim()}
</${tagName}>
`;
}
/**
* Additional customisation template
* @returns {*}
* @protected
*/
protected _extraTemplate()
{
return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing;
}
/**
* Customise how tags are rendered. This overrides what SlSelect
* does in syncItemsFromValue().
* This is a copy+paste from SlSelect.syncItemsFromValue().
*
* @param item
* @protected
*/
protected _createTagNode(item)
{
console.warn("Deprecated");
debugger;
let tag;
if(typeof super._createTagNode == "function")
{
tag = super._createTagNode(item);
}
else
{
tag = <Et2Tag>document.createElement(this.tagTag);
}
tag.value = item.value;
tag.textContent = item?.getTextLabel()?.trim();
tag.class = item.classList.value + " search_tag";
tag.setAttribute("exportparts", "icon");
if(this.size)
{
tag.size = this.size;
}
if(this.readonly || item.option && typeof (item.option.disabled) != "undefined" && item.option.disabled)
{
tag.removable = false;
tag.readonly = true;
}
else
{
tag.addEventListener("dblclick", this._handleDoubleClick);
tag.addEventListener("click", this.handleTagInteraction);
tag.addEventListener("keydown", this.handleTagInteraction);
tag.addEventListener("sl-remove", (event : CustomEvent) => this.handleTagRemove(event, item));
}
// Allow click handler even if read only
if(typeof this.onTagClick == "function")
{
tag.addEventListener("click", (e) => this.onTagClick(e, e.target));
}
let image = this._createImage(item);
if(image)
{
tag.prepend(image);
}
return tag;
}
blur()
{
if(typeof super.blur == "function")
{
super.blur();
}
this.dropdown.hide();
this.hide();
}
/* Parent should be fine now?
@ -711,7 +635,9 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
protected handleValueChange(e : SlChangeEvent)
{
const old_value = this.__value;
this.__value = this.select.value;
this.__value = Array.isArray(this.select.value) ?
this.select.value.map(e => e.replaceAll("___", " ")) :
this.select.value.replaceAll("___", " ");
this.requestUpdate("value", old_value);
}
@ -739,37 +665,6 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
}
/**
* Get the icon for the select option
*
* @param option
* @protected
*/
protected _iconTemplate(option)
{
if(!option.icon)
{
return html``;
}
return html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
src="${option.icon}"></et2-image>`
}
protected _createImage(item)
{
let image = item?.querySelector ? item.querySelector("et2-image") || item.querySelector("[slot='prefix']") : null;
if(image)
{
image = image.clone();
image.slot = "prefix";
image.class = "tag_image";
return image;
}
return "";
}
/** Shows the listbox. */
async show()
{
@ -795,6 +690,162 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
return this.shadowRoot?.querySelector("sl-select");
}
/**
* Custom, dynamic styling
*
* Put as much as you can in static styles for performance reasons
* Override this for custom dynamic styles
*
* @returns {TemplateResult}
* @protected
*/
protected _styleTemplate() : TemplateResult
{
return null;
}
/**
* Used for the "no value" option for single select
* Placeholder is used for multi-select with no value
*
* @returns {TemplateResult}
*/
_emptyLabelTemplate() : TemplateResult
{
if(!this.emptyLabel || this.multiple)
{
return html``;
}
return html`
<sl-option
part="emptyLabel option"
value=""
.selected=${this.getValueAsArray().some(v => v == "")}
>
${this.emptyLabel}
</sl-option>`;
}
/**
* Iterate over all the options
* @returns {TemplateResult}
* @protected
*/
protected _optionsTemplate() : TemplateResult
{
return html`${repeat(this.select_options
// Filter out empty values if we have empty label to avoid duplicates
.filter(o => this.emptyLabel ? o.value !== '' : o), this._groupTemplate.bind(this))
}`;
}
/**
* Used to render each option into the select
* Override for custom select options. Note that spaces are not allowed in option values,
* and sl-select _requires_ options to be <sl-option>
*
* @param {SelectOption} option
* @returns {TemplateResult}
*/
protected _optionTemplate(option : SelectOption) : TemplateResult
{
// Exclude non-matches when searching
if(typeof option.isMatch == "boolean" && !option.isMatch)
{
return html``;
}
// Tag used must match this.optionTag, but you can't use the variable directly.
// Pass option along so SearchMixin can grab it if needed
const value = (<string>option.value).replaceAll(" ", "___");
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>`;
}
/**
* Get the icon for the select option
*
* @param option
* @protected
*/
protected _iconTemplate(option)
{
if(!option.icon)
{
return html``;
}
return html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
src="${option.icon}"></et2-image>`
}
/**
* Custom tag
*
* Override this to customise display when multiple=true.
* There is no restriction on the tag used, unlike _optionTemplate()
*
* @param {Et2Option} option
* @param {number} index
* @returns {TemplateResult}
* @protected
*/
protected _tagTemplate(option : SlOption, index : number) : TemplateResult
{
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
const isEditable = this.editModeEnabled && !readonly;
const image = this._iconTemplate(option);
const tagName = this.tagTag;
return html`
<${tagName}
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base,
icon:icon
"
class=${"search_tag " + option.classList.value}
?pill=${this.pill}
size=${this.size || "medium"}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
.value=${option.value.replaceAll("___", " ")}
@change=${this.handleTagEdit}
@dblclick=${this._handleDoubleClick}
@click=${typeof this.onTagClick == "function" ? (e) => this.onTagClick(e, e.target) : nothing}
>
${image ?? nothing}
${option.getTextLabel().trim()}
</${tagName}>
`;
}
/**
* Additional customisation template
* Override if needed. Added after select options.
*
* @protected
*/
protected _extraTemplate() : TemplateResult | typeof nothing
{
return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing;
}
public render()
{
const value = Array.isArray(this.value) ?
@ -811,12 +862,13 @@ export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
}
}
return html`
${this._styleTemplate()}
<sl-select
exportparts="prefix, tags, display-input, expand-icon, combobox, listbox, option"
label=${this.label}
placeholder=${this.placeholder}
placeholder=${this.placeholder || (this.multiple && this.emptyLabel ? this.emptyLabel : "")}
?multiple=${this.multiple}
?disabled=${this.disabled}
?disabled=${this.disabled || this.readonly}
?clearable=${this.clearable}
?required=${this.required}
helpText=${this.helpText}

View File

@ -160,7 +160,7 @@ export const Et2WidgetWithSelectMixin = <T extends Constructor<LitElement>>(supe
{
return this.value;
}
if(this.value == "null" || typeof this.value == "undefined" || !this.emptyLabel && this.value == "")
if(this.value == "null" || this.value == null || typeof this.value == "undefined" || !this.emptyLabel && this.value == "")
{
return [];
}

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,13 @@
* @author Nathan Gray
*/
import {css, CSSResultGroup, html, LitElement, nothing, render, TemplateResult} from "lit";
import {css, CSSResultGroup, html, LitElement, nothing, TemplateResult} from "lit";
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
import {Validator} from "@lion/form-core";
import {Et2Tag} from "./Tag/Et2Tag";
import {SlMenuItem} from "@shoelace-style/shoelace";
import {StaticOptions} from "./StaticOptions";
import {dedupeMixin} from "@open-wc/dedupe-mixin";
import {SlOption} from "@shoelace-style/shoelace";
// Otherwise import gets stripped
let keep_import : Et2Tag;
@ -71,7 +71,7 @@ export declare class SearchMixinInterface
*
* @type {TemplateResult}
*/
_extraTemplate : TemplateResult
_extraTemplate : TemplateResult | typeof nothing
}
/**
@ -409,7 +409,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
}
}
protected _extraTemplate()
protected _extraTemplate() : TemplateResult | typeof nothing
{
if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
{
@ -419,6 +419,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
return html`
${this._searchInputTemplate()}
${this._moreResultsTemplate()}
${this._noResultsTemplate()}
`;
}
@ -463,6 +464,11 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
protected _noResultsTemplate()
{
if(this._total_result_count !== 0 || !this._searchInputNode?.value)
{
return nothing;
}
return html`
<div class="no-results">${this.egw().lang("no suggestions")}</div>`;
}
@ -543,9 +549,9 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
if(this.allowFreeEntries)
{
this.freeEntries.forEach((item : SlMenuItem) =>
this.freeEntries.forEach((item : SlOption) =>
{
if(!options.some(i => i.value == item.value))
if(!options.some(i => i.value == item.value.replaceAll("___", " ")))
{
options.push({value: item.value, label: item.textContent, class: item.classList.toString()});
}
@ -681,6 +687,8 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
{
return;
}
this.setAttribute("open", "");
// Move search (& menu) if there's no value
this._activeControls?.classList.toggle("novalue", this.multiple && this.value == '' || !this.multiple);
@ -743,8 +751,10 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
focus()
{
this.show();
this._searchInputNode.focus();
this.show().then(() =>
{
this._searchInputNode?.focus();
});
}
_handleMenuHide()
@ -753,6 +763,8 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
{
return;
}
this.removeAttribute("open");
this.clearSearch();
// Reset display
@ -933,7 +945,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
else if(event.key == "Escape")
{
this._handleSearchAbort(event);
this.dropdown.hide();
this.hide();
return;
}
@ -1017,7 +1029,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
// Show a spinner
let spinner = document.createElement("sl-spinner");
spinner.slot = "expand-icon";
this.appendChild(spinner);
this.select.appendChild(spinner);
// Hide clear button
let clear_button = <HTMLElement>this._searchInputNode.shadowRoot.querySelector(".input__clear")
@ -1037,15 +1049,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
this.remoteSearch(this._searchInputNode.value, this.searchOptions)
]).then(() =>
{
// Show no results indicator
if(this.getAllOptions().filter(e => !e.classList.contains("no-match")).length == 0)
{
let target = this._optionTargetNode || this;
let temp = document.createElement("div");
render(this._noResultsTemplate(), temp);
target.append(temp.children[0]);
}
// Remove spinner
spinner.remove();
@ -1080,9 +1083,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
{
let target = this._optionTargetNode || this;
// Remove "no suggestions"
target.querySelector(".no-results")?.remove();
// Remove any previously selected remote options that aren't used anymore
this._selected_remote = this._selected_remote.filter((option) =>
{
@ -1093,14 +1093,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
{
return prev + ":not([value='" + ('' + current.value).replace(/'/g, "\\\'") + "'])";
}, "");
target.querySelectorAll(".remote" + keepers).forEach(o => o.remove());
target.childNodes.forEach((n) =>
{
if(n.nodeType == Node.COMMENT_NODE)
{
n.remove();
}
})
// Not searching anymore, clear flag
this.select_options.map((o) => o.isMatch = null);
this.requestUpdate("select_options");
@ -1301,7 +1294,7 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
return false;
}
// Make sure not to double-add
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.__select_options.find(o => o.value == text))
if(!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.select_options.find(o => o.value == text))
{
this.__select_options.push(<SelectOption>{
value: text.trim(),
@ -1312,20 +1305,17 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
}
// Make sure not to double-add, but wait until the option is there
this.updateComplete.then(() =>
if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
{
if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
{
let value = this.getValueAsArray();
value.push(text);
this.value = value;
}
else if(!this.multiple && this.value !== text)
{
this.value = text;
}
this.requestUpdate("value");
});
let value = this.getValueAsArray();
value.push(text);
this.value = value;
}
else if(!this.multiple && this.value !== text)
{
this.value = text;
}
this.dispatchEvent(new Event("change", {bubbles: true}));
// If we were overlapping edit inputbox with the value display, reset
if(!this.readonly && this._activeControls?.classList.contains("novalue"))
@ -1379,7 +1369,6 @@ export const Et2WithSearchMixin = dedupeMixin(<T extends Constructor<LitElement>
{
this.value = value;
}
this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")?.remove();
this.__select_options = this.__select_options.filter(v => v.value !== original);
}
}

View File

@ -70,14 +70,6 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
this.fetchComplete = Promise.all(fetch);
}
firstUpdated(changedProperties?)
{
super.firstUpdated(changedProperties);
// Due to the different way Et2SelectAccount handles options, we call this explicitly
this._renderOptions();
}
set accountType(type : AccountType)
{
this.__accountType = type;

View File

@ -105,15 +105,15 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
}
/**
* Used to render each option into the select
* Overridden for colors
* Custom, dynamic styling
*
* CSS variables are not making it through to options, re-declaring them here works
*
* @param {SelectOption} option
* @returns {TemplateResult}
* @protected
*/
public render() : TemplateResult
protected _styleTemplate() : TemplateResult
{
/** CSS variables are not making it through to options, re-declaring them here works */
return html`
<style>
${repeat(this.select_options, (option) =>
@ -128,7 +128,6 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
);
})}
</style>
${super.render()}
`;
}
@ -141,20 +140,6 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
{
return literal`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

@ -12,6 +12,7 @@ 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
@ -46,14 +47,51 @@ export class Et2SelectCountry extends Et2StaticSelectMixin(Et2Select)
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);
}
/**
* 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

@ -148,11 +148,6 @@ export class Et2SelectEmail extends Et2Select
}
}
connectedCallback()
{
super.connectedCallback();
}
protected _bindListeners()
{
super._bindListeners();
@ -207,15 +202,21 @@ export class Et2SelectEmail extends Et2Select
*/
_tagTemplate(option, index)
{
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
const isEditable = this.editModeEnabled && !readonly;
return html`
<et2-email-tag
class=${classMap({
...option.classList,
"et2-select-draggable": !this.readonly && this.allowFreeEntries && this.allowDragAndDrop
})}
?.fullEmail=${this.fullEmail}
?.onlyEmail=${this.onlyEmail}
value=${option.value}
.fullEmail=${this.fullEmail}
.onlyEmail=${this.onlyEmail}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
.value=${option.value.replaceAll("___", " ")}
>
${option.getTextLabel().trim()}
</et2-email-tag>
@ -242,16 +243,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

@ -1,13 +1,16 @@
import {Et2Select} from "../Et2Select";
import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
import {cleanSelectOptions} from "../FindSelectOptions";
export class Et2SelectTimezone extends Et2StaticSelectMixin(Et2Select)
{
constructor()
{
super();
this._static_options = StaticOptions.timezone(this, {other: this.other || []});
this.fetchComplete = StaticOptions.timezone(this, {other: this.other ?? []}).then((options) =>
{
this.set_static_options(cleanSelectOptions(options));
})
}
}

View File

@ -1,285 +0,0 @@
/**
* Some static options, no need to transfer them over and over.
* We still need the same thing on the server side to validate, so they
* have to match. See Etemplate\Widget\Select::typeOptions()
* The type specific legacy options wind up in attrs.other, but should be explicitly
* defined and set.
*
* @param {type} widget
*/
import { sprintf } from "../../egw_action/egw_action_common";
import { cleanSelectOptions, find_select_options } from "./FindSelectOptions";
/**
* Base class for things that have static options
*
* We keep static options separate and concatenate them in to allow for extra options without
* overwriting them when we get static options from the server
*/
export const Et2StaticSelectMixin = (superclass) => {
class Et2StaticSelectOptions extends (superclass) {
constructor(...args) {
super(...args);
this.static_options = [];
this.fetchComplete = Promise.resolve();
// Trigger the options to get rendered into the DOM
this.requestUpdate("select_options");
}
get select_options() {
// @ts-ignore
const options = super.select_options || [];
// make sure result is unique
return [...new Map([...(this.static_options || []), ...options].map(item => [item.value, item])).values()];
}
set select_options(new_options) {
// @ts-ignore IDE doesn't recognise property
super.select_options = new_options;
}
set_static_options(new_static_options) {
this.static_options = new_static_options;
this.requestUpdate("select_options");
}
/**
* Override the parent fix_bad_value() to wait for server-side options
* to come back before we check to see if the value is not there.
*/
fix_bad_value() {
this.fetchComplete.then(() => {
// @ts-ignore Doesn't know it's an Et2Select
if (typeof super.fix_bad_value == "function") {
// @ts-ignore Doesn't know it's an Et2Select
super.fix_bad_value();
}
});
}
}
return Et2StaticSelectOptions;
};
/**
* Some options change, or are too complicated to have twice, so we get the
* options from the server once, then keep them to use if they're needed again.
* We use the options string to keep the different possibilities (eg. categories
* for different apps) separate.
*
* @param {et2_selectbox} widget Selectbox we're looking at
* @param {string} options_string
* @param {Object} attrs Widget attributes (not yet fully set)
* @param {boolean} return_promise true: always return a promise
* @returns {Object[]|Promise<Object[]>} Array of options, or empty and they'll get filled in later, or Promise
*/
export const StaticOptions = new class StaticOptionsType {
cached_server_side(widget, type, options_string, return_promise) {
// normalize options by removing trailing commas
options_string = options_string.replace(/,+$/, '');
const cache_id = widget.nodeName + '_' + options_string;
const cache_owner = widget.egw().getCache('Et2Select');
let cache = cache_owner[cache_id];
if (typeof cache === 'undefined') {
// Fetch with json instead of jsonq because there may be more than
// one widget listening for the response by the time it gets back,
// and we can't do that when it's queued.
const req = widget.egw().json('EGroupware\\Api\\Etemplate\\Widget\\Select::ajax_get_options', [type, options_string, widget.value]).sendRequest();
if (typeof cache === 'undefined') {
cache_owner[cache_id] = req;
}
cache = req;
}
if (typeof cache.then === 'function') {
// pending, wait for it
const promise = cache.then((response) => {
cache = cache_owner[cache_id] = response.response[0].data || undefined;
if (return_promise)
return cache;
// Set select_options in attributes in case we get a response before
// the widget is finished loading (otherwise it will re-set to {})
//widget.select_options = cache;
// Avoid errors if widget is destroyed before the timeout
if (widget && typeof widget.id !== 'undefined') {
if (typeof widget.set_static_options == "function") {
widget.set_static_options(cache);
}
else if (typeof widget.set_select_options == "function") {
widget.set_select_options(find_select_options(widget, {}, cache));
}
}
});
return return_promise ? promise : [];
}
else {
// Check that the value is in there
// Make sure we are not requesting server for an empty value option or
// other widgets but select-timezone as server won't find anything and
// it will fall into an infinitive loop, e.g. select-cat widget.
if (widget.value && widget.value != "" && widget.value != "0" && type == "select-timezone") {
var missing_option = true;
for (var i = 0; i < cache.length && missing_option; i++) {
if (cache[i].value == widget.value) {
missing_option = false;
}
}
// Try again - ask the server with the current value this time
if (missing_option) {
delete cache_owner[cache_id];
return this.cached_server_side(widget, type, options_string);
}
else {
if (widget.value && widget && widget.get_value() !== widget.value) {
egw.window.setTimeout(function () {
// Avoid errors if widget is destroyed before the timeout
if (this.widget && typeof this.widget.id !== 'undefined') {
this.widget.set_value(this.widget.options.value);
}
}.bind({ widget: widget }), 1);
}
}
}
return return_promise ? Promise.resolve(cache) : cache;
}
}
cached_from_file(widget, file) {
const cache_owner = widget.egw().getCache('Et2Select');
let cache = cache_owner[file];
if (typeof cache === 'undefined') {
cache_owner[file] = cache = widget.egw().window.fetch(file)
.then((response) => {
// Get the options
if (!response.ok) {
throw response;
}
return response.json();
})
.then(options => {
var _a;
// Need to clean the options because file may be key=>value, may have option list, may be mixed
cache_owner[file] = (_a = cleanSelectOptions(options)) !== null && _a !== void 0 ? _a : [];
return cache_owner[file];
});
}
else if (cache && typeof cache.then === "undefined") {
return Promise.resolve(cache);
}
return cache;
}
priority(widget) {
return [
{ value: "1", label: 'low' },
{ value: "2", label: 'normal' },
{ value: "3", label: 'high' },
{ value: "0", label: 'undefined' }
];
}
bool(widget) {
return [
{ value: "0", label: 'no' },
{ value: "1", label: 'yes' }
];
}
month(widget) {
return [
{ value: "1", label: 'January' },
{ value: "2", label: 'February' },
{ value: "3", label: 'March' },
{ value: "4", label: 'April' },
{ value: "5", label: 'May' },
{ value: "6", label: 'June' },
{ value: "7", label: 'July' },
{ value: "8", label: 'August' },
{ value: "9", label: 'September' },
{ value: "10", label: 'October' },
{ value: "11", label: 'November' },
{ value: "12", label: 'December' }
];
}
number(widget, attrs = {
min: undefined,
max: undefined,
interval: undefined,
format: undefined
}) {
var _a, _b, _c, _d;
var options = [];
var min = (_a = attrs.min) !== null && _a !== void 0 ? _a : parseFloat(widget.min);
var max = (_b = attrs.max) !== null && _b !== void 0 ? _b : parseFloat(widget.max);
var interval = (_c = attrs.interval) !== null && _c !== void 0 ? _c : parseFloat(widget.interval);
var format = (_d = attrs.format) !== null && _d !== void 0 ? _d : '%d';
// leading zero specified in interval
if (widget.leading_zero && widget.leading_zero[0] == '0') {
format = '%0' + ('' + interval).length + 'd';
}
// Suffix
if (widget.suffix) {
format += widget.egw().lang(widget.suffix);
}
// Avoid infinite loop if interval is the wrong direction
if ((min <= max) != (interval > 0)) {
interval = -interval;
}
for (var i = 0, n = min; n <= max && i <= 100; n += interval, ++i) {
options.push({ value: "" + n, label: sprintf(format, n) });
}
return options;
}
percent(widget) {
return this.number(widget);
}
year(widget, attrs) {
if (typeof attrs != 'object') {
attrs = {};
}
var t = new Date();
attrs.min = t.getFullYear() + parseInt(widget.min);
attrs.max = t.getFullYear() + parseInt(widget.max);
return this.number(widget, attrs);
}
day(widget, attrs) {
attrs.other = [1, 31, 1];
return this.number(widget, attrs);
}
hour(widget, attrs) {
var options = [];
var timeformat = widget.egw().preference('common', 'timeformat');
for (var h = 0; h <= 23; ++h) {
options.push({
value: h,
label: timeformat == 12 ?
((12 ? h % 12 : 12) + ' ' + (h < 12 ? egw.lang('am') : egw.lang('pm'))) :
sprintf('%02d', h)
});
}
return options;
}
app(widget, attrs) {
var options = ',' + (attrs.other || []).join(',');
return this.cached_server_side(widget, 'select-app', options);
}
cat(widget) {
var options = [widget.globalCategories, /*?*/ , widget.application, widget.parentCat];
if (typeof options[3] == 'undefined') {
options[3] = widget.application ||
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
(widget.getInstanceManager() && widget.getInstanceManager().app) ||
widget.egw().app_name();
}
return this.cached_server_side(widget, 'select-cat', options.join(','), true);
}
country(widget, attrs, return_promise) {
var options = ',';
return this.cached_server_side(widget, 'select-country', options, return_promise);
}
state(widget, attrs) {
var options = attrs.country_code ? attrs.country_code : 'de';
return this.cached_server_side(widget, 'select-state', options);
}
dow(widget, attrs) {
var options = (widget.rows || "") + ',' + (attrs.other || []).join(',');
return this.cached_server_side(widget, 'select-dow', options, true);
}
lang(widget, attrs) {
var options = ',' + (attrs.other || []).join(',');
return this.cached_server_side(widget, 'select-lang', options);
}
timezone(widget, attrs) {
var options = ',' + (attrs.other || []).join(',');
return this.cached_server_side(widget, 'select-timezone', options);
}
};
//# sourceMappingURL=StaticOptions.js.map

View File

@ -67,7 +67,7 @@ export const Et2StaticSelectMixin = <T extends Constructor<Et2WidgetWithSelect>>
return options;
}
// Merge & make sure result is unique
return [...new Map([...(this._static_options || []), ...options].map(item =>
return [...new Map([...options, ...(this._static_options || [])].map(item =>
[item.value, item])).values()];
}
@ -393,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

@ -218,15 +218,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)
{

View File

@ -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;

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-option value="one">One</sl-option>
<sl-option value="two">Two</sl-option>
<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

@ -9,6 +9,18 @@
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', () =>
{
@ -18,7 +30,13 @@ describe('Et2EmailTag', () =>
{
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', () =>
@ -48,7 +66,8 @@ describe('Et2EmailTag', () =>
it('should open addressbook with email preset on (+) click', () =>
{
component.egw = () => ({
window.egw.open = () =>
{
open: (url, app, mode, extra) =>
{
assert.equal(url, '');
@ -56,7 +75,7 @@ describe('Et2EmailTag', () =>
assert.equal(mode, 'add');
assert.equal(extra['presets[email]'], 'test@example.com');
}
});
};
component.handleMouseDown(new MouseEvent('click'));
});
@ -70,7 +89,8 @@ describe('Et2EmailTag', () =>
};
component.value = 'test@example.com';
component.checkContact = async(email) => contact;
component.egw = () => ({
component.egw.open = () =>
{
open: (id, app, mode, extra) =>
{
assert.equal(id, contact.id);
@ -78,7 +98,7 @@ describe('Et2EmailTag', () =>
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");
@ -64,28 +65,30 @@ describe("Select widget basics", () =>
{
// WIP
const blurSpy = sinon.spy();
element.addEventListener('blur', blurSpy);
element.addEventListener('sl-hide', blurSpy);
const showPromise = new Promise(resolve =>
{
element.addEventListener("sl-after-show", resolve);
});
const hidePromise = new Promise(resolve =>
{
element.addEventListener("blur", 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.dropdown?.hasAttribute("open"));
assert.isFalse(element.select?.hasAttribute("open"));
})
});
@ -97,10 +100,11 @@ describe("Multiple", () =>
// @ts-ignore
element = await fixture<Et2Select>(html`
<et2-select label="I'm a select" multiple="true">
<sl-option value="one">One</sl-option>
<sl-option value="two">Two</sl-option>
<option value="one">One</option>
<option value="two">Two</option>
</et2-select>
`);
element.loadFromXML(element);
element.set_value("one,two");
// Stub egw()
@ -111,14 +115,14 @@ describe("Multiple", () =>
it("Can remove tags", async() =>
{
assert.equal(element.querySelectorAll("sl-option").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);
@ -138,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,9 @@
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";
let parser = new window.DOMParser();
@ -30,7 +31,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 +39,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/>
`);
@ -66,7 +65,7 @@ describe("Select widget", () =>
await element.updateComplete;
/** TESTING **/
assert.isNotNull(element.querySelector("[value='option']"), "Missing static option");
assert.isNotNull(element.select.querySelector("[value='option']"), "Missing static option");
});
it("directly in sel_options", async() =>
@ -86,7 +85,7 @@ describe("Select widget", () =>
await element.updateComplete;
/** TESTING **/
assert.equal(element.querySelectorAll("sl-option").length, 2);
assert.equal(element.select.querySelectorAll("sl-option").length, 2);
});
it("merges static options with sel_options", async() =>
@ -107,9 +106,7 @@ describe("Select widget", () =>
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-option")).map(o => o.value);
let option_keys = Object.values(element.select.querySelectorAll("sl-option")).map(o => o.value);
assert.include(option_keys, "option", "Static option missing");
assert.includeMembers(option_keys, ["1", "2", "option"], "Option mis-match");
assert.equal(option_keys.length, 3);

View File

@ -9,7 +9,7 @@ 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
@ -65,6 +65,7 @@ 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');
@ -72,7 +73,7 @@ describe("Search actions", () =>
await elementUpdated(element);
element.value = "two";
element.select.querySelector("[value='two']").dispatchEvent(new Event("click"));
await elementUpdated(element);
@ -96,7 +97,7 @@ 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>
<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>
@ -111,6 +112,7 @@ describe("Trigger search", () =>
await element.updateComplete;
await element._searchInputNode.updateComplete;
await elementUpdated(element);
});
afterEach(() =>
@ -125,11 +127,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 +147,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,8 +163,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": "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");

View File

@ -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

@ -103,6 +103,41 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
:host([align="right"]) .input-group__input {
justify-content: flex-end;
}
/* Put widget label to the left of the widget */
::part(form-control) {
display: flex;
align-items: center;
flex-wrap: wrap;
}
::part(form-control-label) {
flex: 0 0 auto;
white-space: normal;
}
::part(form-control-input) {
flex: 1 1 auto;
position: relative;
max-width: 100%;
}
::part(form-control-help-text) {
flex-basis: 100%;
position: relative;
}
/* Use .et2-label-fixed class to give fixed label size */
:host(.et2-label-fixed)::part(form-control-label) {
width: initial;
width: var(--label-width, 8em);
}
:host(.et2-label-fixed)::part(form-control-help-text) {
left: calc(var(--sl-spacing-medium) + var(--label-width, 8em));
}
`];
}
@ -126,14 +161,30 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
class: {type: String, reflect: true},
/**
* Defines whether this widget is visible.
* Not to be confused with an input widget's HTML attribute 'disabled'.",
* Defines whether this widget is visibly disabled.
*
* The widget is still visible, but clearly cannot be interacted with. Widgets disabled in the template
* will not return a value to the application code, even if re-enabled via javascript before submitting.
* To allow a disabled widget to be re-enabled and return a value, disable via javascript in the app's
* et2_ready() instead of an attribute in the template file.
*/
disabled: {
type: Boolean,
reflect: true
},
/**
* The widget is not visible.
*
* As far as the user is concerned, the widget does not exist. Widgets hidden with an attribute in the
* template may not be created in the DOM, and will not return a value. Widgets can be hidden after creation,
* and they may return a value if hidden this way.
*/
hidden: {
type: Boolean,
reflect: true
},
/**
* Accesskey provides a hint for generating a keyboard shortcut for the current element.
* The attribute value must consist of a single printable character.
@ -315,12 +366,17 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
/**
* Wrapper on this.disabled because legacy had it.
*
* @deprecated Use widget.disabled for visually disabled, widget.hidden for visually hidden.
* Widgets that are hidden from the server via attribute or $readonlys will not be created.
* Widgets that are disabled from the server will not return a value to the application code.
*
* @param {boolean} value
*/
set_disabled(value : boolean)
{
let oldValue = this.disabled;
this.disabled = value;
this.hidden = value;
this.requestUpdate("disabled", oldValue);
}
@ -725,7 +781,7 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
{
widget = loadWebComponent(_nodeName, _node, this);
if(this.addChild)
if(this.addChild && widget)
{
// webcomponent going into old et2_widget
this.addChild(widget);
@ -1399,6 +1455,13 @@ export function loadWebComponent(_nodeName : string, _template_node : Element|{[
throw Error("Unknown or unregistered WebComponent '" + _nodeName + "', could not find class. Also checked for " + tries.join(','));
}
}
// Don't need to create hidden elements
if(parent?.hidden || attrs["hidden"] && parent?.getArrayMgr("content") && parent.getArrayMgr("content").parseBoolExpression(attrs["hidden"]))
{
//return null;
}
const readonly = parent?.getArrayMgr("readonlys") ?
(<any>parent.getArrayMgr("readonlys")).isReadOnly(
attrs["id"], attrs["readonly"],

View File

@ -14,6 +14,11 @@ import {classMap} from "lit/directives/class-map.js";
import {Et2Widget} from "../../Et2Widget/Et2Widget";
import {et2_IDetachedDOM} from "../../et2_core_interfaces";
/**
* @summary A basic wrapper to group other widgets
*
* @slot - Any other widget
*/
export class Et2Box extends Et2Widget(LitElement) implements et2_IDetachedDOM
{
static get styles()

View File

@ -349,8 +349,13 @@ export class et2_arrayMgr
return _ident;
}
parseBoolExpression(_expression : string)
parseBoolExpression(_expression : string|number|boolean|undefined)
{
if (typeof _expression === "boolean")
{
return _expression;
}
if(typeof _expression === "undefined" || _expression === null)
{
return false;

View File

@ -454,6 +454,10 @@ export class etemplate2
this.close_prompt = this._close_changed_prompt.bind(this);
window.addEventListener("beforeunload", this.close_prompt);
}
else if (window == egw_topWindow())
{
window.addEventListener("beforeunload", this.destroy_session);
}
if(this._etemplate_exec_id)
{
this.destroy_session = jQuery.proxy(function(ev)

View File

@ -515,6 +515,8 @@ egw.extend('links', egw.MODULE_GLOBAL, function()
const parent = document.getElementById(_parent);
const select = document.createElement('et2-select');
select.setAttribute('id', 'quick_add_selectbox');
// Empty label is required to clear value, but we hide it
select.emptyLabel = "Select";
select.placement = "bottom end";
parent.append(select);
const plus = parent.querySelector("span");

View File

@ -95,7 +95,7 @@ class JsCalendar
* @param string $method='PUT' 'PUT', 'POST' or 'PATCH'
* @return array
*/
public static function parseJsEvent(string $json, array $old=[], string $content_type=null, $method='PUT')
public static function parseJsEvent(string $json, array $old=[], string $content_type=null, $method='PUT', int $calendar_owner=null)
{
try
{
@ -147,7 +147,7 @@ class JsCalendar
break;
case 'participants':
$event['participants'] = self::parseParticipants($value);
$event['participants'] = self::parseParticipants($value, $strict, $calendar_owner);
break;
case 'priority':
@ -537,21 +537,22 @@ class JsCalendar
const TYPE_PARTICIPANT = 'Participant';
static $status2jscal = [
'U' => 'needs-action',
'A' => 'accepted',
'R' => 'declined',
'T' => 'tentative',
//'' => 'delegated',
];
/**
* Return participants object
*
* @param array $event
* @return array
*/
* @todo Resources and Groups without email */
protected static function Participants(array $event)
{
static $status2jscal = [
'U' => 'needs-action',
'A' => 'accepted',
'R' => 'declined',
'T' => 'tentative',
//'' => 'delegated',
];
$participants = [];
foreach($event['participants'] as $uid => $status)
{
@ -589,7 +590,7 @@ class JsCalendar
'optional' => $role === 'OPT-PARTICIPANT',
'informational' => $role === 'NON-PARTICIPANT',
]),
'participationStatus' => $status2jscal[$status],
'participationStatus' => self::$status2jscal[$status],
]);
$participants[$uid] = $participant;
}
@ -597,6 +598,101 @@ class JsCalendar
return $participants;
}
/**
* Parse participants object
*
* @param array $participants
* @param bool $strict true: require @types and objects with attributes name, email, ...
* @param ?int $calendar_owner owner of the calendar / collection
* @return array
* @todo Resources and Groups without email
*/
protected static function parseParticipants(array $participants, bool $strict=true, int $calendar_owner=null)
{
$parsed = [];
foreach($participants as $uid => $participant)
{
if ($strict && (!is_array($participant) || $participant[self::AT_TYPE] !== self::TYPE_PARTICIPANT))
{
throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($participant, self::JSON_OPTIONS_ERROR));
}
elseif (!is_array($participant))
{
$participant = [
'email' => $participant,
];
}
// check if the uid is valid and matches the data in the object
if (($test_uid = self::Participants(['participants' => [
$uid => 'U'
]])) && ($test_uid['email'] ?? null) === $participant['email'] &&
($test_uid['kind'] ?? null) === ($participant['kind'] ?? null) &&
($test_uid['name'] ?? null) === ($participant['name'] ?? null))
{
// use $uid as is
}
else
{
if (empty($participant['email']) || !preg_match(Api\Etemplate\Widget\Url::EMAIL_PREG, $participant['email']))
{
throw new \InvalidArgumentException("Missing or invalid email address: ".json_encode($participant, self::JSON_OPTIONS_ERROR));
}
static $contacts = null;
if (!isset($contacts)) $contacts = new Api\Contacts();
if ((list($data) = $contacts->search([
'email' => $participant['email'],
'email_home' => $participant['email'],
], ['id','egw_addressbook.account_id as account_id','n_fn'],
'egw_addressbook.account_id IS NOT NULL DESC, n_fn IS NOT NULL DESC',
'','',false,'OR')))
{
// found an addressbook entry
$uid = $data['account_id'] ? (int)$data['account_id'] : 'c'.$data['id'];
}
else
{
$uid = 'e'.(empty($participant['name']) ? $participant['email'] : $participant['name'].' <'.$participant['email'].'>');
}
}
$default_status = $uid === $GLOBALS['egw_info']['user']['account_id'] ? 'A' : 'U';
$default_role = $uid === $calendar_owner ? 'CHAIR' : 'REQ-PARTICIPANT';
$parsed[$uid] = \calendar_so::combine_status(array_search($participant['participationStatus'] ?? $default_status, self::$status2jscal) ?: $default_status,
1, self::jscalRoles2role($participant['roles'] ?? null, $default_role));
}
return $parsed;
}
protected static function jscalRoles2role(array $roles=null, string $default_role=null)
{
$role = $default_role ?? 'REQ-PARTICIPANT';
foreach($roles ?? [] as $name => $value)
{
if ($value && $role !== 'CHAIR')
{
switch($name)
{
case 'owner': // we ignore the owner, it's set automatic to the owner of the calendar/collection
break;
case 'attendee':
$role = 'REQ-PARTICIPANT';
break;
case 'optional':
$role = 'OPT-PARTICIPANT';
break;
case 'informational':
$role = 'NON-PARTICIPANT';
break;
case 'chair':
$role = 'CHAIR';
break;
}
}
}
return $role;
}
const TYPE_LOCATION = 'Location';
const TYPE_VIRTALLOCATION = 'VirtualLocation';

View File

@ -471,15 +471,18 @@ class Etemplate extends Etemplate\Widget\Template
/**
* Notify server that eT session/request is no longer needed, because user closed window
*
* @param string $_exec_id
* @param string|string[] $_exec_id
*/
static public function ajax_destroy_session($_exec_id)
{
//error_log(__METHOD__."('$_exec_id')");
if (($request = Etemplate\Request::read($_exec_id, false)))
foreach((array)$_exec_id as $exec_id)
{
$request->remove_if_not_modified();
unset($request);
//error_log(__METHOD__."('$_exec_id')");
if (($request = Etemplate\Request::read($exec_id, false)))
{
$request->remove_if_not_modified();
unset($request);
}
}
}

View File

@ -1300,7 +1300,7 @@ class Account implements \ArrayAccess
{
$old_account_ids[] = $row['account_id'];
}
if ($data['account_id'] && ($ids_to_remove = array_diff($old_account_ids, (array)$data['account_id'])))
if (($ids_to_remove = array_diff($old_account_ids, (array)$data['account_id'])))
{
self::$db->delete(self::VALID_TABLE, $where+array(
'account_id' => $ids_to_remove,

File diff suppressed because it is too large Load Diff

View File

@ -78,36 +78,8 @@
display: none;
}
/* Put widget label to the left of the widget, with fixed width */
::part(form-control) {
display: flex;
align-items: center;
flex-wrap: wrap;
}
::part(form-control-label) {
flex: 0 0 auto;
white-space: normal;
}
::part(form-control-input) {
flex: 1 1 auto;
position: relative;
max-width: 100%;
}
::part(form-control-help-text) {
flex-basis: 100%;
position: relative;
}
/* Use .et2-label-fixed class to give fixed label size */
.et2-label-fixed::part(form-control-label) {
width: initial;
width: var(--label-width, 8em);
}
.et2-label-fixed::part(form-control-help-text) {
left: calc(var(--sl-spacing-medium) + var(--label-width,8em));
*[hidden] {
display: none !important;
}
/**
@ -1459,15 +1431,15 @@ div.et2_vfsPath li img {
}
/* Category indents in select options */
sl-menu-item.cat_level1::part(label) {
sl-option.cat_level1::part(label) {
padding-left: var(--sl-spacing-medium, 1em);
}
sl-menu-item.cat_level2::part(label) {
sl-option.cat_level2::part(label) {
padding-left: calc(2 * var(--sl-spacing-medium, 1em));
}
sl-menu-item.cat_level3::part(label) {
sl-option.cat_level3::part(label) {
padding-left: calc(3 * var(--sl-spacing-medium, 1em));
}

View File

@ -1227,7 +1227,7 @@ class calendar_groupdav extends Api\CalDAV\Handler
$type = null;
if (($is_json=Api\CalDAV::isJSON($type)))
{
$event = Api\CalDAV\JsCalendar::parseJsEvent($options['content'], $oldEvent ?? [], $type, $method);
$event = Api\CalDAV\JsCalendar::parseJsEvent($options['content'], $oldEvent ?? [], $type, $method, $user);
$cal_id = $this->bo->save($event);
}
else

View File

@ -56,19 +56,16 @@ export class CalendarOwner extends Et2StaticSelectMixin(Et2Select)
*/
_optionTemplate(option : SelectOption) : TemplateResult
{
const checked = this.value == null ?
option.value === this.value || this.multiple && this.value.indexOf(option.value) >= 0 :
this.value.indexOf(option.value) >= 0;
// Tag used must match this.optionTag, but you can't use the variable directly.
// Pass option along so SearchMixin can grab it if needed
const value = (<string>option.value).replaceAll(" ", "___");
return html`
<sl-option value="${option.value}"
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
class="${option.class}" .option=${option}
?disabled=${option.disabled}
.selected=${checked}
<sl-option
part="option"
exportparts="prefix:tag__prefix, suffix:tag__suffix"
value="${option.value}"
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
class="${option.class}" .option=${option}
?disabled=${option.disabled}
.selected=${this.getValueAsArray().some(v => v == value)}
>
${this._iconTemplate(option)}
${this.noLang ? option.label : this.egw().lang(option.label)}

View File

@ -99,7 +99,7 @@
box-shadow: none;
}
/* Hide email in sidebox */
#calendar-sidebox_owner .title {
#calendar-sidebox_owner::part(tag__suffix) {
display: none;
}

View File

@ -48,7 +48,7 @@
<rows>
<row disabled="@no_add" height="60" class="et2_toolbar">
<calendar-owner id="participant" allowFreeEntries="true" span="4"
empty_label="Add new participants or resource"
placeholder="Add new participants or resource"
onchange="app.calendar.participantOnChange"/>
<et2-hbox width="100%">
<et2-textbox type="integer" id="quantity" min="1" statustext="Number of resources to be booked"></et2-textbox>

View File

@ -115,7 +115,7 @@
box-shadow: none;
}
/* Hide email in sidebox */
#calendar-sidebox_owner .title {
#calendar-sidebox_owner sl-option::part(suffix) {
display: none;
}
/* Conflict display */
@ -2359,52 +2359,12 @@ e.g. the div with class calendar_calTimeGrid is generated by the timeGridWidget
background: #f2f2f2;
}
.calendar_calDayTodos .calendar_calDayTodosTable table td {
display: inline-block;
padding: 3px;
}
.calendar_calDayTodos .calendar_calDayTodosTable table td img[src$="svg"] {
background-color: #0c5da5;
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCAxIDEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiPjxsaW5lYXJHcmFkaWVudCBpZD0iZzc0MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj48c3RvcCBzdG9wLWNvbG9yPSIjMEM1REE1IiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMEM1REE1IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiIGZpbGw9InVybCgjZzc0MykiLz48L3N2Zz4=);
background-image: -moz-linear-gradient(top, #0C5DA5, #0C5DA5);
background-image: -ms-linear-gradient(top, #0C5DA5, #0C5DA5);
background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0C5DA5), to(#0C5DA5));
background-image: -webkit-linear-gradient(top, #0C5DA5, #0C5DA5);
background-image: -o-linear-gradient(top, #0C5DA5, #0C5DA5);
background-image: linear-gradient(top, #0C5DA5, #0C5DA5);
background-repeat: repeat-x;
}
.calendar_calDayTodos .calendar_calDayTodosTable table td img {
width: 12px;
height: 12px;
/*.background_color_10_gray;*/
-webkit-box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
-moz-box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 0, 0, 0.15);
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.calendar_calDayTodos .calendar_calDayTodosTable table td img:hover {
/*.background_color_20_gray;*/
-webkit-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
-moz-box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(0, 0, 0, 0.5);
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.calendar_calDayTodos .calendar_calDayTodosTable table td img:active {
/*.background_color_30_gray;*/
border: 1px solid rgba(0, 0, 0, 0.9);
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
-webkit-box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
-moz-box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
box-shadow: inset 1px 2px 1px rgba(0, 0, 0, 0.5);
background-color: #b3e4a6;
background-color: #ace29e !important;
}
.calendar_calDayTodosHeader {
text-align: center;

View File

@ -103,7 +103,7 @@
box-shadow: none;
}
/* Hide email in sidebox */
#calendar-sidebox_owner .title {
#calendar-sidebox_owner sl-option::part(suffix) {
display: none;
}
/* Conflict display */

12
doc/README.md Normal file
View File

@ -0,0 +1,12 @@
## Notes on automatic documentation
This is a project in itself. Here's how the pieces fit together:
+ `build:dev` package script calls `/doc/scripts/build.mjs` which is responsible for calling the individual pieces. We
pass files to the subprocesses, but options are set in a separate config file.
+ `/doc/scripts/metadata.mjs` extracts the component information
using [CEM](https://custom-elements-manifest.open-wc.org/), and stores it to `/doc/dist/custom-elements.json`
+ `/doc/scripts/etemplate2/eleventy.config.cjs` uses [11ty](11ty.dev) to build a documentation site, from the
subdirectories in `/doc/etemplate2`, and stores it to `/doc/dist/site`
If a component doesn't show up, it's probably not in the manifest.

View File

@ -202,7 +202,7 @@ curl https://example.org/egroupware/groupdav.php/<username>/calendar/ -H "Accept
following GET parameters are supported to customize the returned properties:
- props[]=<DAV-prop-name> eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified)
Default for addressbook collections is to only return address-data (JsContact), other collections return all props.
Default for calendar collections is to only return calendar-data (JsEvent), other collections return all props.
- sync-token=<token> to only request change since last sync-token, like rfc6578 sync-collection REPORT
- nresults=N limit number of responses (only for sync-collection / given sync-token parameter!)
this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk
@ -215,7 +215,7 @@ Examples: see addressbook
<summary>Example: GET request for a single resource</summary>
```
curl 'https://example.org/egroupware/groupdav.php/addressbook/6502' -H "Accept: application/pretty+json" --user <username>
curl 'https://example.org/egroupware/groupdav.php/calendar/6502' -H "Accept: application/pretty+json" --user <username>
{
"@type": "Event",
"prodId": "EGroupware Calendar 23.1.002",

View File

@ -0,0 +1,305 @@
{% extends "default.njk" %}
{# Find the component based on the `tag` front matter #}
{% set component = getComponent(component.tagName) %}
{% block content %}
{# Determine the badge variant #}
{% if component.status == 'stable' %}
{% set badgeVariant = 'primary' %}
{% elseif component.status == 'experimental' %}
{% set badgeVariant = 'warning' %}
{% elseif component.status == 'planned' %}
{% set badgeVariant = 'neutral' %}
{% elseif component.status == 'deprecated' %}
{% set badgeVariant = 'danger' %}
{% else %}
{% set badgeVariant = 'neutral' %}
{% endif %}
{# Header #}
<header class="component-header">
<h1>{{ component.name | classNameToComponentName }}</h1>
<div class="component-header__tag">
<code>&lt;{{ component.tagName }}&gt; | {{ component.name }}</code>
</div>
<div class="component-header__info">
<sl-badge variant="neutral" pill>
Since {{component.since or '?' }}
</sl-badge>
<sl-badge variant="{{ badgeVariant }}" pill style="text-transform: capitalize;">
{{ component.status }}
</sl-badge>
</div>
</header>
<p class="component-summary">
{% if component.summary %}
{{ component.summary | markdownInline | safe }}
{% endif %}
</p>
{# Markdown content #}
{{ content | safe }}
{# Slots #}
{% if component.slots.length %}
<h2>Slots</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
</tr>
</thead>
<tbody>
{% for slot in component.slots %}
<tr>
<td class="nowrap">
{% if slot.name %}
<code>{{ slot.name }}</code>
{% else %}
(default)
{% endif %}
</td>
<td>{{ slot.description | markdownInline | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#slots') }}">using slots</a>.</em></p>
{% endif %}
{# Properties #}
{% if component.properties.length %}
<h2>Properties</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
<th class="table-reflects">Reflects</th>
<th class="table-type">Type</th>
<th class="table-default">Default</th>
</tr>
</thead>
<tbody>
{% for prop in component.properties %}
<tr>
<td>
<code class="nowrap">{{ prop.name }}</code>
{% if prop.attribute != prop.name %}
<br>
<sl-tooltip content="This attribute is different from its property">
<small>
<code class="nowrap">
{{ prop.attribute }}
</code>
</small>
</sl-tooltip>
{% endif %}
</td>
<td>
{{ prop.description | markdownInline | safe }}
</td>
<td style="text-align: center;">
{% if prop.reflects %}
<sl-icon label="yes" name="check-lg"></sl-icon>
{% endif %}
</td>
<td>
{% if prop.type.text %}
<code>{{ prop.type.text | markdownInline | safe }}</code>
{% else %}
-
{% endif %}
</td>
<td>
{% if prop.default %}
<code>{{ prop.default | markdownInline | safe }}</code>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
<tr>
<td class="nowrap"><code>updateComplete</code></td>
<td>
A read-only promise that resolves when the component has
<a href="/getting-started/usage?#component-rendering-and-updating">finished updating</a>.
</td>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#properties') }}">attributes and properties</a>.</em></p>
{% endif %}
{# Events #}
{% if component.events.length %}
<h2>Events</h2>
<table>
<thead>
<tr>
<th class="table-name" data-flavor="html">Name</th>
<th class="table-name" data-flavor="react">React Event</th>
<th class="table-description">Description</th>
<th class="table-event-detail">Event Detail</th>
</tr>
</thead>
<tbody>
{% for event in component.events %}
<tr>
<td data-flavor="html"><code class="nowrap">{{ event.name }}</code></td>
<td data-flavor="react"><code class="nowrap">{{ event.reactName }}</code></td>
<td>{{ event.description | markdownInline | safe }}</td>
<td>
{% if event.type.text %}
<code>{{ event.type.text }}</code>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#events') }}">events</a>.</em></p>
{% endif %}
{# Methods #}
{% if component.methods.length %}
<h2>Methods</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
<th class="table-arguments">Arguments</th>
</tr>
</thead>
<tbody>
{% for method in component.methods %}
<tr>
<td class="nowrap"><code>{{ method.name }}()</code></td>
<td>{{ method.description | markdownInline | safe }}</td>
<td>
{% if method.parameters.length %}
<code>
{% for param in method.parameters %}
{{ param.name }}: {{ param.type.text }}{% if not loop.last %},{% endif %}
{% endfor %}
</code>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#methods') }}">methods</a>.</em></p>
{% endif %}
{# Custom Properties #}
{% if component.cssProperties.length %}
<h2>Custom Properties</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
<th class="table-default">Default</th>
</tr>
</thead>
<tbody>
{% for cssProperty in component.cssProperties %}
<tr>
<td class="nowrap"><code>{{ cssProperty.name }}</code></td>
<td>{{ cssProperty.description | markdownInline | safe }}</td>
<td>{{ cssProperty.default }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#custom-properties') }}">customizing CSS custom properties</a>.</em></p>
{% endif %}
{# CSS Parts #}
{% if component.cssParts.length %}
<h2>Parts</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
</tr>
</thead>
<tbody>
{% for cssPart in component.cssParts %}
<tr>
<td class="nowrap"><code>{{ cssPart.name }}</code></td>
<td>{{ cssPart.description | markdownInline | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#component-parts') }}">customizing CSS parts</a>.</em></p>
{% endif %}
{# Animations #}
{% if component.animations.length %}
<h2>Animations</h2>
<table>
<thead>
<tr>
<th class="table-name">Name</th>
<th class="table-description">Description</th>
</tr>
</thead>
<tbody>
{% for animation in component.animations %}
<tr>
<td class="nowrap"><code>{{ animation.name }}</code></td>
<td>{{ animation.description | markdownInline | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><em>Learn more about <a href="{{ rootUrl('/getting-started/usage#animations') }}">customizing animations</a>.</em></p>
{% endif %}
{# Dependencies #}
{% if component.dependencies.length %}
<h2>Dependencies</h2>
<p>This component automatically imports the following dependencies.</p>
<ul>
{% for dependency in component.dependencies %}
<li><code>&lt;{{ dependency }}&gt;</code></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html
lang="en"
data-layout="{{ layout }}"
data-egroupware-version="{{ meta.version }}"
>
<head>
{# Metadata #}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{ meta.description }}" />
<title>{{ meta.title }}</title>
{# Opt out of Turbo caching #}
<meta name="turbo-cache-control">
{# Stylesheets #}
<!-- <link rel="stylesheet" href="{{ assetUrl('styles/monochrome.css') }}" /> -->
<link rel="stylesheet" href="{{ assetUrl('styles/docs.css') }}" />
<link rel="stylesheet" href="{{ assetUrl('styles/code-previews.css') }}" />
<link rel="stylesheet" href="{{ assetUrl('styles/search.css') }}" />
{# Favicons #}
<link rel="icon" href="{{ assetUrl('images/logo.svg') }}" type="image/x-icon" />
{# OpenGraph #}
<meta property="og:url" content="{{ rootUrl(page.url, true) }}" />
<meta property="og:title" content="{{ meta.title }}" />
<meta property="og:description" content="{{ meta.description }}" />
<meta property="og:image" content="{{ assetUrl(meta.image, true) }}" />
{# API Viewer Element #}
<script type="module" src="https://jspm.dev/api-viewer-element"></script>
{# EGroupware #}
<!--<script src="{{ assetUrl('scripts/etemplate/etemplate2.js') }}" type="module"></script>-->
{# Shoelace #}
<link rel="stylesheet" href="{{ assetUrl('shoelace/themes/light.css') }}" />
<link rel="stylesheet" href="{{ assetUrl('shoelace/themes/dark.css') }}" />
<script type="module" src="{{ assetUrl('shoelace/shoelace.js') }}"></script>
{# Set the initial theme and menu states here to prevent flashing #}
<script>
(() => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = localStorage.getItem('theme') || 'auto';
document.documentElement.classList.toggle('sl-theme-dark', theme === 'dark' || (theme === 'auto' && prefersDark));
})();
</script>
{# Turbo + Scroll positioning #}
<script src="{{ assetUrl('scripts/turbo.js') }}" type="module"></script>
<script src="{{ assetUrl('scripts/docs.js') }}" defer></script>
<script src="{{ assetUrl('scripts/code-previews.js') }}" defer></script>
<script src="{{ assetUrl('scripts/lunr.js') }}" defer></script>
<script src="{{ assetUrl('scripts/search.js') }}" defer></script>
</head>
<body>
<a id="skip-to-main" class="visually-hidden" href="#main-content" data-smooth-link="false">
Skip to main content
</a>
{# Menu toggle #}
<button id="menu-toggle" type="button" aria-label="Menu">
<svg width="148" height="148" viewBox="0 0 148 148" xmlns="http://www.w3.org/2000/svg">
<g stroke="currentColor" stroke-width="18" fill="none" fill-rule="evenodd" stroke-linecap="round">
<path d="M9.5 125.5h129M9.5 74.5h129M9.5 23.5h129"></path>
</g>
</svg>
</button>
{# Icon toolbar #}
<div id="icon-toolbar">
{# GitHub #}
<a href="https://github.com/EGroupware/egroupware" title="View EGroupware on GitHub">
<sl-icon name="github"></sl-icon>
</a>
{# Theme selector #}
<sl-dropdown id="theme-selector" placement="bottom-end" distance="3">
<sl-button slot="trigger" size="small" variant="text" caret title="Press \ to toggle">
<sl-icon class="only-light" name="sun-fill"></sl-icon>
<sl-icon class="only-dark" name="moon-fill"></sl-icon>
</sl-button>
<sl-menu>
<sl-menu-item type="checkbox" value="light">Light</sl-menu-item>
<sl-menu-item type="checkbox" value="dark">Dark</sl-menu-item>
<sl-divider></sl-divider>
<sl-menu-item type="checkbox" value="auto">System</sl-menu-item>
</sl-menu>
</sl-dropdown>
</div>
<aside id="sidebar" data-preserve-scroll>
<header>
<a href="/">
<img src="{{ assetUrl('images/logo.svg') }}" alt="EGroupware" />
</a>
<div class="sidebar-version">
{{ meta.version }}
</div>
</header>
<div class="sidebar-buttons">
<sl-button size="small" class="repo-button repo-button--github" href="https://github.com/EGroupware/egroupware" target="_blank">
<sl-icon slot="prefix" name="github"></sl-icon> Code
</sl-button>
<sl-button size="small" class="repo-button repo-button--star" href="https://github.com/EGroupware/egroupware/stargazers" target="_blank">
<sl-icon slot="prefix" name="star-fill"></sl-icon> Star
</sl-button>
<sl-button size="small" class="repo-button repo-button--twitter" href="https://egroupware.org" target="_blank">
<sl-icon slot="prefix" name="box-arrow-up-right"></sl-icon> EGroupware
</sl-button>
</div>
<button class="search-box" type="button" title="Press / to search" aria-label="Search" data-plugin="search">
<sl-icon name="search"></sl-icon>
<span>Search</span>
</button>
<nav>
{% include 'sidebar.njk' %}
</nav>
</aside>
{# Content #}
<main>
<a id="main-content"></a>
<article id="content" class="content{% if toc %} content--with-toc{% endif %}">
{% if toc %}
<div class="content__toc">
<ul>
<li class="top"><a href="#">{{ meta.title }}</a></li>
</ul>
</div>
{% endif %}
<div class="content__body">
{% block content %}
{{ content | safe }}
{% endblock %}
</div>
</article>
</main>
</body>
</html>

View File

@ -0,0 +1,42 @@
<ul>
<li>
<h2>Getting Started</h2>
<ul>
<li><a href="/">Home</a></li>
<li><a href="https://github.com/EGroupware/egroupware/wiki">Development</a></li>
<li><a href="/getting-started/widgets">Widgets</a></li>
<li><a href="/getting-started/styling">Styling</a></li>
<li><a href="/getting-started/customizing">Customizing</a></li>
<li><a href="/getting-started/localization">Localization</a></li>
</ul>
</li>
<li>
<h2>Resources</h2>
<ul>
<li><a href="https://help.egroupware.org">Community</a></li>
<li><a href="https://www.egroupware.org/en/professional-support">Help &amp; Support</a></li>
<li><a href="https://github.com/EGroupware/egroupware/releases">Changelog</a></li>
</ul>
</li>
<li>
<h2>Components</h2>
<ul>
<li><em><a href="/components/sandbox">Sandbox</a></em></li>
{% for component in meta.components %}
<li>
<a href="/components/{{ component.tagName | removeSlPrefix }}">
{{ component.name | classNameToComponentName }}
</a>
</li>
{% endfor %}
</ul>
</li>
<li>
<h2>Tutorials</h2>
<ul>
<li><a href="/tutorials/creating-a-widget">Creating a widget</a></li>
<li><a href="/tutorials/integrating-with-egw">Integrating with EGroupware</a></li>
<li><a href="/tutorials/link-system">Implementing the linking system</a></li>
</ul>
</li>
</ul>

View File

@ -0,0 +1,35 @@
function normalizePathname(pathname) {
// Remove /index.html
if (pathname.endsWith('/index.html')) {
pathname = pathname.replace(/\/index\.html/, '');
}
// Remove trailing slashes
return pathname.replace(/\/$/, '');
}
/**
* Adds a class name to links that are currently active.
*/
module.exports = function (doc, options) {
options = {
className: 'active-link', // the class to add to active links
pathname: undefined, // the current pathname to compare
within: 'body', // element containing the target links
...options
};
const within = doc.querySelector(options.within);
if (!within) {
return doc;
}
within.querySelectorAll('a').forEach(link => {
if (normalizePathname(options.pathname) === normalizePathname(link.pathname)) {
link.classList.add(options.className);
}
});
return doc;
};

View File

@ -0,0 +1,64 @@
const { createSlug } = require('./strings.cjs');
/**
* Turns headings into clickable, deep linkable anchors. The provided doc should be a document object provided by JSDOM.
* The same document will be returned with the appropriate DOM manipulations.
*/
module.exports = function (doc, options) {
options = {
levels: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], // the headings to convert
className: 'anchor-heading', // the class name to add
within: 'body', // the element containing the target headings
...options
};
const within = doc.querySelector(options.within);
if (!within) {
return doc;
}
within.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(heading => {
const hasAnchor = heading.querySelector('a');
const anchor = doc.createElement('a');
let id = heading.textContent ?? '';
let suffix = 0;
// Skip heading levels we don't care about
if (!options.levels?.includes(heading.tagName.toLowerCase())) {
return;
}
// Convert dots to underscores
id = id.replace(/\./g, '_');
// Turn it into a slug
id = createSlug(id);
// Make sure it starts with a letter
if (!/^[a-z]/i.test(id)) {
id = `id_${id}`;
}
// Make sure the id is unique
const originalId = id;
while (doc.getElementById(id) !== null) {
id = `${originalId}-${++suffix}`;
}
if (hasAnchor || !id) return;
heading.setAttribute('id', id);
anchor.setAttribute('href', `#${encodeURIComponent(id)}`);
anchor.setAttribute('aria-label', `Direct link to "${heading.textContent}"`);
if (options.className) {
heading.classList.add(options.className);
}
// Append the anchor
heading.append(anchor);
});
return doc;
};

View File

@ -0,0 +1,89 @@
const customElementsManifest = require('../../dist/custom-elements.json');
//
// Export it here so we can import it elsewhere and use the same version
//
module.exports.customElementsManifest = customElementsManifest;
//
// Gets all components from custom-elements.json and returns them in a more documentation-friendly format.
//
module.exports.getAllComponents = function ()
{
const allComponents = [];
customElementsManifest.modules?.forEach(module =>
{
module.declarations?.forEach(declaration =>
{
if (declaration.customElement)
{
// Generate the dist path based on the src path and attach it to the component
declaration.path = module.path.replace(/^src\//, 'dist/').replace(/\.ts$/, '.js');
// Remove members that are private or don't have a description
const members = declaration.members?.filter(member => member.description && member.privacy !== 'private');
const methods = members?.filter(prop => prop.kind === 'method' && prop.privacy !== 'private');
const properties = members?.filter(prop =>
{
// Look for a corresponding attribute
const attribute = declaration.attributes?.find(attr => attr.fieldName === prop.name);
if (attribute)
{
prop.attribute = attribute.name || attribute.fieldName;
}
return prop.kind === 'field' && prop.privacy !== 'private';
});
allComponents.push({
...declaration,
methods,
properties
});
}
});
});
// Build dependency graphs
allComponents.forEach(component =>
{
const dependencies = [];
// Recursively fetch sub-dependencies
function getDependencies(tag)
{
const cmp = allComponents.find(c => c.tagName === tag);
if (!cmp || !Array.isArray(component.dependencies))
{
return;
}
cmp.dependencies?.forEach(dependentTag =>
{
if (!dependencies.includes(dependentTag))
{
dependencies.push(dependentTag);
}
getDependencies(dependentTag);
});
}
getDependencies(component.tagName);
component.dependencies = dependencies.sort();
});
// Sort by name
return allComponents.sort((a, b) =>
{
if (a.name < b.name)
{
return -1;
}
if (a.name > b.name)
{
return 1;
}
return 0;
});
};

View File

@ -0,0 +1,138 @@
let count = 1;
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Turns code fields with the :preview suffix into interactive code previews.
*/
module.exports = function (doc, options) {
options = {
within: 'body', // the element containing the code fields to convert
...options
};
const within = doc.querySelector(options.within);
if (!within) {
return doc;
}
within.querySelectorAll('[class*=":preview"]').forEach(code => {
const pre = code.closest('pre');
if (!pre) {
return;
}
const adjacentPre = pre.nextElementSibling?.tagName.toLowerCase() === 'pre' ? pre.nextElementSibling : null;
const reactCode = adjacentPre?.querySelector('code[class$="react"]');
const sourceGroupId = `code-preview-source-group-${count}`;
const isExpanded = code.getAttribute('class').includes(':expanded');
const noCodePen = code.getAttribute('class').includes(':no-codepen');
count++;
const htmlButton = `
<button type="button"
title="Show HTML code"
class="code-preview__button code-preview__button--html"
>
HTML
</button>
`;
const reactButton = `
<button type="button" title="Show React code" class="code-preview__button code-preview__button--react">
React
</button>
`;
const codePenButton = `
<button type="button" class="code-preview__button code-preview__button--codepen" title="Edit on CodePen">
<svg
width="138"
height="26"
viewBox="0 0 138 26"
fill="none"
stroke="currentColor"
stroke-width="2.3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M80 6h-9v14h9 M114 6h-9 v14h9 M111 13h-6 M77 13h-6 M122 20V6l11 14V6 M22 16.7L33 24l11-7.3V9.3L33 2L22 9.3V16.7z M44 16.7L33 9.3l-11 7.4 M22 9.3l11 7.3 l11-7.3 M33 2v7.3 M33 16.7V24 M88 14h6c2.2 0 4-1.8 4-4s-1.8-4-4-4h-6v14 M15 8c-1.3-1.3-3-2-5-2c-4 0-7 3-7 7s3 7 7 7 c2 0 3.7-0.8 5-2 M64 13c0 4-3 7-7 7h-5V6h5C61 6 64 9 64 13z" />
</svg>
</button>
`;
const codePreview = `
<div class="code-preview ${isExpanded ? 'code-preview--expanded' : ''}">
<div class="code-preview__preview">
${code.textContent}
<div class="code-preview__resizer">
<sl-icon name="grip-vertical"></sl-icon>
</div>
</div>
<div class="code-preview__source-group" id="${sourceGroupId}">
<div class="code-preview__source code-preview__source--html" ${reactCode ? 'data-flavor="html"' : ''}>
<pre><code class="language-html">${escapeHtml(code.textContent)}</code></pre>
</div>
${
reactCode
? `
<div class="code-preview__source code-preview__source--react" data-flavor="react">
<pre><code class="language-jsx">${escapeHtml(reactCode.textContent)}</code></pre>
</div>
`
: ''
}
</div>
<div class="code-preview__buttons">
<button
type="button"
class="code-preview__button code-preview__toggle"
aria-expanded="${isExpanded ? 'true' : 'false'}"
aria-controls="${sourceGroupId}"
>
Source
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
${reactCode ? ` ${htmlButton} ${reactButton} ` : ''}
${noCodePen ? '' : codePenButton}
</div>
</div>
`;
pre.insertAdjacentHTML('afterend', codePreview);
pre.remove();
if (adjacentPre) {
adjacentPre.remove();
}
});
// Wrap code preview scripts in anonymous functions so they don't run in the global scope
doc.querySelectorAll('.code-preview__preview script').forEach(script => {
if (script.type === 'module') {
// Modules are already scoped
script.textContent = script.innerHTML;
} else {
// Wrap non-modules in an anonymous function so they don't run in the global scope
script.textContent = `(() => { ${script.innerHTML} })();`;
}
});
return doc;
};

View File

@ -0,0 +1,26 @@
/**
* Adds copy code buttons to code fields. The provided doc should be a document object provided by JSDOM. The same
* document will be returned with the appropriate DOM manipulations.
*/
module.exports = function (doc) {
doc.querySelectorAll('pre > code').forEach(code => {
const pre = code.closest('pre');
const button = doc.createElement('button');
button.setAttribute('type', 'button');
button.classList.add('copy-code-button');
button.setAttribute('aria-label', 'Copy');
button.innerHTML = `
<svg class="copy-code-button__copy-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16" part="svg">
<path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"></path>
</svg>
<svg class="copy-code-button__copied-icon" style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-lg" viewBox="0 0 16 16" part="svg">
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"></path>
</svg>
`;
pre.append(button);
});
return doc;
};

View File

@ -0,0 +1,41 @@
const { isExternalLink } = require('./strings.cjs');
/**
* Transforms external links to make them safer and optionally add a target. The provided doc should be a document
* object provided by JSDOM. The same document will be returned with the appropriate DOM manipulations.
*/
module.exports = function (doc, options) {
options = {
className: 'external-link', // the class name to add to links
noopener: true, // sets rel="noopener"
noreferrer: true, // sets rel="noreferrer"
ignore: () => false, // callback function to filter links that should be ignored
within: 'body', // element that contains the target links
target: '', // sets the target attribute
...options
};
const within = doc.querySelector(options.within);
if (within) {
within.querySelectorAll('a').forEach(link => {
if (isExternalLink(link) && !options.ignore(link)) {
link.classList.add(options.className);
const rel = [];
if (options.noopener) rel.push('noopener');
if (options.noreferrer) rel.push('noreferrer');
if (rel.length) {
link.setAttribute('rel', rel.join(' '));
}
if (options.target) {
link.setAttribute('target', options.target);
}
}
});
}
return doc;
};

View File

@ -0,0 +1,63 @@
const Prism = require('prismjs');
const PrismLoader = require('prismjs/components/index.js');
PrismLoader('diff');
PrismLoader.silent = true;
/** Highlights a code string. */
function highlight(code, language) {
const alias = language.replace(/^diff-/, '');
const isDiff = /^diff-/i.test(language);
// Auto-load the target language
if (!Prism.languages[alias]) {
PrismLoader(alias);
if (!Prism.languages[alias]) {
throw new Error(`Unsupported language for code highlighting: "${language}"`);
}
}
// Register diff-* languages to use the diff grammar
if (isDiff) {
Prism.languages[language] = Prism.languages.diff;
}
return Prism.highlight(code, Prism.languages[language], language);
}
/**
* Highlights all code fields that have a language parameter. If the language has a colon in its name, the first chunk
* will be the language used and additional chunks will be applied as classes to the `<pre>`. For example, a code field
* tagged with "html:preview" will be rendered as `<pre class="language-html preview">`.
*
* The provided doc should be a document object provided by JSDOM. The same document will be returned with the
* appropriate DOM manipulations.
*/
module.exports = function (doc) {
doc.querySelectorAll('pre > code[class]').forEach(code => {
// Look for class="language-*" and split colons into separate classes
code.classList.forEach(className => {
if (className.startsWith('language-')) {
//
// We use certain suffixes to indicate code previews, expanded states, etc. The class might look something like
// this:
//
// class="language-html:preview:expanded"
//
// The language will always come first, so we need to drop the "language-" prefix and everything after the first
// color to get the highlighter language.
//
const language = className.replace(/^language-/, '').split(':')[0];
try {
code.innerHTML = highlight(code.textContent ?? '', language);
} catch (err) {
// Language not found, skip it
}
}
});
});
return doc;
};

View File

@ -0,0 +1,67 @@
const MarkdownIt = require('markdown-it');
const markdownItContainer = require('markdown-it-container');
const markdownItIns = require('markdown-it-ins');
const markdownItKbd = require('markdown-it-kbd');
const markdownItMark = require('markdown-it-mark');
const markdownItReplaceIt = require('markdown-it-replace-it');
const markdown = MarkdownIt({
html: true,
xhtmlOut: false,
breaks: false,
langPrefix: 'language-',
linkify: false,
typographer: false
});
// Third-party plugins
markdown.use(markdownItContainer);
markdown.use(markdownItIns);
markdown.use(markdownItKbd);
markdown.use(markdownItMark);
markdown.use(markdownItReplaceIt);
// Callouts
['tip', 'warning', 'danger'].forEach(type => {
markdown.use(markdownItContainer, type, {
render: function (tokens, idx) {
if (tokens[idx].nesting === 1) {
return `<div role="alert" class="callout callout--${type}">`;
}
return '</div>\n';
}
});
});
// Asides
markdown.use(markdownItContainer, 'aside', {
render: function (tokens, idx) {
if (tokens[idx].nesting === 1) {
return `<aside>`;
}
return '</aside>\n';
}
});
// Details
markdown.use(markdownItContainer, 'details', {
validate: params => params.trim().match(/^details\s+(.*)$/),
render: (tokens, idx) => {
const m = tokens[idx].info.trim().match(/^details\s+(.*)$/);
if (tokens[idx].nesting === 1) {
return `<details>\n<summary><span>${markdown.utils.escapeHtml(m[1])}</span></summary>\n`;
}
return '</details>\n';
}
});
// Replace [#1234] with a link to GitHub issues
markdownItReplaceIt.replacements.push({
name: 'github-issues',
re: /\[#([0-9]+)\]/gs,
sub: '<a href="https://github.com/shoelace-style/shoelace/issues/$1">#$1</a>',
html: true,
default: true
});
module.exports = markdown;

View File

@ -0,0 +1,27 @@
const {format} = require('prettier');
/** Formats markup using prettier. */
module.exports = function (content, options)
{
options = {
arrowParens: 'avoid',
bracketSpacing: true,
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
bracketSameLine: false,
jsxSingleQuote: false,
parser: 'html',
printWidth: 120,
proseWrap: 'preserve',
quoteProps: 'as-needed',
requirePragma: false,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'none',
useTabs: false,
...options
};
return format(content, options);
};

View File

@ -0,0 +1,19 @@
/**
* @typedef {object} Replacement
* @property {string | RegExp} pattern
* @property {string} replacement
*/
/**
* @typedef {Array<Replacement>} Replacements
*/
/**
* @param {Document} content
* @param {Replacements} replacements
*/
module.exports = function (content, replacements) {
replacements.forEach(replacement => {
content.body.innerHTML = content.body.innerHTML.replaceAll(replacement.pattern, replacement.replacement);
});
};

View File

@ -0,0 +1,21 @@
/**
* Turns headings into clickable, deep linkable anchors. The provided doc should be a document object provided by JSDOM.
* The same document will be returned with the appropriate DOM manipulations.
*/
module.exports = function (doc, options) {
const tables = [...doc.querySelectorAll('table')];
options = {
className: 'table-scroll', // the class name to add to the table's container
...options
};
tables.forEach(table => {
const div = doc.createElement('div');
div.classList.add(options.className);
table.insertAdjacentElement('beforebegin', div);
div.append(table);
});
return doc;
};

View File

@ -0,0 +1,16 @@
const slugify = require('slugify');
/** Creates a slug from an arbitrary string of text. */
module.exports.createSlug = function (text) {
return slugify(String(text), {
remove: /[^\w|\s]/g,
lower: true
});
};
/** Determines whether or not a link is external. */
module.exports.isExternalLink = function (link) {
// We use the "internal" hostname when initializing JSDOM so we know that those are local links
if (!link.hostname || link.hostname === 'internal') return false;
return true;
};

View File

@ -0,0 +1,42 @@
/**
* Generates an in-page table of contents based on headings.
*/
module.exports = function (doc, options) {
options = {
levels: ['h2'], // headings to include (they must have an id)
container: 'nav', // the container to append links to
listItem: true, // if true, links will be wrapped in <li>
within: 'body', // the element containing the headings to summarize
...options
};
const container = doc.querySelector(options.container);
const within = doc.querySelector(options.within);
const headingSelector = options.levels.map(h => `${h}[id]`).join(', ');
if (!container || !within) {
return doc;
}
within.querySelectorAll(headingSelector).forEach(heading => {
const listItem = doc.createElement('li');
const link = doc.createElement('a');
const level = heading.tagName.slice(1);
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
if (options.listItem) {
// List item + link
listItem.setAttribute('data-level', level);
listItem.append(link);
container.append(listItem);
} else {
// Link only
link.setAttribute('data-level', level);
container.append(link);
}
});
return doc;
};

View File

@ -0,0 +1,23 @@
const smartquotes = require('smartquotes');
smartquotes.replacements.push([/---/g, '\u2014']); // em dash
smartquotes.replacements.push([/--/g, '\u2013']); // en dash
smartquotes.replacements.push([/\.\.\./g, '\u2026']); // ellipsis
smartquotes.replacements.push([/\(c\)/gi, '\u00A9']); // copyright
smartquotes.replacements.push([/\(r\)/gi, '\u00AE']); // registered trademark
smartquotes.replacements.push([/\?!/g, '\u2048']); // ?!
smartquotes.replacements.push([/!!/g, '\u203C']); // !!
smartquotes.replacements.push([/\?\?/g, '\u2047']); // ??
smartquotes.replacements.push([/([0-9]\s?)-(\s?[0-9])/g, '$1\u2013$2']); // number ranges use en dash
/**
* Improves typography by adding smart quotes and similar corrections within the specified element(s).
*
* The provided doc should be a document object provided by JSDOM. The same document will be returned with the
* appropriate DOM manipulations.
*/
module.exports = function (doc, selector = 'body') {
const elements = [...doc.querySelectorAll(selector)];
elements.forEach(el => smartquotes.element(el));
return doc;
};

View File

@ -0,0 +1,19 @@
<p style="margin-top: 0">
The content in this example was included from
<a href="/assets/examples/include.html" target="_blank">a separate file</a>. 🤯
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi. Fringilla urna porttitor rhoncus dolor purus
non enim. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Gravida in fermentum et sollicitudin.
</p>
<p>
Cursus sit amet dictum sit amet justo donec enim. Sed id semper risus in hendrerit gravida. Viverra accumsan in nisl
nisi scelerisque eu ultrices vitae. Et molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit. Nec
ullamcorper sit amet risus nullam. Et egestas quis ipsum suspendisse ultrices gravida dictum. Lorem donec massa sapien
faucibus et molestie. A cras semper auctor neque vitae.
</p>
<script>
console.log('This will only execute if the `allow-scripts` prop is present');
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,286 @@
(() =>
{
function convertModuleLinks(html)
{
html = html
.replace(/@shoelace-style\/shoelace/g, `https://esm.sh/@shoelace-style/shoelace@${egwVersion}`)
.replace(/from 'react'/g, `from 'https://esm.sh/react@${reactVersion}'`)
.replace(/from "react"/g, `from "https://esm.sh/react@${reactVersion}"`);
return html;
}
function getAdjacentExample(name, pre)
{
let currentPre = pre.nextElementSibling;
while (currentPre?.tagName.toLowerCase() === 'pre')
{
if (currentPre?.getAttribute('data-lang').split(' ').includes(name))
{
return currentPre;
}
currentPre = currentPre.nextElementSibling;
}
return null;
}
function runScript(script)
{
const newScript = document.createElement('script');
if (script.type === 'module')
{
newScript.type = 'module';
newScript.textContent = script.innerHTML;
}
else
{
newScript.appendChild(document.createTextNode(`(() => { ${script.innerHTML} })();`));
}
script.parentNode.replaceChild(newScript, script);
}
function getFlavor()
{
return sessionStorage.getItem('flavor') || 'html';
}
function setFlavor(newFlavor)
{
flavor = ['html', 'react'].includes(newFlavor) ? newFlavor : 'html';
sessionStorage.setItem('flavor', flavor);
// Set the flavor class on the body
document.documentElement.classList.toggle('flavor-html', flavor === 'html');
document.documentElement.classList.toggle('flavor-react', flavor === 'react');
}
function syncFlavor()
{
setFlavor(getFlavor());
document.querySelectorAll('.code-preview__button--html').forEach(preview =>
{
if (flavor === 'html')
{
preview.classList.add('code-preview__button--selected');
}
});
document.querySelectorAll('.code-preview__button--react').forEach(preview =>
{
if (flavor === 'react')
{
preview.classList.add('code-preview__button--selected');
}
});
}
const egwVersion = document.documentElement.getAttribute('data-egroupware-version');
const reactVersion = '18.2.0';
const cdndir = 'cdn';
const npmdir = 'dist';
let flavor = getFlavor();
let count = 1;
// We need the version to open
if (!egwVersion)
{
throw new Error('The data-egroupware-version attribute is missing from <html>.');
}
// Sync flavor UI on page load
syncFlavor();
//
// Resizing previews
//
document.addEventListener('mousedown', handleResizerDrag);
document.addEventListener('touchstart', handleResizerDrag, {passive: true});
function handleResizerDrag(event)
{
const resizer = event.target.closest('.code-preview__resizer');
const preview = event.target.closest('.code-preview__preview');
if (!resizer || !preview)
{
return;
}
let startX = event.changedTouches ? event.changedTouches[0].pageX : event.clientX;
let startWidth = parseInt(document.defaultView.getComputedStyle(preview).width, 10);
event.preventDefault();
preview.classList.add('code-preview__preview--dragging');
document.documentElement.addEventListener('mousemove', dragMove);
document.documentElement.addEventListener('touchmove', dragMove);
document.documentElement.addEventListener('mouseup', dragStop);
document.documentElement.addEventListener('touchend', dragStop);
function dragMove(event)
{
const width = startWidth + (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - startX;
preview.style.width = `${width}px`;
}
function dragStop()
{
preview.classList.remove('code-preview__preview--dragging');
document.documentElement.removeEventListener('mousemove', dragMove);
document.documentElement.removeEventListener('touchmove', dragMove);
document.documentElement.removeEventListener('mouseup', dragStop);
document.documentElement.removeEventListener('touchend', dragStop);
}
}
//
// Toggle source mode
//
document.addEventListener('click', event =>
{
const button = event.target.closest('.code-preview__button');
const codeBlock = button?.closest('.code-preview');
if (button?.classList.contains('code-preview__button--html'))
{
// Show HTML
setFlavor('html');
toggleSource(codeBlock, true);
}
else if (button?.classList.contains('code-preview__button--react'))
{
// Show React
setFlavor('react');
toggleSource(codeBlock, true);
}
else if (button?.classList.contains('code-preview__toggle'))
{
// Toggle source
toggleSource(codeBlock);
}
else
{
return;
}
// Update flavor buttons
[...document.querySelectorAll('.code-preview')].forEach(cb =>
{
cb.querySelector('.code-preview__button--html')?.classList.toggle(
'code-preview__button--selected',
flavor === 'html'
);
cb.querySelector('.code-preview__button--react')?.classList.toggle(
'code-preview__button--selected',
flavor === 'react'
);
});
});
function toggleSource(codeBlock, force)
{
codeBlock.classList.toggle('code-preview--expanded', force);
event.target.setAttribute('aria-expanded', codeBlock.classList.contains('code-preview--expanded'));
}
//
// Open in CodePen
//
document.addEventListener('click', event =>
{
const button = event.target.closest('button');
if (button?.classList.contains('code-preview__button--codepen'))
{
const codeBlock = button.closest('.code-preview');
const htmlExample = codeBlock.querySelector('.code-preview__source--html > pre > code')?.textContent;
const reactExample = codeBlock.querySelector('.code-preview__source--react > pre > code')?.textContent;
const isReact = flavor === 'react' && typeof reactExample === 'string';
const theme = document.documentElement.classList.contains('sl-theme-dark') ? 'dark' : 'light';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (theme === 'auto' && prefersDark);
const editors = isReact ? '0010' : '1000';
let htmlTemplate = '';
let jsTemplate = '';
let cssTemplate = '';
const form = document.createElement('form');
form.action = 'https://codepen.io/pen/define';
form.method = 'POST';
form.target = '_blank';
// HTML templates
if (!isReact)
{
htmlTemplate =
`<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@${egwVersion}/${cdndir}/shoelace.js"></script>\n` +
`\n${htmlExample}`;
jsTemplate = '';
}
// React templates
if (isReact)
{
htmlTemplate = '<div id="root"></div>';
jsTemplate =
`import React from 'https://esm.sh/react@${reactVersion}';\n` +
`import ReactDOM from 'https://esm.sh/react-dom@${reactVersion}';\n` +
`import { setBasePath } from 'https://esm.sh/@shoelace-style/shoelace@${egwVersion}/${cdndir}/utilities/base-path';\n` +
`\n` +
`// Set the base path for Shoelace assets\n` +
`setBasePath('https://esm.sh/@shoelace-style/shoelace@${egwVersion}/${npmdir}/')\n` +
`\n${convertModuleLinks(reactExample)}\n` +
`\n` +
`ReactDOM.render(<App />, document.getElementById('root'));`;
}
// CSS templates
cssTemplate =
`@import 'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@${egwVersion}/${cdndir}/themes/${
isDark ? 'dark' : 'light'
}.css';\n` +
'\n' +
'body {\n' +
' font: 16px sans-serif;\n' +
' background-color: var(--sl-color-neutral-0);\n' +
' color: var(--sl-color-neutral-900);\n' +
' padding: 1rem;\n' +
'}';
// Docs: https://blog.codepen.io/documentation/prefill/
const data = {
title: '',
description: '',
tags: ['shoelace', 'web components'],
editors,
head: `<meta name="viewport" content="width=device-width">`,
html_classes: `sl-theme-${isDark ? 'dark' : 'light'}`,
css_external: ``,
js_external: ``,
js_module: true,
js_pre_processor: isReact ? 'babel' : 'none',
html: htmlTemplate,
css: cssTemplate,
js: jsTemplate
};
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'data';
input.value = JSON.stringify(data);
form.append(input);
document.documentElement.append(form);
form.submit();
form.remove();
}
});
// Set the initial flavor
window.addEventListener('turbo:load', syncFlavor);
})();

View File

@ -0,0 +1,298 @@
//
// Sidebar
//
// When the sidebar is hidden, we apply the inert attribute to prevent focus from reaching it. Due to the many states
// the sidebar can have (e.g. static, hidden, expanded), we test for visibility by checking to see if it's placed
// offscreen or not. Then, on resize/transition we make sure to update the attribute accordingly.
//
(() => {
function getSidebar() {
return document.getElementById('sidebar');
}
function isSidebarOpen() {
return document.documentElement.classList.contains('sidebar-open');
}
function isSidebarVisible() {
return getSidebar().getBoundingClientRect().x >= 0;
}
function toggleSidebar(force) {
const isOpen = typeof force === 'boolean' ? force : !isSidebarOpen();
return document.documentElement.classList.toggle('sidebar-open', isOpen);
}
function updateInert() {
getSidebar().inert = !isSidebarVisible();
}
// Toggle the menu
document.addEventListener('click', event => {
const menuToggle = event.target.closest('#menu-toggle');
if (!menuToggle) return;
toggleSidebar();
});
// Update the sidebar's inert state when the window resizes and when the sidebar transitions
window.addEventListener('resize', () => toggleSidebar(false));
document.addEventListener('transitionend', event => {
const sidebar = event.target.closest('#sidebar');
if (!sidebar) return;
updateInert();
});
// Close when a menu item is selected on mobile
document.addEventListener('click', event => {
const sidebar = event.target.closest('#sidebar');
const link = event.target.closest('a');
if (!sidebar || !link) return;
if (isSidebarOpen()) {
toggleSidebar();
}
});
// Close when open and escape is pressed
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && isSidebarOpen()) {
event.stopImmediatePropagation();
toggleSidebar();
}
});
// Close when clicking outside of the sidebar
document.addEventListener('mousedown', event => {
if (isSidebarOpen() & !event.target?.closest('#sidebar, #menu-toggle')) {
event.stopImmediatePropagation();
toggleSidebar();
}
});
updateInert();
})();
//
// Theme selector
//
(() => {
function getTheme() {
return localStorage.getItem('theme') || 'auto';
}
function isDark() {
if (theme === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
}
function setTheme(newTheme) {
theme = newTheme;
localStorage.setItem('theme', theme);
// Update the UI
updateSelection();
// Toggle the dark mode class
document.documentElement.classList.toggle('sl-theme-dark', isDark());
}
function updateSelection() {
const menu = document.querySelector('#theme-selector sl-menu');
if (!menu) return;
[...menu.querySelectorAll('sl-menu-item')].map(item => (item.checked = item.getAttribute('value') === theme));
}
let theme = getTheme();
// Selection is not preserved when changing page, so update when opening dropdown
document.addEventListener('sl-show', event => {
const themeSelector = event.target.closest('#theme-selector');
if (!themeSelector) return;
updateSelection();
});
// Listen for selections
document.addEventListener('sl-select', event => {
const menu = event.target.closest('#theme-selector sl-menu');
if (!menu) return;
setTheme(event.detail.item.value);
});
// Update the theme when the preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => setTheme(theme));
// Toggle with backslash
document.addEventListener('keydown', event => {
if (
event.key === '\\' &&
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
) {
event.preventDefault();
setTheme(isDark() ? 'light' : 'dark');
}
});
// Set the initial theme and sync the UI
setTheme(theme);
})();
//
// Open details when printing
//
(() => {
const detailsOpenOnPrint = new Set();
window.addEventListener('beforeprint', () => {
detailsOpenOnPrint.clear();
document.querySelectorAll('details').forEach(details => {
if (details.open) {
detailsOpenOnPrint.add(details);
}
details.open = true;
});
});
window.addEventListener('afterprint', () => {
document.querySelectorAll('details').forEach(details => {
details.open = detailsOpenOnPrint.has(details);
});
detailsOpenOnPrint.clear();
});
})();
//
// Copy code buttons
//
(() => {
document.addEventListener('click', event => {
const button = event.target.closest('.copy-code-button');
const pre = button?.closest('pre');
const code = pre?.querySelector('code');
const copyIcon = button?.querySelector('.copy-code-button__copy-icon');
const copiedIcon = button?.querySelector('.copy-code-button__copied-icon');
if (button && code) {
navigator.clipboard.writeText(code.innerText);
copyIcon.style.display = 'none';
copiedIcon.style.display = 'inline';
button.classList.add('copy-code-button--copied');
setTimeout(() => {
copyIcon.style.display = 'inline';
copiedIcon.style.display = 'none';
button.classList.remove('copy-code-button--copied');
}, 1000);
}
});
})();
//
// Smooth links
//
(() => {
document.addEventListener('click', event => {
const link = event.target.closest('a');
const id = (link?.hash ?? '').substr(1);
const isFragment = link?.hasAttribute('href') && link?.getAttribute('href').startsWith('#');
if (!link || !isFragment || link.getAttribute('data-smooth-link') === 'false') {
return;
}
// Scroll to the top
if (link.hash === '') {
event.preventDefault();
window.scroll({ top: 0, behavior: 'smooth' });
history.pushState(undefined, undefined, location.pathname);
}
// Scroll to an id
if (id) {
const target = document.getElementById(id);
if (target) {
event.preventDefault();
window.scroll({ top: target.offsetTop, behavior: 'smooth' });
history.pushState(undefined, undefined, `#${id}`);
}
}
});
})();
//
// Table of Contents scrollspy
//
(() => {
// This will be stale if its not a function.
const getLinks = () => [...document.querySelectorAll('.content__toc a')];
const linkTargets = new WeakMap();
const visibleTargets = new WeakSet();
const observer = new IntersectionObserver(handleIntersect, { rootMargin: '0px 0px' });
let debounce;
function handleIntersect(entries) {
entries.forEach(entry => {
// Remember which targets are visible
if (entry.isIntersecting) {
visibleTargets.add(entry.target);
} else {
visibleTargets.delete(entry.target);
}
});
updateActiveLinks();
}
function updateActiveLinks() {
const links = getLinks();
// Find the first visible target and activate the respective link
links.find(link => {
const target = linkTargets.get(link);
if (target && visibleTargets.has(target)) {
links.forEach(el => el.classList.toggle('active', el === link));
return true;
}
return false;
});
}
// Observe link targets
function observeLinks() {
getLinks().forEach(link => {
const hash = link.hash.slice(1);
const target = hash ? document.querySelector(`.content__body #${hash}`) : null;
if (target) {
linkTargets.set(link, target);
observer.observe(target);
}
});
}
observeLinks();
document.addEventListener('turbo:load', updateActiveLinks);
document.addEventListener('turbo:load', observeLinks);
})();
//
// Show custom versions in the sidebar
//
(() => {
function updateVersion() {
const el = document.querySelector('.sidebar-version');
if (!el) return;
if (location.hostname === 'next.shoelace.style') el.textContent = 'Next';
if (location.hostname === 'localhost') el.textContent = 'Development';
}
updateVersion();
document.addEventListener('turbo:load', updateVersion);
})();

View File

@ -0,0 +1,376 @@
(() => {
// Append the search dialog to the body
const siteSearch = document.createElement('div');
const scrollbarWidth = Math.abs(window.innerWidth - document.documentElement.clientWidth);
siteSearch.classList.add('search');
siteSearch.innerHTML = `
<div class="search__overlay"></div>
<dialog id="search-dialog" class="search__dialog">
<div class="search__content">
<div class="search__header">
<div id="search-combobox" class="search__input-wrapper">
<sl-icon name="search"></sl-icon>
<input
id="search-input"
class="search__input"
type="search"
placeholder="Search"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
enterkeyhint="go"
spellcheck="false"
maxlength="100"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="search-listbox"
aria-haspopup="listbox"
aria-activedescendant
>
<button type="button" class="search__clear-button" aria-label="Clear entry" tabindex="-1" hidden>
<sl-icon name="x-circle-fill"></sl-icon>
</button>
</div>
</div>
<div class="search__body">
<ul
id="search-listbox"
class="search__results"
role="listbox"
aria-label="Search results"
></ul>
<div class="search__empty">No matching pages</div>
</div>
<footer class="search__footer">
<small><kbd></kbd> <kbd></kbd> Navigate</small>
<small><kbd></kbd> Select</small>
<small><kbd>Esc</kbd> Close</small>
</footer>
</div>
</dialog>
`;
const overlay = siteSearch.querySelector('.search__overlay');
const dialog = siteSearch.querySelector('.search__dialog');
const input = siteSearch.querySelector('.search__input');
const clearButton = siteSearch.querySelector('.search__clear-button');
const results = siteSearch.querySelector('.search__results');
const version = document.documentElement.getAttribute('data-shoelace-version');
const key = `search_${version}`;
const searchDebounce = 50;
const animationDuration = 150;
let isShowing = false;
let searchTimeout;
let searchIndex;
let map;
const loadSearchIndex = new Promise(resolve => {
const cache = localStorage.getItem(key);
const wait = 'requestIdleCallback' in window ? requestIdleCallback : requestAnimationFrame;
// Cleanup older search indices (everything before this version)
try {
const items = { ...localStorage };
Object.keys(items).forEach(k => {
if (key > k) {
localStorage.removeItem(k);
}
});
} catch {
/* do nothing */
}
// Look for a cached index
try {
if (cache) {
const data = JSON.parse(cache);
searchIndex = window.lunr.Index.load(data.searchIndex);
map = data.map;
return resolve();
}
} catch {
/* do nothing */
}
// Wait until idle to fetch the index
wait(() => {
fetch('/assets/search.json')
.then(res => res.json())
.then(data => {
if (!window.lunr) {
console.error('The Lunr search client has not yet been loaded.');
}
searchIndex = window.lunr.Index.load(data.searchIndex);
map = data.map;
// Cache the search index for this version
if (version) {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (err) {
console.warn(`Unable to cache the search index: ${err}`);
}
}
resolve();
});
});
});
async function show() {
isShowing = true;
document.body.append(siteSearch);
document.body.classList.add('search-visible');
document.body.style.setProperty('--docs-search-scroll-lock-size', `${scrollbarWidth}px`);
clearButton.hidden = true;
requestAnimationFrame(() => input.focus());
updateResults();
dialog.showModal();
await Promise.all([
dialog.animate(
[
{ opacity: 0, transform: 'scale(.9)', transformOrigin: 'top' },
{ opacity: 1, transform: 'scale(1)', transformOrigin: 'top' }
],
{ duration: animationDuration }
).finished,
overlay.animate([{ opacity: 0 }, { opacity: 1 }], { duration: animationDuration }).finished
]);
dialog.addEventListener('mousedown', handleMouseDown);
dialog.addEventListener('keydown', handleKeyDown);
}
async function hide() {
isShowing = false;
await Promise.all([
dialog.animate(
[
{ opacity: 1, transform: 'scale(1)', transformOrigin: 'top' },
{ opacity: 0, transform: 'scale(.9)', transformOrigin: 'top' }
],
{ duration: animationDuration }
).finished,
overlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: animationDuration }).finished
]);
dialog.close();
input.blur(); // otherwise Safari will scroll to the bottom of the page on close
input.value = '';
document.body.classList.remove('search-visible');
document.body.style.removeProperty('--docs-search-scroll-lock-size');
siteSearch.remove();
updateResults();
dialog.removeEventListener('mousedown', handleMouseDown);
dialog.removeEventListener('keydown', handleKeyDown);
}
function handleInput() {
clearButton.hidden = input.value === '';
// Debounce search queries
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => updateResults(input.value), searchDebounce);
}
function handleClear() {
clearButton.hidden = true;
input.value = '';
input.focus();
updateResults();
}
function handleMouseDown(event) {
if (!event.target.closest('.search__content')) {
hide();
}
}
function handleKeyDown(event) {
// Close when pressing escape
if (event.key === 'Escape') {
event.preventDefault(); // prevent <dialog> from closing immediately so it can animate
event.stopImmediatePropagation();
hide();
return;
}
// Handle keyboard selections
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter'].includes(event.key)) {
event.preventDefault();
const currentEl = results.querySelector('[data-selected="true"]');
const items = [...results.querySelectorAll('li')];
const index = items.indexOf(currentEl);
let nextEl;
if (items.length === 0) {
return;
}
switch (event.key) {
case 'ArrowUp':
nextEl = items[Math.max(0, index - 1)];
break;
case 'ArrowDown':
nextEl = items[Math.min(items.length - 1, index + 1)];
break;
case 'Home':
nextEl = items[0];
break;
case 'End':
nextEl = items[items.length - 1];
break;
case 'Enter':
currentEl?.querySelector('a')?.click();
break;
}
// Update the selected item
items.forEach(item => {
if (item === nextEl) {
input.setAttribute('aria-activedescendant', item.id);
item.setAttribute('data-selected', 'true');
nextEl.scrollIntoView({ block: 'nearest' });
} else {
item.setAttribute('data-selected', 'false');
}
});
}
}
async function updateResults(query = '') {
try {
await loadSearchIndex;
const hasQuery = query.length > 0;
const searchTerms = query
.split(' ')
.map((term, index, arr) => {
// Search API: https://lunrjs.com/guides/searching.html
if (index === arr.length - 1) {
// The last term is not mandatory and 1x fuzzy. We also duplicate it with a wildcard to match partial words
// as the user types.
return `${term}~1 ${term}*`;
} else {
// All other terms are mandatory and 1x fuzzy
return `+${term}~1`;
}
})
.join(' ');
const matches = hasQuery ? searchIndex.search(searchTerms) : [];
const hasResults = hasQuery && matches.length > 0;
siteSearch.classList.toggle('search--has-results', hasQuery && hasResults);
siteSearch.classList.toggle('search--no-results', hasQuery && !hasResults);
input.setAttribute('aria-activedescendant', '');
results.innerHTML = '';
matches.forEach((match, index) => {
const page = map[match.ref];
const li = document.createElement('li');
const a = document.createElement('a');
const displayTitle = page.title ?? '';
const displayDescription = page.description ?? '';
const displayUrl = page.url.replace(/^\//, '').replace(/\/$/, '');
let icon = 'file-text';
a.setAttribute('role', 'option');
a.setAttribute('id', `search-result-item-${match.ref}`);
if (page.url.includes('getting-started/')) {
icon = 'lightbulb';
}
if (page.url.includes('resources/')) {
icon = 'book';
}
if (page.url.includes('components/')) {
icon = 'puzzle';
}
if (page.url.includes('tokens/')) {
icon = 'palette2';
}
if (page.url.includes('utilities/')) {
icon = 'wrench';
}
if (page.url.includes('tutorials/')) {
icon = 'joystick';
}
li.classList.add('search__result');
li.setAttribute('role', 'option');
li.setAttribute('id', `search-result-item-${match.ref}`);
li.setAttribute('data-selected', index === 0 ? 'true' : 'false');
a.href = page.url;
a.innerHTML = `
<div class="search__result-icon" aria-hidden="true">
<sl-icon name="${icon}"></sl-icon>
</div>
<div class="search__result__details">
<div class="search__result-title"></div>
<div class="search__result-description"></div>
<div class="search__result-url"></div>
</div>
`;
a.querySelector('.search__result-title').textContent = displayTitle;
a.querySelector('.search__result-description').textContent = displayDescription;
a.querySelector('.search__result-url').textContent = displayUrl;
li.appendChild(a);
results.appendChild(li);
});
} catch {
// Ignore query errors as the user types
}
}
// Show the search dialog when clicking on data-plugin="search"
document.addEventListener('click', event => {
const searchButton = event.target.closest('[data-plugin="search"]');
if (searchButton) {
show();
}
});
// Show the search dialog when slash (or CMD+K) is pressed and focus is not inside a form element
document.addEventListener('keydown', event => {
if (
!isShowing &&
(event.key === '/' || (event.key === 'k' && (event.metaKey || event.ctrlKey))) &&
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
) {
event.preventDefault();
show();
}
});
// Purge cache when we press CMD+CTRL+R
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'r') {
localStorage.clear();
}
});
input.addEventListener('input', handleInput);
clearButton.addEventListener('click', handleClear);
// Close when a result is selected
results.addEventListener('click', event => {
if (event.target.closest('a')) {
hide();
}
});
})();

View File

@ -0,0 +1,29 @@
import * as Turbo from 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@7.3.0/+esm';
(() => {
if (!window.scrollPositions) {
window.scrollPositions = {};
}
function preserveScroll() {
document.querySelectorAll('[data-preserve-scroll').forEach(element => {
scrollPositions[element.id] = element.scrollTop;
});
}
function restoreScroll(event) {
document.querySelectorAll('[data-preserve-scroll').forEach(element => {
element.scrollTop = scrollPositions[element.id];
});
if (event.detail && event.detail.newBody) {
event.detail.newBody.querySelectorAll('[data-preserve-scroll').forEach(element => {
element.scrollTop = scrollPositions[element.id];
});
}
}
window.addEventListener('turbo:before-cache', preserveScroll);
window.addEventListener('turbo:before-render', restoreScroll);
window.addEventListener('turbo:render', restoreScroll);
})();

View File

@ -0,0 +1,173 @@
/* Interactive code blocks */
.code-preview {
position: relative;
border-radius: 3px;
background-color: var(--sl-color-neutral-50);
margin-bottom: 1.5rem;
}
.code-preview__preview {
position: relative;
border: solid 1px var(--sl-color-neutral-200);
border-bottom: none;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
background-color: var(--sl-color-neutral-0);
min-width: 20rem;
max-width: 100%;
padding: 1.5rem 3.25rem 1.5rem 1.5rem;
}
/* Block the preview while dragging to prevent iframes from intercepting drag events */
.code-preview__preview--dragging:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: ew-resize;
}
.code-preview__resizer {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 1.75rem;
font-size: 20px;
color: var(--sl-color-neutral-600);
background-color: var(--sl-color-neutral-0);
border-left: solid 1px var(--sl-color-neutral-200);
border-top-right-radius: 3px;
cursor: ew-resize;
}
@media screen and (max-width: 600px) {
.code-preview__preview {
padding-right: 1.5rem;
}
.code-preview__resizer {
display: none;
}
}
.code-preview__source {
border: solid 1px var(--sl-color-neutral-200);
border-bottom: none;
border-radius: 0 !important;
display: none;
}
.code-preview--expanded .code-preview__source {
display: block;
}
.code-preview__source pre {
margin: 0;
}
.code-preview__buttons {
position: relative;
border: solid 1px var(--sl-color-neutral-200);
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
display: flex;
}
.code-preview__button {
flex: 0 0 auto;
height: 2.5rem;
min-width: 2.5rem;
border: none;
border-radius: 0;
background: var(--sl-color-neutral-0);
font: inherit;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
color: var(--sl-color-neutral-600);
padding: 0 1rem;
cursor: pointer;
}
.code-preview__button:not(:last-of-type) {
border-right: solid 1px var(--sl-color-neutral-200);
}
.code-preview__button--html,
.code-preview__button--react {
width: 70px;
display: flex;
place-items: center;
justify-content: center;
}
.code-preview__button--selected {
font-weight: 700;
color: var(--sl-color-primary-600);
}
.code-preview__button--codepen {
display: flex;
place-items: center;
width: 6rem;
}
.code-preview__button:first-of-type {
border-bottom-left-radius: 3px;
}
.code-preview__button:last-of-type {
border-bottom-right-radius: 3px;
}
.code-preview__button:hover,
.code-preview__button:active {
box-shadow: 0 0 0 1px var(--sl-color-primary-400);
border-right-color: transparent;
background-color: var(--sl-color-primary-50);
color: var(--sl-color-primary-600);
z-index: 1;
}
.code-preview__button:focus-visible {
outline: none;
outline: var(--sl-focus-ring);
z-index: 2;
}
.code-preview__toggle {
position: relative;
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
width: 100%;
color: var(--sl-color-neutral-600);
cursor: pointer;
}
.code-preview__toggle svg {
width: 1em;
height: 1em;
margin-left: 0.25rem;
}
.code-preview--expanded .code-preview__toggle svg {
transform: rotate(180deg);
}
/* We can apply data-flavor="html|react" to any element on the page to toggle it when the flavor changes */
.flavor-html [data-flavor]:not([data-flavor='html']) {
display: none;
}
.flavor-react [data-flavor]:not([data-flavor='react']) {
display: none;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,347 @@
/* Search plugin */
:root,
:root.sl-theme-dark {
--docs-search-box-background: var(--sl-color-neutral-0);
--docs-search-box-border-width: 1px;
--docs-search-box-border-color: var(--sl-color-neutral-300);
--docs-search-box-color: var(--sl-color-neutral-600);
--docs-search-dialog-background: var(--sl-color-neutral-0);
--docs-search-border-width: var(--docs-border-width);
--docs-search-border-color: var(--docs-border-color);
--docs-search-text-color: var(--sl-color-neutral-900);
--docs-search-text-color-muted: var(--sl-color-neutral-500);
--docs-search-font-weight-normal: var(--sl-font-weight-normal);
--docs-search-font-weight-semibold: var(--sl-font-weight-semibold);
--docs-search-border-radius: calc(2 * var(--docs-border-radius));
--docs-search-accent-color: var(--sl-color-primary-600);
--docs-search-icon-color: var(--sl-color-neutral-500);
--docs-search-icon-color-active: var(--sl-color-neutral-600);
--docs-search-shadow: var(--docs-shadow-x-large);
--docs-search-result-background-hover: var(--sl-color-neutral-100);
--docs-search-result-color-hover: var(--sl-color-neutral-900);
--docs-search-result-background-active: var(--sl-color-primary-600);
--docs-search-result-color-active: var(--sl-color-neutral-0);
--docs-search-focus-ring: var(--sl-focus-ring);
--docs-search-overlay-background: rgb(0 0 0 / 0.33);
}
:root.sl-theme-dark {
--docs-search-overlay-background: rgb(71 71 71 / 0.33);
}
body.search-visible {
padding-right: var(--docs-search-scroll-lock-size) !important;
overflow: hidden !important;
}
/* Search box */
.search-box {
flex: 1 1 auto;
display: flex;
align-items: center;
width: 100%;
border: none;
border-radius: 9999px;
background: var(--docs-search-box-background);
border: solid var(--docs-search-box-border-width) var(--docs-search-box-border-color);
font: inherit;
color: var(--docs-search-box-color);
padding: 0.75rem 1rem;
margin: var(--sl-spacing-large) 0;
cursor: pointer;
}
.search-box span {
flex: 1 1 auto;
width: 1rem;
height: 1rem;
text-align: left;
line-height: 1;
margin: 0 0.75rem;
}
.search-box:focus {
outline: none;
}
.search-box:focus-visible {
outline: var(--docs-search-focus-ring);
}
/* Site search */
.search {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.search[hidden] {
display: none;
}
.search__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--docs-search-overlay-background);
z-index: -1;
}
.search__dialog {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
.search__dialog:focus {
outline: none;
}
.search__dialog::backdrop {
display: none;
}
/* Fixes an iOS Safari 16.4 bug that draws the parent element's border radius incorrectly when showing/hiding results */
.search__header {
background-color: var(--docs-search-dialog-background);
border-radius: var(--docs-search-border-radius);
}
.search--has-results .search__header {
border-top-left-radius: var(--docs-search-border-radius);
border-top-right-radius: var(--docs-search-border-radius);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.search__content {
display: flex;
flex-direction: column;
width: 100%;
max-width: 500px;
max-height: calc(100vh - 20rem);
background-color: var(--docs-search-dialog-background);
border-radius: var(--docs-search-border-radius);
box-shadow: var(--docs-search-shadow);
padding: 0;
margin: 10rem auto;
}
@media screen and (max-width: 900px) {
.search__content {
max-width: calc(100% - 2rem);
max-height: calc(90svh);
margin: 4vh 1rem;
}
}
.search__input-wrapper {
display: flex;
align-items: center;
}
.search__input-wrapper sl-icon {
width: 1.5rem;
height: 1.5rem;
flex: 0 0 auto;
color: var(--docs-search-icon-color);
margin: 0 1.5rem;
}
.search__clear-button {
display: flex;
background: none;
border: none;
font: inherit;
padding: 0;
margin: 0;
cursor: pointer;
}
.search__clear-button[hidden] {
display: none;
}
.search__clear-button:active sl-icon {
color: var(--docs-search-icon-color-active);
}
.search__input {
flex: 1 1 auto;
min-width: 0;
border: none;
font: inherit;
font-size: 1.5rem;
font-weight: var(--docs-search-font-weight-normal);
color: var(--docs-search-text-color);
background: transparent;
padding: 1rem 0;
margin: 0;
}
.search__input::placeholder {
color: var(--docs-search-text-color-muted);
}
.search__input::-webkit-search-decoration,
.search__input::-webkit-search-cancel-button,
.search__input::-webkit-search-results-button,
.search__input::-webkit-search-results-decoration {
display: none;
}
.search__input:focus,
.search__input:focus-visible {
outline: none;
}
.search__body {
flex: 1 1 auto;
overflow: auto;
}
.search--has-results .search__body {
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
}
.search__results {
display: none;
line-height: 1.2;
list-style: none;
padding: 0.5rem 0;
margin: 0;
}
.search--has-results .search__results {
display: block;
}
.search__results a {
display: block;
text-decoration: none;
padding: 0.5rem 1.5rem;
}
.search__results a:focus-visible {
outline: var(--docs-search-focus-ring);
}
.search__results li a:hover,
.search__results li a:hover small {
background-color: var(--docs-search-result-background-hover);
color: var(--docs-search-result-color-hover);
}
.search__results li[data-selected='true'] a,
.search__results li[data-selected='true'] a * {
outline: none;
background-color: var(--docs-search-result-background-active);
color: var(--docs-search-result-color-active);
}
.search__results h3 {
font-weight: var(--docs-search-font-weight-semibold);
margin: 0;
}
.search__results small {
display: block;
color: var(--docs-search-text-color-muted);
}
.search__result {
padding: 0;
margin: 0;
}
.search__result a {
display: flex;
align-items: center;
gap: 1rem;
}
.search__result-icon {
flex: 0 0 auto;
display: flex;
color: var(--docs-search-text-color-muted);
}
.search__result-icon sl-icon {
font-size: 1.5rem;
}
.search__result__details {
width: calc(100% - 3rem);
}
.search__result-title,
.search__result-description,
.search__result-url {
max-width: 400px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search__result-title {
font-size: 1.2rem;
font-weight: var(--docs-search-font-weight-semibold);
color: var(--docs-search-accent-color);
}
.search__result-description {
font-size: 0.875rem;
color: var(--docs-search-text-color);
}
.search__result-url {
font-size: 0.875rem;
color: var(--docs-search-text-color-muted);
}
.search__empty {
display: none;
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
text-align: center;
color: var(--docs-search-text-color-muted);
padding: 2rem;
}
.search--no-results .search__empty {
display: block;
}
.search__footer {
display: flex;
justify-content: center;
gap: 2rem;
border-top: solid var(--docs-search-border-width) var(--docs-search-border-color);
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
padding: 1rem;
}
.search__footer small {
color: var(--docs-search-text-color-muted);
}
.search__footer small kbd:last-of-type {
margin-right: 0.25rem;
}
@media screen and (max-width: 900px) {
.search__footer {
display: none;
}
}

View File

@ -0,0 +1,198 @@
import * as path from 'path';
//import {customElementJetBrainsPlugin} from 'custom-element-jet-brains-integration';
//import {customElementVsCodePlugin} from 'custom-element-vs-code-integration';
import {parse} from 'comment-parser';
import {pascalCase} from 'pascal-case';
import commandLineArgs from 'command-line-args';
import fs from 'fs';
const packageData = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const {name, description, version, author, homepage, license} = packageData;
function noDash(string)
{
return string.replace(/^\s?-/, '').trim();
}
function replace(string, terms)
{
terms.forEach(({from, to}) =>
{
string = string?.replace(from, to);
});
return string;
}
export default {
globs: ["api/js/etemplate/**/Et2[!DFILST]*/*.ts"], // There's something wrong with some widgets, they break the parser
/** Globs to exclude */
exclude: ["*Date*"],//, 'et2_*.ts', '**/test/*', '**/*.styles.ts', '**/*.test.ts'],
dev: true,
litelement: true,
plugins: [
// Append package data
{
name: 'egroupware-package-data',
packageLinkPhase({customElementsManifest})
{
customElementsManifest.package = {name, description, version, author, homepage, license};
}
},
// Parse custom jsDoc tags
{
name: 'shoelace-custom-tags',
analyzePhase({ts, node, moduleDoc})
{
switch (node.kind)
{
case ts.SyntaxKind.ClassDeclaration:
{
const className = node.name.getText();
const classDoc = moduleDoc?.declarations?.find(declaration => declaration.name === className);
const customTags = ['animation', 'dependency', 'documentation', 'since', 'status', 'title'];
let customComments = '/**';
node.jsDoc?.forEach(jsDoc =>
{
jsDoc?.tags?.forEach(tag =>
{
const tagName = tag.tagName.getText();
if (customTags.includes(tagName))
{
customComments += `\n * @${tagName} ${tag.comment}`;
}
});
});
// This is what allows us to map JSDOC comments to ReactWrappers.
classDoc['jsDoc'] = node.jsDoc?.map(jsDoc => jsDoc.getFullText()).join('\n');
// const parsed = parse(`${customComments}\n */`);
/*
parsed[0].tags?.forEach(t =>
{
switch (t.tag)
{
// Animations
case 'animation':
if (!Array.isArray(classDoc['animations']))
{
classDoc['animations'] = [];
}
classDoc['animations'].push({
name: t.name,
description: noDash(t.description)
});
break;
// Dependencies
case 'dependency':
if (!Array.isArray(classDoc['dependencies']))
{
classDoc['dependencies'] = [];
}
classDoc['dependencies'].push(t.name);
break;
// Value-only metadata tags
case 'documentation':
case 'since':
case 'status':
case 'title':
classDoc[t.tag] = t.name;
break;
// All other tags
default:
if (!Array.isArray(classDoc[t.tag]))
{
classDoc[t.tag] = [];
}
classDoc[t.tag].push({
name: t.name,
description: t.description,
type: t.type || undefined
});
}
});
*/
}
}
}
},
{
name: 'shoelace-translate-module-paths',
packageLinkPhase({customElementsManifest})
{
customElementsManifest?.modules?.forEach(mod =>
{
//
// CEM paths look like this:
//
// src/components/button/button.ts
//
// But we want them to look like this:
//
// components/button/button.js
//
const terms = [
{from: /^src\//, to: ''}, // Strip the src/ prefix
{from: /\.component.(t|j)sx?$/, to: '.js'} // Convert .ts to .js
];
mod.path = replace(mod.path, terms);
for (const ex of mod.exports ?? [])
{
ex.declaration.module = replace(ex.declaration.module, terms);
}
for (const dec of mod.declarations ?? [])
{
if (dec.kind === 'class')
{
for (const member of dec.members ?? [])
{
if (member.inheritedFrom)
{
member.inheritedFrom.module = replace(member.inheritedFrom.module, terms);
}
}
}
}
});
}
},
// Generate custom VS Code data
/*
customElementVsCodePlugin({
outdir,
cssFileName: null,
referencesTemplate: (_, tag) => [
{
name: 'Documentation',
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
}
]
}),
customElementJetBrainsPlugin({
excludeCss: true,
referencesTemplate: (_, tag) =>
{
return {
name: 'Documentation',
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
};
}
})
*/
]
};

View File

@ -0,0 +1,283 @@
/* eslint-disable no-invalid-this */
const fs = require('fs');
const path = require('path');
const lunr = require('lunr');
const {capitalCase} = require('change-case');
const {JSDOM} = require('jsdom');
const {customElementsManifest, getAllComponents} = require('./_utilities/cem.cjs');
const egwFlavoredMarkdown = require('./_utilities/markdown.cjs');
const activeLinks = require('./_utilities/active-links.cjs');
const anchorHeadings = require('./_utilities/anchor-headings.cjs');
const codePreviews = require('./_utilities/code-previews.cjs');
const copyCodeButtons = require('./_utilities/copy-code-buttons.cjs');
const externalLinks = require('./_utilities/external-links.cjs');
const highlightCodeBlocks = require('./_utilities/highlight-code.cjs');
const tableOfContents = require('./_utilities/table-of-contents.cjs');
const prettier = require('./_utilities/prettier.cjs');
const scrollingTables = require('./_utilities/scrolling-tables.cjs');
const typography = require('./_utilities/typography.cjs');
const replacer = require('./_utilities/replacer.cjs');
const assetsDir = 'assets';
const cdndir = 'cdn';
const npmdir = 'dist';
const allComponents = getAllComponents();
let hasBuiltSearchIndex = false;
// Write component data to file, 11ty will pick it up and create pages - the name & location are important
fs.writeFileSync("_data/components.json", JSON.stringify(allComponents));
// Put it here too, since addPassthroughCopy() ignores it
fs.copyFileSync("../dist/custom-elements.json", "assets/custom-elements.json");
module.exports = function (eleventyConfig)
{
//
// Global data
//
eleventyConfig.addGlobalData('baseUrl', 'https://egroupware.org/'); // the production URL
eleventyConfig.addGlobalData('layout', 'default'); // make 'default' the default layout
eleventyConfig.addGlobalData('toc', true); // enable the table of contents
eleventyConfig.addGlobalData('meta', {
title: 'EGroupware',
description: '',
image: 'images/logo.svg',
version: customElementsManifest.package.version,
components: allComponents,
cdndir,
npmdir
});
//
// Layout aliases
//
eleventyConfig.addLayoutAlias('default', 'default.njk');
//
// Copy EGw stuff in
//
eleventyConfig.addPassthroughCopy({"../../api/templates/default/images/logo.svg": "assets/images/logo.svg"});
eleventyConfig.addPassthroughCopy({"../../pixelegg/css/monochrome.css": "assets/styles/monochrome.css"});
//eleventyConfig.addPassthroughCopy({"../../api/js": "assets/scripts/chunks"});
eleventyConfig.addPassthroughCopy({"../../api/js/etemplate/etemplate2.js": "assets/scripts/etemplate/etemplate2.js"});
// Shoelace
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/": "assets/shoelace/"});
/*
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/shoelace-autoloader.js": "assets/shoelace/shoelace-autoloader.js"});
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/themes/*": "assets/shoelace/themes/"});
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/chunks/*": "assets/shoelace/chunks/"});
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/assets/*": "assets/shoelace/assets/"});
eleventyConfig.addPassthroughCopy({"../../node_modules/@shoelace-style/shoelace/dist/components/*": "assets/shoelace/components/"});
*/
//
// Copy assets
//
eleventyConfig.addPassthroughCopy(assetsDir);
eleventyConfig.setServerPassthroughCopyBehavior('passthrough'); // emulates passthrough copy during --serve
//
// Functions
//
// Generates a URL relative to the site's root
eleventyConfig.addNunjucksGlobal('rootUrl', (value = '', absolute = false) =>
{
value = path.join('/', value);
return absolute ? new URL(value, eleventyConfig.globalData.baseUrl).toString() : value;
});
// Generates a URL relative to the site's asset directory
eleventyConfig.addNunjucksGlobal('assetUrl', (value = '', absolute = false) =>
{
value = path.join(`/${assetsDir}`, value);
return absolute ? new URL(value, eleventyConfig.globalData.baseUrl).toString() : value;
});
// Fetches a specific component's metadata
eleventyConfig.addNunjucksGlobal('getComponent', tagName =>
{
const component = allComponents.find(c => c.tagName === tagName);
if (!component)
{
throw new Error(
`Unable to find a component called "${tagName}". Make sure the file name is the same as the component's tag ` +
`name (minus the sl- prefix).`
);
}
return component;
});
//
// Custom markdown syntaxes
//
eleventyConfig.setLibrary('md', egwFlavoredMarkdown);
//
// Filters
//
eleventyConfig.addFilter('markdown', content =>
{
return egwFlavoredMarkdown.render(content);
});
eleventyConfig.addFilter('markdownInline', content =>
{
return egwFlavoredMarkdown.renderInline(content);
});
eleventyConfig.addFilter('classNameToComponentName', className =>
{
let name = capitalCase(className.replace(/^Sl/, ''));
if (name === 'Qr Code')
{
name = 'QR Code';
} // manual override
return name;
});
eleventyConfig.addFilter('removeSlPrefix', tagName =>
{
return tagName.replace(/^sl-/, '');
});
//
// Transforms
//
eleventyConfig.addTransform('html-transform', function (content)
{
// Parse the template and get a Document object
const doc = new JSDOM(content, {
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
// identify which ones are internal and which ones are external.
url: `https://internal/`
}).window.document;
// DOM transforms
activeLinks(doc, {pathname: this.page.url});
anchorHeadings(doc, {
within: '#content .content__body',
levels: ['h2', 'h3', 'h4', 'h5']
});
tableOfContents(doc, {
levels: ['h2', 'h3'],
container: '#content .content__toc > ul',
within: '#content .content__body'
});
codePreviews(doc);
externalLinks(doc, {target: '_blank'});
highlightCodeBlocks(doc);
scrollingTables(doc);
copyCodeButtons(doc); // must be after codePreviews + highlightCodeBlocks
typography(doc, '#content');
replacer(doc, [
{pattern: '%VERSION%', replacement: customElementsManifest.package.version},
{pattern: '%CDNDIR%', replacement: cdndir},
{pattern: '%NPMDIR%', replacement: npmdir}
]);
// Serialize the Document object to an HTML string and prepend the doctype
content = `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
// String transforms
content = prettier(content);
return content;
});
//
// Build a search index
//
eleventyConfig.on('eleventy.after', ({results}) =>
{
// We only want to build the search index on the first run so all pages get indexed.
if (hasBuiltSearchIndex)
{
return;
}
const map = {};
const searchIndexFilename = path.join(eleventyConfig.dir.output, assetsDir, 'search.json');
const lunrInput = path.resolve('../../node_modules/lunr/lunr.min.js');
const lunrOutput = path.join(eleventyConfig.dir.output, assetsDir, 'scripts/lunr.js');
const searchIndex = lunr(function ()
{
// The search index uses these field names extensively, so shortening them can save some serious bytes. The
// initial index file went from 468 KB => 401 KB by using single-character names!
this.ref('id'); // id
this.field('t', {boost: 50}); // title
this.field('h', {boost: 25}); // headings
this.field('c'); // content
results.forEach((result, index) =>
{
const url = path
.join('/', path.relative(eleventyConfig.dir.output, result.outputPath))
.replace(/\\/g, '/') // convert backslashes to forward slashes
.replace(/\/index.html$/, '/'); // convert trailing /index.html to /
const doc = new JSDOM(result.content, {
// We must set a default URL so links are parsed with a hostname. Let's use a bogus TLD so we can easily
// identify which ones are internal and which ones are external.
url: `https://internal/`
}).window.document;
const content = doc.querySelector('#content');
// Get title and headings
const title = (doc.querySelector('title')?.textContent || path.basename(result.outputPath)).trim();
const headings = [...content.querySelectorAll('h1, h2, h3, h4')]
.map(heading => heading.textContent)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
// Remove code blocks and whitespace from content
[...content.querySelectorAll('code[class|=language]')].forEach(code => code.remove());
const textContent = content.textContent.replace(/\s+/g, ' ').trim();
// Update the index and map
this.add({id: index, t: title, h: headings, c: textContent});
map[index] = {title, url};
});
});
// Copy the Lunr search client and write the index
fs.mkdirSync(path.dirname(lunrOutput), {recursive: true});
fs.copyFileSync(lunrInput, lunrOutput);
fs.writeFileSync(searchIndexFilename, JSON.stringify({searchIndex, map}), 'utf-8');
hasBuiltSearchIndex = true;
});
//
// Send a signal to stdout that let's the build know we've reached this point
//
eleventyConfig.on('eleventy.after', () =>
{
console.log('[eleventy.after]');
});
//
// Dev server options (see https://www.11ty.dev/docs/dev-server/#options)
//
eleventyConfig.setServerOptions({
domDiff: false, // disable dom diffing so custom elements don't break on reload,
port: 4000, // if port 4000 is taken, 11ty will use the next one available
watch: ['cdn/**/*'] // additional files to watch that will trigger server updates (array of paths or globs)
});
//
// 11ty config
//
return {
dir: {
input: 'pages',
output: '../dist/site',
includes: '../_includes', // resolved relative to the input dir
data: '../_data'
},
markdownTemplateEngine: 'njk', // use Nunjucks instead of Liquid for markdown files
templateEngineOverride: ['njk'] // just Nunjucks and then markdown
};
};

View File

@ -0,0 +1,8 @@
---
pagination:
data: components
size: 1
alias: component
permalink: "components/{{component.tagName | slugify}}/"
layout: component.njk
---

View File

@ -0,0 +1,6 @@
## Widget Sandbox
You can see and play with the widgets, once they're loaded.
Maybe this should go on each page, limited to just that widget.
<api-viewer src="/assets/custom-elements.json"></api-viewer>

View File

@ -0,0 +1,26 @@
## Styling
Our overall styling is a combination of our site-wide style (pixelegg), etemplate2.css
and [Shoelace](https://shoelace.style/) styles
Some handy excerpts:
### Global CSS variables
```css
:root {
--primary-background-color: #4177a2;
--highlight-background-color: rgba(153, 204, 255, .4);
--label-color: #000000;
/* For fixed width labels - use class 'et2-label-fixed'*/
--label-width: 8em;
--input-border-color: #E6E6E6;
--input-text-color: #26537C;
--warning-color: rgba(255, 204, 0, .5);
--error-color: rgba(204, 0, 51, .5);
}
```

View File

@ -0,0 +1,24 @@
## Widgets
Widgets are the building blocks of our UI.
We are currently making all our
widgets [WebComponents](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)
based on [Lit](https://lit.dev/docs/). Many of our widgets use [Shoelace](https://shoelace.style) components as building
blocks.
If you just want to use existing widgets, you can put them in your .xet template file:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="myapp.mytemplate">
<et2-vbox>
<et2-textbox id="name" label="Your name" class="et2-label-fixed"></et2-textbox>
<et2-select-number id="quantity" label="Quantity" class="et2-label-fixed"></et2-select-number>
</et2-vbox>
</template>
</overlay>
```
<img src="/assets/images/widgets_rendered_example.png" alt="Rendered example template">

View File

@ -0,0 +1,18 @@
---
meta:
title: 'EGroupware'
description: Web based groupware server
toc: false
---
EGroupware Etemplate development docs
## Quick Start
EGroupware UI is created from templates stored in &lt;app>/templates/default/*.xet files.
The templates are composed of widget tags.
If this works, here is a box:
```html:preview
<et2-box>Box content</et2-box>
```

View File

@ -0,0 +1,69 @@
## Automatic testing
Automatic tests go in the `test/` subfolder of your component's directory. They will be found and run by
“web-test-runner”.
Tests are written using
* Mocha (https://mochajs.org/) & Chai Assertion Library (https://www.chaijs.com/api/assert/)
* Playwright (https://playwright.dev/docs/intro) runs the tests in actual browsers.
Here's a simple example:
```ts
/**
* Test file for Etemplate webComponent Textbox
*/
import {assert, fixture, html} from '@open-wc/testing';
import {Et2Textbox} from "../Et2Textbox";
import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests";
// Reference to component under test
let element : Et2Textbox;
async function before()
{
// Create an element to test with, and wait until it's ready
element = await fixture<Et2Textbox>(html`
<et2-textbox></et2-textbox>
`);
return element;
}
describe("Textbox widget", () =>
{
// Setup run before each test
beforeEach(before);
it('is defined', () =>
{
assert.instanceOf(element, Et2Textbox);
});
it('has a label', () =>
{
element.set_label("Yay label");
assert.isEmpty(element.shadowRoot.querySelectorAll('.et2_label'));
})
});
// Run some common, basic tests for inputs (readonly, value, etc.)
inputBasicTests(before, "I'm a good test value", "input");
```
This verifies that the component can be loaded and created. `inputBasicTests()` checks readonly and in/out values.
### What to test
#### Can the component be loaded and created?
Quite often components get accidental dependencies that complicate things, but sometimes they just break.
#### Value in = value out
Many of our components do correction and coercion on bad data or invalid values, but you should test that values out
match
the values going in. How to do this, and what to do with bad values, depends on the component.
### Test tips
* Always use `this.egw()`. It can be easily stubbed for your test. Global `egw` cannot.

View File

@ -0,0 +1,39 @@
## Creating a component
ETemplate components are [LitElements](https://lit.dev/docs/) that are wrapped with
our [Et2Widget](https://github.com/EGroupware/egroupware/blob/master/api/js/etemplate/Et2Widget/Et2Widget.ts) mixin,
which adds properties and methods to support loading from our template files and returning values to the server. They
should (relatively) stand-alone.
Common components are in `api/js/etemplate/`. You can add application specific components in `<appname>/js/`.
### Create the files
```
myapp/
js/
MyWidget/
test/
MyWidget.ts
```
You should have [automatic tests](/tutorials/automatic-testing) to verify your component and avoid regressions
in `test/`.
### Get it loaded
To have EGroupware load your component, it must be included somewhere.
Add your component to the `include` block at the top of `/api/js/etemplate/etemplate2.js`. If you have an application
specific component, include at the top of your `app.js`.
```typescript
...
import './MyWidget/MyWidget.ts';
...
```
### Load and return
### AJAX data

407
doc/scripts/build.mjs Normal file
View File

@ -0,0 +1,407 @@
import {deleteAsync} from 'del';
import {exec, spawn} from 'child_process';
import {globby} from 'globby';
import browserSync from 'browser-sync';
import chalk from 'chalk';
import commandLineArgs from 'command-line-args';
import copy from 'recursive-copy';
import esbuild from 'esbuild';
import fs from 'fs/promises';
import getPort, {portNumbers} from 'get-port';
import ora from 'ora';
import util from 'util';
import * as path from 'path';
import {readFileSync} from 'fs';
import {replace} from 'esbuild-plugin-replace';
const {serve, dev} = commandLineArgs([
{name: 'serve', type: Boolean},
{name: 'dev', type: Boolean}
]);
const outdir = 'doc/dist';
const cdndir = 'cdn';
const sitedir = 'doc/dist/site';
const spinner = ora({hideCursor: false}).start();
const execPromise = util.promisify(exec);
let childProcess;
let buildResults;
const bundleDirectories = [outdir];
let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
const egwVersion = JSON.stringify(packageData.version.toString());
//
// Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child
// process and an array of strings containing any output are included in the resolved promise.
//
// To debug:
// > DEBUG=Eleventy* npx @11ty/eleventy
//
async function buildTheDocs(watch = false)
{
return new Promise(async (resolve, reject) =>
{
const afterSignal = '[eleventy.after]';
const args = ['@11ty/eleventy', '--quiet'];
const output = [];
if (watch)
{
args.push('--watch');
args.push('--incremental');
}
// To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy
const child = spawn('npx', args, {
stdio: 'pipe',
cwd: 'doc/etemplate2',
shell: true // for Windows
});
child.stdout.on('data', data =>
{
if (data.includes(afterSignal))
{
return;
} // don't log the signal
output.push(data.toString());
});
if (watch)
{
// The process doesn't terminate in watch mode so, before resolving, we listen for a known signal in stdout that
// tells us when the first build completes.
child.stdout.on('data', data =>
{
if (data.includes(afterSignal))
{
resolve({child, output});
}
});
}
else
{
child.on('close', () =>
{
resolve({child, output});
});
}
});
}
//
// Builds the source with esbuild.
//
async function buildTheSource()
{
const alwaysExternal = ['@lit'];
const cdnConfig = {
format: 'esm',
target: 'es2017',
entryPoints: [
//
// NOTE: Entry points must be mapped in package.json > exports, otherwise users won't be able to import them!
//
// The whole shebang
'./src/shoelace.ts',
// The auto-loader
'./src/shoelace-autoloader.ts',
// Components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
...(await globby('./src/translations/**/*.ts')),
// Public utilities
...(await globby('./src/utilities/**/!(*.(style|test)).ts')),
// Theme stylesheets
...(await globby('./src/themes/**/!(*.test).ts')),
// React wrappers
...(await globby('./src/react/**/*.ts'))
],
outdir: cdndir,
chunkNames: 'chunks/[name].[hash]',
define: {
// Floating UI requires this to be set
'process.env.NODE_ENV': '"production"'
},
bundle: true,
//
// We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers,
// allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.)
//
// We never bundle React or @lit-labs/react though!
//
external: alwaysExternal,
splitting: true,
plugins: [
replace({
__EGROUPWARE_VERSION__: egwVersion
})
]
};
const npmConfig = {
...cdnConfig,
external: undefined,
minify: false,
packages: 'external',
outdir
};
if (serve)
{
// Use the context API to allow incremental dev builds
const contexts = await Promise.all([esbuild.context(cdnConfig), esbuild.context(npmConfig)]);
await Promise.all(contexts.map(context => context.rebuild()));
return contexts;
}
else
{
// Use the standard API for production builds
return await Promise.all([esbuild.build(cdnConfig), esbuild.build(npmConfig)]);
}
}
async function rollup(watch = false)
{
return new Promise(async (resolve, reject) =>
{
const afterSignal = '[rollup.after]';
const args = ['--silent'];
const output = [];
if (watch)
{
args.push('--watch');
args.push('--incremental');
}
// To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy
const child = spawn('rollup', args, {
stdio: 'pipe',
cwd: '.',
shell: true // for Windows
});
child.stdout.on('data', data =>
{
if (data.includes(afterSignal))
{
return;
} // don't log the signal
output.push(data.toString());
});
// Not even waiting
resolve({child, output});
});
}
//
// Called on SIGINT or SIGTERM to cleanup the build and child processes.
//
function handleCleanup()
{
buildResults.forEach(result => result.dispose());
if (childProcess)
{
childProcess.kill('SIGINT');
}
process.exit();
}
//
// Helper function to draw a spinner while tasks run.
//
async function nextTask(label, action)
{
spinner.text = label;
spinner.start();
try
{
await action();
spinner.stop();
console.log(`${chalk.green('✔')} ${label}`);
}
catch (err)
{
spinner.stop();
console.error(`${chalk.red('✘')} ${err}`);
if (err.stdout)
{
console.error(chalk.red(err.stdout));
}
if (err.stderr)
{
console.error(chalk.red(err.stderr));
}
process.exit(1);
}
}
await nextTask('Cleaning up the previous build', async () =>
{
await Promise.all([deleteAsync(sitedir), ...bundleDirectories.map(dir => deleteAsync(dir))]);
await fs.mkdir(outdir, {recursive: true});
});
await nextTask('Generating component metadata', () =>
{
return Promise.all(
bundleDirectories.map(dir =>
{
return execPromise(`node doc/scripts/metadata.mjs --outdir "${dir}"`, {stdio: 'inherit'});
})
);
});
/*
await nextTask('Generating themes', () =>
{
return execPromise(`node scripts/make-themes.js --outdir "${outdir}"`, {stdio: 'inherit'});
});
*/
/* We don't do these
await nextTask('Running the TypeScript compiler', () =>
{
return execPromise(`tsc --project ./tsconfig.json --outdir "${outdir}"`, {stdio: 'inherit'});
});
await nextTask('Building source files', async () =>
{
buildResults = await buildTheSource();
});
*/
// EGroupware way of packaging
// We can't watch
await nextTask('Rolling up', async () =>
{
await rollup(dev);
});
// Launch the dev server
if (serve)
{
let result;
// Spin up Eleventy and Wait for the search index to appear before proceeding. The search index is generated during
// eleventy.after, so it appears after the docs are fully published. This is kinda hacky, but here we are.
// Kick off the Eleventy dev server with --watch and --incremental
await nextTask('Building docs', async () =>
{
result = await buildTheDocs(true);
});
const bs = browserSync.create();
const port = await getPort({port: portNumbers(4000, 4999)});
const browserSyncConfig = {
startPath: '/',
port,
logLevel: 'silent',
logPrefix: '[egw]',
logFileChanges: true,
notify: false,
single: false,
ghostMode: false,
server: {
baseDir: sitedir,
routes: {
'/dist': './cdn'
}
}
};
// Launch browser sync
bs.init(browserSyncConfig, () =>
{
const url = `http://localhost:${port}`;
console.log(chalk.cyan(`\n🥾 The dev server is available at ${url}`));
// Log deferred output
if (result.output.length > 0)
{
console.log('\n' + result.output.join('\n'));
}
// Log output that comes later on
result.child.stdout.on('data', data =>
{
console.log(data.toString());
});
});
// Rebuild and reload when source files change
bs.watch('src/**/!(*.test).*').on('change', async filename =>
{
console.log('[build] File changed: ', filename);
try
{
const isTheme = /^src\/themes/.test(filename);
const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename);
// Rebuild the source
const rebuildResults = buildResults.map(result => result.rebuild());
await Promise.all(rebuildResults);
// Rebuild stylesheets when a theme file changes
if (isTheme)
{
await Promise.all(
bundleDirectories.map(dir =>
{
execPromise(`node scripts/make-themes.js --outdir "${dir}"`, {stdio: 'inherit'});
})
);
}
// Rebuild metadata (but not when styles are changed)
if (!isStylesheet)
{
await Promise.all(
bundleDirectories.map(dir =>
{
return execPromise(`node scripts/make-metadata.js --outdir "${dir}"`, {stdio: 'inherit'});
})
);
}
bs.reload();
}
catch (err)
{
console.error(chalk.red(err));
}
});
// Reload without rebuilding when the docs change
bs.watch([`${sitedir}/**/*.*`]).on('change', filename =>
{
bs.reload();
});
}
// Build for production
if (!serve)
{
let result;
await nextTask('Building the docs', async () =>
{
result = await buildTheDocs();
});
// Log deferred output
if (result.output.length > 0)
{
console.log('\n' + result.output.join('\n'));
}
}
// Cleanup on exit
process.on('SIGINT', handleCleanup);
process.on('SIGTERM', handleCleanup);

14
doc/scripts/metadata.mjs Normal file
View File

@ -0,0 +1,14 @@
//
// This script runs the Custom Elements Manifest analyzer to generate custom-elements.json
//
import {execSync} from 'child_process';
import commandLineArgs from 'command-line-args';
const {outdir} = commandLineArgs([
{name: 'outdir', type: String},
{name: 'watch', type: Boolean}
]);
execSync(`cem analyze --config "doc/etemplate2/custom-elements-manifest.config.mjs" --outdir "${outdir}"`, {stdio: 'inherit'});
//execSync(`cem analyze --globs "api/js/etemplate/Et2Widget" --outdir "${outdir}"`, {stdio: 'inherit'});

View File

@ -1070,7 +1070,8 @@ div#mail-index_nm.splitter-pane {min-height: 100px;}
min-width: 7em;
color: darkgrey;
}
.mailPreviewHeaders et2-select-email::part(control) {
.mailPreviewHeaders et2-select-email::part(combobox) {
border: none;
}
#popupMainDiv {height: 100%}

View File

@ -15,7 +15,8 @@ use EGroupware\Api;
/**
* Ajax methods for notifications
*/
class notifications_ajax {
class notifications_ajax
{
/**
* Appname
*/
@ -36,6 +37,11 @@ class notifications_ajax {
*/
const _type = 'base';
/**
* Do NOT consider notifications older than this
*/
const CUT_OFF_DATE = '-30days';
/**
* holds account object for user to notify
*
@ -64,22 +70,6 @@ class notifications_ajax {
*/
private $db;
/**
* holds the users session data
*
* @var array
*/
var $session_data;
/**
* holds the users session data defaults
*
* @var array
*/
var $session_data_defaults = array(
'notified_mail_uids' => array(),
);
/**
* the xml response object
*
@ -98,15 +88,11 @@ class notifications_ajax {
* constructor
*
*/
public function __construct() {
public function __construct()
{
$this->response = Api\Json\Response::get();
$this->recipient = (object)$GLOBALS['egw']->accounts->read($GLOBALS['egw_info']['user']['account_id']);
$this->config = (object)Api\Config::read(self::_appname);
$prefs = new Api\Preferences($this->recipient->account_id);
$this->preferences = $prefs->read();
$this->db = $GLOBALS['egw']->db;
$this->isPushServer = Api\Cache::getInstance('notifications', 'isPushServer', function ()
@ -147,18 +133,9 @@ class notifications_ajax {
*
* @param array $notifymessages one or multiple notify_id(s)
*/
public function delete_message($notifymessages)
public function delete_message(array $notifymessages)
{
$notify_ids = $this->fetch_notify_ids($notifymessages);
if (!empty($notify_ids))
{
$this->db->delete(self::_notification_table,array(
'notify_id' => $notify_ids,
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type
),__LINE__,__FILE__,self::_appname);
}
$this->response->data(['deleted'=>$notify_ids]);
$this->update($notifymessages, null); // null = delete
}
/**
@ -172,80 +149,95 @@ class notifications_ajax {
* this status has been used more specifically for browser type
* of notifications.
*/
public function update_status($notifymessages, $status = "SEEN")
public function update_status(array $notifymessages, $status = "SEEN")
{
$notify_ids = $this->fetch_notify_ids($notifymessages);
if (!empty($notify_ids))
{
$this->db->update(self::_notification_table,array('notify_status' => $status),array(
'notify_id' => $notify_ids,
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type
),__LINE__,__FILE__,self::_appname);
}
$this->update($notifymessages, $status);
}
/**
* gets all relevant notify ids based on given notify message data
* @param $notifymessages
* Update or delete the given notification messages, incl. not explicitly mentioned ones with same app:id
*
* @param array $notifymessages
* @param string|null $status use null to delete
* @return array
*/
public function fetch_notify_ids ($notifymessages)
protected function update(array $notifymessages, $status='SEEN')
{
$notify_ids = [];
$notify_ids = $app_ids = [];
foreach ($notifymessages as $data)
{
if (is_array($data) && $data['id'])
if (is_array($data) && !empty($data['id']))
{
array_push($notify_ids, (string)$data['id']);
if (is_array($data['data'])) $notify_ids = array_unique(array_merge($notify_ids, $this->search_in_notify_data($data['data']['id'], $data['data']['app'])));
if (is_array($data['data'] ?? null) && !empty($data['data']['id']))
{
$app_ids[$data['data']['app']][$data['data']['id']] = $data['data']['id'];
}
$notify_ids[] = $data['id'];
}
else
{
array_push($notify_ids, (string)$data);
$notify_ids[] = $data;
}
}
return $notify_ids;
}
/**
* Fetches all notify_ids relevant to the entry
* @param $_id
* @param $_appname
* @return array
*/
public function search_in_notify_data($_id, $_appname)
{
$ret = [];
if ($_id && $_appname)
{
try {
// mariaDB supported query
$ret = $this->db->select(self::_notification_table, 'notify_id', array(
$cut_off = $this->db->quote(Api\DateTime::to(self::CUT_OFF_DATE, Api\DateTime::DATABASE));
try {
// MariaDB code using JSON_EXTRACT()
foreach($app_ids as $app => $ids)
{
$where = [
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type,
'notify_data->"$.appname"' => $_appname,
'notify_data->"$.data.id"' => $_id
),
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname);
}
catch (Api\Db\Exception $e) {
// do it manual for all other DB
foreach($this->db->select(self::_notification_table, '*', array(
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type
),
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname) as $row)
"JSON_EXTRACT(notify_data, '$.appname') = ".$this->db->quote($app),
"JSON_EXTRACT(notify_data, '$.data.id') IN (".implode(',', array_map([$this->db, 'quote'], array_unique($ids))).')',
'notify_created > '.$cut_off,
];
if (isset($status))
{
$data = json_decode($row['notify_data'], true);
if ($data['appname'] == $_appname && $data['data']['id'] == $_id) $ret[] = $row['notify_id'];
$this->db->update(self::_notification_table, ['notify_status' => $status], $where, __LINE__, __FILE__, self::_appname);
}
else
{
$this->db->delete(self::_notification_table, $where, __LINE__, __FILE__, self::_appname);
}
}
}
return $ret;
// other DBs
catch (Api\Db\Exception $e) {
foreach($this->db->select(self::_notification_table, 'notify_id,notify_data', [
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type,
'notify_created > '.$cut_off,
"notify_data <> '[]'", // does not return NULL or '[]' rows
]) as $row)
{
if (($data = json_decode($row['notify_data'], true)) &&
isset($data['data']['id']) && in_array($data['data']['id'], $app_ids[$data['appname']] ?? []))
{
$notify_ids[] = $row['notify_id'];
}
}
}
$where = [
'notify_id' => array_unique($notify_ids),
'account_id' => $this->recipient->account_id,
'notify_type' => self::_type
];
if (isset($status))
{
$this->db->update(self::_notification_table, ['notify_status' => $status], $where, __LINE__, __FILE__, self::_appname);
}
else
{
$this->db->delete(self::_notification_table, $where, __LINE__, __FILE__, self::_appname);
}
// cleanup messages older than our cut-off-date
$this->db->delete(self::_notification_table, [
'notify_created <= '.$cut_off,
'notify_type' => self::_type
], __LINE__, __FILE__, self::_appname);
}
/**
* gets all egwpopup notifications for calling user
*
@ -257,30 +249,4 @@ class notifications_ajax {
$this->response->apply('app.notifications.append', array($entries['rows']??[], $browserNotify, $entries['total']??0));
return true;
}
/**
* restores the users session data for notifications
*
* @return boolean true
*/
private function restore_session_data() {
$session_data = Api\Cache::getSession(self::_appname, 'session_data');
if(is_array($session_data)) {
$this->session_data = $session_data;
} else {
$this->session_data = $this->session_data_defaults;
}
return true;
}
/**
* saves the users session data for notifications
*
* @return boolean true
*/
private function save_session_data() {
Api\Cache::setSession(self::_appname, 'session_data', $this->session_data);
return true;
}
}

View File

@ -21,8 +21,8 @@ use EGroupware\Api;
* out the table to look if there is a notificaton for this
* client. The second stage is done in class.notifications_ajax.inc.php
*/
class notifications_popup implements notifications_iface {
class notifications_popup implements notifications_iface
{
/**
* Appname
*/
@ -120,13 +120,14 @@ class notifications_popup implements notifications_iface {
* @param array $_user_sessions
* @param array $_data
*/
private function save($_message, $_data) {
private function save($_message, $_data)
{
$result = $this->db->insert( self::_notification_table, array(
'account_id' => $this->recipient->account_id,
'notify_message' => $_message,
'notify_type' => self::_type,
'notify_data' => is_array($_data) ? json_encode($_data) : NULL,
'notify_created' => Api\DateTime::user2server('now'),
'notify_data' => $_data && is_array($_data) ? json_encode($_data) : NULL,
'notify_created' => new Api\DateTime(),
), false,__LINE__,__FILE__,self::_appname);
if ($result === false) throw new Exception("Can't save notification into SQL table");
$push = new Api\Json\Push($this->recipient->account_id);
@ -136,50 +137,83 @@ class notifications_popup implements notifications_iface {
/**
* read all notification messages for given recipient
* Read the 100 most recent notification messages for given recipient
*
* We use a cut-off-date of 30day, not returning anything older!
*
* @param $_account_id
* @param int $num_rows
* @return array
*/
public static function read($_account_id)
public static function read($_account_id, int $num_rows=100)
{
if (!$_account_id) return [];
$rs = $GLOBALS['egw']->db->select(self::_notification_table, '*', array(
'account_id' => $_account_id,
'notify_type' => self::_type
),
__LINE__,__FILE__,0 ,'ORDER BY notify_id DESC',self::_appname, 100);
// Fetch the total
$total = $GLOBALS['egw']->db->select(self::_notification_table, 'COUNT(*)', array(
'account_id' => $_account_id,
'notify_type' => self::_type
),
__LINE__,__FILE__,0 ,'',self::_appname)->fetchColumn();
$result = array();
if ($rs->NumRows() > 0) {
foreach ($rs as $notification) {
$actions = null;
$data = json_decode($notification['notify_data'], true);
if (!empty($data['appname']) && !empty($data['data']))
{
$_actions = Api\Hooks::process (array(
'location' => 'notifications_actions',
'data' => $data['data']
), $data['appname'], true);
$actions = $_actions[$data['appname']];
}
$result[] = array(
'id' => $notification['notify_id'],
'message' => $notification['notify_message'],
'status' => $notification['notify_status'],
'created' => Api\DateTime::server2user($notification['notify_created']),
'current' => new Api\DateTime('now'),
'actions' => is_array($actions)?$actions:NULL,
'extra_data' => $data['data'] ?? [],
);
/** @var Api\Db $db */
$db = $GLOBALS['egw']->db;
$result = [];
if (($total = $db->select(self::_notification_table, 'COUNT(*)', [
'account_id' => $_account_id,
'notify_type' => self::_type,
'notify_created > '.($cut_off=$db->quote(Api\DateTime::to(notifications_ajax::CUT_OFF_DATE, Api\DateTime::DATABASE))),
], __LINE__, __FILE__, false, '', self::_appname)->fetchColumn()))
{
$n = 0;
$chunk_size = 150;
do
{
$notification = null;
foreach ($rs=$db->select(self::_notification_table, '*', [
'account_id' => $_account_id,
'notify_type' => self::_type,
'notify_created > ' . $cut_off,
], __LINE__, __FILE__, $n, 'ORDER BY notify_id DESC', self::_appname, $chunk_size) as $notification)
{
$actions = null;
$data = json_decode($notification['notify_data'], true);
if (!empty($data['appname']) && !empty($data['data']))
{
$_actions = Api\Hooks::process(array(
'location' => 'notifications_actions',
'data' => $data['data']
), $data['appname'], true);
$actions = $_actions[$data['appname']];
}
$data = [
'id' => $notification['notify_id'],
'message' => $notification['notify_message'],
'status' => $notification['notify_status'],
'created' => Api\DateTime::server2user($notification['notify_created']),
'current' => new Api\DateTime('now'),
'actions' => is_array($actions) ? $actions : NULL,
'extra_data' => $data['data'] ?? [],
];
// aggregate by app:id reporting only the newest entry
if (!empty($data['extra_data']['id']))
{
if (!isset($result[$id = $data['extra_data']['app'] . ':' . $data['extra_data']['id']]))
{
$result[$id] = $data;
}
else
{
$total--;
/* in case we want to show all
$result['id']['others'][] = $data;
*/
}
}
else
{
$result[] = $data;
}
}
$n += $chunk_size;
}
return ['rows' => $result, 'total'=> $total];
while(!$notification || count($result) < min($num_rows, $total));
return ['rows' => array_values($result), 'total'=> $total];
}
}
@ -273,9 +307,10 @@ class notifications_popup implements notifications_iface {
*
* @param settings array with keys account_id and new_owner (new_owner is optional)
*/
public static function deleteaccount($settings) {
public static function deleteaccount($settings)
{
$GLOBALS['egw']->db->delete( self::_notification_table, array(
'account_id' => $settings['account_id']
),__LINE__,__FILE__,self::_appname);
}
}
}

14312
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,18 @@
"scripts": {
"build": "rollup -c",
"build:watch": "rollup -cw",
"build:dev": "node doc/scripts/build.mjs --dev --serve",
"jstest": "tsc &> /dev/null; web-test-runner",
"jstest:watch": "web-test-runner --watch"
},
"devDependencies": {
"@11ty/eleventy": "^2.0.1",
"@babel/core": "^7.14.6",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.22.10",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.14.5",
"@custom-elements-manifest/analyzer": "^0.8.4",
"@interactjs/interactjs": "^1.10.11",
"@open-wc/testing": "^3.0.3",
"@rollup/plugin-babel": "^5.3.0",
@ -23,16 +26,38 @@
"@rollup/plugin-typescript": "^8.2.1",
"@types/chai": "^4.2.21",
"@types/mocha": "^8.2.3",
"@web/dev-server-esbuild": "^0.2.14",
"@web/dev-server-esbuild": "^0.4.1",
"@web/dev-server-rollup": "^0.3.9",
"@web/test-runner": "^0.13.16",
"@web/test-runner-playwright": "^0.8.8",
"browser-sync": "^2.29.3",
"cem": "^1.0.4",
"change-case": "^4.1.2",
"custom-element-jet-brains-integration": "^1.2.1",
"custom-element-vs-code-integration": "^1.2.1",
"del": "^7.1.0",
"esbuild": "^0.19.3",
"esbuild-plugin-replace": "^1.4.0",
"get-port": "^7.0.0",
"globby": "^13.2.2",
"grunt": "^1.5.3",
"grunt-contrib-cssmin": "^2.2.1",
"jsdom": "^22.1.0",
"lunr": "^2.3.9",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-ins": "^3.0.1",
"markdown-it-kbd": "^2.2.2",
"markdown-it-mark": "^3.0.1",
"markdown-it-replace-it": "^1.0.0",
"ora": "^7.0.1",
"prettier": "^3.0.3",
"prismjs": "^1.29.0",
"rimraf": "^3.0.2",
"rollup": "^2.79.1",
"rollup-plugin-terser": "^7.0.2",
"sinon": "^11.1.2",
"smartquotes": "^2.3.2",
"terser": "^4.8.1",
"typescript": "^3.9.7"
},

View File

@ -5613,6 +5613,11 @@ div.timesheet_timer {
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
white-space: nowrap;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
/* do NOT show empty label, required for clearing value */
display: none;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),

View File

@ -5593,6 +5593,11 @@ div.timesheet_timer {
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
white-space: nowrap;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
/* do NOT show empty label, required for clearing value */
display: none;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),

View File

@ -5603,6 +5603,11 @@ div.timesheet_timer {
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
white-space: nowrap;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
/* do NOT show empty label, required for clearing value */
display: none;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),

View File

@ -298,6 +298,11 @@ div.timesheet_timer {
}
}
#quick_add_selectbox::part(emptyLabel) {
/* do NOT show empty label, required for clearing value */
display: none;
}
#quick_add_selectbox::part(form-control-input), #timer_selectbox::part(form-control-input) {
border: none !important;
}

View File

@ -5624,6 +5624,11 @@ div.timesheet_timer {
#egw_fw_topmenu_info_items #topmenu_info_timer #timer_selectbox sl-option {
white-space: nowrap;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(emptyLabel),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(emptyLabel) {
/* do NOT show empty label, required for clearing value */
display: none;
}
#egw_fw_topmenu_info_items #topmenu_info_quick_add #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_timer #quick_add_selectbox::part(form-control-input),
#egw_fw_topmenu_info_items #topmenu_info_quick_add #timer_selectbox::part(form-control-input),