mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-22 14:41:29 +01:00
655 lines
17 KiB
TypeScript
655 lines
17 KiB
TypeScript
/**
|
|
* EGroupware eTemplate2 - Duration date widget (WebComponent)
|
|
*
|
|
* @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
|
|
*/
|
|
|
|
|
|
import {css, html, LitElement, nothing} from "lit";
|
|
import {classMap} from "lit/directives/class-map.js";
|
|
import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget";
|
|
import {sprintf} from "../../egw_action/egw_action_common";
|
|
import {dateStyles} from "./DateStyles";
|
|
import shoelace from "../Styles/shoelace";
|
|
import {customElement} from "lit/decorators/custom-element.js";
|
|
import {property} from "lit/decorators/property.js";
|
|
import {live} from "lit/directives/live.js";
|
|
|
|
export interface formatOptions
|
|
{
|
|
selectUnit : string;
|
|
displayFormat : string;
|
|
dataFormat : string;
|
|
hoursPerDay : number;
|
|
emptyNot0 : boolean;
|
|
number_format? : string;
|
|
}
|
|
|
|
/**
|
|
* Format a number as a time duration
|
|
*
|
|
* @param {number} value
|
|
* @param {object} options
|
|
* set 'timeFormat': "12" to specify a particular format
|
|
* @returns {value: string, unit: string}
|
|
*/
|
|
export function formatDuration(value : number | string, options : formatOptions) : { value : string, unit : string }
|
|
{
|
|
// Handle empty / 0 / no value
|
|
if(value === "" || value == "0" || !value)
|
|
{
|
|
return {value: options.emptyNot0 ? "0" : "", unit: ""};
|
|
}
|
|
// Make sure it's a number now
|
|
value = typeof value == "string" ? parseInt(value) : value;
|
|
|
|
if(!options.selectUnit)
|
|
{
|
|
let vals = [];
|
|
for(let i = 0; i < options.displayFormat.length; ++i)
|
|
{
|
|
let unit = options.displayFormat[i];
|
|
let val = this._unit_from_value(value, unit, i === 0, options);
|
|
if(unit === 's' || unit === 'm' || unit === 'h' && options.displayFormat[0] === 'd')
|
|
{
|
|
vals.push(sprintf('%02d', val));
|
|
}
|
|
else
|
|
{
|
|
vals.push(val);
|
|
}
|
|
}
|
|
return {value: vals.join(':'), unit: ''};
|
|
}
|
|
|
|
// Put value into minutes for further processing
|
|
switch(options.dataFormat)
|
|
{
|
|
case 'd':
|
|
value *= options.hoursPerDay;
|
|
// fall-through
|
|
case 'h':
|
|
value *= 60;
|
|
break;
|
|
case 's': // round to full minutes, unless this would give 0, use 1 digit instead
|
|
value = value < 30 ? Math.round(value / 6.0)/10.0 : Math.round(value / 60.0);
|
|
break;
|
|
}
|
|
|
|
// Figure out the best unit for display
|
|
let _unit = options.displayFormat == "d" ? "d" : "h";
|
|
if(options.displayFormat.indexOf('m') > -1 && value < 60)
|
|
{
|
|
_unit = 'm';
|
|
}
|
|
else if(options.displayFormat.indexOf('d') > -1 && value >= (60 * options.hoursPerDay))
|
|
{
|
|
_unit = 'd';
|
|
}
|
|
let out_value = "" + (_unit == 'm' ? value : (Math.round((value / 60.0 / (_unit == 'd' ? options.hoursPerDay : 1)) * 100) / 100));
|
|
|
|
if(out_value === '')
|
|
{
|
|
_unit = '';
|
|
}
|
|
|
|
// use decimal separator from user prefs
|
|
var format = options.number_format || this.egw().preference('number_format');
|
|
var sep = format ? format[0] : '.';
|
|
if(format && sep && sep != '.')
|
|
{
|
|
out_value = out_value.replace('.', sep);
|
|
}
|
|
|
|
return {value: out_value, unit: _unit};
|
|
}
|
|
|
|
/**
|
|
* Display a time duration (eg: 3 days, 6 hours)
|
|
*
|
|
* If not specified, the time is in assumed to be minutes and will be displayed with a calculated unit
|
|
* but this can be specified with the properties.
|
|
*/
|
|
@customElement("et2-date-duration")
|
|
export class Et2DateDuration extends Et2InputWidget(LitElement)
|
|
{
|
|
static get styles()
|
|
{
|
|
return [
|
|
...super.styles,
|
|
shoelace,
|
|
...dateStyles,
|
|
css`
|
|
.form-field__group-two {
|
|
max-width: 100%;
|
|
}
|
|
|
|
.form-control-input {
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
align-items: baseline;
|
|
}
|
|
|
|
.input-group__after {
|
|
display: contents;
|
|
margin-inline-start: var(--sl-input-spacing-medium);
|
|
}
|
|
|
|
sl-select {
|
|
color: var(--input-text-color);
|
|
border-left: 1px solid var(--input-border-color);
|
|
flex: 2 1 auto;
|
|
min-width: min-content;
|
|
width: 8em;
|
|
}
|
|
|
|
sl-select::part(control) {
|
|
border-top-left-radius: 0px;
|
|
border-bottom-left-radius: 0px;
|
|
}
|
|
|
|
.duration__input {
|
|
flex: 1 1 auto;
|
|
width: min-content;
|
|
min-width: 5em;
|
|
margin-right: -2px;
|
|
}
|
|
|
|
|
|
.duration__input:not(:first-child)::part(base) {
|
|
border-top-left-radius: 0px;
|
|
border-bottom-left-radius: 0px;
|
|
}
|
|
.duration__input:not(:last-child)::part(base) {
|
|
border-right: none;
|
|
border-top-right-radius: 0px;
|
|
border-bottom-right-radius: 0px;
|
|
}
|
|
|
|
|
|
`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Data format
|
|
*
|
|
* Units to read/store the data. 'd' = days (float), 'h' = hours (float), 'm' = minutes (int), 's' = seconds (int).
|
|
*/
|
|
@property({type: String, reflect: true})
|
|
dataFormat = "m";
|
|
/**
|
|
* Display format
|
|
*
|
|
* Permitted units for displaying the data.
|
|
* 'd' = days, 'h' = hours, 'm' = minutes, 's' = seconds. Use combinations to give a choice.
|
|
* Default is 'dhm' = days or hours with selectbox.
|
|
*/
|
|
@property({type: String, reflect: true})
|
|
set displayFormat(value : string)
|
|
{
|
|
this.__displayFormat = "";
|
|
if(!value)
|
|
{
|
|
// Don't allow an empty value, but don't throw a real error
|
|
console.warn("Invalid displayFormat ", value, this);
|
|
value = "dhm";
|
|
}
|
|
// Display format must be in decreasing size order (dhms) or the calculations
|
|
// don't work out nicely
|
|
for(let f of Object.keys(Et2DateDuration.time_formats))
|
|
{
|
|
if(value.indexOf(f) !== -1)
|
|
{
|
|
this.__displayFormat += f;
|
|
}
|
|
}
|
|
}
|
|
|
|
get displayFormat()
|
|
{
|
|
return this.__displayFormat;
|
|
}
|
|
|
|
/**
|
|
* Select unit or input per unit
|
|
*
|
|
* Display a unit-selection for multiple units, or an input field per unit.
|
|
* Default is true
|
|
*/
|
|
@property({type: Boolean, reflect: true})
|
|
selectUnit = true;
|
|
|
|
/**
|
|
* Percent allowed
|
|
*
|
|
* Allows to enter a percentage instead of numbers
|
|
*/
|
|
@property({type: Boolean})
|
|
percentAllowed = false;
|
|
|
|
/**
|
|
* Hours per day
|
|
*
|
|
* Number of hours in a day, used for converting between hours and (working) days.
|
|
* Default 8
|
|
*/
|
|
@property({type: Number, reflect: true})
|
|
hoursPerDay = 8;
|
|
|
|
/**
|
|
* 0 or empty
|
|
*
|
|
* Should the widget differ between 0 and empty, which get then returned as NULL
|
|
* Default false, empty is considered as 0
|
|
*/
|
|
@property({type: Boolean, reflect: true})
|
|
emptyNot0 = false;
|
|
|
|
/**
|
|
* Short labels
|
|
*
|
|
* use d/h/m instead of day/hour/minute
|
|
*/
|
|
@property({type: Boolean, reflect: true})
|
|
shortLabels = false;
|
|
|
|
/**
|
|
* Step limit
|
|
*
|
|
* Works with the min and max attributes to limit the increments at which a numeric or date-time value can be set.
|
|
*/
|
|
@property({type: Number, reflect: true})
|
|
step = 1;
|
|
|
|
protected static time_formats = {d: "d", h: "h", m: "m", s: "s"};
|
|
protected _display = {value: "", unit: ""};
|
|
|
|
constructor()
|
|
{
|
|
super();
|
|
|
|
// Property defaults
|
|
this.displayFormat = "dhm";
|
|
|
|
this.formatter = formatDuration;
|
|
}
|
|
|
|
async getUpdateComplete()
|
|
{
|
|
const result = await super.getUpdateComplete();
|
|
|
|
// Format select does not start with value, needs an update
|
|
this._formatNode?.requestUpdate("value");
|
|
|
|
return result;
|
|
}
|
|
|
|
transformAttributes(attrs)
|
|
{
|
|
// Clean formats, but avoid things that need to be expanded like $cont[displayFormat]
|
|
const check = new RegExp('[\$\@' + Object.keys(Et2DateDuration.time_formats).join('') + ']');
|
|
for(let attr in ["displayFormat", "dataFormat"])
|
|
{
|
|
if(typeof attrs[attrs] === 'string' && !check.test(attrs[attr]))
|
|
{
|
|
console.warn("Invalid format for " + attr + "'" + attrs[attr] + "'", this);
|
|
attrs[attr] = attrs[attr].replace(/[^dhms]/g, '');
|
|
}
|
|
}
|
|
|
|
super.transformAttributes(attrs);
|
|
}
|
|
|
|
get value() : string
|
|
{
|
|
let value = 0;
|
|
|
|
if(!this.selectUnit)
|
|
{
|
|
for(let i = this._durationNode.length; --i >= 0;)
|
|
{
|
|
value += this._durationNode[i].valueAsNumber * this._unit2seconds(this._durationNode[i].name);
|
|
}
|
|
if(this.dataFormat !== 's')
|
|
{
|
|
value /= this._unit2seconds(this.dataFormat);
|
|
}
|
|
return "" + (this.dataFormat === 'm' ? Math.round(value) : value);
|
|
}
|
|
|
|
let val = this._durationNode.length ? this._durationNode[0].valueAsNumber : '';
|
|
if(val === '' || isNaN(val))
|
|
{
|
|
return this.emptyNot0 ? '' : "0";
|
|
}
|
|
value = parseFloat(val);
|
|
|
|
// Put value into minutes for further processing
|
|
switch(this._formatNode && this._formatNode.value ? this._formatNode.value : this.displayFormat)
|
|
{
|
|
case 'd':
|
|
value *= this.hoursPerDay;
|
|
// fall-through
|
|
case 'h':
|
|
value *= 60;
|
|
break;
|
|
}
|
|
// Minutes should be an integer. Floating point math.
|
|
if(this.dataFormat !== 's')
|
|
{
|
|
value = Math.round(value);
|
|
}
|
|
switch(this.dataFormat)
|
|
{
|
|
case 'd':
|
|
value /= this.hoursPerDay;
|
|
// fall-through
|
|
case 'h':
|
|
value /= 60.0;
|
|
break;
|
|
case 's':
|
|
value = Math.round(value * 60.0);
|
|
break;
|
|
}
|
|
|
|
return "" + value;
|
|
}
|
|
|
|
set value(_value)
|
|
{
|
|
this._display = this._convert_to_display(this.emptyNot0 && ""+_value === "" ? '' : parseFloat(_value));
|
|
// Update values
|
|
(typeof this._display.value == "string" ? this._display.value.split(":") : [this._display.value])
|
|
.forEach((v, index) =>
|
|
{
|
|
if(!this._durationNode[index])
|
|
{
|
|
return;
|
|
}
|
|
const old = this._durationNode[index]?.value;
|
|
this._durationNode[index].value = v;
|
|
this._durationNode[index].requestUpdate("value", old);
|
|
});
|
|
this.requestUpdate();
|
|
}
|
|
|
|
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._inputTemplate()}
|
|
${this._formatTemplate()}
|
|
</div>
|
|
${helpTemplate}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Converts the value in data format into value in display format.
|
|
*
|
|
* @param _value int/float Data in data format
|
|
*
|
|
* @return Object {value: Value in display format, unit: unit for display}
|
|
*/
|
|
protected _convert_to_display(_value)
|
|
{
|
|
if(!this.selectUnit)
|
|
{
|
|
let vals = [];
|
|
for(let i = 0; i < this.displayFormat.length; ++i)
|
|
{
|
|
let unit = this.displayFormat[i];
|
|
let val = this._unit_from_value(_value, unit, i === 0, {
|
|
hoursPerDay: this.hoursPerDay,
|
|
dataFormat: this.dataFormat
|
|
});
|
|
if(unit === 's' || unit === 'm' || unit === 'h' && this.displayFormat[0] === 'd')
|
|
{
|
|
vals.push(sprintf('%02d', val));
|
|
}
|
|
else
|
|
{
|
|
vals.push(val);
|
|
}
|
|
}
|
|
return {value: vals.join(':'), unit: ''};
|
|
}
|
|
if(_value)
|
|
{
|
|
// Put value into minutes for further processing
|
|
switch(this.dataFormat)
|
|
{
|
|
case 'd':
|
|
_value *= this.hoursPerDay;
|
|
// fall-through
|
|
case 'h':
|
|
_value *= 60;
|
|
break;
|
|
case 's':
|
|
_value /= 60.0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Figure out best unit for display
|
|
var _unit = this.displayFormat == "d" ? "d" : "h";
|
|
if(this.displayFormat.indexOf('m') > -1 && _value && _value < 60)
|
|
{
|
|
_unit = 'm';
|
|
}
|
|
else if(this.displayFormat.indexOf('d') > -1 && _value >= 60 * this.hoursPerDay)
|
|
{
|
|
_unit = 'd';
|
|
}
|
|
_value = this.emptyNot0 && _value === '' || !this.emptyNot0 && !_value ? '' :
|
|
(_unit == 'm' ? parseInt(_value) : (Math.round((_value / 60.0 / (_unit == 'd' ? this.hoursPerDay : 1)) * 100) / 100));
|
|
|
|
if(_value === '')
|
|
{
|
|
_unit = '';
|
|
}
|
|
|
|
// use decimal separator from user prefs
|
|
var format = this.egw().preference('number_format');
|
|
var sep = format ? format[0] : '.';
|
|
if(typeof _value == 'string' && format && sep && sep != '.')
|
|
{
|
|
_value = _value.replace('.', sep);
|
|
}
|
|
|
|
return {value: _value, unit: _unit};
|
|
}
|
|
|
|
private _unit2seconds(_unit)
|
|
{
|
|
switch(_unit)
|
|
{
|
|
case 's':
|
|
return 1;
|
|
case 'm':
|
|
return 60;
|
|
case 'h':
|
|
return 3600;
|
|
case 'd':
|
|
return 3600 * this.hoursPerDay;
|
|
}
|
|
}
|
|
|
|
private _unit_from_value(_value, _unit, _highest, options)
|
|
{
|
|
_value *= this._unit2seconds(options.dataFormat);
|
|
// get value for given _unit
|
|
switch(_unit)
|
|
{
|
|
case 's':
|
|
return _highest ? _value : _value % 60;
|
|
case 'm':
|
|
_value = Math.floor(_value / 60);
|
|
return _highest ? _value : _value % 60;
|
|
case 'h':
|
|
_value = Math.floor(_value / 3600);
|
|
return _highest ? _value : _value % options.hoursPerDay;
|
|
case 'd':
|
|
return Math.floor(_value / 3600 / options.hoursPerDay);
|
|
}
|
|
}
|
|
|
|
handleInputChange(event)
|
|
{
|
|
// Rather than error, roll over when possible
|
|
const changed = event.target;
|
|
if(typeof changed.max == "number" && parseInt(changed.value) >= changed.max)
|
|
{
|
|
const next = changed.previousElementSibling;
|
|
if(next)
|
|
{
|
|
next.value = next.valueAsNumber + Math.floor(changed.valueAsNumber / changed.max);
|
|
changed.value = changed.valueAsNumber % changed.max;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the needed number inputs according to selectUnit & displayFormat properties
|
|
*
|
|
* @returns {any}
|
|
* @protected
|
|
*/
|
|
protected _inputTemplate()
|
|
{
|
|
let inputs = [];
|
|
let value = typeof this._display.value === "number" ? this._display.value : (this._display.value.split(":") || []);
|
|
let count = this.selectUnit ? 1 : this.displayFormat.length;
|
|
for(let i = count; i > 0; --i)
|
|
{
|
|
let input = {
|
|
name: "",
|
|
title: "",
|
|
value: typeof value == "number" ? value : ((this.selectUnit ? value.pop() : value[i]) || ""),
|
|
min: undefined,
|
|
max: undefined,
|
|
precision: count == 1 ? 2 : 0
|
|
};
|
|
if(!this.selectUnit)
|
|
{
|
|
input.min = 0;
|
|
input.name = this.displayFormat[this.displayFormat.length - i];
|
|
// @ts-ignore - it doesn't like that it may have been an array
|
|
input.value = <string>(value[this.displayFormat.length - i]);
|
|
switch(this.displayFormat[this.displayFormat.length - i])
|
|
{
|
|
case 's':
|
|
input.max = 60;
|
|
input.title = this.egw().lang('Seconds');
|
|
break;
|
|
case 'm':
|
|
input.max = 60;
|
|
input.title = this.egw().lang('Minutes');
|
|
break;
|
|
case 'h':
|
|
input.max = 24;
|
|
input.title = this.egw().lang('Hours');
|
|
break;
|
|
case 'd':
|
|
input.title = this.egw().lang('Days');
|
|
break;
|
|
}
|
|
}
|
|
inputs.push(input);
|
|
}
|
|
return html`${inputs.map((input : any) =>
|
|
html`
|
|
<et2-number part="${"duration__" + input.name}" class="duration__input"
|
|
exportparts="scroll:scroll,scrollbutton:scrollbutton"
|
|
name=${input.name}
|
|
min=${typeof input.min === "number" ? input.min : nothing}
|
|
max=${typeof input.max === "number" ? input.max : nothing}
|
|
step=${this.step}
|
|
precision=${typeof input.precision === "number" ? input.precision : nothing}
|
|
title=${input.title || nothing}
|
|
value=${live(input.value)}
|
|
@sl-change=${this.handleInputChange}
|
|
></et2-number>`
|
|
)}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Generate the format selector according to the selectUnit and displayFormat properties
|
|
*
|
|
* @returns {any}
|
|
* @protected
|
|
*/
|
|
protected _formatTemplate()
|
|
{
|
|
// If no formats or only 1 format, no need for a selector
|
|
if(!this.displayFormat || this.displayFormat.length < 1 ||
|
|
(!this.selectUnit && this.displayFormat.length > 1))
|
|
{
|
|
return html``;
|
|
}
|
|
// Get translations
|
|
this.time_formats = this.time_formats || {
|
|
d: this.shortLabels ? this.egw().lang("d") : this.egw().lang("Days"),
|
|
h: this.shortLabels ? this.egw().lang("h") : this.egw().lang("Hours"),
|
|
m: this.shortLabels ? this.egw().lang("m") : this.egw().lang("Minutes"),
|
|
s: this.shortLabels ? this.egw().lang("s") : this.egw().lang("Seconds")
|
|
};
|
|
// It would be nice to use an et2-select here, but something goes weird with the styling
|
|
const current = this._display.unit || this.displayFormat[0];
|
|
return html`
|
|
<sl-select value="${current}">
|
|
${[...this.displayFormat].map((format : string) =>
|
|
html`
|
|
<sl-option
|
|
value=${format}
|
|
.selected=${(format == current)}
|
|
>
|
|
${this.time_formats[format]}
|
|
</sl-option>`
|
|
)}
|
|
</sl-select>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* @returns {HTMLInputElement}
|
|
*/
|
|
get _durationNode() : HTMLInputElement[]
|
|
{
|
|
return this.shadowRoot ? this.shadowRoot.querySelectorAll(".duration__input") || [] : [];
|
|
}
|
|
|
|
|
|
/**
|
|
* @returns {HTMLSelectElement}
|
|
*/
|
|
get _formatNode() : HTMLSelectElement
|
|
{
|
|
return this.shadowRoot ? this.shadowRoot.querySelector("sl-select") : null;
|
|
}
|
|
} |