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;