Home: Weather portlet improvements

This commit is contained in:
nathan 2023-03-10 14:44:01 -07:00
parent 665dcc18da
commit 5cfe1cef58
7 changed files with 452 additions and 112 deletions

View File

@ -22,6 +22,7 @@ import {Et2Dialog} from "../Et2Dialog/Et2Dialog";
import {et2_IResizeable} from "../et2_core_interfaces"; import {et2_IResizeable} from "../et2_core_interfaces";
import {HomeApp} from "../../../../home/js/app"; import {HomeApp} from "../../../../home/js/app";
import {etemplate2} from "../etemplate2"; import {etemplate2} from "../etemplate2";
import {SelectOption} from "../Et2Select/FindSelectOptions";
/** /**
* Participate in Home * 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 * Create & show a dialog for customizing this portlet
* *
@ -404,7 +416,7 @@ export class Et2Portlet extends Et2Widget(SlCard)
callback: this._process_edit.bind(this), callback: this._process_edit.bind(this),
template: this.editTemplate, template: this.editTemplate,
value: { value: {
content: this.settings content: {...this.settings, ...this.portletProperties}
}, },
buttons: [ buttons: [
{ {
@ -455,6 +467,7 @@ export class Et2Portlet extends Et2Widget(SlCard)
{ {
this.settings = {...this.settings, value}; this.settings = {...this.settings, value};
} }
this.requestUpdate();
} }
public update_settings(settings) public update_settings(settings)

View File

@ -11,6 +11,8 @@ import {ClassWithAttributes, ClassWithInterfaces} from "../et2_core_inheritance"
import {css, dedupeMixin, LitElement, PropertyValues, unsafeCSS} from "@lion/core"; import {css, dedupeMixin, LitElement, PropertyValues, unsafeCSS} from "@lion/core";
import type {et2_container} from "../et2_core_baseWidget"; import type {et2_container} from "../et2_core_baseWidget";
import type {et2_DOMWidget} from "../et2_core_DOMWidget"; 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 * This mixin will allow any LitElement to become an Et2Widget
@ -84,6 +86,11 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
*/ */
protected _deferred_properties : { [key : string] : string } = {}; protected _deferred_properties : { [key : string] : string } = {};
/**
* EGroupware action system action manager
*/
private _actionManager : egwAction;
/** WebComponent **/ /** WebComponent **/
static get styles() static get styles()
@ -91,18 +98,20 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
return [ return [
...(super.styles ? (Array.isArray(super.styles) ? super.styles : [super.styles]) : []), ...(super.styles ? (Array.isArray(super.styles) ? super.styles : [super.styles]) : []),
css` css`
:host([disabled]) { :host([disabled]) {
display: none; display: none;
} }
/* CSS to align internal inputs according to box alignment */ /* CSS to align internal inputs according to box alignment */
:host([align="center"]) .input-group__input {
:host([align="center"]) .input-group__input {
justify-content: center; justify-content: center;
} }
:host([align="right"]) .input-group__input {
:host([align="right"]) .input-group__input {
justify-content: flex-end; justify-content: flex-end;
} }
`]; `];
} }
static get properties() static get properties()
@ -192,6 +201,9 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
data: { data: {
type: String, type: String,
reflect: false reflect: false
},
actions: {
type: Object
} }
}; };
} }
@ -1331,6 +1343,124 @@ const Et2WidgetMixin = <T extends Constructor>(superClass : T) =>
// If we're the root object, return the phpgwapi API instance // If we're the root object, return the phpgwapi API instance
return typeof egw === "function" ? egw('phpgwapi', wnd) : (window['egw'] ? window['egw'] : null); 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 // Add some more stuff in
@ -1627,3 +1757,61 @@ export function cssImage(image_name : string, app_name? : string)
return css``; 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;
}
}

View File

@ -189,7 +189,10 @@ class home_ui
$settings = $portlet->get_properties(); $settings = $portlet->get_properties();
foreach($settings as $key => $setting) 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; $settings += $context;
foreach(home_portlet::$common_attributes as $attr) foreach(home_portlet::$common_attributes as $attr)
@ -197,6 +200,10 @@ class home_ui
unset($settings[$attr]); unset($settings[$attr]);
} }
$portlet_content['settings'] = $settings; $portlet_content['settings'] = $settings;
if(method_exists($portlet, "get_value"))
{
$portlet_content['value'] = $portlet->get_value();
}
// Set actions // Set actions
// Must be after settings so actions can take settings into account // Must be after settings so actions can take settings into account

View File

@ -27,7 +27,6 @@ class home_weather_portlet extends home_portlet
{ {
const API_URL = "http://api.openweathermap.org/data/2.5/"; const API_URL = "http://api.openweathermap.org/data/2.5/";
const ICON_URL = 'http://openweathermap.org/img/w/';
const API_KEY = '45484f039c5caa14d31aefe7f5514292'; const API_KEY = '45484f039c5caa14d31aefe7f5514292';
const CACHE_TIME = 3600; // Cache weather for an hour const CACHE_TIME = 3600; // Cache weather for an hour
@ -51,22 +50,15 @@ class home_weather_portlet extends home_portlet
$this->context = $context; $this->context = $context;
} }
public function exec($id = null, Etemplate &$etemplate = null) public function get_value()
{ {
// Allow to submit directly back here $id = $this->context['id'];
if(is_array($id) && $id['id']) $content = array();
{
$id = $id['id'];
}
$etemplate->read('home.weather');
$etemplate->set_dom_id($id);
$content = $this->context;
$request = array( $request = array(
'units' => $this->context['units'] ? $this->context['units'] : 'metric', 'units' => $this->context['units'] ?: 'metric',
'lang' => $GLOBALS['egw_info']['user']['preferences']['common']['lang'], 'lang' => $GLOBALS['egw_info']['user']['preferences']['common']['lang'],
// Always get (& cache) 10 days, we'll cut down later // Always get (& cache) 10 days, we'll cut down later
'cnt' => 10 'cnt' => 10
); );
if($this->context['city_id']) if($this->context['city_id'])
{ {
@ -78,9 +70,9 @@ class home_weather_portlet extends home_portlet
$request['q'] = $this->context['city']; $request['q'] = $this->context['city'];
$content += $this->get_weather($request); $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); $content += $this->get_weather($request);
} }
@ -94,34 +86,25 @@ class home_weather_portlet extends home_portlet
// Save updated Api\Preferences // Save updated Api\Preferences
$portlets[$id]['city_id'] = $content['city_id']; $portlets[$id]['city_id'] = $content['city_id'];
$this->context['city'] = $portlets[$id]['city'] = $content['settings']['city'] = $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']); unset($portlets[$id]['position']);
$GLOBALS['egw']->preferences->add('home', $id, $portlets[$id]); $GLOBALS['egw']->preferences->add('home', $id, $portlets[$id]);
$GLOBALS['egw']->preferences->save_repository(True); $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 // 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']; $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 =& $massage[$i];
$forecast['day'] = Api\DateTime::to($forecast['dt'],'l'); $forecast['day'] = Api\DateTime::to($forecast['dt'], 'l');
self::format_forecast($forecast); self::format_forecast($forecast);
} }
// Chop data to fit into portlet // Chop data to fit into portlet
@ -223,29 +206,49 @@ class home_weather_portlet extends home_portlet
$weather =& $data['weather'] ? $data['weather'] : $data; $weather =& $data['weather'] ? $data['weather'] : $data;
$temp =& $data['temp'] ? $data['temp'] : $data; $temp =& $data['temp'] ? $data['temp'] : $data;
// Full URL for icon // Find icon
if(is_array($weather)) if(is_array($weather))
{ {
foreach($weather as &$w) foreach($weather as &$w)
{ {
$w['icon'] = static::ICON_URL . $w['icon'].'.png'; $w['icon'] = static::get_icon($w);
} }
} }
// Round // 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)) 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() public function get_actions()
{ {
$actions = array( $actions = array();
);
return $actions; return $actions;
} }
@ -270,9 +273,9 @@ class home_weather_portlet extends home_portlet
$properties = parent::get_properties(); $properties = parent::get_properties();
$properties[] = array( $properties[] = array(
'name' => 'city', 'name' => 'city',
'type' => 'textbox', 'type' => 'textbox',
'label' => lang('Location'), 'label' => lang('Location'),
); );
return $properties; return $properties;
} }
@ -280,9 +283,14 @@ class home_weather_portlet extends home_portlet
public function get_description() public function get_description()
{ {
return array( return array(
'displayName'=> lang('Weather'), 'displayName' => lang('Weather'),
'title'=> $this->context['city'], 'title' => $this->context['city'],
'description'=> lang('Weather') 'description' => lang('Weather')
); );
} }
public function get_type()
{
return 'et2-portlet-weather';
}
} }

View File

@ -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`
<div class="weather__day-forecast">
<et2-description class="temperature__day-label" value="${day.day}"></et2-description>
<et2-hbox part="day" class="day__forecast">
<sl-icon class="weather_icon" name="${day.weather[0].icon}"></sl-icon>
${(typeof day.temp?.temp != "undefined") ? html`
<et2-hbox class="temperature">
<span>${day.temp.temp}</span>
</et2-hbox>` : nothing
}
<et2-vbox class="temperature__high_low">
<span class="temperature__max">${day.temp.max}</span>
<span class="temperature__min">${day.temp.min}</span>
</et2-vbox>
</et2-hbox>
</div>`;
}
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`
<div
part="base"
class=${classMap({
portlet: true,
"portlet--weather": true
})}
>
<div part="current" class="temperature__current">
${this.forecastDayTemplate({
...{
day: 'Today',
// Current has a different data format
temp: {
min: current.temp.temp_min,
max: current.temp.temp_max
}, ...current
}
})}
</div>
${doList ? html`
<div part="list" class="temperature__day-list">
${repeat(list, (item, index) =>
{
return this.forecastDayTemplate(item);
})}
</div>` : nothing
}
</div>
`;
}
}
if(!customElements.get("et2-portlet-weather"))
{
customElements.define("et2-portlet-weather", Et2PortletWeather);
}

View File

@ -16,6 +16,7 @@ import {Et2PortletFavorite} from "./Et2PortletFavorite";
import {loadWebComponent} from "../../api/js/etemplate/Et2Widget/Et2Widget"; import {loadWebComponent} from "../../api/js/etemplate/Et2Widget/Et2Widget";
import "./Et2PortletList"; import "./Et2PortletList";
import "./Et2PortletNote"; import "./Et2PortletNote";
import './Et2PortletWeather';
import "../../calendar/js/Et2PortletCalendar" import "../../calendar/js/Et2PortletCalendar"
import Sortable from "sortablejs/modular/sortable.complete.esm.js"; import Sortable from "sortablejs/modular/sortable.complete.esm.js";

View File

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2.0//EN" "https://www.egroupware.org/etemplate2.0.dtd">
<overlay>
<template id="home.weather" template="" lang="" group="0" version="1.9.001">
<grid id="current" disabled="!@current" width="100%">
<columns>
<column/>
<column/>
<column/>
<column/>
</columns>
<rows>
<row>
<et2-image class="weather_icon" src="weather[0][icon]"></et2-image>
<et2-hbox id="temp">
<et2-description class="current temperature" id="temp" noLang="true"></et2-description>
</et2-hbox>
<et2-vbox id="temp" disabled="@no_current_temp">
<et2-description class="high_low temperature" id="max" noLang="true"></et2-description>
<et2-description class="high_low temperature" id="min" noLang="true"></et2-description>
</et2-vbox>
</row>
<row disabled="!@weather[0][description]">
<et2-description id="weather[0][description]" noLang="true"></et2-description>
</row>
</rows>
</grid>
<et2-box id="list" class="forecast" disabled="!@list" width="100%">
<!-- Box wrapper needed to get box to auto-repeat -->
<et2-box id="${row}">
<grid width="100%">
<columns>
<column/>
</columns>
<rows>
<row><et2-description align="center" id="day"></et2-description></row>
<row class="weather_icon"><et2-image align="center" class="weather_icon" src="weather[0][icon]"></et2-image></row>
<row>
<et2-vbox align="center" id="temp">
<et2-description class="high_low temperature" id="max" noLang="true"></et2-description>
<et2-description class="high_low temperature" id="min" noLang="true"></et2-description>
</et2-vbox>
</row>
</rows>
</grid>
</et2-box>
</et2-box>
<et2-description align="center" class="attribution" href="@attribution" value="openweathermap.org" activateLinks="true" extraLinkTarget="_blank"></et2-description>
</template>
</overlay>