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