egroupware_official/api/js/etemplate/Et2Portlet/Et2Portlet.ts
2023-03-06 13:19:48 -07:00

452 lines
11 KiB
TypeScript

/**
* EGroupware eTemplate2 - Portlet base
*
* @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 {Et2Widget} from "../Et2Widget/Et2Widget";
import {SlCard} from "@shoelace-style/shoelace";
import interact from "@interactjs/interactjs";
import {egw} from "../../jsapi/egw_global";
import {classMap, css, html} from "@lion/core";
import {HasSlotController} from "@shoelace-style/shoelace/dist/internal/slot";
import shoelace from "../Styles/shoelace";
import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {et2_IResizeable} from "../et2_core_interfaces";
import {HomeApp} from "../../../../home/js/app";
/**
* Participate in Home
*/
export class Et2Portlet extends (Et2Widget(SlCard))
{
static get properties()
{
return {
...super.properties,
/**
* Give a title
* Goes in the header at the top with the icons
*/
title: {type: String},
/**
* Custom etemplate used to customize / set up the portlet
*/
editTemplate: {type: String},
/**
* Set the portlet color
*/
color: {type: String},
/**
* Array of customization settings, similar in structure to preference settings
*/
settings: {type: Object},
actions: {type: Object},
}
}
static get styles()
{
return [
...shoelace,
...(super.styles || []),
css`
.portlet__header {
flex: 1 0 auto;
display: flex;
font-style: inherit;
font-variant: inherit;
font-weight: inherit;
font-stretch: inherit;
font-family: inherit;
font-size: var(--sl-font-size-medium);
line-height: var(--sl-line-height-dense);
padding: var(--header-spacing);
margin: 0px;
}
.portlet__title {
flex: 1 1 auto;
font-size: var(--sl-font-size-medium);
user-select: none;
}
.portlet__header et2-button-icon {
display: none;
order: 99;
margin-left: auto;
}
.portlet__header:hover et2-button-icon {
display: initial;
}
.card {
width: 100%;
height: 100%
}
.card__body {
/* display block to prevent overflow from our size */
display: block;
overflow: hidden;
flex: 1 1 auto;
}
::slotted(div) {
}
`
]
}
protected readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
/**
* These are the "normal" actions that every portlet is expected to have.
* The widget provides default actions for all of these, but they can
* be added to or overridden if needed by setting the action attribute.
*/
protected static default_actions : any = {
edit_settings: {
icon: "edit",
caption: "Configure",
"default": true,
hideOnDisabled: true,
group: "portlet"
},
remove_portlet: {
icon: "delete",
caption: "Remove",
group: "portlet"
}
};
constructor()
{
super();
this.editTemplate = egw.webserverUrl + "/home/templates/default/edit.xet"
this.actions = {};
this._onMoveResize = this._onMoveResize.bind(this);
this._onMoveResizeEnd = this._onMoveResizeEnd.bind(this);
}
connectedCallback()
{
super.connectedCallback();
Promise.all([/* any others here...*/ this.updateComplete])
.then(() => this._setupMoveResize());
}
/**
* Overriden from parent to add in default actions
*/
set_actions(actions)
{
// Set targets for actions
let defaults : any = {};
for(let action_name in Et2Portlet.default_actions)
{
defaults[action_name] = Et2Portlet.default_actions[action_name];
// Translate caption here, as translations aren't available earlier
defaults[action_name].caption = this.egw().lang(Et2Portlet.default_actions[action_name].caption);
if(typeof this[action_name] == "function")
{
defaults[action_name].onExecute = this[action_name].bind(this);
}
}
// Add in defaults, but let provided actions override them
this.actions = jQuery.extend(true, {}, defaults, actions);
}
/**
* Set up moving & resizing
*/
_setupMoveResize()
{
// Quick calculation of min size - dialog is made up of header, content & buttons
let minHeight = 0;
for(let e of this.children)
{
minHeight += e.getBoundingClientRect().height + parseFloat(getComputedStyle(e).marginTop) + parseFloat(getComputedStyle(e).marginBottom)
}
// Get parent's dimensions
let style = getComputedStyle(this.parentElement);
let parentDimensions = {
width: parseInt(style.gridAutoColumns) + parseInt(style.gap) || HomeApp.GRID,
height: parseInt(style.gridAutoRows) + parseInt(style.gap) || HomeApp.GRID
};
let gridTarget = interact.snappers.grid({
x: parentDimensions.width,
y: parentDimensions.height
});
interact(this)
.resizable({
edges: {bottom: true, right: true},
listeners: {
move: this._onMoveResize,
end: this._onMoveResizeEnd
},
modifiers: [
// Snap to grid
interact.modifiers.snap({
targets: [gridTarget],
offset: "startCoords",
limits: {top: 0, left: 0}
}),
// keep the edges inside the parent
interact.modifiers.restrictEdges({
outer: 'parent'
})
]
})
.draggable({
allowFrom: ".portlet__header",
autoScroll: true,
listeners: {
move: this._onMoveResize,
end: this._onMoveResizeEnd
},
modifiers: [
// Restrict interferes with grid making it act strangely
//interact.modifiers.restrict({
// restriction: 'parent'
//}),
// Snap to grid
interact.modifiers.snap({
targets: [gridTarget],
offset: "startCoords",
limits: {top: 0, left: 0}
})
]
});
}
/**
* Handle moving and resizing
*
* @param event
*/
_onMoveResize(event : InteractEvent)
{
let target = event.target
let x = (parseFloat(target.getAttribute('data-x')) || 0) + (event.deltaRect ? 0 : event.dx);
let y = (parseFloat(target.getAttribute('data-y')) || 0) + (event.deltaRect ? 0 : event.dy);
// update the element's style
// Size
target.style.width = event.rect.width + 'px'
target.style.height = event.rect.height + 'px'
// Position
target.style.transform = 'translate(' + x + 'px,' + y + 'px)';
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
/**
* Move or resize has completed. Now into parent grid and update settings.
*
* @param {InteractEvent} event
*/
_onMoveResizeEnd(event : InteractEvent)
{
// Get parent's dimensions
let style = getComputedStyle(this.parentElement);
let parentDimensions = {
x: parseInt(style.gridAutoColumns) || 1,
y: parseInt(style.gridAutoRows) || 1
}
let target = event.target
let dx = Math.round((parseInt(target.getAttribute('data-x')) || 0) / parentDimensions.x);
let dy = Math.round((parseInt(target.getAttribute('data-y')) || 0) / parentDimensions.y);
let dwidth = Math.round((event.deltaRect?.width || 1) / parentDimensions.x);
let dheight = Math.round((event.deltaRect?.height || 1) / parentDimensions.y);
let [o_x, o_width] = this.style.gridColumn.split(" / span");
let [o_y, o_height] = this.style.gridRow.split(" / span");
// Clear temp stuff from moving
target.style.transform = "";
target.style.width = "";
target.style.height = "";
target.removeAttribute('data-x');
target.removeAttribute('data-y');
let col = Math.max(1, (dx + (parseInt(o_x) || 0)));
let row = Math.max(1, (dy + (parseInt(o_y) || 0)));
let width = (dwidth + parseInt(o_width)) || 1;
let height = (dheight + parseInt(o_height)) || 1;
// Set grid position
target.style.gridArea = row + " / " +
col + "/ span " +
height + " / span " +
width;
// Update position settings
this.update_settings({row: row, col: col, width: width, height: height});
}
imageTemplate()
{
return '';
}
headerTemplate()
{
return html`
<header class="portlet__header">
<h2 class="portlet__title">${this.title}</h2>
<et2-button-icon name="gear" label="Settings" noSubmit=true
@click="${() => this.edit_settings()}"></et2-button-icon>
</header>`;
}
footerTemplate()
{
return '';
}
/**
* Create & show a dialog for customizing this portlet
*
* Properties for customization are sent in the 'settings' attribute
*/
edit_settings()
{
let dialog = new Et2Dialog(this.egw());
dialog.transformAttributes({
callback: this._process_edit.bind(this),
template: this.editTemplate,
value: {
content: this.settings
},
buttons: Et2Dialog.BUTTONS_OK_CANCEL
});
// Set separately to avoid translation
dialog.title = this.egw().lang("Edit") + " " + (this.title || '');
this.appendChild(dialog);
}
_process_edit(button_id, value)
{
if(button_id != Et2Dialog.OK_BUTTON)
{
return;
}
// Pass updated settings, unless we're removing
this.update_settings((typeof value == 'string') ? {} : this.settings || {});
// Extend, not replace, because settings has types while value has just value
if(typeof value == 'object')
{
this.settings = {...this.settings, value};
}
}
protected update_settings(settings)
{
// Save settings - server might reply with new content if the portlet needs an update,
// but ideally it doesn't
this.classList.add("loading");
this.egw().jsonq("home.home_ui.ajax_set_properties", [this.id, [], settings, this.settings ? this.settings.group : false],
function(data)
{
// This section not for us
if(!data || typeof data.attributes == 'undefined')
{
return false;
}
this.classList.remove("loading");
this.transformAttributes(data.attributes);
// Flagged as needing to edit settings? Open dialog
if(typeof data.edit_settings != 'undefined' && data.edit_settings)
{
this.edit_settings();
}
// Only resize once, and only if needed
if(data.attributes.width || data.attributes.height)
{
this.style.columnSpan = data.attributes.width;
this.style.rowSpan = data.attributes.height;
// Tell children
try
{
this.iterateOver(function(widget)
{
if(typeof widget.resize === 'function')
{
widget.resize();
}
}, null, et2_IResizeable);
}
catch(e)
{
// Something went wrong, but do not stop
this.egw().debug('warn', e, this);
}
}
},
this);
}
render()
{
return html`
<style>
${this.color ? ".card {--border-color: " + this.color + "}" : ""}
</style>
<div
part="base"
class=${classMap({
card: true,
'card--has-footer': this.hasSlotController.test('footer'),
'card--has-image': this.hasSlotController.test('image'),
'card--has-header': true,
'et2-portlet': true
})}
>
<slot name="image" part="image" class="card__image">${this.imageTemplate()}</slot>
<slot name="header" part="header" class="card__header">${this.headerTemplate()}</slot>
<slot part="body" class="card__body"></slot>
<slot name="footer" part="footer" class="card__footer">${this.footerTemplate()}</slot>
</div>
`;
}
}
if(!customElements.get("et2-portlet"))
{
customElements.define("et2-portlet", Et2Portlet);
}