diff --git a/api/js/etemplate/Et2Date/Et2Date.ts b/api/js/etemplate/Et2Date/Et2Date.ts index 726360533d..0d5c94efcb 100644 --- a/api/js/etemplate/Et2Date/Et2Date.ts +++ b/api/js/etemplate/Et2Date/Et2Date.ts @@ -390,7 +390,7 @@ export class Et2Date extends Et2InputWidget(FormControlMixin(ValidateMixin(LitFl * @see https://flatpickr.js.org/options/ * @returns {any} */ - protected getOptions() + getOptions() { let options = super.getOptions(); diff --git a/api/js/etemplate/Et2Date/Et2DateRange.ts b/api/js/etemplate/Et2Date/Et2DateRange.ts new file mode 100644 index 0000000000..ae928b7ab8 --- /dev/null +++ b/api/js/etemplate/Et2Date/Et2DateRange.ts @@ -0,0 +1,356 @@ +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import {dateStyles} from "./DateStyles"; +import {css, html, LitElement, repeat, TemplateResult} from "@lion/core"; +import {egw} from "../../jsapi/egw_global"; +import {Et2Select} from "../Et2Select/Et2Select"; +import {Et2widgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin"; +import {SelectOption} from "../Et2Select/FindSelectOptions"; +import "flatpickr/dist/plugins/rangePlugin.js"; +import {Et2Date} from "./Et2Date"; + +export class Et2DateRange extends Et2widgetWithSelectMixin(Et2InputWidget(LitElement)) +{ + static get styles() + { + return [ + ...super.styles, + dateStyles, + css` + :host { + width: auto; + } + `, + ]; + } + + static get properties() + { + return { + ...super.properties, + /** + * An object with keys 'from' and 'to' for absolute ranges, or a relative range string + */ + value: {type: Object}, + /** + * Is the date range relative (this week) or absolute (2016-02-15 - 2016-02-21). This will affect the value returned. + */ + relative: {type: Boolean}, + + placeholder: {type: String} + } + } + + // Class Constants + static readonly relative_dates = [ + // Start and end are relative offsets, see et2_date.set_min() + // or Date objects + { + value: 'Today', + label: 'Today', + from(date) {return date;}, + to(date) {return date;} + }, + { + label: 'Yesterday', + value: 'Yesterday', + from(date) + { + date.setUTCDate(date.getUTCDate() - 1); + return date; + }, + to: '' + }, + { + label: 'This week', + value: 'This week', + from(date) {return egw.week_start(date);}, + to(date) + { + date.setUTCDate(date.getUTCDate() + 6); + return date; + } + }, + { + label: 'Last week', + value: 'Last week', + from(date) + { + var d = egw.week_start(date); + d.setUTCDate(d.getUTCDate() - 7); + return d; + }, + to(date) + { + date.setUTCDate(date.getUTCDate() + 6); + return date; + } + }, + { + label: 'This month', + value: 'This month', + from(date) + { + date.setUTCDate(1); + return date; + }, + to(date) + { + date.setUTCMonth(date.getUTCMonth() + 1); + date.setUTCDate(0); + return date; + } + }, + { + label: 'Last month', + value: 'Last month', + from(date) + { + date.setUTCMonth(date.getUTCMonth() - 1); + date.setUTCDate(1); + return date; + }, + to(date) + { + date.setUTCMonth(date.getUTCMonth() + 1); + date.setUTCDate(0); + return date; + } + }, + { + label: 'Last 3 months', + value: 'Last 3 months', + from(date) + { + date.setUTCMonth(date.getUTCMonth() - 2); + date.setUTCDate(1); + return date; + }, + to(date) + { + date.setUTCMonth(date.getUTCMonth() + 3); + date.setUTCDate(0); + return date; + } + }, + /* + 'This quarter'=> array(0,0,0,0, 0,0,0,0), // Just a marker, needs special handling + 'Last quarter'=> array(0,-4,0,0, 0,-4,0,0), // Just a marker + */ + { + label: 'This year', + value: 'This year', + from(d) + { + d.setUTCMonth(0); + d.setUTCDate(1); + return d; + }, + to(d) + { + d.setUTCMonth(11); + d.setUTCDate(31); + return d; + } + }, + { + label: 'Last year', + value: 'Last year', + from(d) + { + d.setUTCMonth(0); + d.setUTCDate(1); + d.setUTCYear(d.getUTCYear() - 1); + return d; + }, + to(d) + { + d.setUTCMonth(11); + d.setUTCDate(31); + d.setUTCYear(d.getUTCYear() - 1); + return d; + } + } + ]; + + + connectedCallback() + { + super.connectedCallback(); + this.updateComplete.then(() => + { + if(!this.relative) + { + let options = this._fromNode.getOptions(); + //@ts-ignore rangePlugin is there, really + options.plugins.push(rangePlugin({input: this._toNode.findInputField()})); + } + }) + } + + render() + { + if(this.relative) + { + return this.relativeTemplate(); + } + return this.absoluteTemplate(); + } + + protected relativeTemplate() + { + return html` + + ${repeat(this.select_options, (d) => d.value, (o) => this._optionTemplate(o))} + + `; + } + + _optionTemplate(option : SelectOption) : TemplateResult + { + let icon = option.icon ? html` + ` : ""; + + // Tag used must match this.optionTag, but you can't use the variable directly. + // Pass option along so SearchMixin can grab it if needed + return html` + + ${icon} + ${this.egw().lang(option.label)} + `; + } + + protected absoluteTemplate() + { + return html` + + `; + } + + get select_options() : SelectOption[] + { + // @ts-ignore + const options = super.select_options || []; + // make sure result is unique + + return [...new Map([...options, ...(Et2DateRange.relative_dates || [])].map(item => + [item.value, item])).values()]; + + } + + get value() : string | string[] | { from : string; to : string } + { + return this.relative ? + (this._selectNode?.value || this.__value) : + {from: this._fromNode?.value, to: this._toNode?.value}; + } + + set value(new_value : string | string[] | { from : string, to : string }) + { + let oldValue = this.value; + if(!new_value || new_value == null || typeof new_value == "undefined") + { + this._selectNode.value = ''; + this._fromNode.value = null; + this._toNode.value = null; + } + // Relative + else if(new_value && typeof new_value === 'string') + { + this._set_relative_value(new_value); + } + else if(new_value && typeof new_value.from === 'undefined' && new_value[0]) + { + new_value = { + from: new_value[0], + to: new_value[1] || '' + }; + } + if(new_value && new_value.from && new_value.to) + { + this._fromNode._instance.setDate([new_value.from, new_value.to], false); + } + } + + _set_relative_value(_value) + { + + // Show description + this.__value = _value; + /* + let tempDate = new Date(); + let today = new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate(), 0, -tempDate.getTimezoneOffset(), 0); + + // Use strings to avoid references + this._fromNode.value = today.toJSON(); + this._toNode.value = today.toJSON(); + + let relative = null; + for(var index in Et2DateRange.relative_dates) + { + if(Et2DateRange.relative_dates[index].value === _value) + { + relative = Et2DateRange.relative_dates[index]; + break; + } + } + if(relative) + { + let dates = ["from", "to"]; + let value = today.toJSON(); + for(let i = 0; i < dates.length; i++) + { + let date = dates[i]; + if(typeof relative[date] == "function") + { + value = relative[date](new Date(value)); + } + else + { + value = this[date]._relativeDate(relative[date]); + } + this["_" + date + "Node"].value = value; + } + } + + */ + } + + /** + * Get the node where we're putting the options + * + * @returns {HTMLElement} + */ + get _optionTargetNode() : HTMLElement + { + return this._selectNode; + } + + /** + * Render select_options as child DOM Nodes + * Overridden here because we can do it in the normal way (render()) + * @protected + */ + protected _renderOptions() + {} + + get _selectNode() : Et2Select + { + return this.shadowRoot?.querySelector("[part='relative']"); + } + + get _fromNode() : Et2Date + { + return this.shadowRoot?.querySelector("[part='from']") + } + + get _toNode() : Et2Date + { + return this.shadowRoot?.querySelector("[part='to']") + } +} + +customElements.define("et2-date-range", Et2DateRange); \ No newline at end of file diff --git a/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts b/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts index e4c50d2e04..ae5c88c188 100644 --- a/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts +++ b/api/js/etemplate/Et2InputWidget/test/InputBasicTests.ts @@ -101,5 +101,34 @@ export function inputBasicTests(before : Function, test_value : string, value_se // widget returns what we gave it assert.equal(element.get_value(), test_value); }); - }) + }); + + describe("Required", () => + { + beforeEach(async() => + { + element = await before(); + }); + + // This is just visually comparing for a difference, no deep inspection + it("looks different when required") + + /* + Not yet working attempt to have playwright compare visually + + const pre = await page.locator(element.localName).screenshot(); + + element.required = true; + + // wait for asychronous changes to the DOM + await elementUpdated(element); + + + const post = await page.locator(element.localName).screenshot(); + + expect(post).toMatchSnapshot(pre); + + */ + + }); } \ No newline at end of file diff --git a/api/src/Etemplate/Widget/Date.php b/api/src/Etemplate/Widget/Date.php index e4b2568cb5..799f06767e 100644 --- a/api/src/Etemplate/Widget/Date.php +++ b/api/src/Etemplate/Widget/Date.php @@ -70,7 +70,14 @@ class Date extends Transformer $form_name = self::form_name($cname, $this->id, $expand); $value =& self::get_array(self::$request->content, $form_name, false, true); - if($this->type != 'date-duration' && $value) + if($this->type == 'et2-date-range') + { + $value = $this->attrs['relative'] || $this->getElementAttribute($form_name, 'relative') ? + $value : + ['from' => is_array($value) && array_key_exists('from', $value) ? $this->format_date($value['from']) : '', + 'to' => is_array($value) && array_key_exists('from', $value) ? $this->format_date($value['to']) : '']; + } + elseif($this->type != 'date-duration' && $value) { $value = $this->format_date($value); } @@ -165,6 +172,10 @@ class Date extends Transformer $value = self::get_array($content, $form_name); $valid =& self::get_array($validated, $form_name, true); + if($value && $this->type == 'et2-date-range') + { + return $this->validateRange($form_name, $value, $valid); + } if($value && $this->type !== 'date-duration') { try @@ -282,8 +293,29 @@ class Date extends Transformer //error_log("$this : ($valid)" . Api\DateTime::to($valid)); } } + + protected function validateRange($form_name, $value, &$valid) + { + if($this->attrs['relative'] || $this->getElementAttribute($form_name, "relative")) + { + $valid = "" . $value; + return; + } + foreach(['from', 'to'] as $field) + { + if(!$value[$field]) + { + continue; + } + if(substr($value[$field], -1) === 'Z') + { + $value[$field] = substr($value[$field], 0, -1); + } + $valid[$field] = new Api\DateTime($value[$field]); + } + } } \EGroupware\Api\Etemplate\Widget::registerWidget(__NAMESPACE__ . '\\Date', - array('et2-date', 'et2-date-time', 'time_or_date') + array('et2-date', 'et2-date-time', 'et2-date-range', 'time_or_date') ); \ No newline at end of file