egroupware_official/api/js/etemplate/Et2Link/Et2Link.ts
nathan 846852ca22 Et2Link: Wait for link title before we say update is complete
Overriding _getUpdateComplete() lets us wait for whatever conditions we want, so we can wait on the Promise from link_title()
2023-06-02 09:05:14 -06:00

468 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* EGroupware eTemplate2 - JS Link 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 {ExposeMixin, ExposeValue} from "../Expose/ExposeMixin";
import {css, html, LitElement, TemplateResult} from "@lion/core";
import {Et2Widget} from "../Et2Widget/Et2Widget";
import {et2_IDetachedDOM} from "../et2_core_interfaces";
/**
* Display a specific, single entry from an application
*
* The entry is specified with the application name, and the app's ID for that entry.
* You can set it directly in the properties (application, entryId) or use set_value() to
* pass an object {app: string, id: string, [title: string]} or string in the form <application>::<ID>.
* If title is not specified, it will be fetched using framework's egw.link_title()
*
*/
// @ts-ignore TypeScript says there's something wrong with types
export class Et2Link extends ExposeMixin<Et2Widget>(Et2Widget(LitElement)) implements et2_IDetachedDOM
{
static get styles()
{
return [
...super.styles,
css`
:host {
display: block;
cursor: pointer;
}
.link {
display: flex;
gap: 0.5rem;
}
.link__title {
flex: 2 1 50%;
overflow: hidden;
text-overflow: ellipsis;
max-width: max-content;
width: 0;
}
.link__remark {
flex: 1 1 50%;
overflow: hidden;
text-overflow: ellipsis;
max-width: max-content;
width: 0;
}
:host:hover {
text-decoration: underline
}
/** Style based on parent **/
:host(et2-link-string) div {
display: inline;
}
:host-context(et2-link-list):hover {
text-decoration: none;
}
`
];
}
static get properties()
{
return {
...super.properties,
/**
* Specify the application for the entry
*/
app: {
type: String,
reflect: true,
},
/**
* Application entry ID
*/
entryId: {
type: String,
reflect: true
},
/**
* Pass value as an object, will be parsed to set application & entryId
*/
value: {
type: Object,
reflect: false
},
/**
* View link type
* Used for displaying the linked entry
* [view|edit|add]
* default "view"
*/
linkHook: {
type: String
},
/**
* Target application
*
* Passed to egw.open() to open entry in specified application
*/
targetApp: {
type: String
},
/**
* Optional parameter to be passed to egw().open in order to open links in specified target eg. _blank
*/
extraLinkTarget: {
type: String
},
/**
* Breaks title into multiple lines based on this delimiter by replacing it with '\r\n'"
*/
breakTitle: {
type: String
}
}
}
static MISSING_TITLE = "??";
// Title is read-only inside
private _title : string;
private _titlePromise : Promise<string>;
constructor()
{
super();
this._title = Et2Link.MISSING_TITLE;
this.__linkHook = "view";
}
connectedCallback()
{
super.connectedCallback();
}
async _getUpdateComplete()
{
await super._getUpdateComplete();
if(this._titlePromise)
{
// Wait for the title to arrive before we say we're done
await this._titlePromise;
}
}
/**
* Build a thumbnail for the link
* @param link
* @returns {TemplateResult}
* @protected
*/
protected _thumbnailTemplate(link : LinkInfo) : TemplateResult
{
// If we have a mimetype, use a Et2VfsMime
// Files have path set in 'icon' property, and mime in 'type'
if(link.type && link.icon)
{
return html`
<et2-vfs-mime part="icon" class="link__icon" ._parent=${this} .value=${Object.assign({
name: link.title,
mime: link.type,
path: link.icon
}, link)}
></et2-vfs-mime>`;
}
return html`
<et2-image-expose
part="icon"
class="link__icon"
._parent=${this}
href="${link.href}"
src=${this.egw().image("" + link.icon)}
?disabled=${!(link.href || link.icon)}
></et2-image-expose>`;
}
render()
{
let title = this.title;
if(this.breakTitle)
{
// Set up title to optionally break on the provided character - replace all space with nbsp, add a
// zero-width space after the break string
title = title
.replace(this.breakTitle, this.breakTitle.trimEnd() + "\u200B")
.replace(/ /g, '\u00a0')
// Change hyphen to non-breaking hyphen
.replace(/-/g, '');
}
return html`
<div part="base" class="link et2_link" draggable="${this.app == 'file'}" @dragstart=${this._handleDragStart.bind(this, this.dataset)}>
${this._thumbnailTemplate({id: this.entryId, app: this.app, ...this.dataset})}
<span part="title" class="link__title">${title}</span>
<span part="remark" class="link__remark">${this.dataset.remark}</span>
</div>`;
}
public set title(_title)
{
this._title = _title;
}
public get title()
{
return this._title;
}
/**
* Get a value representation of the link.
*
* @returns {LinkInfo | string}
*/
get value() : LinkInfo | string
{
return this.app && this.entryId ? this.app + ":" + this.entryId : "";
}
set value(_value : LinkInfo | string)
{
if(!_value)
{
this.entryId = "";
this.title = "";
return;
}
if(typeof _value != 'object' && _value)
{
if(_value.indexOf(':') >= 0)
{
// application_name:ID
let app = _value.split(':', 1);
let id = _value.substr(app[0].length + 1);
_value = {app: app[0], id: id};
}
else if(this.app)
{
// Application set, just passed ID
_value = {app: this.app, id: _value};
}
else
{
console.warn("Bad value for link widget. Need an object with keys 'app', 'id', and optionally 'title'", _value);
return;
}
}
if(typeof _value !== "string")
{
this.app = _value.app;
this.entryId = _value.id;
if(_value.title)
{
this._title = _value.title;
}
Object.keys(_value).forEach(key =>
{
// Skip these, they're either handled explicitly, or ID which we don't want to mess with
if(["app", "entryId", "title", "id"].indexOf(key) != -1)
{
return;
}
// we should not let null value being stored into dataset as 'null'
if (_value[key] === null)
{
this.dataset[key] = "";
}
else
{
this.dataset[key] = _value[key];
}
})
}
this.requestUpdate("value");
}
set_value(_value : LinkInfo | string)
{
this.value = _value;
}
get exposeValue() : ExposeValue
{
let info = <ExposeValue><unknown>{
app: this.app,
id: this.entryId,
path: this.dataset['icon']
};
info['label'] = this.title;
info = Object.assign(info, this.dataset);
if(info['remark'])
{
info['label'] += " - " + info['remark'];
}
if(!info.path && this.app == "file")
{
// Fallback to check the "normal" place if path wasn't available
info.path = "/webdav.php/apps/" + this.dataset.app2 + "/" + this.dataset.id2 + "/" + this.entryId;
}
if(typeof info["type"] !== "undefined")
{
// Links use "type" for mimetype.
info.mime = info["type"];
}
return info;
}
/**
* If app or entryId has changed, we'll update the title
*
* @param changedProperties
*/
willUpdate(changedProperties)
{
super.willUpdate(changedProperties);
super.requestUpdate();
if(changedProperties.has("app") || changedProperties.has("entryId"))
{
if(this.app && this.entryId && !this._title)
{
this._title = Et2Link.MISSING_TITLE;
}
if(this.app && this.entryId && this._title == Et2Link.MISSING_TITLE)
{
// Title will be fetched from server and then set
this._titlePromise = this.egw()?.link_title(this.app, this.entryId, true).then(title =>
{
this._title = title;
// It's probably already been rendered
this.requestUpdate();
});
}
}
}
/**
* Handle dragstart event for dragging out a file
*
* @param _data
* @param _ev
* @protected
*/
protected _handleDragStart (_data, _ev)
{
// // Unfortunately, dragging files is currently only supported by Chrome
if(navigator && navigator.userAgent.indexOf('Chrome') >= 0) {
if (_ev.dataTransfer == null) {
return;
}
if (_data && _data.type && _data.download_url) {
_ev.dataTransfer.dropEffect = "copy";
_ev.dataTransfer.effectAllowed = "copy";
let url = _data.download_url;
// NEED an absolute URL
if(url[0] == '/')
{
url = this.egw().link(url);
}
// egw.link adds the webserver, but that might not be an absolute URL - try again
if (url[0] == '/') url = window.location.origin + url;
// Unfortunately, dragging files is currently only supported by Chrome
if (navigator && navigator.userAgent.indexOf('Chrome')) {
_ev.dataTransfer.setData("DownloadURL", _data.type + ':' + this.title + ':' + url);
}
// Include URL as a fallback
_ev.dataTransfer.setData("text/uri-list", url);
}
if (_ev.dataTransfer.types.length == 0) {
// No file data? Abort: drag does nothing
_ev.preventDefault();
return;
}
}
}
_handleClick(_ev : MouseEvent) : boolean
{
// If we don't have app & entryId, nothing we can do
if(!this.app || !this.entryId || typeof this.entryId !== "string")
{
return false;
}
// If super didn't handle it (returns false), just use egw.open()
if(super._handleClick(_ev))
{
this.egw().open(Object.assign({
app: this.app,
id: this.entryId
}, this.dataset), "", this.linkHook, this.dataset.extra_args, this.extraLinkTarget || this.app, this.targetApp || this.app);
}
_ev.stopImmediatePropagation();
return false;
}
getDetachedAttributes(_attrs : string[])
{
_attrs.push("app", "entryId", "statustext");
}
getDetachedNodes() : HTMLElement[]
{
return [<HTMLElement><unknown>this];
}
setDetachedAttributes(_nodes : HTMLElement[], _values : object, _data?)
{
for(let k in _values)
{
this[k] = _values[k];
}
}
}
// @ts-ignore TypeScript says there's something wrong with types
customElements.define("et2-link", Et2Link);
/**
* Interface to describe needed information about a link
*/
export interface LinkInfo
{
app : string,
id : string,
title? : string,
link_id? : string;
comment? : string
icon? : string,
help? : string,
// Extra information for things like files
download_url? : string,
target? : string,
mode? : number
}