egroupware_official/api/js/etemplate/Et2Link/Et2LinkTo.ts
2024-05-07 14:46:44 -06:00

620 lines
17 KiB
TypeScript

/**
* 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 {css, html, LitElement, nothing} from "lit";
import {et2_createWidget, et2_widget} from "../et2_core_widget";
import {et2_file} from "../et2_widget_file";
import {Et2Button} from "../Et2Button/Et2Button";
import {Et2LinkEntry} from "./Et2LinkEntry";
import {egw} from "../../jsapi/egw_global";
import {LinkInfo} from "./Et2Link";
import {ManualMessage} from "../Validators/ManualMessage";
import {Et2Tabs} from "../Layout/Et2Tabs/Et2Tabs";
import {Et2VfsSelectButton} from "../Et2Vfs/Et2VfsSelectButton";
import {Et2LinkPasteDialog, getClipboardFiles} from "./Et2LinkPasteDialog";
import {waitForEvent} from "../Et2Widget/event";
import {classMap} from "lit/directives/class-map.js";
/**
* 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(LitElement)
{
static get properties()
{
return {
...super.properties,
/**
* Hide buttons to attach files
*/
noFiles: {type: Boolean},
/**
* Limit to just this application - hides app selection
*/
onlyApp: {type: String},
/**
* Limit to the listed applications (comma seperated)
*/
applicationList: {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;
}
.form-control-input {
display: flex;
width: 100%;
gap: 0.5rem;
}
::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,
'et2-vfs-select': Et2VfsSelectButton,
'et2-link-paste-dialog': Et2LinkPasteDialog
};
}
private get pasteButton() { return this.shadowRoot?.querySelector("#paste"); }
private get pasteDialog() { return this.pasteButton?.querySelector("et2-link-paste-dialog"); }
constructor()
{
super();
this.noFiles = false;
this.handleFilesUploaded = this.handleFilesUploaded.bind(this);
this.handleEntrySelected = this.handleEntrySelected.bind(this);
this.handleEntryCleared = this.handleEntryCleared.bind(this);
this.handleLinkButtonClick = this.handleLinkButtonClick.bind(this);
this.handleVfsSelected = this.handleVfsSelected.bind(this);
this.handleLinkDeleted = this.handleLinkDeleted.bind(this);
}
firstUpdated()
{
// Add file buttons in
// TODO: Replace when they're webcomponents
this._fileButtons();
}
connectedCallback()
{
super.connectedCallback();
this.getInstanceManager().DOMContainer.addEventListener("et2-delete", this.handleLinkDeleted);
}
disconnectedCallback()
{
super.disconnectedCallback();
this.getInstanceManager().DOMContainer.removeEventListener("et2-delete", this.handleLinkDeleted);
}
_inputGroupBeforeTemplate()
{
// 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
let method = null;
let method_id = null;
let pasteEnabled = false;
let pasteTooltip = ""
if(this.value && this.value.to_id && typeof this.value.to_id != 'object')
{
method = 'EGroupware\\Api\\Etemplate\\Widget\\Link::ajax_link_existing';
method_id = this.value.to_app + ':' + this.value.to_id;
let clipboard_files = getClipboardFiles();
pasteEnabled = clipboard_files.length > 0;
}
return html`
<slot name="before"></slot>
<et2-vfs-select
id="link"
?readonly=${this.readonly}
method=${method || nothing}
method-id=${method_id || nothing}
multiple
title=${this.egw().lang("select file(s) from vfs")}
.buttonLabel=${this.egw().lang('Link')}
@change=${async() =>
{
this.handleVfsSelected(await this.shadowRoot.getElementById("link")._dialog.getComplete());
}}
>
<et2-button slot="footer" image="copy" id="copy" style="order:3" noSubmit="true"
label=${this.egw().lang("copy")}></et2-button>
<et2-button slot="footer" image="move" id="move" style="order:3" noSubmit="true"
label=${this.egw().lang("move")}></et2-button>
</et2-vfs-select>
<et2-vfs-select
id="paste"
image="linkpaste" aria-label=${this.egw().lang("clipboard contents")} noSubmit="true"
title=${this.egw().lang("Clipboard contents")}
?readonly=${this.readonly}
?disabled=${!pasteEnabled}
multiple
@click=${async(e) =>
{
// Pre-select all files
let files = [];
let cbFiles = await getClipboardFiles();
cbFiles.forEach(f => files.push(f.path));
e.target.firstElementChild.value = files;
e.target.firstElementChild.requestUpdate();
waitForEvent(e.target._dialog, "sl-after-show").then(async() =>
{
this.handleVfsSelected(await this.pasteButton._dialog.getComplete());
});
}}
>
<et2-link-paste-dialog open
title=${this.egw().lang("Clipboard contents")}
.buttonLabel=${this.egw().lang("link")}
>
<et2-button slot="footer" image="copy" id="copy" style="order:3" noSubmit="true"
?disabled=${!this.value?.to_id}
label=${this.egw().lang("copy")}
title=${this.egw().lang("Copy selected files")}
></et2-button>
<et2-button slot="footer" image="move" id="move" style="order:3" noSubmit="true"
?disabled=${!this.value?.to_id}
label=${this.egw().lang("move")}
title=${this.egw().lang("Move selected files")}
></et2-button>
</et2-link-paste-dialog>
</et2-vfs-select>
`;
}
/**
* @return {TemplateResult}
* @protected
*/
_inputGroupInputTemplate()
{
return html`
<et2-link-entry .onlyApp="${this.onlyApp}"
.applicationList="${this.applicationList}"
.readonly=${this.readonly}
@sl-change=${this.handleEntrySelected}
@sl-clear="${this.handleEntryCleared}">
</et2-link-entry>
<et2-button id="link_button" label="Link" class="link" .noSubmit=${true}
@click=${this.handleLinkButtonClick}>
</et2-button>
`;
}
// TODO: Replace when they're webcomponents
_fileButtons()
{
if(this.noFiles)
{
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() != 'ET2-TABBOX');
if(tabs != self.getRoot())
{
(<Et2Tabs><unknown>tabs).activateTab(self);
}
return true;
},
onFinish: function(event, file_count)
{
// Auto-link uploaded files
self.handleFilesUploaded(event);
}
};
this.file_upload = <et2_file>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());
}
/**
* 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;
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 || '';
}
}
}
// Send an event so listeners can update
this.dispatchEvent(new CustomEvent("et2-change", {
bubbles: true,
detail: typeof success == "object" ? Object.values(success) : []
}));
}
else
{
this.validators.push(new ManualMessage(this.egw().lang("failed")));
}
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: ""};
this.select._searchNode.clearSearch();
this.select._searchNode.select_options = [];
}
/**
* 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, '&quot') + "'] > 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.target == this.select._searchNode)
{
this.classList.add("can_link");
this.link_button.focus();
}
}
/**
* An entry was selected, but instead of clicking "Link", the user cleared the selection
*/
handleEntryCleared(event)
{
this.classList.remove("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.onlyApp)
{
selected = <LinkInfo>{app: this.onlyApp, id: selected};
}
link_info.push(<LinkInfo>selected);
}
this.createLink(link_info)
}
/**
* Handle a link being removed
*
* Event is thrown every time a link is removed (from a LinkList) but we only care if the
* entry hasn't been saved yet and has no ID. In this case we've been keeping the list
* to submit and link server-side so we have to remove the deleted link from our list.
*
* @param {CustomEvent} e
*/
handleLinkDeleted(e : CustomEvent)
{
if(e && e.detail && this.value && typeof this.value.to_id == "object")
{
delete this.value.to_id[e.detail.link_id || ""]
}
}
handleFilePaste([button, files])
{
if(!button)
{
return;
}
let values = {};
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]
};
}
this._link_result(values);
}
handleVfsSelected([button, selected])
{
if(!button || !selected?.length)
{
return;
}
let values = true;
// If entry not yet saved, store for linking on server
if(!this.value.to_id || typeof this.value.to_id == 'object')
{
values = this.value.to_id || {};
for(let i = 0; i < selected.length; i++)
{
const info = this.pasteDialog.fileInfo(selected[i]);
values['link:' + selected[i]] = {
app: info?.app == "filemanager" ? "link" : info?.app,
id: info?.app == "filemanager" ? selected[i] : info?.id,
type: 'unknown',
icon: 'link',
remark: '',
title: selected[i]
};
}
}
else
{
// Send to server to link
const files = [];
const links = [];
selected.forEach(id =>
{
const info = this.pasteDialog.fileInfo(id);
switch(info?.app)
{
case "filemanager":
files.push(id);
break;
default:
links.push({app: info.app, id: info.id});
}
});
if(files.length > 0)
{
const file_method = 'EGroupware\\Api\\Etemplate\\Widget\\Link::ajax_link_existing';
const methodId = this.value.to_app + ':' + this.value.to_id;
this.egw().request(
file_method,
[methodId, files, button]
);
}
if(links.length > 0)
{
this.createLink(links);
}
}
this.pasteButton.value = [];
this._link_result(values);
}
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'];
}
render()
{
const labelTemplate = this._labelTemplate();
const helpTemplate = this._helpTextTemplate();
return html`
<div
part="form-control"
class=${classMap({
'form-control': true,
'form-control--medium': true,
'form-control--has-label': labelTemplate !== nothing,
'form-control--has-help-text': helpTemplate !== nothing
})}
>
${labelTemplate}
<div part="form-control-input" class="form-control-input" @sl-change=${() =>
{
this.dispatchEvent(new Event("change", {bubbles: true}));
}}>
${this._inputGroupBeforeTemplate()}
${this._inputGroupInputTemplate()}
</div>
${helpTemplate}
</div>
`;
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-link-to", Et2LinkTo);