Skip to content

Commit 50fd58d

Browse files
feat(ui5-dynamic-date-range): introduce last/next X options (#11621)
Introducing new **Last/Next** options for the `<ui5-dynamic-date-range>` component. These options allow relative date selections like "Last 7 Days", "Next 3 Months", etc. The implementation supports both individual time units and flexible grouping of multiple units under a single option. **The New Options That Are Added (Inclusive):** - `LASTDAYS`, `LASTWEEKS`, `LASTMONTHS`, `LASTQUARTERS`, `LASTYEARS` - Select periods going backward from today - `NEXTDAYS`, `NEXTWEEKS`, `NEXTMONTHS`, `NEXTQUARTERS`, `NEXTYEARS` - Select periods going forward from today **Sample usage with individual options:** ```html <ui5-dynamic-date-range options="TODAY, LASTDAYS, NEXTWEEKS"></ui5-dynamic-date-range> ``` **Sample usage with grouped options:** ```html <ui5-dynamic-date-range options="LASTDAYS, LASTWEEKS, LASTMONTHS"></ui5-dynamic-date-range> <!-- Shows: Number input + Unit selector (Days/Weeks/Months) --> ``` - `DateRange.ts`: Fixed date range for backwards selections
1 parent 684f1fc commit 50fd58d

23 files changed

+1406
-201
lines changed

packages/main/cypress/specs/DynamicDateRange.cy.tsx

Lines changed: 384 additions & 53 deletions
Large diffs are not rendered by default.

packages/main/src/DynamicDateRange.ts

Lines changed: 100 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { isF4, isShow } from "@ui5/webcomponents-base/dist/Keys.js";
1212
import DynamicDateRangeTemplate from "./DynamicDateRangeTemplate.js";
1313
import IconMode from "./types/IconMode.js";
1414
import type Input from "./Input.js";
15+
import type List from "./List.js";
16+
import type ListItem from "./ListItem.js";
1517
import {
1618
DYNAMIC_DATE_RANGE_SELECTED_TEXT,
1719
DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT,
@@ -25,8 +27,6 @@ import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js";
2527
import dynamicDateRangeCss from "./generated/themes/DynamicDateRange.css.js";
2628
import dynamicDateRangePopoverCss from "./generated/themes/DynamicDateRangePopover.css.js";
2729
import ResponsivePopoverCommonCss from "./generated/themes/ResponsivePopoverCommon.css.js";
28-
import type List from "./List.js";
29-
import type ListItem from "./ListItem.js";
3030

3131
type DynamicDateRangeValue = {
3232
/**
@@ -41,7 +41,7 @@ type DynamicDateRangeValue = {
4141
* @default []
4242
* @public
4343
*/
44-
values?: Date[] | number[];
44+
values?: Array<Date> | Array<number>;
4545
}
4646

4747
/**
@@ -61,7 +61,7 @@ type DynamicDateRangeValue = {
6161
* Methods:
6262
* - `format(value: DynamicDateRangeValue): string`: Formats the given dynamic date range value into a string representation.
6363
* - `parse(value: string): DynamicDateRangeValue | undefined`: Parses a string into a dynamic date range value.
64-
* - `toDates(value: DynamicDateRangeValue): Date[]`: Converts a dynamic date range value into an array of `Date` objects.
64+
* - `toDates(value: DynamicDateRangeValue): Array<Date>`: Converts a dynamic date range value into an array of `Date` objects.
6565
* - `handleSelectionChange?(event: CustomEvent): DynamicDateRangeValue | undefined`: (Optional) Handles selection changes in the UI of the dynamic date range option.
6666
* - `isValidString(value: string): boolean`: Validates whether a given string is a valid representation of the dynamic date range value.
6767
*
@@ -74,7 +74,7 @@ interface IDynamicDateRangeOption {
7474
text: string;
7575
format: (value: DynamicDateRangeValue) => string;
7676
parse: (value: string) => DynamicDateRangeValue | undefined;
77-
toDates: (value: DynamicDateRangeValue) => Date[];
77+
toDates: (value: DynamicDateRangeValue) => Array<Date>;
7878
handleSelectionChange?: (event: CustomEvent) => DynamicDateRangeValue | undefined;
7979
template?: JsxTemplate;
8080
isValidString: (value: string) => boolean;
@@ -104,6 +104,16 @@ interface IDynamicDateRangeOption {
104104
* - "TOMORROW" - Represents the next date. An example value is `{ operator: "TOMORROW"}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/Tomorrow.js";`
105105
* - "DATE" - Represents a single date. An example value is `{ operator: "DATE", values: [new Date()]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/SingleDate.js";`
106106
* - "DATERANGE" - Represents a range of dates. An example value is `{ operator: "DATERANGE", values: [new Date(), new Date()]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/DateRange.js";`
107+
* - "LASTDAYS" - Represents Last X Days from today. An example value is `{ operator: "LASTDAYS", values: [2]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js";`
108+
* - "LASTWEEKS" - Represents Last X Weeks from today. An example value is `{ operator: "LASTWEEKS", values: [3]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js";`
109+
* - "LASTMONTHS" - Represents Last X Months from today. An example value is `{ operator: "LASTMONTHS", values: [6]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js";`
110+
* - "LASTQUARTERS" - Represents Last X Quarters from today. An example value is `{ operator: "LASTQUARTERS", values: [2]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js";`
111+
* - "LASTYEARS" - Represents Last X Years from today. An example value is `{ operator: "LASTYEARS", values: [1]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js";`
112+
* - "NEXTDAYS" - Represents Next X Days from today. An example value is `{ operator: "NEXTDAYS", values: [2]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js";`
113+
* - "NEXTWEEKS" - Represents Next X Weeks from today. An example value is `{ operator: "NEXTWEEKS", values: [3]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js";`
114+
* - "NEXTMONTHS" - Represents Next X Months from today. An example value is `{ operator: "NEXTMONTHS", values: [6]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js";`
115+
* - "NEXTQUARTERS" - Represents Next X Quarters from today. An example value is `{ operator: "NEXTQUARTERS", values: [2]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js";`
116+
* - "NEXTYEARS" - Represents Next X Years from today. An example value is `{ operator: "NEXTYEARS", values: [1]}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js";`
107117
*
108118
* ### ES6 Module Import
109119
*
@@ -173,9 +183,9 @@ class DynamicDateRange extends UI5Element {
173183
@property({ type: Object })
174184
currentValue?: DynamicDateRangeValue;
175185

176-
optionsObjects: IDynamicDateRangeOption[] = [];
186+
optionsObjects: Array<IDynamicDateRangeOption> = [];
177187

178-
static optionsClasses: Map<string, new () => IDynamicDateRangeOption> = new Map();
188+
static optionsClasses: Map<string, new (operators?: Array<string>) => IDynamicDateRangeOption> = new Map();
179189

180190
@query("[ui5-input]")
181191
_input?: Input;
@@ -184,31 +194,51 @@ class DynamicDateRange extends UI5Element {
184194
_list?: List;
185195

186196
onBeforeRendering() {
187-
const optionKeys = this.options.split(",").map(option => option.trim());
197+
this.optionsObjects = this._createNormalizedOptions();
198+
this._focusSelectedItem();
199+
}
188200

189-
this.optionsObjects = optionKeys.map(option => {
190-
const OptionClass = DynamicDateRange.getOptionClass(option);
191-
let optionObject;
201+
/**
202+
* Creates and normalizes options from the options string
203+
*/
204+
_createNormalizedOptions(): Array<IDynamicDateRangeOption> {
205+
if (!this.optionsObjects.length) { // initialize options on first use
206+
const optionKeys = this.splitOptions(this.options).filter(Boolean);
207+
const createdOptions: Array<IDynamicDateRangeOption> = [];
208+
const classToOperators = new Map<new(operators?: Array<string>) => IDynamicDateRangeOption, Array<string>>();
209+
210+
// Group operators by their class
211+
optionKeys.forEach(option => {
212+
const OptionClass = DynamicDateRange.getOptionClass(option);
213+
if (OptionClass) {
214+
const operators = classToOperators.get(OptionClass) || [];
215+
operators.push(option);
216+
classToOperators.set(OptionClass, operators);
217+
}
218+
});
192219

193-
if (OptionClass) {
194-
optionObject = new OptionClass();
195-
}
220+
classToOperators.forEach((operators, OptionClass) => {
221+
createdOptions.push(new OptionClass(operators));
222+
});
196223

197-
return optionObject;
198-
}).filter(optionObject => optionObject !== undefined);
224+
return createdOptions;
225+
}
226+
return this.optionsObjects;
227+
}
199228

200-
if (this.value) {
201-
const selectedItem = this._list?.items.find(item => {
202-
const option = this.optionsObjects.find(x => x.operator === this.value?.operator);
203-
return option && item.textContent === option.text;
204-
}) as ListItem;
229+
splitOptions(options: string): Array<string> {
230+
return options.split(",").map(s => s.trim());
231+
}
205232

206-
this._list?.focusItem(selectedItem);
233+
_focusSelectedItem() {
234+
if (!this.value) {
235+
return;
207236
}
208-
}
209237

210-
get _optionsTitles(): Array<string> {
211-
return this.optionsObjects.map(option => option.text);
238+
const listItem = this._list?.items.find(item => (item as ListItem).selected === true);
239+
if (listItem) {
240+
this._list?.focusItem(listItem as ListItem);
241+
}
212242
}
213243

214244
/**
@@ -233,23 +263,37 @@ class DynamicDateRange extends UI5Element {
233263

234264
_selectOption(e: CustomEvent): void {
235265
this._currentOption = this.optionsObjects.find(option => option.text === e.detail.item.textContent);
266+
236267
if (!this._currentOption?.template) {
237268
this.currentValue = this._currentOption?.parse(this._currentOption.text);
238269
this._submitValue();
270+
} else if (!this.currentValue || this.currentValue.operator !== this._currentOption.operator) {
271+
this.currentValue = undefined;
239272
}
240273

241274
if (this._currentOption?.operator === this.value?.operator) {
242275
this.currentValue = this.value;
243276
}
244277
}
245278

246-
getOption(operator: string) {
279+
getOption(operator?: string) {
280+
if (!operator) {
281+
return this._currentOption;
282+
}
283+
247284
const resultOption = this.optionsObjects.find(option => option.operator === operator);
248285

249286
if (!resultOption) {
250287
const OptionClass = DynamicDateRange.getOptionClass(operator);
251288

252289
if (OptionClass) {
290+
const existingOption = this.optionsObjects.find(option => option.constructor === OptionClass);
291+
292+
if (existingOption) {
293+
existingOption.operator = operator;
294+
return existingOption;
295+
}
296+
253297
const optionObject = new OptionClass();
254298
this.optionsObjects.push(optionObject);
255299

@@ -290,23 +334,26 @@ class DynamicDateRange extends UI5Element {
290334
* @param value The option to convert into an array of date ranges
291335
* @returns An array of two `Date` objects representing the start and end dates.
292336
*/
293-
toDates(value: DynamicDateRangeValue): Date[] {
294-
return this.getOption(value.operator)?.toDates(value) as Date[];
337+
toDates(value: DynamicDateRangeValue): Array<Date> {
338+
return this.getOption(value.operator)?.toDates(value) as Array<Date>;
295339
}
296340

297341
get _hasCurrentOptionTemplate(): boolean {
298342
return !!this._currentOption?.template;
299343
}
300344

301345
_submitValue() {
302-
const stringValue = this._currentOption?.format(this.currentValue!) as string;
346+
const valueToSubmit = this.currentValue || { operator: this._currentOption?.operator || "", values: [] };
347+
const displayString = this._currentOption?.format(valueToSubmit) || "";
303348

304349
if (this._input) {
305-
this._input.value = stringValue;
350+
this._input.value = displayString;
306351
}
307352

308-
if (this._currentOption?.isValidString(stringValue)) {
309-
this.value = this.currentValue as DynamicDateRangeValue;
353+
if (!this._currentOption || !valueToSubmit.operator) {
354+
this.value = undefined;
355+
} else if (this._currentOption.isValidString(displayString)) {
356+
this.value = valueToSubmit;
310357
this.fireDecoratorEvent("change");
311358
} else {
312359
this.value = undefined;
@@ -332,15 +379,34 @@ class DynamicDateRange extends UI5Element {
332379
}
333380

334381
get currentValueText() {
335-
if (this.currentValue && this.currentValue.operator === this._currentOption?.operator) {
336-
return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${this._currentOption?.format(this.currentValue)}`;
382+
if (this.currentValue) {
383+
const correctOption = this.getOption(this.currentValue.operator);
384+
if (correctOption) {
385+
const dates = correctOption.toDates(this.currentValue);
386+
const displayValue = { ...this.currentValue, values: dates };
387+
const displayText = correctOption.format(displayValue);
388+
return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${displayText}`;
389+
}
390+
}
391+
392+
if (this._currentOption) {
393+
const emptyValue = { operator: this._currentOption.operator, values: [] };
394+
const displayText = this._currentOption.format(emptyValue);
395+
if (displayText && displayText.trim()) {
396+
return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${displayText}`;
397+
}
337398
}
338399

339400
return DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT);
340401
}
341402

342403
handleSelectionChange(e: CustomEvent) {
343404
this.currentValue = this._currentOption?.handleSelectionChange && this._currentOption?.handleSelectionChange(e) as DynamicDateRangeValue;
405+
406+
// Update _currentOption if the operator changed
407+
if (this.currentValue && this.currentValue.operator !== this._currentOption?.operator) {
408+
this._currentOption = this.getOption(this.currentValue.operator);
409+
}
344410
}
345411

346412
onInputKeyDown(e: KeyboardEvent) {

packages/main/src/DynamicDateRangeInputTemplate.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@ import appointment from "@ui5/webcomponents-icons/dist/appointment-2.js";
55

66
export default function DynamicDateRangeInputTemplate(this: DynamicDateRange) {
77
return (
8-
<div
9-
class="ui5-dynamic-date-range-root"
10-
style={{
11-
width: "100%",
12-
}}
13-
>
8+
<div class="ui5-dynamic-date-range-root">
149
<Input
1510
data-sap-focus-ref
1611
id={`${this._id}-inner`}

packages/main/src/DynamicDateRangePopoverTemplate.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,17 @@ export default function DynamicDateRangePopoverTemplate(this: DynamicDateRange)
4141
selectionMode="Single"
4242
onItemClick={this._selectOption}
4343
>
44-
{this.optionsObjects.map(option => {
45-
return <ListItemStandard
44+
{this.optionsObjects.map(option => (
45+
<ListItemStandard
4646
selected={option.operator === this.value?.operator}
4747
iconEnd={true}
4848
icon={option.icon}
49-
type={!option.template ? ListItemType.Active : ListItemType.Navigation}>
49+
wrappingType="Normal"
50+
type={option.template ? ListItemType.Navigation : ListItemType.Active}
51+
>
5052
{option.text}
51-
</ListItemStandard>;
52-
})}
53+
</ListItemStandard>
54+
))}
5355
</List>
5456
</div>
5557
:

packages/main/src/bundle.esm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,12 @@ import Input from "./Input.js";
7777
import SuggestionItemCustom from "./SuggestionItemCustom.js";
7878
import MultiInput from "./MultiInput.js";
7979
import Label from "./Label.js";
80+
import LastOptions from "./dynamic-date-range-options/LastOptions.js";
8081
import Link from "./Link.js";
8182
import Menu from "./Menu.js";
8283
import MenuItem from "./MenuItem.js";
8384
import MenuSeparator from "./MenuSeparator.js";
85+
import NextOptions from "./dynamic-date-range-options/NextOptions.js";
8486
import MenuItemGroup from "./MenuItemGroup.js";
8587
import Popover from "./Popover.js";
8688
import Panel from "./Panel.js";

packages/main/src/dynamic-date-range-options/DateRange.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import DateRangeRangeTemplate from "./DateRangeTemplate.js";
1+
import DateRangeTemplate from "./DateRangeTemplate.js";
22
import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js";
33
import DateFormat from "@ui5/webcomponents-localization/dist/DateFormat.js";
4+
import UI5Date from "@ui5/webcomponents-localization/dist/dates/UI5Date.js";
45
import type { JsxTemplate } from "@ui5/webcomponents-base/dist/index.js";
56
import {
67
DYNAMIC_DATE_RANGE_DATERANGE_TEXT,
@@ -15,11 +16,11 @@ import DynamicDateRange from "../DynamicDateRange.js";
1516
* @since 2.11.0
1617
*/
1718

18-
class DateRangeRange implements IDynamicDateRangeOption {
19+
class DateRange implements IDynamicDateRangeOption {
1920
template: JsxTemplate;
2021

2122
constructor() {
22-
this.template = DateRangeRangeTemplate;
23+
this.template = DateRangeTemplate;
2324
}
2425

2526
parse(value: string): DynamicDateRangeValue {
@@ -32,9 +33,9 @@ class DateRangeRange implements IDynamicDateRangeOption {
3233
}
3334

3435
format(value: DynamicDateRangeValue) {
35-
const valuesArray = value?.values as Date[];
36+
const valuesArray = value?.values as Array<Date>;
3637

37-
if (!valuesArray || valuesArray.length !== 2) {
38+
if (!valuesArray || valuesArray.length !== 2 || !valuesArray[1]) {
3839
return "";
3940
}
4041

@@ -43,7 +44,7 @@ class DateRangeRange implements IDynamicDateRangeOption {
4344
return formattedValue;
4445
}
4546

46-
toDates(value: DynamicDateRangeValue): Date[] {
47+
toDates(value: DynamicDateRangeValue): Array<Date> {
4748
return dateRangeOptionToDates(value);
4849
}
4950

@@ -60,7 +61,7 @@ class DateRangeRange implements IDynamicDateRangeOption {
6061
}
6162

6263
isValidString(value: string): boolean {
63-
const dates = this.getFormat().parse(value) as Date[];
64+
const dates = this.getFormat().parse(value) as Array<Date>;
6465

6566
if (!dates[0] || !dates[1] || Number.isNaN(dates[0].getTime()) || Number.isNaN(dates[1].getTime())) {
6667
return false;
@@ -83,17 +84,28 @@ class DateRangeRange implements IDynamicDateRangeOption {
8384
currentValue.operator = this.operator;
8485

8586
if (e.detail.selectedDates[0]) {
86-
currentValue.values[0] = new Date(e.detail.selectedDates[0] * 1000);
87+
currentValue.values[0] = UI5Date.getInstance(e.detail.selectedDates[0] * 1000);
8788
}
8889

8990
if (e.detail.selectedDates[1]) {
90-
currentValue.values[1] = new Date(e.detail.selectedDates[1] * 1000);
91+
currentValue.values[1] = UI5Date.getInstance(e.detail.selectedDates[1] * 1000);
92+
}
93+
94+
// Handle backwards date ranges by automatically flipping them
95+
if (currentValue.values.length === 2 && currentValue.values[0] && currentValue.values[1]) {
96+
const startDate = currentValue.values[0] as UI5Date;
97+
const endDate = currentValue.values[1] as UI5Date;
98+
99+
// If start date is after end date, flip them
100+
if (startDate.getTime() > endDate.getTime()) {
101+
currentValue.values = [endDate, startDate];
102+
}
91103
}
92104

93105
return currentValue;
94106
}
95107
}
96108

97-
DynamicDateRange.register("DATERANGE", DateRangeRange);
109+
DynamicDateRange.register("DATERANGE", DateRange);
98110

99-
export default DateRangeRange;
111+
export default DateRange;

packages/main/src/dynamic-date-range-options/DateRangeTemplate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type DynamicDateRange from "../DynamicDateRange.js";
22
import Calendar from "../Calendar.js";
33
import CalendarDateRange from "../CalendarDateRange.js";
44

5-
export default function DateRangeRangeTemplate(this: DynamicDateRange) {
5+
export default function DateRangeTemplate(this: DynamicDateRange) {
66
return (
77
<Calendar onSelectionChange={this.handleSelectionChange} selectionMode="Range">
88
<CalendarDateRange

0 commit comments

Comments
 (0)