/** * 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; /* This is the same as max-width of the number field */ max-width: 7em; 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`