diff --git a/api/js/etemplate/Et2Link/Et2LinkAppSelect.ts b/api/js/etemplate/Et2Link/Et2LinkAppSelect.ts
index ec648af291..e6bb64a9e8 100644
--- a/api/js/etemplate/Et2Link/Et2LinkAppSelect.ts
+++ b/api/js/etemplate/Et2Link/Et2LinkAppSelect.ts
@@ -81,6 +81,7 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
constructor()
{
super();
+ this.only_app = "";
this.app_icons = true;
this.application_list = [];
this.hoist = true;
@@ -93,13 +94,17 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
set only_app(app : string)
{
- this.__only_app = app;
- this.style.display = app ? 'inline' : 'none';
+ this.__only_app = app || "";
+ this.updateComplete.then(() =>
+ {
+ this.style.display = this.only_app ? 'none' : '';
+ });
}
get only_app() : string
{
- return this.__only_app;
+ // __only_app may be undefined during creation
+ return this.__only_app || "";
}
connectedCallback()
@@ -154,7 +159,7 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
get value()
{
- return this.__only_app ? this.__only_app : super.value;
+ return this.only_app ? this.only_app : super.value;
}
set value(new_value)
@@ -221,7 +226,7 @@ export class Et2LinkAppSelect extends SlotMixin(Et2Select)
_iconTemplate(appname)
{
- let url = this.egw().image('navbar', appname);
+ let url = appname ? this.egw().image('navbar', appname) : "";
return html`
`;
}
diff --git a/api/js/etemplate/Et2Link/Et2LinkEntry.ts b/api/js/etemplate/Et2Link/Et2LinkEntry.ts
index f5da783ca3..d2bc1ad626 100644
--- a/api/js/etemplate/Et2Link/Et2LinkEntry.ts
+++ b/api/js/etemplate/Et2Link/Et2LinkEntry.ts
@@ -14,13 +14,6 @@ import {FormControlMixin, ValidateMixin} from "@lion/form-core";
import {Et2LinkSearch} from "./Et2LinkSearch";
import {LinkInfo} from "./Et2Link";
-export interface LinkEntry
-{
- app : string;
- id : string | number;
- title? : string;
-}
-
/**
* Find and select a single entry using the link system.
*
@@ -85,7 +78,7 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
{
app.only_app = this.__only_app;
}
- else if(typeof this._value !== "undefined")
+ else if(typeof this._value !== "undefined" && this._value.app)
{
app.value = this._value.app;
}
@@ -94,7 +87,7 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
select: () =>
{
const select = document.createElement("et2-link-search");
- if(typeof this._value !== "undefined")
+ if(typeof this._value !== "undefined" && this._value.id)
{
if(this._value.title)
{
@@ -150,8 +143,11 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
set only_app(app)
{
- this.__only_app = app;
- this.app = app;
+ this.__only_app = app || "";
+ if(app)
+ {
+ this.app = app;
+ }
}
get only_app()
@@ -170,7 +166,7 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
get app()
{
- return this._appNode?.value || this.__app;
+ return this._appNode?.value || "";
}
get _appNode() : Et2LinkAppSelect
@@ -187,8 +183,8 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
protected _bindListeners()
{
this._appNode.addEventListener("change", this._handleAppChange);
- this.addEventListener("sl-select", this._handleEntrySelect);
- this.addEventListener("sl-clear", this._handleEntryClear);
+ this._searchNode.addEventListener("sl-select", this._handleEntrySelect);
+ this._searchNode.addEventListener("sl-clear", this._handleEntryClear);
this.addEventListener("sl-show", this._handleShow);
this.addEventListener("sl-hide", this._handleHide);
}
@@ -263,7 +259,7 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
}
}
- get value() : LinkEntry | string | number
+ get value() : LinkInfo | string | number
{
if(this.only_app)
{
@@ -276,11 +272,11 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
} : this._value;
}
- set value(val: LinkEntry|string|number)
+ set value(val : LinkInfo | string | number)
{
let value : LinkInfo = {app: "", id: ""};
- if(typeof val === 'string')
+ if(typeof val === 'string' && val.length > 0)
{
if(val.indexOf(',') > 0)
{
@@ -290,11 +286,11 @@ export class Et2LinkEntry extends Et2InputWidget(FormControlMixin(ValidateMixin(
value.app = vals[0];
value.id = vals[1];
}
- else if(typeof val === "number")
+ else if(typeof val === "number" && val)
{
value.id = String(val);
}
- else // object with attributes: app, id, title
+ else if(typeof val === "object") // object with attributes: app, id, title
{
value = (val);
}
diff --git a/api/js/etemplate/Et2Link/Et2LinkTo.ts b/api/js/etemplate/Et2Link/Et2LinkTo.ts
new file mode 100644
index 0000000000..4c62cd1d2a
--- /dev/null
+++ b/api/js/etemplate/Et2Link/Et2LinkTo.ts
@@ -0,0 +1,473 @@
+/**
+ * EGroupware eTemplate2 - JS Link list object
+ *
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package etemplate
+ * @subpackage api
+ * @link https://www.egroupware.org
+ * @author Nathan Gray
+ * @copyright 2022 Nathan Gray
+ */
+
+
+import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
+import {FormControlMixin, ValidateMixin} from "@lion/form-core";
+import {css, html, LitElement, ScopedElementsMixin} from "@lion/core";
+import {et2_createWidget, et2_widget} from "../et2_core_widget";
+import {et2_file} from "../et2_widget_file";
+import {et2_tabbox} from "../et2_widget_tabs";
+import {Et2Button} from "../Et2Button/Et2Button";
+import {Et2LinkEntry} from "./Et2LinkEntry";
+import {egw} from "../../jsapi/egw_global";
+import {et2_vfsSelect} from "../et2_widget_vfs";
+import {LinkInfo} from "./Et2Link";
+import {Et2LinkList} from "./Et2LinkList";
+import {et2_DOMWidget} from "../et2_core_DOMWidget";
+import {et2_link_list} from "../et2_widget_link";
+import {ValidationType} from "@lion/form-core/types/validate/ValidateMixinTypes";
+import {ManualMessage} from "../Validators/ManualMessage";
+
+/**
+ * Choose an existing entry, VFS file or local file, and link it to the current entry.
+ *
+ * If there is no "current entry", link information will be stored for submission instead
+ * of being directly linked.
+ */
+export class Et2LinkTo extends Et2InputWidget(ScopedElementsMixin(FormControlMixin(ValidateMixin(LitElement))))
+{
+ static get properties()
+ {
+ return {
+ ...super.properties,
+ /**
+ * Hide buttons to attach files
+ */
+ no_files: {type: Boolean},
+ /**
+ * Limit to just this application - hides app selection
+ */
+ only_app: {type: String},
+ /**
+ * Limit to the listed applications (comma seperated)
+ */
+ application_list: {type: String},
+
+ value: {type: Object}
+ }
+ }
+
+ static get styles()
+ {
+ return [
+ ...super.styles,
+ css`
+ :host(.can_link) #link_button {
+ display: initial;
+ }
+ #link_button {
+ display: none;
+ }
+ et2-link-entry {
+ flex: 1 1 auto;
+ }
+ .input-group__container {
+ flex: 1 1 auto;
+ }
+ .input-group {
+ display: flex;
+ width: 100%;
+ }
+ ::slotted(.et2_file) {
+ width: 30px;
+ }
+ `
+ ];
+ }
+
+ // Still not sure what this does, but it's important.
+ // Seems to be related to rendering and what's available "inside"
+ static get scopedElements()
+ {
+ return {
+ // @ts-ignore
+ ...super.scopedElements,
+ 'et2-button': Et2Button,
+ 'et2-link-entry': Et2LinkEntry
+ };
+ }
+
+ constructor()
+ {
+ super();
+ this.no_files = false;
+
+ this.handleFilesUploaded = this.handleFilesUploaded.bind(this);
+ this.handleEntrySelected = this.handleEntrySelected.bind(this);
+ this.handleLinkButtonClick = this.handleLinkButtonClick.bind(this);
+ }
+
+ connectedCallback()
+ {
+ super.connectedCallback();
+
+ // Add file buttons in
+ // TODO: Replace when they're webcomponents
+ this._fileButtons();
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @protected
+ */
+ _inputGroupInputTemplate()
+ {
+ return html`
+
+
+
+
+ `;
+ }
+
+ // TODO: Replace when they're webcomponents
+ _fileButtons()
+ {
+ if(this.no_files)
+ {
+ return "";
+ }
+
+ // File upload
+ //@ts-ignore IDE doesn't know about Et2WidgetClass
+ let self : Et2WidgetClass | et2_widget = this;
+ let file_attrs = {
+ multiple: true,
+ id: this.id + '_file',
+ label: '',
+ // Make the whole template a drop target
+ drop_target: this.getInstanceManager().DOMContainer.getAttribute("id"),
+ readonly: this.readonly,
+
+ // Change to this tab when they drop
+ onStart: function(event, file_count)
+ {
+ // Find the tab widget, if there is one
+ let tabs = self;
+ do
+ {
+ tabs = tabs.getParent();
+ }
+ while(tabs != self.getRoot() && tabs.getType() != 'tabbox');
+ if(tabs != self.getRoot())
+ {
+ (tabs).activateTab(self);
+ }
+ return true;
+ },
+ onFinish: function(event, file_count)
+ {
+ // Auto-link uploaded files
+ self.handleFilesUploaded(event);
+ }
+ };
+
+ this.file_upload = et2_createWidget("file", file_attrs, this);
+ this.file_upload.set_readonly(this.readonly);
+ this.file_upload.getDOMNode().slot = "before";
+
+ this.append(this.file_upload.getDOMNode());
+
+ // Filemanager select
+ var select_attrs : any = {
+ button_label: egw.lang('Link'),
+ button_caption: '',
+ button_icon: 'link',
+ readonly: this.readonly,
+ dialog_title: egw.lang('Link'),
+ extra_buttons: [{text: egw.lang("copy"), id: "copy", image: "copy"},
+ {text: egw.lang("move"), id: "move", image: "move"}],
+ onchange: function()
+ {
+ var values = true;
+ // If entry not yet saved, store for linking on server
+ if(!self.value.to_id || typeof self.value.to_id == 'object')
+ {
+ values = self.value.to_id || {};
+ var files = this.getValue();
+ if(typeof files !== 'undefined')
+ {
+ for(var i = 0; i < files.length; i++)
+ {
+ values['link:' + files[i]] = {
+ app: 'link',
+ id: files[i],
+ type: 'unknown',
+ icon: 'link',
+ remark: '',
+ title: files[i]
+ };
+ }
+ }
+ }
+ self._link_result(values);
+ }
+ };
+ // only set server-side callback, if we have a real application-id (not null or array)
+ // otherwise it only gives an error on server-side
+ if(self.value && self.value.to_id && typeof self.value.to_id != 'object')
+ {
+ select_attrs.method = 'EGroupware\\Api\\Etemplate\\Widget\\Link::ajax_link_existing';
+ select_attrs.method_id = self.value.to_app + ':' + self.value.to_id;
+ }
+ this.vfs_select = et2_createWidget("vfs-select", select_attrs, this);
+ this.vfs_select.set_readonly(this.readonly);
+ this.vfs_select.getDOMNode().slot = "before";
+
+ this.append(this.vfs_select.getDOMNode())
+ }
+
+ /**
+ * Create links
+ *
+ * Using current value for one end of the link, create links to the provided files or entries
+ *
+ * @param _links
+ */
+ createLink(_links : LinkInfo[])
+ {
+ let links : LinkInfo[];
+ if(typeof _links == 'undefined')
+ {
+ links = [];
+ }
+ else
+ {
+ links = _links;
+ }
+
+ // If no link array was passed in, don't make the ajax call
+ if(links.length > 0)
+ {
+ egw.request("EGroupware\\Api\\Etemplate\\Widget\\Link::ajax_link",
+ [this.value.to_app, this.value.to_id, links]).then((result) => this._link_result(result))
+
+ }
+ }
+
+ /**
+ * Sent some links, server has a result
+ *
+ * @param {Object} success
+ */
+ _link_result(success)
+ {
+ if(success)
+ {
+ // Show some kind of success...
+
+ // Reset
+ this.resetAfterLink();
+
+ // Server says it's OK, but didn't store - we'll send this again on submit
+ // This happens if you link to something before it's saved to the DB
+ if(typeof success == "object")
+ {
+ // Save as appropriate in value
+ if(typeof this.value != "object")
+ {
+ this.value = {};
+ }
+ this.value.to_id = success;
+ debugger;
+ for(let link in success)
+ {
+ // Icon should be in registry
+ if(typeof success[link].icon == 'undefined')
+ {
+ success[link].icon = egw.link_get_registry(success[link].app, 'icon');
+ // No icon, try by mime type - different place for un-saved entries
+ if(success[link].icon == false && success[link].id.type)
+ {
+ // Triggers icon by mime type, not thumbnail or app
+ success[link].type = success[link].id.type;
+ success[link].icon = true;
+ }
+ }
+ // Special handling for file - if not existing, we can't ask for title
+ if(success[link].app == 'file' && typeof success[link].title == 'undefined')
+ {
+ success[link].title = success[link].id.name || '';
+ }
+ }
+ }
+
+ // Look for a link-list with the same ID, refresh it
+ var self = this;
+ var list_widget = null;
+ this.getRoot().iterateOver(
+ function(widget)
+ {
+ if(widget.id == self.id)
+ {
+ list_widget = widget;
+ if(success === true)
+ {
+ widget._get_links();
+ }
+ }
+ },
+ this, et2_link_list
+ );
+
+ // If there's an array of data (entry is not yet saved), updating the list will
+ // not work, so add them in explicitly.
+ if(list_widget && success)
+ {
+ // Clear list
+ list_widget.set_value(null);
+
+ // Add temp links in
+ for(var link_id in success)
+ {
+ let link = success[link_id];
+ if(typeof link.title == 'undefined')
+ {
+ // Callback to server for title
+ egw.link_title(link.app, link.id, true).then(title =>
+ {
+ link.title = title;
+ list_widget._add_link(link);
+ });
+ }
+ else
+ {
+ // Add direct
+ list_widget._add_link(link);
+ }
+ }
+ }
+ // Update any neighbouring link lists
+ ((this.getParent()).getDOMNode().querySelector('et2-link-list'))?.get_links(Object.values(success));
+ }
+ else
+ {
+ this.validators.push(new ManualMessage(success));
+ }
+ this.dispatchEvent(new CustomEvent('link.et2_link_to', {bubbles: true, detail: success}));
+ }
+
+ /**
+ * A link was attempted. Reset internal values to get ready for the next one.
+ */
+ resetAfterLink()
+ {
+ // Hide link button again
+ this.classList.remove("can_link");
+ this.link_button.image = "";
+
+ // Clear internal
+ delete this.value.app;
+ delete this.value.id;
+
+ // Clear file upload
+ for(var file in this.file_upload.options.value)
+ {
+ delete this.file_upload.options.value[file];
+ }
+ this.file_upload.progress.empty();
+
+ // Clear link entry
+ this.select.value = {app: this.select.app, id: ""};
+ }
+
+ /**
+ * Files have been uploaded (successfully), ready to link
+ *
+ * @param event
+ * @protected
+ */
+ handleFilesUploaded(event)
+ {
+ this.classList.add("can_link");
+
+ let links = [];
+
+ // Get files from file upload widget
+ let files = this.file_upload.get_value();
+ for(let file in files)
+ {
+ links.push({
+ app: 'file',
+ id: file,
+ name: files[file].name,
+ type: files[file].type,
+
+ // Not sure what this is...
+ /*
+ remark: jQuery("li[file='" + files[file].name.replace(/'/g, '"') + "'] > input", self.file_upload.progress)
+ .filter(function ()
+ {
+ return jQuery(this).attr("placeholder") != jQuery(this).val();
+ }).val()
+ */
+ });
+ }
+ this.createLink(links);
+ }
+
+ /**
+ * An entry has been selected, ready to link
+ *
+ */
+ handleEntrySelected(event)
+ {
+ // Could be the app, could be they selected an entry
+ if(event.currentTarget == this.select)
+ {
+ this.classList.add("can_link");
+ }
+ }
+
+ handleLinkButtonClick(event : MouseEvent)
+ {
+ this.link_button.image = "loading";
+ let link_info : LinkInfo[] = [];
+ if(this.select.value)
+ {
+ let selected = this.select.value;
+ // Extra complicated because LinkEntry doesn't always return a LinkInfo
+ if(this.only_app)
+ {
+ selected = {app: this.only_app, id: selected};
+ }
+ link_info.push(selected);
+ }
+ this.createLink(link_info)
+ }
+
+ get link_button() : Et2Button
+ {
+ return this.shadowRoot.querySelector("#link_button");
+ }
+
+ get select() : Et2LinkEntry
+ {
+ return this.shadowRoot.querySelector("et2-link-entry");
+ }
+
+ /**
+ * Types of validation supported by this FormControl (for instance 'error'|'warning'|'info')
+ *
+ * @type {ValidationType[]}
+ */
+ static get validationTypes() : ValidationType[]
+ {
+ return ['error', 'success'];
+ }
+}
+
+// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
+customElements.define("et2-link-to", Et2LinkTo);
\ No newline at end of file
diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts
index 1a3ba5c37f..0dd8f2f5e4 100644
--- a/api/js/etemplate/etemplate2.ts
+++ b/api/js/etemplate/etemplate2.ts
@@ -48,6 +48,7 @@ import './Et2Link/Et2LinkEntry';
import './Et2Link/Et2LinkList';
import './Et2Link/Et2LinkSearch';
import './Et2Link/Et2LinkString';
+import './Et2Link/Et2LinkTo';
import './Et2Select/Et2Select';
import './Et2Select/Et2SelectAccount';
import './Et2Select/Et2SelectReadonly';
diff --git a/api/templates/default/etemplate2.css b/api/templates/default/etemplate2.css
index 56896a04ac..13909d3ae6 100644
--- a/api/templates/default/etemplate2.css
+++ b/api/templates/default/etemplate2.css
@@ -999,6 +999,7 @@ span.et2_file_span {
background-size: 16px;
cursor: pointer;
height: 15px;
+ width: 100%;
text-align: left;
text-indent: 22px;
white-space: nowrap;