diff --git a/api/js/etemplate/Et2Portlet/Et2Portlet.ts b/api/js/etemplate/Et2Portlet/Et2Portlet.ts index 886ef38662..6c9593583a 100644 --- a/api/js/etemplate/Et2Portlet/Et2Portlet.ts +++ b/api/js/etemplate/Et2Portlet/Et2Portlet.ts @@ -22,6 +22,7 @@ import {Et2Dialog} from "../Et2Dialog/Et2Dialog"; import {et2_IResizeable} from "../et2_core_interfaces"; import {HomeApp} from "../../../../home/js/app"; import {etemplate2} from "../etemplate2"; +import {SelectOption} from "../Et2Select/FindSelectOptions"; /** * Participate in Home @@ -392,6 +393,17 @@ export class Et2Portlet extends Et2Widget(SlCard) } + /** + * Get a list of user-configurable properties + * @returns {[{name : string, type : string, select_options? : [SelectOption]}]} + */ + get portletProperties() : { name : string, type : string, label : string, select_options? : SelectOption[] }[] + { + return [ + {name: 'color', label: "Color", type: 'et2-colorpicker'} + ]; + } + /** * Create & show a dialog for customizing this portlet * @@ -404,7 +416,7 @@ export class Et2Portlet extends Et2Widget(SlCard) callback: this._process_edit.bind(this), template: this.editTemplate, value: { - content: this.settings + content: {...this.settings, ...this.portletProperties} }, buttons: [ { @@ -455,6 +467,7 @@ export class Et2Portlet extends Et2Widget(SlCard) { this.settings = {...this.settings, value}; } + this.requestUpdate(); } public update_settings(settings) diff --git a/api/js/etemplate/Et2Widget/Et2Widget.ts b/api/js/etemplate/Et2Widget/Et2Widget.ts index a6bd1293a6..93fff0600a 100644 --- a/api/js/etemplate/Et2Widget/Et2Widget.ts +++ b/api/js/etemplate/Et2Widget/Et2Widget.ts @@ -11,6 +11,8 @@ import {ClassWithAttributes, ClassWithInterfaces} from "../et2_core_inheritance" import {css, dedupeMixin, LitElement, PropertyValues, unsafeCSS} from "@lion/core"; import type {et2_container} from "../et2_core_baseWidget"; import type {et2_DOMWidget} from "../et2_core_DOMWidget"; +import {egw_getActionManager, egw_getAppObjectManager} from "../../egw_action/egw_action.js"; +import {EGW_AI_DRAG_OUT, EGW_AI_DRAG_OVER} from "../../egw_action/egw_action_constants"; /** * This mixin will allow any LitElement to become an Et2Widget @@ -84,6 +86,11 @@ const Et2WidgetMixin = (superClass : T) => */ protected _deferred_properties : { [key : string] : string } = {}; + /** + * EGroupware action system action manager + */ + private _actionManager : egwAction; + /** WebComponent **/ static get styles() @@ -91,18 +98,20 @@ const Et2WidgetMixin = (superClass : T) => return [ ...(super.styles ? (Array.isArray(super.styles) ? super.styles : [super.styles]) : []), css` - :host([disabled]) { + :host([disabled]) { display: none; - } - - /* CSS to align internal inputs according to box alignment */ - :host([align="center"]) .input-group__input { + } + + /* CSS to align internal inputs according to box alignment */ + + :host([align="center"]) .input-group__input { justify-content: center; - } - :host([align="right"]) .input-group__input { + } + + :host([align="right"]) .input-group__input { justify-content: flex-end; - } - `]; + } + `]; } static get properties() @@ -192,6 +201,9 @@ const Et2WidgetMixin = (superClass : T) => data: { type: String, reflect: false + }, + actions: { + type: Object } }; } @@ -1331,6 +1343,124 @@ const Et2WidgetMixin = (superClass : T) => // If we're the root object, return the phpgwapi API instance return typeof egw === "function" ? egw('phpgwapi', wnd) : (window['egw'] ? window['egw'] : null); } + + /** + * Set Actions on the widget + * + * Each action is defined as an object: + * + * move: { + * type: "drop", + * acceptedTypes: "mail", + * icon: "move", + * caption: "Move to" + * onExecute: javascript:app.mail.drop_move" + * } + * + * This will turn the widget into a drop target for "mail" drag types. When "mail" drag types are dropped, + * the function drop_move(egwAction action, egwActionObject sender) will be called. The ID of the + * dragged "mail" will be in sender.id, some information about the sender will be in sender.context. The + * widget involved can typically be found in action.parent.data.widget, so your handler + * can operate in the widget context easily. The location varies depending on your action though. It + * might be action.parent.parent.data.widget + * + * To customise how the actions are handled for a particular widget, override _link_actions(). It handles + * the more widget-specific parts. + * + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + set actions(actions : egwAction[]) + { + if(this.id == "" || typeof this.id == "undefined") + { + return; + } + // Initialize the action manager and add some actions to it + // Only look 1 level deep + let gam = egw_getActionManager(this.egw().app_name(), true, 1); + if(typeof this._actionManager != "object" && actions.length > 0) + { + if(gam.getActionById(this.getInstanceManager().uniqueId, 1) !== null) + { + gam = gam.getActionById(this.getInstanceManager().uniqueId, 1); + } + if(gam.getActionById(this.id, 1) != null) + { + this._actionManager = gam.getActionById(this.id, 1); + } + else + { + this._actionManager = gam.addAction("actionManager", this.id); + } + } + this._actionManager.updateActions(actions, this.egw().appName); + //if (this.options.default_execute) this._actionManager.setDefaultExecute(this.options.default_execute); + + // Put a reference to the widget into the action stuff, so we can + // easily get back to widget context from the action handler + this._actionManager.data = {widget: this}; + + // Link the actions to the DOM + this._link_actions(actions); + } + + get actions() + { + return this._actionManager?.children || [] + } + + /** + * Link the EGroupware actions to the DOM nodes / widget bits. + * + * @param {object} actions {ID: {attributes..}+} map of egw action information + */ + protected _link_actions(actions) + { + // Get the top level element for the tree + const objectManager = egw_getAppObjectManager(true); + let widget_object = objectManager.getObjectById(this.id); + + if(widget_object == null) + { + // Add a new container to the object manager which will hold the widget + // objects + widget_object = objectManager.insertObject(false, new egwActionObject( + this.id, objectManager, (new Et2ActionObjectForWidget(this)).getAOI(), + this._actionManager || objectManager.manager.getActionById(this.id) || objectManager.manager + )); + } + else + { + widget_object.setAOI((new Et2ActionObjectForWidget(this, this.getDOMNode())).getAOI()); + } + + // Delete all old objects + widget_object.clear(); + widget_object.unregisterActions(); + + // Go over the widget & add links - this is where we decide which actions are + // 'allowed' for this widget at this time + widget_object.updateActionLinks(this._get_action_links(actions)); + } + + /** + * Get all action-links / id's of 1.-level actions from a given action object + * + * This can be overwritten to not allow all actions, by not returning them here. + * + * @param actions + * @returns {Array} + */ + _get_action_links(actions) + { + const action_links = []; + for(let i in actions) + { + let action = actions[i]; + action_links.push(typeof action.id != 'undefined' ? action.id : i); + } + return action_links; + } } // Add some more stuff in @@ -1626,4 +1756,62 @@ export function cssImage(image_name : string, app_name? : string) { return css``; } +} + +/** + * The egw_action system requires an egwActionObjectInterface Interface implementation + * to tie actions to DOM nodes. This one can be used by any widget. + * + * + * @param {et2_DOMWidget} widget + * @param {Object} node + * + */ +export class Et2ActionObjectForWidget +{ + aoi : egwActionObjectInterface; + + constructor(_widget : LitElement, _node? : HTMLElement) + { + const widget = _widget; + const objectNode = _node; + this.aoi = new egwActionObjectInterface(); + this.aoi['getWidget'] = function() + { + return widget; + }; + this.aoi.doGetDOMNode = function() + { + return widget + }; + +// _outerCall may be used to determine, whether the state change has been +// evoked from the outside and the stateChangeCallback has to be called +// or not. + this.aoi.doSetState = function(_state, _outerCall) + { + }; + +// The doTiggerEvent function may be overritten by the aoi if it wants to +// support certain action implementation specific events like EGW_AI_DRAG_OVER +// or EGW_AI_DRAG_OUT + this.aoi.doTriggerEvent = function(_event, _data) + { + switch(_event) + { + case EGW_AI_DRAG_OVER: + this.widget.classList.add("ui-state-active"); + break; + case EGW_AI_DRAG_OUT: + this.widget.classList.remove("ui-state-active"); + break; + } + return true; + }; + } + + getAOI() + { + return this.aoi; + } } \ No newline at end of file diff --git a/home/inc/class.home_ui.inc.php b/home/inc/class.home_ui.inc.php index 6e812d9185..73be169eed 100644 --- a/home/inc/class.home_ui.inc.php +++ b/home/inc/class.home_ui.inc.php @@ -189,7 +189,10 @@ class home_ui $settings = $portlet->get_properties(); foreach($settings as $key => $setting) { - if(is_array($setting) && !array_key_exists('type',$setting)) unset($settings[$key]); + if(is_array($setting) && !array_key_exists('type', $setting)) + { + unset($settings[$key]); + } } $settings += $context; foreach(home_portlet::$common_attributes as $attr) @@ -197,6 +200,10 @@ class home_ui unset($settings[$attr]); } $portlet_content['settings'] = $settings; + if(method_exists($portlet, "get_value")) + { + $portlet_content['value'] = $portlet->get_value(); + } // Set actions // Must be after settings so actions can take settings into account diff --git a/home/inc/class.home_weather_portlet.inc.php b/home/inc/class.home_weather_portlet.inc.php index 8a9763d8f5..577c7fc701 100644 --- a/home/inc/class.home_weather_portlet.inc.php +++ b/home/inc/class.home_weather_portlet.inc.php @@ -27,7 +27,6 @@ class home_weather_portlet extends home_portlet { const API_URL = "http://api.openweathermap.org/data/2.5/"; - const ICON_URL = 'http://openweathermap.org/img/w/'; const API_KEY = '45484f039c5caa14d31aefe7f5514292'; const CACHE_TIME = 3600; // Cache weather for an hour @@ -51,22 +50,15 @@ class home_weather_portlet extends home_portlet $this->context = $context; } - public function exec($id = null, Etemplate &$etemplate = null) + public function get_value() { - // Allow to submit directly back here - if(is_array($id) && $id['id']) - { - $id = $id['id']; - } - $etemplate->read('home.weather'); - - $etemplate->set_dom_id($id); - $content = $this->context; + $id = $this->context['id']; + $content = array(); $request = array( - 'units' => $this->context['units'] ? $this->context['units'] : 'metric', - 'lang' => $GLOBALS['egw_info']['user']['preferences']['common']['lang'], + 'units' => $this->context['units'] ?: 'metric', + 'lang' => $GLOBALS['egw_info']['user']['preferences']['common']['lang'], // Always get (& cache) 10 days, we'll cut down later - 'cnt' => 10 + 'cnt' => 10 ); if($this->context['city_id']) { @@ -78,9 +70,9 @@ class home_weather_portlet extends home_portlet $request['q'] = $this->context['city']; $content += $this->get_weather($request); } - elseif ($this->context['position']) + elseif($this->context['position']) { - list($request['lat'],$request['lon']) = explode(',',$this->context['position']); + list($request['lat'], $request['lon']) = explode(',', $this->context['position']); $content += $this->get_weather($request); } @@ -94,34 +86,25 @@ class home_weather_portlet extends home_portlet // Save updated Api\Preferences $portlets[$id]['city_id'] = $content['city_id']; $this->context['city'] = $portlets[$id]['city'] = $content['settings']['city'] = - $content['settings']['title'] = $content['city'] = is_array($content['city']) ? $content['city']['name'] : $content['city']; + $content['settings']['title'] = $content['city'] = is_array($content['city']) ? $content['city']['name'] : $content['city']; unset($portlets[$id]['position']); $GLOBALS['egw']->preferences->add('home', $id, $portlets[$id]); $GLOBALS['egw']->preferences->save_repository(True); } - // Adjust data to match portlet size - if($this->context['height'] <= 2 && $this->context['width'] <= 3) - { - // Too small for the other days - unset($content['list']); - } - else if ($this->context['height'] == 2 && $this->context['width'] > 3) - { - // Wider, but not taller - unset($content['current']); - } - // Even too small for current high/low - if($this->context['width'] < 3) - { - $content['current']['no_current_temp'] = true; - } - - // Direct to full forecast page - $content['attribution'] ='http://openweathermap.org/city/'.$content['city_id']; + $content['attribution'] = 'http://openweathermap.org/city/' . $content['city_id']; - $etemplate->exec('home.home_weather_portlet.exec',$content,array(),array('__ALL__'=>true),array('id' =>$id)); + return [ + 'color' => $this->context['color'], + 'city' => $this->context['city'], + 'display' => $this->context['display'], + 'weather' => $content + ]; + } + + public function exec($id = null, Etemplate &$etemplate = null) + { } /** @@ -188,10 +171,10 @@ class home_weather_portlet extends home_portlet { $massage =& $data['list']; - for($i = 0; $i < min(count($massage), $this->context['width']); $i++) + for($i = 0; $i < count($data['list']); $i++) { $forecast =& $massage[$i]; - $forecast['day'] = Api\DateTime::to($forecast['dt'],'l'); + $forecast['day'] = Api\DateTime::to($forecast['dt'], 'l'); self::format_forecast($forecast); } // Chop data to fit into portlet @@ -223,29 +206,49 @@ class home_weather_portlet extends home_portlet $weather =& $data['weather'] ? $data['weather'] : $data; $temp =& $data['temp'] ? $data['temp'] : $data; - // Full URL for icon + // Find icon if(is_array($weather)) { foreach($weather as &$w) { - $w['icon'] = static::ICON_URL . $w['icon'].'.png'; + $w['icon'] = static::get_icon($w); } } // Round - foreach(array('temp','temp_min','temp_max','min','max') as $temp_name) + foreach(array('temp', 'temp_min', 'temp_max', 'min', 'max') as $temp_name) { if(array_key_exists($temp_name, $temp)) { - $temp[$temp_name] = ''.round($temp[$temp_name]); + $temp[$temp_name] = '' . round($temp[$temp_name]); } } } + /** + * Get an icon to represent the forecast + * + * We use icon names from shoelace + * @param $weather + * @return string + */ + protected static function get_icon(&$weather) + { + $icon = "question-lg"; + switch(strtolower($weather['main'])) + { + case 'clear' : + $icon = 'sun'; + break; + default: + $icon = strtolower($weather['main']); + } + return $icon; + } + public function get_actions() { - $actions = array( - ); + $actions = array(); return $actions; } @@ -270,9 +273,9 @@ class home_weather_portlet extends home_portlet $properties = parent::get_properties(); $properties[] = array( - 'name' => 'city', - 'type' => 'textbox', - 'label' => lang('Location'), + 'name' => 'city', + 'type' => 'textbox', + 'label' => lang('Location'), ); return $properties; } @@ -280,9 +283,14 @@ class home_weather_portlet extends home_portlet public function get_description() { return array( - 'displayName'=> lang('Weather'), - 'title'=> $this->context['city'], - 'description'=> lang('Weather') + 'displayName' => lang('Weather'), + 'title' => $this->context['city'], + 'description' => lang('Weather') ); } + + public function get_type() + { + return 'et2-portlet-weather'; + } } \ No newline at end of file diff --git a/home/js/Et2PortletWeather.ts b/home/js/Et2PortletWeather.ts new file mode 100644 index 0000000000..a055c75a15 --- /dev/null +++ b/home/js/Et2PortletWeather.ts @@ -0,0 +1,173 @@ +import {Et2Portlet} from "../../api/js/etemplate/Et2Portlet/Et2Portlet"; +import {classMap, css, html, nothing, repeat, TemplateResult} from "@lion/core"; +import shoelace from "../../api/js/etemplate/Styles/shoelace"; +import {SelectOption} from "../../api/js/etemplate/Et2Select/FindSelectOptions"; + +/** + * Show current and forecast weather + */ +export class Et2PortletWeather extends Et2Portlet +{ + static get styles() + { + return [ + ...shoelace, + ...(super.styles || []), + css` + .portlet--weather { + display: flex; + } + + .day__forecast { + width: fit-content; + min-width: 12ex; + max-width: 20ex; + } + + .temperature__day-label { + text-align: center; + font-size: 120%; + padding-bottom: var(--sl-spacing-medium); + } + + .temperature { + font-size: 160%; + } + + .temperature__high_low { + } + + sl-icon { + font-size: 32px; + } + + .portlet--weather .temperature__current { + /* Make current day a little bigger */ + font-size: 180%; + padding: var(--sl-spacing-large); + } + + .temperature__current .day__forecast { + padding: var(--sl-spacing-medium) 0px; + } + + :host([style*="span 1"]) .temperature__current { + /* No padding if portlet is small */ + padding: 0px; + } + + .portlet--weather .temperature__current sl-icon { + font-size: 250%; + } + + .temperature__day-list { + flex: 1 1 auto; + display: grid; + gap: var(--sl-spacing-x-large) var(--sl-spacing-medium); + grid-template-columns: repeat(auto-fill, minmax(12ex, 1fr)); + padding-top: var(--sl-spacing-large); + } + + .temperature__day-list .weather__day-forecast { + min-height: 12ex; + } + ` + ] + } + + /** + * Get a list of user-configurable properties + * @returns {[{name : string, type : string, select_options? : [SelectOption]}]} + */ + get portletProperties() : { name : string, type : string, label : string, select_options? : SelectOption[] }[] + { + return [ + ...super.portletProperties, + { + 'name': 'city', + 'type': 'et2-textbox', + 'label': this.egw().lang('Location'), + } + /* Use size to control what we show + { + name: "display", label: "Display", type: "et2-select", select_options: [ + {'value': 'today', 'label': this.egw().lang('today')}, + {'value': '3', 'label': this.egw().lang('%1 day', 3)}, + {'value': '10', 'label': this.egw().lang('%1 day', 10)}, + ] + } + */ + ]; + } + + /** + * Template for one day of the forecast + * @param day + * @protected + */ + protected forecastDayTemplate(day) + { + return html` +
+ + + + ${(typeof day.temp?.temp != "undefined") ? html` + + ${day.temp.temp} + ` : nothing + } + + ${day.temp.max} + ${day.temp.min} + + +
`; + } + + bodyTemplate() : TemplateResult + { + const doList = parseInt(getComputedStyle(this).width) > 300; + const current = this.settings?.weather?.current || {weather: [{icon: "question-lg"}], temp: {temp: "?"}}; + + // Get the forecast, excluding today + let list = this.settings.weather && (Object.values(this.settings?.weather?.list).slice(1) || []); + + return html` +
+
+ ${this.forecastDayTemplate({ + ...{ + day: 'Today', + // Current has a different data format + temp: { + min: current.temp.temp_min, + max: current.temp.temp_max + }, ...current + } + })} +
+ ${doList ? html` +
+ ${repeat(list, (item, index) => + { + return this.forecastDayTemplate(item); + })} +
` : nothing + } +
+ `; + } + +} + +if(!customElements.get("et2-portlet-weather")) +{ + customElements.define("et2-portlet-weather", Et2PortletWeather); +} \ No newline at end of file diff --git a/home/js/app.ts b/home/js/app.ts index 0ad46d59cd..4403bbb65d 100644 --- a/home/js/app.ts +++ b/home/js/app.ts @@ -16,6 +16,7 @@ import {Et2PortletFavorite} from "./Et2PortletFavorite"; import {loadWebComponent} from "../../api/js/etemplate/Et2Widget/Et2Widget"; import "./Et2PortletList"; import "./Et2PortletNote"; +import './Et2PortletWeather'; import "../../calendar/js/Et2PortletCalendar" import Sortable from "sortablejs/modular/sortable.complete.esm.js"; diff --git a/home/templates/default/weather.xet b/home/templates/default/weather.xet deleted file mode 100644 index 69c39a7e48..0000000000 --- a/home/templates/default/weather.xet +++ /dev/null @@ -1,50 +0,0 @@ - - - - - \ No newline at end of file