diff --git a/packages/main/cypress/specs/DynamicDateRange.cy.tsx b/packages/main/cypress/specs/DynamicDateRange.cy.tsx index 58e9a9a6f908..24ff954ca576 100644 --- a/packages/main/cypress/specs/DynamicDateRange.cy.tsx +++ b/packages/main/cypress/specs/DynamicDateRange.cy.tsx @@ -1,7 +1,9 @@ -import DynamicDateRange from '../../src/DynamicDateRange.js'; +import DynamicDateRange, { IDynamicDateRangeOption } from '../../src/DynamicDateRange.js'; import SingleDate from '../../src/dynamic-date-range-options/SingleDate.js'; import DateRange from '../../src/dynamic-date-range-options/DateRange.js'; import Today from '../../src/dynamic-date-range-options/Today.js'; +import LastOptions from '../../src/dynamic-date-range-options/LastOptions.js'; +import NextOptions from '../../src/dynamic-date-range-options/NextOptions.js'; describe('DynamicDateRange Component', () => { beforeEach(() => { @@ -11,68 +13,147 @@ describe('DynamicDateRange Component', () => { }); it('renders the DynamicDateRange component', () => { - cy.get('[ui5-dynamic-date-range]').as("ddr"); - cy.get("@ddr").shadow().find('[ui5-input]').as("input"); - - cy.get("@input").should('exist'); - - cy.get("@input").find('[ui5-icon]').as("icon"); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); - cy.get("@icon").should('have.attr', 'name', 'appointment-2'); + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .should('exist'); + + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .should('have.attr', 'name', 'appointment-2'); }); it('displays all options correctly', () => { - const mockOptions = [ + const mockOptions: Array = [ new Today(), new SingleDate(), new DateRange() ]; - cy.get('[ui5-dynamic-date-range]').as("ddr"); - cy.get("@ddr").shadow().find('[ui5-input]').as("input"); - cy.get("@input").should('exist'); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .should('exist'); + + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); // Open the picker + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .should('exist'); + + cy.get("@popover") + .find("[ui5-list]") + .as("list"); - cy.get("@input").find('[ui5-icon]').as("icon"); - cy.get("@icon").realClick(); // Open the picker + cy.get('@list') + .find("[ui5-li]") + .as("listItems"); - cy.get("@ddr").shadow().find("[ui5-responsive-popover]").as("popover"); - cy.get("@popover").should('exist'); - cy.get("@popover").find("[ui5-list]").as("list"); - cy.get('@list').find("[ui5-li]").as("listItems"); - cy.get("@listItems").should('have.length', mockOptions.length); + cy.get("@listItems") + .should('have.length', mockOptions.length); mockOptions.forEach((option, index) => { - cy.get('@listItems').eq(index).should('contain.text', option.text); + cy.get('@listItems') + .eq(index) + .should('contain.text', option.text); }); }); it('selects an option and updates the current value', () => { - cy.get('[ui5-dynamic-date-range]').as("ddr"); - cy.get("@ddr").shadow().find('[ui5-input]').as("input"); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .should('exist'); + + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); // Open the picker + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .should('exist'); - cy.get("@input").should('exist'); + cy.get("@popover") + .find("[ui5-list]") + .as("list"); - cy.get("@input").find('[ui5-icon]').as("icon"); - cy.get("@icon").realClick(); // Open the picker + cy.get('@list') + .find("[ui5-li]") + .as("listItems"); - cy.get("@ddr").shadow().find("[ui5-responsive-popover]").as("popover"); - cy.get("@popover").should('exist'); - cy.get("@popover").find("[ui5-list]").as("list"); - cy.get('@list').find("[ui5-li]").as("listItems"); - cy.get("@listItems").contains('Today').realClick(); + cy.get("@listItems") + .contains('Today') + .realClick(); - cy.get("@input").should('have.value', 'Today'); + cy.get("@input") + .should('have.value', 'Today'); }); it('handles selection change correctly', () => { - cy.get('[ui5-dynamic-date-range]').as("ddr"); - cy.get("@ddr").shadow().find('[ui5-input]').as("input"); - - cy.get("@input").should('exist'); - cy.get("@input").realClick(); - cy.get("@input").realType("Today"); - cy.get("@input").realPress("Enter"); - cy.get("@input").should('have.value', 'Today'); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .shadow() + .find("input") + .as("innerInput"); + + cy.get("@input") + .should('exist'); + + cy.get("@innerInput") + .realClick(); + + cy.get("@innerInput") + .realType("Today"); + + cy.get("@innerInput") + .realPress("Enter"); + + cy.get("@innerInput") + .should('have.value', 'Today'); }); // Check why it fails remotely @@ -194,27 +275,277 @@ describe('DynamicDateRange Component', () => { }); it('writes a date in the input and verifies it is selected in the calendar for the Date option', () => { - cy.get('[ui5-dynamic-date-range]').as("ddr"); - cy.get("@ddr").shadow().find('[ui5-input]').as("input"); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); - cy.get("@input").should('exist'); + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); - cy.get("@input").shadow().find("input").clear().realType('May 15, 2025'); + cy.get("@input") + .shadow() + .find("input") + .clear() + .realType('May 15, 2025'); cy.realPress("Enter"); - cy.get("@input").find('[ui5-icon]').as("icon"); - cy.get("@icon").realClick(); + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .should('exist'); + + cy.get("@popover") + .find("[ui5-list]") + .as("list"); + + cy.get("@list") + .find("[ui5-li]") + .contains('Date') + .realClick(); + + cy.get("@popover") + .find("[ui5-calendar]") + .as("calendar"); + + cy.get("@calendar") + .should('exist'); + + cy.get("@calendar") + .shadow() + .find("ui5-daypicker") + .as("dayPicker"); + + cy.get("@dayPicker") + .shadow() + .find("div[data-sap-timestamp='1747267200']") + .should('have.class', 'ui5-dp-item--selected'); // Timestamp for May 15, 2025 + }); +}); + +describe('DynamicDateRange Last/Next Options', () => { + beforeEach(() => { + cy.mount( + + ); + }); + + it('selects Last X Days option with custom number input', () => { + new LastOptions(); + new NextOptions(); - cy.get("@ddr").shadow().find("[ui5-responsive-popover]").as("popover"); - cy.get("@popover").should('exist'); + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .shadow() + .find("input") + .as("innerInput"); + + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .should('exist'); + + cy.get("@popover") + .find("[ui5-list]") + .as("list"); + + cy.get("@list") + .find("[ui5-li]") + .as("listItems"); + + cy.get("@listItems") + .should('have.length', 2); // Since we unified the options, we only have 2 options + + // Select the first option (Last X Days / Months) + cy.get("@listItems") + .first() + .realClick(); + + cy.get("@popover") + .find("[slot='header']") + .should('contain.text', 'Last X'); + + cy.get("@popover") + .find("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .should('exist'); + + cy.get("@stepInput") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .as("stepInputInner"); + + cy.get("@stepInputInner") + .clear() + .realType('7'); + + cy.get("@popover") + .find("[ui5-button][design='Emphasized']") + .as("submitButton"); + + cy.get("@submitButton") + .realClick(); + + cy.get("@innerInput") + .should('have.value', 'Last 7 Days'); + }); + + it('handles Next X Weeks option and verifies date range calculation', () => { + new LastOptions(); + new NextOptions(); + + cy.window().then((win) => { + cy.stub(win.Date, 'now').returns(new Date(2025, 5, 15).getTime()); // June 15, 2025 + }); + + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .shadow() + .find("input") + .as("innerInput"); + + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .find("[ui5-list]") + .as("list"); + + cy.get("@list") + .find("[ui5-li]") + .as("listItems"); + + cy.get("@listItems") + .last() + .realClick(); + + cy.get("@popover") + .find("[slot='header']") + .should('contain.text', 'Next X'); + + cy.get("@popover") + .find("[ui5-step-input]") + .as("stepInput"); + + cy.get("@stepInput") + .shadow() + .find("[ui5-input]") + .shadow() + .find("input") + .as("stepInputInner"); + + cy.get("@stepInputInner") + .clear() + .realType('3'); + + cy.get("@stepInputInner") + .realPress("Enter"); + + cy.get("@popover") + .find(".ui5-ddr-current-value") + .should('contain.text', 'Selected:'); + + cy.get("@popover") + .find("[ui5-button][design='Emphasized']") + .as("submitButton"); - cy.get("@popover").find("[ui5-list]").as("list"); - cy.get("@list").find("[ui5-li]").contains('Date').realClick(); + cy.get("@submitButton") + .realClick(); + + cy.get("@innerInput") + .should('have.value', 'Next 3 Weeks'); + }); + + it('validates text input for Last X Months and parses correctly', () => { + cy.get('[ui5-dynamic-date-range]') + .as("ddr"); + + cy.get("@ddr") + .shadow() + .find('[ui5-input]') + .as("input"); + + cy.get("@input") + .shadow() + .find("input") + .as("innerInput"); + + cy.get("@innerInput") + .clear() + .realType('Last 6 Days'); + + cy.get("@innerInput") + .realPress("Enter"); - cy.get("@popover").find("[ui5-calendar]").as("calendar"); - cy.get("@calendar").should('exist'); + cy.get("@innerInput") + .should('have.value', 'Last 6 Days'); - cy.get("@calendar").shadow().find("ui5-daypicker").as("dayPicker"); - cy.get("@dayPicker").shadow().find("div[data-sap-timestamp='1747267200']").should('have.class', 'ui5-dp-item--selected'); // Timestamp for May 15, 2025 + cy.get("@input") + .find('[ui5-icon]') + .as("icon"); + + cy.get("@icon") + .realClick(); + + cy.get("@ddr") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover"); + + cy.get("@popover") + .find("[ui5-list]") + .as("list"); + + cy.get("@list") + .find("[ui5-li]") + .contains("Last X Days / Months") + .should('have.attr', 'selected'); }); }); diff --git a/packages/main/src/DynamicDateRange.ts b/packages/main/src/DynamicDateRange.ts index b05b48b5dab1..2c584d8d4f02 100644 --- a/packages/main/src/DynamicDateRange.ts +++ b/packages/main/src/DynamicDateRange.ts @@ -12,6 +12,8 @@ import { isF4, isShow } from "@ui5/webcomponents-base/dist/Keys.js"; import DynamicDateRangeTemplate from "./DynamicDateRangeTemplate.js"; import IconMode from "./types/IconMode.js"; import type Input from "./Input.js"; +import type List from "./List.js"; +import type ListItem from "./ListItem.js"; import { DYNAMIC_DATE_RANGE_SELECTED_TEXT, DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT, @@ -25,8 +27,6 @@ import "@ui5/webcomponents-localization/dist/features/calendar/Gregorian.js"; import dynamicDateRangeCss from "./generated/themes/DynamicDateRange.css.js"; import dynamicDateRangePopoverCss from "./generated/themes/DynamicDateRangePopover.css.js"; import ResponsivePopoverCommonCss from "./generated/themes/ResponsivePopoverCommon.css.js"; -import type List from "./List.js"; -import type ListItem from "./ListItem.js"; type DynamicDateRangeValue = { /** @@ -41,7 +41,7 @@ type DynamicDateRangeValue = { * @default [] * @public */ - values?: Date[] | number[]; + values?: Array | Array; } /** @@ -61,7 +61,7 @@ type DynamicDateRangeValue = { * Methods: * - `format(value: DynamicDateRangeValue): string`: Formats the given dynamic date range value into a string representation. * - `parse(value: string): DynamicDateRangeValue | undefined`: Parses a string into a dynamic date range value. - * - `toDates(value: DynamicDateRangeValue): Date[]`: Converts a dynamic date range value into an array of `Date` objects. + * - `toDates(value: DynamicDateRangeValue): Array`: Converts a dynamic date range value into an array of `Date` objects. * - `handleSelectionChange?(event: CustomEvent): DynamicDateRangeValue | undefined`: (Optional) Handles selection changes in the UI of the dynamic date range option. * - `isValidString(value: string): boolean`: Validates whether a given string is a valid representation of the dynamic date range value. * @@ -74,7 +74,7 @@ interface IDynamicDateRangeOption { text: string; format: (value: DynamicDateRangeValue) => string; parse: (value: string) => DynamicDateRangeValue | undefined; - toDates: (value: DynamicDateRangeValue) => Date[]; + toDates: (value: DynamicDateRangeValue) => Array; handleSelectionChange?: (event: CustomEvent) => DynamicDateRangeValue | undefined; template?: JsxTemplate; isValidString: (value: string) => boolean; @@ -104,6 +104,16 @@ interface IDynamicDateRangeOption { * - "TOMORROW" - Represents the next date. An example value is `{ operator: "TOMORROW"}`. Import: `import "@ui5/webcomponents/dist/dynamic-date-range-options/Tomorrow.js";` * - "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";` * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` + * - "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";` * * ### ES6 Module Import * @@ -173,9 +183,9 @@ class DynamicDateRange extends UI5Element { @property({ type: Object }) currentValue?: DynamicDateRangeValue; - optionsObjects: IDynamicDateRangeOption[] = []; + optionsObjects: Array = []; - static optionsClasses: Map IDynamicDateRangeOption> = new Map(); + static optionsClasses: Map) => IDynamicDateRangeOption> = new Map(); @query("[ui5-input]") _input?: Input; @@ -184,31 +194,51 @@ class DynamicDateRange extends UI5Element { _list?: List; onBeforeRendering() { - const optionKeys = this.options.split(",").map(option => option.trim()); + this.optionsObjects = this._createNormalizedOptions(); + this._focusSelectedItem(); + } - this.optionsObjects = optionKeys.map(option => { - const OptionClass = DynamicDateRange.getOptionClass(option); - let optionObject; + /** + * Creates and normalizes options from the options string + */ + _createNormalizedOptions(): Array { + if (!this.optionsObjects.length) { // initialize options on first use + const optionKeys = this.splitOptions(this.options).filter(Boolean); + const createdOptions: Array = []; + const classToOperators = new Map) => IDynamicDateRangeOption, Array>(); + + // Group operators by their class + optionKeys.forEach(option => { + const OptionClass = DynamicDateRange.getOptionClass(option); + if (OptionClass) { + const operators = classToOperators.get(OptionClass) || []; + operators.push(option); + classToOperators.set(OptionClass, operators); + } + }); - if (OptionClass) { - optionObject = new OptionClass(); - } + classToOperators.forEach((operators, OptionClass) => { + createdOptions.push(new OptionClass(operators)); + }); - return optionObject; - }).filter(optionObject => optionObject !== undefined); + return createdOptions; + } + return this.optionsObjects; + } - if (this.value) { - const selectedItem = this._list?.items.find(item => { - const option = this.optionsObjects.find(x => x.operator === this.value?.operator); - return option && item.textContent === option.text; - }) as ListItem; + splitOptions(options: string): Array { + return options.split(",").map(s => s.trim()); + } - this._list?.focusItem(selectedItem); + _focusSelectedItem() { + if (!this.value) { + return; } - } - get _optionsTitles(): Array { - return this.optionsObjects.map(option => option.text); + const listItem = this._list?.items.find(item => (item as ListItem).selected === true); + if (listItem) { + this._list?.focusItem(listItem as ListItem); + } } /** @@ -233,9 +263,12 @@ class DynamicDateRange extends UI5Element { _selectOption(e: CustomEvent): void { this._currentOption = this.optionsObjects.find(option => option.text === e.detail.item.textContent); + if (!this._currentOption?.template) { this.currentValue = this._currentOption?.parse(this._currentOption.text); this._submitValue(); + } else if (!this.currentValue || this.currentValue.operator !== this._currentOption.operator) { + this.currentValue = undefined; } if (this._currentOption?.operator === this.value?.operator) { @@ -243,13 +276,24 @@ class DynamicDateRange extends UI5Element { } } - getOption(operator: string) { + getOption(operator?: string) { + if (!operator) { + return this._currentOption; + } + const resultOption = this.optionsObjects.find(option => option.operator === operator); if (!resultOption) { const OptionClass = DynamicDateRange.getOptionClass(operator); if (OptionClass) { + const existingOption = this.optionsObjects.find(option => option.constructor === OptionClass); + + if (existingOption) { + existingOption.operator = operator; + return existingOption; + } + const optionObject = new OptionClass(); this.optionsObjects.push(optionObject); @@ -290,8 +334,8 @@ class DynamicDateRange extends UI5Element { * @param value The option to convert into an array of date ranges * @returns An array of two `Date` objects representing the start and end dates. */ - toDates(value: DynamicDateRangeValue): Date[] { - return this.getOption(value.operator)?.toDates(value) as Date[]; + toDates(value: DynamicDateRangeValue): Array { + return this.getOption(value.operator)?.toDates(value) as Array; } get _hasCurrentOptionTemplate(): boolean { @@ -299,14 +343,17 @@ class DynamicDateRange extends UI5Element { } _submitValue() { - const stringValue = this._currentOption?.format(this.currentValue!) as string; + const valueToSubmit = this.currentValue || { operator: this._currentOption?.operator || "", values: [] }; + const displayString = this._currentOption?.format(valueToSubmit) || ""; if (this._input) { - this._input.value = stringValue; + this._input.value = displayString; } - if (this._currentOption?.isValidString(stringValue)) { - this.value = this.currentValue as DynamicDateRangeValue; + if (!this._currentOption || !valueToSubmit.operator) { + this.value = undefined; + } else if (this._currentOption.isValidString(displayString)) { + this.value = valueToSubmit; this.fireDecoratorEvent("change"); } else { this.value = undefined; @@ -332,8 +379,22 @@ class DynamicDateRange extends UI5Element { } get currentValueText() { - if (this.currentValue && this.currentValue.operator === this._currentOption?.operator) { - return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${this._currentOption?.format(this.currentValue)}`; + if (this.currentValue) { + const correctOption = this.getOption(this.currentValue.operator); + if (correctOption) { + const dates = correctOption.toDates(this.currentValue); + const displayValue = { ...this.currentValue, values: dates }; + const displayText = correctOption.format(displayValue); + return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${displayText}`; + } + } + + if (this._currentOption) { + const emptyValue = { operator: this._currentOption.operator, values: [] }; + const displayText = this._currentOption.format(emptyValue); + if (displayText && displayText.trim()) { + return `${DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_SELECTED_TEXT)}: ${displayText}`; + } } return DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT); @@ -341,6 +402,11 @@ class DynamicDateRange extends UI5Element { handleSelectionChange(e: CustomEvent) { this.currentValue = this._currentOption?.handleSelectionChange && this._currentOption?.handleSelectionChange(e) as DynamicDateRangeValue; + + // Update _currentOption if the operator changed + if (this.currentValue && this.currentValue.operator !== this._currentOption?.operator) { + this._currentOption = this.getOption(this.currentValue.operator); + } } onInputKeyDown(e: KeyboardEvent) { diff --git a/packages/main/src/DynamicDateRangeInputTemplate.tsx b/packages/main/src/DynamicDateRangeInputTemplate.tsx index c3316c99f865..3d3389913a76 100644 --- a/packages/main/src/DynamicDateRangeInputTemplate.tsx +++ b/packages/main/src/DynamicDateRangeInputTemplate.tsx @@ -5,12 +5,7 @@ import appointment from "@ui5/webcomponents-icons/dist/appointment-2.js"; export default function DynamicDateRangeInputTemplate(this: DynamicDateRange) { return ( -
+
- {this.optionsObjects.map(option => { - return ( + + wrappingType="Normal" + type={option.template ? ListItemType.Navigation : ListItemType.Active} + > {option.text} - ; - })} + + ))}
: diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 65880608717e..a6c644bc9d3c 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -77,10 +77,12 @@ import Input from "./Input.js"; import SuggestionItemCustom from "./SuggestionItemCustom.js"; import MultiInput from "./MultiInput.js"; import Label from "./Label.js"; +import LastOptions from "./dynamic-date-range-options/LastOptions.js"; import Link from "./Link.js"; import Menu from "./Menu.js"; import MenuItem from "./MenuItem.js"; import MenuSeparator from "./MenuSeparator.js"; +import NextOptions from "./dynamic-date-range-options/NextOptions.js"; import MenuItemGroup from "./MenuItemGroup.js"; import Popover from "./Popover.js"; import Panel from "./Panel.js"; diff --git a/packages/main/src/dynamic-date-range-options/DateRange.ts b/packages/main/src/dynamic-date-range-options/DateRange.ts index c38996f81b91..f26c03010650 100644 --- a/packages/main/src/dynamic-date-range-options/DateRange.ts +++ b/packages/main/src/dynamic-date-range-options/DateRange.ts @@ -1,6 +1,7 @@ -import DateRangeRangeTemplate from "./DateRangeTemplate.js"; +import DateRangeTemplate from "./DateRangeTemplate.js"; import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js"; import DateFormat from "@ui5/webcomponents-localization/dist/DateFormat.js"; +import UI5Date from "@ui5/webcomponents-localization/dist/dates/UI5Date.js"; import type { JsxTemplate } from "@ui5/webcomponents-base/dist/index.js"; import { DYNAMIC_DATE_RANGE_DATERANGE_TEXT, @@ -15,11 +16,11 @@ import DynamicDateRange from "../DynamicDateRange.js"; * @since 2.11.0 */ -class DateRangeRange implements IDynamicDateRangeOption { +class DateRange implements IDynamicDateRangeOption { template: JsxTemplate; constructor() { - this.template = DateRangeRangeTemplate; + this.template = DateRangeTemplate; } parse(value: string): DynamicDateRangeValue { @@ -32,9 +33,9 @@ class DateRangeRange implements IDynamicDateRangeOption { } format(value: DynamicDateRangeValue) { - const valuesArray = value?.values as Date[]; + const valuesArray = value?.values as Array; - if (!valuesArray || valuesArray.length !== 2) { + if (!valuesArray || valuesArray.length !== 2 || !valuesArray[1]) { return ""; } @@ -43,7 +44,7 @@ class DateRangeRange implements IDynamicDateRangeOption { return formattedValue; } - toDates(value: DynamicDateRangeValue): Date[] { + toDates(value: DynamicDateRangeValue): Array { return dateRangeOptionToDates(value); } @@ -60,7 +61,7 @@ class DateRangeRange implements IDynamicDateRangeOption { } isValidString(value: string): boolean { - const dates = this.getFormat().parse(value) as Date[]; + const dates = this.getFormat().parse(value) as Array; if (!dates[0] || !dates[1] || Number.isNaN(dates[0].getTime()) || Number.isNaN(dates[1].getTime())) { return false; @@ -83,17 +84,28 @@ class DateRangeRange implements IDynamicDateRangeOption { currentValue.operator = this.operator; if (e.detail.selectedDates[0]) { - currentValue.values[0] = new Date(e.detail.selectedDates[0] * 1000); + currentValue.values[0] = UI5Date.getInstance(e.detail.selectedDates[0] * 1000); } if (e.detail.selectedDates[1]) { - currentValue.values[1] = new Date(e.detail.selectedDates[1] * 1000); + currentValue.values[1] = UI5Date.getInstance(e.detail.selectedDates[1] * 1000); + } + + // Handle backwards date ranges by automatically flipping them + if (currentValue.values.length === 2 && currentValue.values[0] && currentValue.values[1]) { + const startDate = currentValue.values[0] as UI5Date; + const endDate = currentValue.values[1] as UI5Date; + + // If start date is after end date, flip them + if (startDate.getTime() > endDate.getTime()) { + currentValue.values = [endDate, startDate]; + } } return currentValue; } } -DynamicDateRange.register("DATERANGE", DateRangeRange); +DynamicDateRange.register("DATERANGE", DateRange); -export default DateRangeRange; +export default DateRange; diff --git a/packages/main/src/dynamic-date-range-options/DateRangeTemplate.tsx b/packages/main/src/dynamic-date-range-options/DateRangeTemplate.tsx index f9d9d19f872c..522e023485c1 100644 --- a/packages/main/src/dynamic-date-range-options/DateRangeTemplate.tsx +++ b/packages/main/src/dynamic-date-range-options/DateRangeTemplate.tsx @@ -2,7 +2,7 @@ import type DynamicDateRange from "../DynamicDateRange.js"; import Calendar from "../Calendar.js"; import CalendarDateRange from "../CalendarDateRange.js"; -export default function DateRangeRangeTemplate(this: DynamicDateRange) { +export default function DateRangeTemplate(this: DynamicDateRange) { return ( + currentOption.options?.includes(info.operator) || currentOption.operator === info.operator + ); + + const isGrouped = filteredOptions.length > 1; + + const currentNumber = this.currentValue?.values ? typeof this.currentValue.values[0] === "number" ? this.currentValue.values[0] : 1 : 1; + const currentOperator = this.currentValue?.operator || filteredOptions[0]?.operator || ""; + + // Input handlers + const handleNumberChange = (e: CustomEvent) => { + const newValue = Number((e.target as StepInput).value); + this.currentValue = { + operator: currentOperator, + values: [newValue], + }; + }; + + const handleUnitChange = (e: CustomEvent) => { + const newOperator = String(e.detail.selectedOption.value); + this.currentValue = { + operator: newOperator, + values: [currentNumber], + }; + }; + + return ( +
+
+ + + + {isGrouped && ( + <> + + + + )} +
+
+ ); +} diff --git a/packages/main/src/dynamic-date-range-options/LastNextUtils.ts b/packages/main/src/dynamic-date-range-options/LastNextUtils.ts new file mode 100644 index 000000000000..c25f1acd1461 --- /dev/null +++ b/packages/main/src/dynamic-date-range-options/LastNextUtils.ts @@ -0,0 +1,155 @@ +import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js"; +import type StepInput from "../StepInput.js"; +import DateFormat from "@ui5/webcomponents-localization/dist/DateFormat.js"; +import UI5Date from "@ui5/webcomponents-localization/dist/dates/UI5Date.js"; + +type LastNextOption = { + operator: string; + text: string; + unitText: string; +} + +/** + * Interface for Last/Next options that have additional methods + */ +interface ILastNextOption extends IDynamicDateRangeOption { + availableOptions: Array; + options?: Array; + _operator: string; +} + +/** + * Parses a string value for Last/Next options + * @param value - The string to parse (e.g., "Last 7 Days") + * @param option - The option instance + * @returns Parsed DynamicDateRangeValue + */ +export function parseLastNext(value: string, option: IDynamicDateRangeOption): DynamicDateRangeValue | undefined { + const returnValue = { operator: "", values: [] } as DynamicDateRangeValue; + returnValue.operator = option.operator; + + // Extract number from text like "Last 7 Days" or "Next 3 Months" + const match = value.match(/(\d+)/); + if (match) { + returnValue.values = [Number.parseInt(match[1])]; + } else { + returnValue.values = [1]; + } + + return returnValue; +} + +/** + * Formats a DynamicDateRangeValue for Last/Next options + * @param value - The value to format + * @param option - The option instance + * @returns Formatted string + */ +export function formatLastNext(value: DynamicDateRangeValue, option: IDynamicDateRangeOption): string { + const amount = value?.values?.[0] as number || 1; + return option.text.replace("X", amount.toString()); +} + +/** + * Validates if a string is valid for Last/Next options + * @param value - The string to validate + * @param option - The option instance + * @returns True if valid, false otherwise + */ +export function isValidStringLastNext(value: string, option: IDynamicDateRangeOption): boolean { + // Check if string matches the pattern "Last X Days" or "Next X Months" + const pattern = option.text.replace("X", "\\d+"); + const regex = new RegExp(`^${pattern}$`, "i"); + return regex.test(value); +} + +/** + * Handles selection change events for Last/Next options + * @param e - The custom event + * @param option - The option instance + * @returns Updated DynamicDateRangeValue + */ +export function handleSelectionChangeLastNext(e: CustomEvent, option: IDynamicDateRangeOption): DynamicDateRangeValue | undefined { + const currentValue = { operator: "", values: [] } as DynamicDateRangeValue; + currentValue.operator = option.operator; + + // For StepInput, the value is accessed from the target element itself + const stepInputValue = (e.target as StepInput)?.value; + currentValue.values = [stepInputValue || 1]; + + return currentValue; +} + +/** + * Checks if a date range represents a single day + * @param startDate - The start date + * @param endDate - The end date + * @returns True if the range is a single day + */ +export function isSingleDayRange(startDate: Date, endDate: Date): boolean { + const normalizedStart = UI5Date.getInstance(startDate.getTime()); + const normalizedEnd = UI5Date.getInstance(endDate.getTime()); + const diffInDays = Math.round((normalizedEnd.getTime() - normalizedStart.getTime()) / (1000 * 60 * 60 * 24)); + return diffInDays + 1 === 1; +} + +/** + * Formats date values for Last/Next options + * @param startDate - The start date + * @param endDate - The end date + * @param operator - The operator (used to check for DAYS operations) + * @returns Formatted date string + */ +export function formatDateRange(startDate: Date, endDate: Date, operator: string): string { + const dateFormat = DateFormat.getDateInstance({ strictParsing: true }); + + // Single day check for DAYS operations + const isSingleDay = operator.includes("DAYS") && isSingleDayRange(startDate, endDate); + const isSameDay = startDate.getFullYear() === endDate.getFullYear() + && startDate.getMonth() === endDate.getMonth() + && startDate.getDate() === endDate.getDate(); + + if (isSingleDay || isSameDay) { + return dateFormat.format(startDate); + } + return `${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}`; +} + +/** + * Complete format function for Last/Next options that handles all value types + * @param value - The value to format + * @param option - The Last/Next option instance + * @returns Formatted string + */ +export function formatLastNextValue(value: DynamicDateRangeValue, option: ILastNextOption): string { + // for empty/default values, convert to actual dates and format them + if (!value.values || value.values.length === 0) { + const firstOption = option.availableOptions.find(info => option.options?.includes(info.operator) || info.operator === option._operator); + if (firstOption) { + // Create default value with numeric 1, convert to dates, then format the dates directly + const defaultValue = { operator: firstOption.operator, values: [1] }; + const dates = option.toDates(defaultValue); + + // Format the dates directly + if (dates && dates.length >= 2) { + const [startDate, endDate] = dates; + return formatDateRange(startDate, endDate, firstOption.operator); + } + } + } + + // for date values + if (value.values && value.values.length >= 2 && value.values[0] instanceof Date && value.values[1] instanceof Date) { + const [startDate, endDate] = value.values; + return formatDateRange(startDate, endDate, value.operator); + } + + // for numeric values + const optionInfo = option.availableOptions.find(info => info.operator === value.operator); + if (optionInfo) { + const numberValue = (value.values?.[0] as number) || 1; + return formatLastNext({ operator: value.operator, values: [numberValue] }, { text: optionInfo.text } as IDynamicDateRangeOption); + } + + return ""; +} diff --git a/packages/main/src/dynamic-date-range-options/LastOptions.ts b/packages/main/src/dynamic-date-range-options/LastOptions.ts new file mode 100644 index 000000000000..bb1e24f5e26e --- /dev/null +++ b/packages/main/src/dynamic-date-range-options/LastOptions.ts @@ -0,0 +1,146 @@ +import LastNextTemplate from "./LastNextTemplate.js"; +import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js"; +import type { JsxTemplate } from "@ui5/webcomponents-base/dist/index.js"; +import type { I18nText } from "@ui5/webcomponents-base/dist/i18nBundle.js"; + +import { + handleSelectionChangeLastNext, isValidStringLastNext, parseLastNext, formatLastNextValue, +} from "./LastNextUtils.js"; +import { toDatesLastNext } from "./toDates.js"; +import DynamicDateRange from "../DynamicDateRange.js"; +import { + DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT, + DYNAMIC_DATE_RANGE_LAST_WEEKS_TEXT, + DYNAMIC_DATE_RANGE_LAST_MONTHS_TEXT, + DYNAMIC_DATE_RANGE_LAST_QUARTERS_TEXT, + DYNAMIC_DATE_RANGE_LAST_YEARS_TEXT, + DYNAMIC_DATE_RANGE_DAYS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_WEEKS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_MONTHS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_QUARTERS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_YEARS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_LAST_COMBINED_TEXT, + DYNAMIC_DATE_RANGE_INCLUDED_TEXT, +} from "../generated/i18n/i18n-defaults.js"; + +/** + * @class + * @constructor + * @public + * @since 2.14.0 + */ +class LastOptions implements IDynamicDateRangeOption { + template: JsxTemplate = LastNextTemplate; + _operator: string; + i18nKey: I18nText; + options?: Array = []; + + constructor(operators?: Array) { + this.options = operators; + this._operator = this.options?.[0] || "LASTDAYS"; + this.i18nKey = this._getI18nKeyForOperator(this._operator); + } + + _getI18nKeyForOperator(operator: string): I18nText { + switch (operator) { + case "LASTDAYS": + return DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT; + case "LASTWEEKS": + return DYNAMIC_DATE_RANGE_LAST_WEEKS_TEXT; + case "LASTMONTHS": + return DYNAMIC_DATE_RANGE_LAST_MONTHS_TEXT; + case "LASTQUARTERS": + return DYNAMIC_DATE_RANGE_LAST_QUARTERS_TEXT; + case "LASTYEARS": + return DYNAMIC_DATE_RANGE_LAST_YEARS_TEXT; + default: + return DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT; + } + } + + parse(value: string): DynamicDateRangeValue | undefined { + const matchingOption = this.availableOptions.find(optionInfo => { + const tempOption = { operator: optionInfo.operator, text: optionInfo.text }; + return isValidStringLastNext(value, tempOption as IDynamicDateRangeOption); + }); + + if (matchingOption) { + const tempOption = { operator: matchingOption.operator, text: matchingOption.text }; + return parseLastNext(value, tempOption as IDynamicDateRangeOption); + } + + return undefined; + } + + format(value: DynamicDateRangeValue): string { + return formatLastNextValue(value, this); + } + + toDates(value: DynamicDateRangeValue): Array { + return toDatesLastNext(value, { operator: value.operator } as IDynamicDateRangeOption); + } + + isValidString(value: string): boolean { + return this.availableOptions.some(optionInfo => { + const tempOption = { operator: optionInfo.operator, text: optionInfo.text }; + return isValidStringLastNext(value, tempOption as IDynamicDateRangeOption); + }); + } + + handleSelectionChange(e: CustomEvent): DynamicDateRangeValue | undefined { + return handleSelectionChangeLastNext(e, this); + } + + get text(): string { + if (this.options?.length && this.options.length > 1) { + const units = this.availableOptions + .filter(info => this.options?.includes(info.operator)) + .map(info => info.unitText); + const unitsText = units.join(` / `); + return DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_LAST_COMBINED_TEXT, unitsText); + } + + const baseText = DynamicDateRange.i18nBundle.getText(this.i18nKey); + const includedText = DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_INCLUDED_TEXT); + return `${baseText} ${includedText}`; + } + + get icon(): string { + return ""; + } + + // Simple getter that provides all available Last options based on current component options + get availableOptions() { + const allOptions = [ + { operator: "LASTDAYS", i18nKey: DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_DAYS_UNIT_TEXT }, + { operator: "LASTWEEKS", i18nKey: DYNAMIC_DATE_RANGE_LAST_WEEKS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_WEEKS_UNIT_TEXT }, + { operator: "LASTMONTHS", i18nKey: DYNAMIC_DATE_RANGE_LAST_MONTHS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_MONTHS_UNIT_TEXT }, + { operator: "LASTQUARTERS", i18nKey: DYNAMIC_DATE_RANGE_LAST_QUARTERS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_QUARTERS_UNIT_TEXT }, + { operator: "LASTYEARS", i18nKey: DYNAMIC_DATE_RANGE_LAST_YEARS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_YEARS_UNIT_TEXT }, + ]; + + return allOptions.map(info => ({ + operator: info.operator, + unitText: DynamicDateRange.i18nBundle.getText(info.unitI18nKey), + text: DynamicDateRange.i18nBundle.getText(info.i18nKey), + })); + } + + get operator(): string { + return this._operator; + } + + set operator(value: string) { + if (this.options?.includes(value)) { + this._operator = value; + } + } +} + +DynamicDateRange.register("LASTDAYS", LastOptions); +DynamicDateRange.register("LASTWEEKS", LastOptions); +DynamicDateRange.register("LASTMONTHS", LastOptions); +DynamicDateRange.register("LASTQUARTERS", LastOptions); +DynamicDateRange.register("LASTYEARS", LastOptions); + +export default LastOptions; diff --git a/packages/main/src/dynamic-date-range-options/NextOptions.ts b/packages/main/src/dynamic-date-range-options/NextOptions.ts new file mode 100644 index 000000000000..6688a0c2df58 --- /dev/null +++ b/packages/main/src/dynamic-date-range-options/NextOptions.ts @@ -0,0 +1,146 @@ +import LastNextTemplate from "./LastNextTemplate.js"; +import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js"; +import type { JsxTemplate } from "@ui5/webcomponents-base/dist/index.js"; +import type { I18nText } from "@ui5/webcomponents-base/dist/i18nBundle.js"; + +import { + handleSelectionChangeLastNext, isValidStringLastNext, parseLastNext, formatLastNextValue, +} from "./LastNextUtils.js"; +import { toDatesLastNext } from "./toDates.js"; +import DynamicDateRange from "../DynamicDateRange.js"; +import { + DYNAMIC_DATE_RANGE_NEXT_DAYS_TEXT, + DYNAMIC_DATE_RANGE_NEXT_WEEKS_TEXT, + DYNAMIC_DATE_RANGE_NEXT_MONTHS_TEXT, + DYNAMIC_DATE_RANGE_NEXT_QUARTERS_TEXT, + DYNAMIC_DATE_RANGE_NEXT_YEARS_TEXT, + DYNAMIC_DATE_RANGE_DAYS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_WEEKS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_MONTHS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_QUARTERS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_YEARS_UNIT_TEXT, + DYNAMIC_DATE_RANGE_NEXT_COMBINED_TEXT, + DYNAMIC_DATE_RANGE_INCLUDED_TEXT, +} from "../generated/i18n/i18n-defaults.js"; + +/** + * @class + * @constructor + * @public + * @since 2.14.0 + */ +class NextOptions implements IDynamicDateRangeOption { + template: JsxTemplate = LastNextTemplate; + _operator: string; + i18nKey: I18nText; + options?: Array = []; + + constructor(operators?: Array) { + this.options = operators; + this._operator = this.options?.[0] || "NEXTDAYS"; + this.i18nKey = this._getI18nKeyForOperator(this._operator); + } + + _getI18nKeyForOperator(operator: string): I18nText { + switch (operator) { + case "NEXTDAYS": + return DYNAMIC_DATE_RANGE_NEXT_DAYS_TEXT; + case "NEXTWEEKS": + return DYNAMIC_DATE_RANGE_NEXT_WEEKS_TEXT; + case "NEXTMONTHS": + return DYNAMIC_DATE_RANGE_NEXT_MONTHS_TEXT; + case "NEXTQUARTERS": + return DYNAMIC_DATE_RANGE_NEXT_QUARTERS_TEXT; + case "NEXTYEARS": + return DYNAMIC_DATE_RANGE_NEXT_YEARS_TEXT; + default: + return DYNAMIC_DATE_RANGE_NEXT_DAYS_TEXT; + } + } + + parse(value: string): DynamicDateRangeValue | undefined { + const matchingOption = this.availableOptions.find(optionInfo => { + const tempOption = { operator: optionInfo.operator, text: optionInfo.text }; + return isValidStringLastNext(value, tempOption as IDynamicDateRangeOption); + }); + + if (matchingOption) { + const tempOption = { operator: matchingOption.operator, text: matchingOption.text }; + return parseLastNext(value, tempOption as IDynamicDateRangeOption); + } + + return undefined; + } + + format(value: DynamicDateRangeValue): string { + return formatLastNextValue(value, this); + } + + toDates(value: DynamicDateRangeValue): Array { + return toDatesLastNext(value, { operator: value.operator } as IDynamicDateRangeOption); + } + + isValidString(value: string): boolean { + return this.availableOptions.some(optionInfo => { + const tempOption = { operator: optionInfo.operator, text: optionInfo.text }; + return isValidStringLastNext(value, tempOption as IDynamicDateRangeOption); + }); + } + + handleSelectionChange(e: CustomEvent): DynamicDateRangeValue | undefined { + return handleSelectionChangeLastNext(e, this); + } + + get text(): string { + if (this.options?.length && this.options.length > 1) { + const units = this.availableOptions + .filter(info => this.options?.includes(info.operator)) + .map(info => info.unitText); + const unitsText = units.join(` / `); + return DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_NEXT_COMBINED_TEXT, unitsText); + } + + const baseText = DynamicDateRange.i18nBundle.getText(this.i18nKey); + const includedText = DynamicDateRange.i18nBundle.getText(DYNAMIC_DATE_RANGE_INCLUDED_TEXT); + return `${baseText} ${includedText}`; + } + + get icon(): string { + return ""; + } + + // Simple getter that provides all available Next options based on current component options + get availableOptions() { + const allOptions = [ + { operator: "NEXTDAYS", i18nKey: DYNAMIC_DATE_RANGE_NEXT_DAYS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_DAYS_UNIT_TEXT }, + { operator: "NEXTWEEKS", i18nKey: DYNAMIC_DATE_RANGE_NEXT_WEEKS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_WEEKS_UNIT_TEXT }, + { operator: "NEXTMONTHS", i18nKey: DYNAMIC_DATE_RANGE_NEXT_MONTHS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_MONTHS_UNIT_TEXT }, + { operator: "NEXTQUARTERS", i18nKey: DYNAMIC_DATE_RANGE_NEXT_QUARTERS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_QUARTERS_UNIT_TEXT }, + { operator: "NEXTYEARS", i18nKey: DYNAMIC_DATE_RANGE_NEXT_YEARS_TEXT, unitI18nKey: DYNAMIC_DATE_RANGE_YEARS_UNIT_TEXT }, + ]; + + return allOptions.map(info => ({ + operator: info.operator, + unitText: DynamicDateRange.i18nBundle.getText(info.unitI18nKey), + text: DynamicDateRange.i18nBundle.getText(info.i18nKey), + })); + } + + get operator(): string { + return this._operator; + } + + set operator(value: string) { + if (this.options?.includes(value)) { + this._operator = value; + } + } +} + +DynamicDateRange.register("NEXTDAYS", NextOptions); +DynamicDateRange.register("NEXTWEEKS", NextOptions); +DynamicDateRange.register("NEXTMONTHS", NextOptions); +DynamicDateRange.register("NEXTQUARTERS", NextOptions); +DynamicDateRange.register("NEXTYEARS", NextOptions); + +export default NextOptions; diff --git a/packages/main/src/dynamic-date-range-options/SingleDate.ts b/packages/main/src/dynamic-date-range-options/SingleDate.ts index 7345895ec8ca..a68cf35de71f 100644 --- a/packages/main/src/dynamic-date-range-options/SingleDate.ts +++ b/packages/main/src/dynamic-date-range-options/SingleDate.ts @@ -32,9 +32,9 @@ class SingleDate implements IDynamicDateRangeOption { } format(value: DynamicDateRangeValue) { - const valuesArray = value?.values as Date[]; + const valuesArray = value?.values as Array; - if (!valuesArray || valuesArray.length !== 1) { + if (!valuesArray) { return ""; } @@ -43,7 +43,7 @@ class SingleDate implements IDynamicDateRangeOption { return this.getFormat().format(date); } - toDates(value: DynamicDateRangeValue): Date[] { + toDates(value: DynamicDateRangeValue): Array { return dateOptionToDates(value); } diff --git a/packages/main/src/dynamic-date-range-options/Today.ts b/packages/main/src/dynamic-date-range-options/Today.ts index ed72ca15438b..e1299c3b818c 100644 --- a/packages/main/src/dynamic-date-range-options/Today.ts +++ b/packages/main/src/dynamic-date-range-options/Today.ts @@ -23,7 +23,7 @@ class Today implements IDynamicDateRangeOption { return "Today"; } - toDates(): Date[] { + toDates(): Array { return todayToDates(); } diff --git a/packages/main/src/dynamic-date-range-options/Tomorrow.ts b/packages/main/src/dynamic-date-range-options/Tomorrow.ts index 8238509d3819..d6d939ebee96 100644 --- a/packages/main/src/dynamic-date-range-options/Tomorrow.ts +++ b/packages/main/src/dynamic-date-range-options/Tomorrow.ts @@ -23,7 +23,7 @@ class Tomorrow implements IDynamicDateRangeOption { return "Tomorrow"; } - toDates(): Date[] { + toDates() : Array { return tomorrowToDates(); } diff --git a/packages/main/src/dynamic-date-range-options/Yesterday.ts b/packages/main/src/dynamic-date-range-options/Yesterday.ts index 92b59c2790a3..c09bcb72bb44 100644 --- a/packages/main/src/dynamic-date-range-options/Yesterday.ts +++ b/packages/main/src/dynamic-date-range-options/Yesterday.ts @@ -24,7 +24,7 @@ class Yesterday implements IDynamicDateRangeOption { return "Yesterday"; } - toDates(): Date[] { + toDates() : Array { return yesterdayToDates(); } diff --git a/packages/main/src/dynamic-date-range-options/toDates.ts b/packages/main/src/dynamic-date-range-options/toDates.ts index 2b6d42a70d9d..d50711ddd7fa 100644 --- a/packages/main/src/dynamic-date-range-options/toDates.ts +++ b/packages/main/src/dynamic-date-range-options/toDates.ts @@ -1,8 +1,9 @@ -import type { DynamicDateRangeValue } from "../DynamicDateRange.js"; +import type { DynamicDateRangeValue, IDynamicDateRangeOption } from "../DynamicDateRange.js"; +import UI5Date from "@ui5/webcomponents-localization/dist/dates/UI5Date.js"; -const dateOptionToDates = (value: DynamicDateRangeValue): Date[] => { - const startDate = value.values ? value.values[0] as Date : new Date(); - const endDate = new Date(startDate); +const dateOptionToDates = (value: DynamicDateRangeValue): Array => { + const startDate = value.values ? value.values[0] as Date : UI5Date.getInstance(); + const endDate = UI5Date.getInstance(startDate.getTime()); startDate?.setHours(0, 0, 0, 0); endDate?.setHours(23, 59, 59, 999); @@ -10,9 +11,9 @@ const dateOptionToDates = (value: DynamicDateRangeValue): Date[] => { return [startDate, endDate]; }; -const dateRangeOptionToDates = (value: DynamicDateRangeValue): Date[] => { - const startDate = value.values ? value.values[0] as Date : new Date(); - const endDate = value.values ? value.values[1] as Date : new Date(); +const dateRangeOptionToDates = (value: DynamicDateRangeValue): Array => { + const startDate = value.values ? value.values[0] as Date : UI5Date.getInstance(); + const endDate = value.values ? value.values[1] as Date : UI5Date.getInstance(); startDate?.setHours(0, 0, 0, 0); endDate?.setHours(23, 59, 59, 999); @@ -20,44 +21,168 @@ const dateRangeOptionToDates = (value: DynamicDateRangeValue): Date[] => { return [startDate, endDate]; }; -const todayToDates = (): Date[] => { - const startDate = new Date(); - const endDate = new Date(); +const todayToDates = (): Array => { + const startDate = UI5Date.getInstance(); + const endDate = UI5Date.getInstance(); - startDate?.setHours(0, 0, 0, 0); - endDate?.setHours(23, 59, 59, 999); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + return [startDate, endDate]; +}; + +const yesterdayToDates = (): Array => { + const startDate = UI5Date.getInstance(); + const endDate = UI5Date.getInstance(); + + startDate.setHours(0, 0, 0, 0); + startDate.setDate(startDate.getDate() - 1); + + endDate.setHours(23, 59, 59, 999); + endDate.setDate(endDate.getDate() - 1); return [startDate, endDate]; }; -const tomorrowToDates = (): Date[] => { - const startDate = new Date(); - const endDate = new Date(); +const tomorrowToDates = (): Array => { + const startDate = UI5Date.getInstance(); + const endDate = UI5Date.getInstance(); + startDate.setHours(0, 0, 0, 0); startDate.setDate(startDate.getDate() + 1); + + endDate.setHours(23, 59, 59, 999); endDate.setDate(endDate.getDate() + 1); - startDate?.setHours(0, 0, 0, 0); - endDate?.setHours(23, 59, 59, 999); return [startDate, endDate]; }; -const yesterdayToDates = (): Date[] => { - const startDate = new Date(); - const endDate = new Date(); +const lastToDates = (value: DynamicDateRangeValue, unit: string): Array => { + const today = UI5Date.getInstance(); + const startDate = UI5Date.getInstance(today.getTime()); + const endDate = UI5Date.getInstance(today.getTime()); + const amount = value.values?.[0] as number || 1; + + switch (unit) { + case "days": + // For "Last X Days": start X days before today (inclusive), end today + // "Last 1 Day" = today only, "Last 2 Days" = yesterday + today, etc. + startDate.setDate(today.getDate() - (amount - 1)); + break; + case "weeks": { + const currentDayOfWeek = today.getDay(); + const daysToStartOfWeek = currentDayOfWeek === 0 ? 6 : currentDayOfWeek - 1; + startDate.setDate(today.getDate() - daysToStartOfWeek - ((amount - 1) * 7)); + break; + } + case "months": + startDate.setMonth(today.getMonth() - (amount - 1)); + startDate.setDate(1); // Start of the month + break; + case "quarters": { + const currentQuarter = Math.floor(today.getMonth() / 3); + const quarterStartMonth = currentQuarter * 3 - ((amount - 1) * 3); + startDate.setMonth(quarterStartMonth); + startDate.setDate(1); // Start of the quarter + break; + } + case "years": + startDate.setFullYear(today.getFullYear() - (amount - 1)); + startDate.setMonth(0); // January + startDate.setDate(1); // Start of the year + break; + } - startDate.setDate(startDate.getDate() - 1); - endDate.setDate(endDate.getDate() - 1); - startDate?.setHours(0, 0, 0, 0); - endDate?.setHours(23, 59, 59, 999); + return [startDate, endDate]; +}; + +const nextToDates = (value: DynamicDateRangeValue, unit: string): Array => { + const today = UI5Date.getInstance(); + const startDate = UI5Date.getInstance(today.getTime()); + const endDate = UI5Date.getInstance(today.getTime()); + const amount = value.values?.[0] as number || 1; + + switch (unit) { + case "days": + // For "Next X Days": start today, end X days from today (inclusive) + // "Next 1 Day" = today only, "Next 2 Days" = today + tomorrow, etc. + endDate.setDate(today.getDate() + (amount - 1)); + break; + case "weeks": { + const currentDayOfWeek = today.getDay(); + const daysToEndOfWeek = currentDayOfWeek === 0 ? 0 : 7 - currentDayOfWeek; + endDate.setDate(today.getDate() + daysToEndOfWeek + ((amount - 1) * 7)); + break; + } + case "months": + endDate.setMonth(today.getMonth() + amount); + endDate.setDate(0); // Last day of the previous month (the target month) + break; + case "quarters": { + const currentQuarter = Math.floor(today.getMonth() / 3); + const quarterEndMonth = (currentQuarter + 1) * 3 - 1 + ((amount - 1) * 3); + endDate.setMonth(quarterEndMonth + 1); + endDate.setDate(0); // Last day of the quarter + break; + } + case "years": + endDate.setFullYear(today.getFullYear() + amount); + endDate.setMonth(0); // January of the next year + endDate.setDate(0); // Last day of December of the target year + break; + } return [startDate, endDate]; }; +const lastNextToDates = (value: DynamicDateRangeValue, unit: string, direction: "last" | "next"): Array => { + return direction === "last" ? lastToDates(value, unit) : nextToDates(value, unit); +}; + +/** + * Converts DynamicDateRangeValue to dates for Last/Next options. + * Uses operator name to determine time unit and direction. + */ +const toDatesLastNext = (value: DynamicDateRangeValue, option: IDynamicDateRangeOption): Array => { + const operator = option.operator; + + // Extract direction from operator name + let direction: "last" | "next"; + if (operator.startsWith("LAST")) { + direction = "last"; + } else if (operator.startsWith("NEXT")) { + direction = "next"; + } else { + // Not a LastNext option, return today's date range + return todayToDates(); + } + + // Extract time unit from operator name + let unit: string; + if (operator.includes("DAYS")) { + unit = "days"; + } else if (operator.includes("WEEKS")) { + unit = "weeks"; + } else if (operator.includes("MONTHS")) { + unit = "months"; + } else if (operator.includes("QUARTERS")) { + unit = "quarters"; + } else if (operator.includes("YEARS")) { + unit = "years"; + } else { + // Unknown time unit, return today's date range as fallback + return todayToDates(); + } + + return lastNextToDates(value, unit, direction); +}; + export { dateOptionToDates, dateRangeOptionToDates, todayToDates, tomorrowToDates, yesterdayToDates, + lastNextToDates, + toDatesLastNext, }; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 1001534b0102..8c586cc6f69a 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -751,3 +751,63 @@ DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT=Choose Dates DYNAMIC_DATE_RANGE_NAVIGATION_ICON_TOOLTIP=Navigate back #XACT: ARIA description for the column header row of the table TABLE_COLUMN_HEADER_ROW=Column Header Row + +#XFLD: Text for the "Last Days" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT=Last X Days + +#XFLD: Text for the "Next Days" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_NEXT_DAYS_TEXT=Next X Days + +#XFLD: Text for the "Last Weeks" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_LAST_WEEKS_TEXT=Last X Weeks + +#XFLD: Text for the "Next Weeks" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_NEXT_WEEKS_TEXT=Next X Weeks + +#XFLD: Text for the "Last Months" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_LAST_MONTHS_TEXT=Last X Months + +#XFLD: Text for the "Next Months" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_NEXT_MONTHS_TEXT=Next X Months + +#XFLD: Text for the "Last Quarters" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_LAST_QUARTERS_TEXT=Last X Quarters + +#XFLD: Text for the "Next Quarters" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_NEXT_QUARTERS_TEXT=Next X Quarters + +#XFLD: Text for the "Last Years" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_LAST_YEARS_TEXT=Last X Years + +#XFLD: Text for the "Next Years" option in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_NEXT_YEARS_TEXT=Next X Years + +#XFLD: Label for the value input field in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_VALUE_LABEL_TEXT=Value for X + +#XFLD: Label for the unit of time selection in the DynamicDateRange component. +DYNAMIC_DATE_RANGE_UNIT_OF_TIME_LABEL_TEXT=Unit of Time + +#XFLD: Unit name for days in DynamicDateRange options. +DYNAMIC_DATE_RANGE_DAYS_UNIT_TEXT=Days + +#XFLD: Unit name for weeks in DynamicDateRange options. +DYNAMIC_DATE_RANGE_WEEKS_UNIT_TEXT=Weeks + +#XFLD: Unit name for months in DynamicDateRange options. +DYNAMIC_DATE_RANGE_MONTHS_UNIT_TEXT=Months + +#XFLD: Unit name for quarters in DynamicDateRange options. +DYNAMIC_DATE_RANGE_QUARTERS_UNIT_TEXT=Quarters + +#XFLD: Unit name for years in DynamicDateRange options. +DYNAMIC_DATE_RANGE_YEARS_UNIT_TEXT=Years + +#XFLD: Template for combined Last options. {0} = units separated by separator. +DYNAMIC_DATE_RANGE_LAST_COMBINED_TEXT=Last X {0} (included) + +#XFLD: Template for combined Next options. {0} = units separated by separator. +DYNAMIC_DATE_RANGE_NEXT_COMBINED_TEXT=Next X {0} (included) + +#XFLD: Suffix text for included date range options. +DYNAMIC_DATE_RANGE_INCLUDED_TEXT=(included) diff --git a/packages/main/src/themes/DynamicDateRange.css b/packages/main/src/themes/DynamicDateRange.css index fced6e1328a2..6fbf44e4903b 100644 --- a/packages/main/src/themes/DynamicDateRange.css +++ b/packages/main/src/themes/DynamicDateRange.css @@ -4,4 +4,8 @@ :host { width: var(--_ui5_input_width, 12.5625rem); display: inline-block; +} + +.ui5-dynamic-date-range-root { + width: 100%; } \ No newline at end of file diff --git a/packages/main/src/themes/DynamicDateRangePopover.css b/packages/main/src/themes/DynamicDateRangePopover.css index d886e127e522..7003e42617d3 100644 --- a/packages/main/src/themes/DynamicDateRangePopover.css +++ b/packages/main/src/themes/DynamicDateRangePopover.css @@ -1,47 +1,94 @@ [ui5-responsive-popover]::part(content) { - padding: 0; + padding: 0; } [ui5-responsive-popover] { - width: 320px; - height: 512px; + width: 320px; + height: 512px; } [ui5-responsive-popover]::part(content) { - width: 320px; - height: 512px; + width: 320px; + height: 512px; } .ui5-dynamic-date-range-option-container { - display: flex; - flex-direction: column; - align-content: center; - justify-content: space-around; - height: 100%; - width: 100%; + display: flex; + flex-direction: column; + align-content: center; + justify-content: space-around; + height: 100%; + width: 100%; +} + +.ui5-dynamic-date-range-unified-option { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + height: 100%; +} + +.ui5-last-next-container { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + height: 100%; +} + +.ui5-last-next-container-padded { + padding: 0 1rem 0.5rem 1rem; +} + +.ui5-ddr-input-container { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0.25rem; + width: fit-content; +} + +.ui5-ddr-input-container-right-aligned { + text-align: right; +} + +.ui5-ddr-label { + margin: 1rem 0 0.5rem 0; + text-align: left; +} + +.ui5-ddr-input-row { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 1rem; } .ui5-ddr-current-value { - display: flex; - justify-content: flex-start; - align-items: center; - height: 50px; - font-size: .875rem; - font-family: var(--sapFontFamily); - font-weight: normal; - color: var(--sapContentLabelColor); - background-color: lightgray; - padding-inline-start: 10px; - font-style: italic; - text-overflow: ellipsis; - overflow: hidden; + display: flex; + justify-content: flex-start; + align-items: center; + height: 50px; + font-size: .875rem; + font-family: var(--sapFontFamily); + font-weight: normal; + color: var(--sapContentLabelColor); + background-color: lightgray; + padding-inline-start: 10px; + font-style: italic; + text-overflow: ellipsis; + overflow: hidden; } [ui5-responsive-popover]::part(footer) { - display: flex; - justify-content: end; - padding-top: 0.125rem; - padding-bottom: 0.125rem; + display: flex; + justify-content: end; + padding-top: 0.125rem; + padding-bottom: 0.125rem; } .ui5-ddr-arrow-back-btn { @@ -73,17 +120,17 @@ } [ui5-responsive-popover]::part(header) { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - height: 2.5rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 2.5rem; } .ui5-ddr-header { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - height: 2.5rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 2.5rem; } \ No newline at end of file diff --git a/packages/main/test/pages/DynamicDateRange.html b/packages/main/test/pages/DynamicDateRange.html index 3bcab9de4c17..91bb340c456c 100644 --- a/packages/main/test/pages/DynamicDateRange.html +++ b/packages/main/test/pages/DynamicDateRange.html @@ -1,28 +1,65 @@ - - - - DynamicDateRange test page + + + + DynamicDateRange test page - + - + + + + +
+

Basic Options

+

+ Standard date options (Today, Yesterday, Tomorrow, Single Date, + Date Range): +

+ +
- - - - -
- - - -
- +
+

Last/Next Options

+

+ Last/Next options (Last Days, Next Days, Last Weeks, Next Weeks, + Last Months, Next Months, Last Quarters, Next Quarters, Last + Years, Next Years): +

+ +
+ +
+

Single Last/Next Options

+ +
+ + + diff --git a/packages/website/docs/_components_pages/main/DynamicDateRange.mdx b/packages/website/docs/_components_pages/main/DynamicDateRange.mdx index 914d5e6752df..338287cdc0e5 100644 --- a/packages/website/docs/_components_pages/main/DynamicDateRange.mdx +++ b/packages/website/docs/_components_pages/main/DynamicDateRange.mdx @@ -1,3 +1,7 @@ +--- +sidebar_class_name: newComponentBadge +--- + import Basic from "../../_samples/main/DynamicDateRange/Basic/Basic.md"; import DynamicDateRangeValueSample from "../../_samples/main/DynamicDateRange/DynamicDateRangeValueSample/DynamicDateRangeValueSample.md"; diff --git a/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/main.js b/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/main.js index 1f359b7e8aba..d42f3dc4f8f3 100644 --- a/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/main.js +++ b/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/main.js @@ -4,6 +4,8 @@ import "@ui5/webcomponents/dist/dynamic-date-range-options/Yesterday.js"; import "@ui5/webcomponents/dist/dynamic-date-range-options/Tomorrow.js"; import "@ui5/webcomponents/dist/dynamic-date-range-options/SingleDate.js"; import "@ui5/webcomponents/dist/dynamic-date-range-options/DateRange.js"; +import "@ui5/webcomponents/dist/dynamic-date-range-options/LastOptions.js"; +import "@ui5/webcomponents/dist/dynamic-date-range-options/NextOptions.js"; const dynamicDateRange = document.getElementById("dynamicDateRange"); const selectedValueInput = document.getElementById("selectedValue"); diff --git a/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/sample.html b/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/sample.html index 395275e5f147..0f42466f273d 100644 --- a/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/sample.html +++ b/packages/website/docs/_samples/main/DynamicDateRange/DynamicDateRangeValueSample/sample.html @@ -12,7 +12,7 @@ + options="TODAY, TOMORROW, YESTERDAY, DATE, DATERANGE, LASTDAYS, NEXTDAYS, LASTWEEKS, NEXTWEEKS, LASTMONTHS, NEXTMONTHS, LASTQUARTERS, NEXTQUARTERS, LASTYEARS, NEXTYEARS">