From a7fad30f4a4aa6e056ff808ada40d7fca46eefec Mon Sep 17 00:00:00 2001 From: Martin Boulais <31805063+martinboulais@users.noreply.github.com> Date: Tue, 13 Dec 2022 15:29:34 +0100 Subject: [PATCH] [O2B-532] Create a reusable filtering system --- .../components/Filters/common/FilterModel.js | 61 +++++++ .../Filters/common/FilteringModel.js | 148 +++++++++++++++++ .../CommaSeparatedValuesFilterModel.js | 103 ++++++++++++ .../filters/ComparisonOperatorFilterModel.js | 120 ++++++++++++++ .../filters/DateTimeLimitsFilterModel.js | 150 ++++++++++++++++++ .../common/filters/DurationFilterModel.js | 71 +++++++++ .../filters/commaSeparatedValuesFilter.js | 53 +++++++ .../filters/comparisonOperatorFilter.js | 3 +- .../common/filters/dateTimeLimitsFilter.js | 113 +++++++++++++ .../Filters/common/filters/durationFilter.js | 76 +++++++++ lib/public/utilities/arrayHasSameContent.js | 22 +++ .../formatting/formatDateForHTMLInput.js | 28 ++++ .../utilities/serializeQueryParameters.js | 69 ++++++++ 13 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 lib/public/components/Filters/common/FilterModel.js create mode 100644 lib/public/components/Filters/common/FilteringModel.js create mode 100644 lib/public/components/Filters/common/filters/CommaSeparatedValuesFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/ComparisonOperatorFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/DateTimeLimitsFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/DurationFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/commaSeparatedValuesFilter.js create mode 100644 lib/public/components/Filters/common/filters/dateTimeLimitsFilter.js create mode 100644 lib/public/components/Filters/common/filters/durationFilter.js create mode 100644 lib/public/utilities/arrayHasSameContent.js create mode 100644 lib/public/utilities/formatting/formatDateForHTMLInput.js create mode 100644 lib/public/utilities/serializeQueryParameters.js diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js new file mode 100644 index 0000000000..95d8665806 --- /dev/null +++ b/lib/public/components/Filters/common/FilterModel.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import { Observable } from '/js/src/index.js'; + +/** + * Model storing the state of a given filter + */ +export class FilterModel extends Observable { + /** + * Constructor + */ + constructor() { + super(); + + this._visualChange$ = new Observable(); + } + + /** + * Reset the filter to its initial state + * @return {void} + */ + reset() { + } + + /** + * States if the filter has been filled with a valid value + * + * @return {boolean} true if the filter is filled + */ + get isEmpty() { + return true; + } + + /** + * Returns the normalized value of the filter, that can be used as URL parameter + * + * @return {string|number|object|array|null} the normalized value + */ + get normalized() { + return null; + } + + /** + * Returns the observable notified any time there is a visual change which has no impact on the actual filter value + * + * @return {Observable} the observable + */ + get visualChange$() { + return this._visualChange$; + } +} diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js new file mode 100644 index 0000000000..8a07d73f43 --- /dev/null +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -0,0 +1,148 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Observable } from '/js/src/index.js'; +import { FilterModel } from './FilterModel.js'; +import { ToggleableModel } from '../../common/toggle/TogglableModel.js'; + +/** + * Model representing a filtering system, including filter inputs visibility, filters values and so on + */ +export class FilteringModel extends Observable { + /** + * Constructor + * + * @param {{getAll, reset}} filters the filters list model + */ + constructor(filters) { + super(); + + this._visualChange$ = new Observable(); + + this._toggleModel = new ToggleableModel(); + this._toggleModel.bubbleTo(this._visualChange$); + + /** + * @type {Map} + * @private + */ + this._filtersMeta = new Map(); + for (const propertyKey in filters) { + if (filters[propertyKey] instanceof FilterModel) { + this._addFilter(propertyKey, filters[propertyKey]); + } + } + + this._filtersStore = filters; + } + + /** + * Reset the filters + * + * @return {void} + */ + reset() { + this._filtersMeta.forEach(({ filter }) => filter.reset()); + } + + /** + * Returns the normalized value of all the filters, without null values + * + * @return {Object} the normalized values + */ + get normalized() { + const ret = {}; + for (const [filterKey, { filter }] of this._filtersMeta) { + if (!filter.isEmpty) { + ret[filterKey] = filter.normalized; + } + } + return ret; + } + + /** + * States if there is currently at least one filter active + * + * @return {boolean} true if at least one filter is active + */ + isAnyFilterActive() { + for (const [, { filter }] of this._filtersMeta) { + if (!filter.isEmpty) { + return true; + } + } + return false; + } + + /** + * Returns the list of human-readable names of currently active filters + * + * @return {string} the active filters names + */ + get activeFiltersNames() { + const ret = []; + for (const [, { filter, humanName }] of this._filtersMeta) { + if (!filter.isEmpty) { + ret.push(humanName); + } + } + return ret.join(', '); + } + + /** + * Returns the observable notified any time there is a visual change which has no impact on the actual filtering + * + * @return {Observable} the filters visibility observable + */ + get visualChange$() { + return this._visualChange$; + } + + /** + * Returns the object storing all the filters models + * + * @return {Object} the filters store + */ + get filters() { + return this._filtersStore; + } + + /** + * The visibility state of the filters popup + * + * @return {ToggleableModel} the toggle model + */ + get toggleModel() { + return this._toggleModel; + } + + /** + * Add a filter to the list of registered filters, and bubble filters events (global and visual) to this model + * + * @param {string} filterKey the key of the filter, used to normalize filtering request + * @param {FilterModel} filter the filter model + * @return {void} + * @private + */ + _addFilter(filterKey, filter) { + this._filtersMeta.set( + filterKey, + { + filter, + humanName: `${filterKey[0].toUpperCase()}${filterKey.slice(1).replaceAll(/([A-Z])/g, ' $1').toLowerCase()}`, + }, + ); + filter.bubbleTo(this); + filter.visualChange$.bubbleTo(this._visualChange$); + } +} diff --git a/lib/public/components/Filters/common/filters/CommaSeparatedValuesFilterModel.js b/lib/public/components/Filters/common/filters/CommaSeparatedValuesFilterModel.js new file mode 100644 index 0000000000..721ac130e1 --- /dev/null +++ b/lib/public/components/Filters/common/filters/CommaSeparatedValuesFilterModel.js @@ -0,0 +1,103 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import { arrayHasSameContent } from '../../../../utilities/arrayHasSameContent.js'; +import { FilterModel } from '../FilterModel.js'; + +/** + * Model for a coma separated values filter + * + * This filter input is a comma separated list of values and its value is an array of values + */ +export class CommaSeparatedValuesFilterModel extends FilterModel { + /** + * Constructor + */ + constructor() { + super(); + + this._values = null; + this._raw = ''; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + reset() { + this._values = null; + this._raw = ''; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + get isEmpty() { + const { values } = this; + return !values || values.length === 0; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + get normalized() { + return this.values; + } + + /** + * Define the current value of the filter + * + * @param {string} raw the raw value of the filter + * @param {array} values the list of parsed values of the filter + * + * @return {void} + */ + update(raw, values) { + const previousValues = [...this._values || []]; + + this._values = values; + this._raw = raw; + + if (arrayHasSameContent(values || [], previousValues)) { + // Only raw value changed + this.visualChange$.notify(); + } else { + this.notify(); + } + } + + /** + * Returns the raw value of the filter (the user input) + * + * @return {string} the raw value + */ + get raw() { + return this._raw; + } + + /** + * Return the parsed values of the filter + * + * @return {array} the parsed values + */ + get values() { + if (!Array.isArray(this._values) || this._values.length === 0) { + return null; + } + return this._values; + } +} diff --git a/lib/public/components/Filters/common/filters/ComparisonOperatorFilterModel.js b/lib/public/components/Filters/common/filters/ComparisonOperatorFilterModel.js new file mode 100644 index 0000000000..f9d49e3b47 --- /dev/null +++ b/lib/public/components/Filters/common/filters/ComparisonOperatorFilterModel.js @@ -0,0 +1,120 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { FilterModel } from '../FilterModel.js'; + +export const COMPARISON_OPERATORS = ['<', '<=', '=', '>=', '>']; +export const DEFAULT_COMPARISON_OPERATOR = '='; + +/** + * Model representing comparison operator filter + * + * @template T + */ +export class ComparisonOperatorFilterModel extends FilterModel { + /** + * Constructor + */ + constructor() { + super(); + + this._operator = DEFAULT_COMPARISON_OPERATOR; + + /** + * @type {(T|null)} + * @private + */ + this._limit = null; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @override + * @inheritDoc + */ + reset() { + this._operator = DEFAULT_COMPARISON_OPERATOR; + this._limit = null; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + get isEmpty() { + return this._limit === null; + } + + /** + * Updates the value of the operator and limit + * + * @param {object} raw the raw operator or/and limit of the filter + * + * @return {void} + */ + update({ operator: rawOperator, limit: rawLimit }) { + const operator = COMPARISON_OPERATORS.includes(rawOperator) ? rawOperator : this._operator; + let limit = this._limit; + if (rawLimit !== undefined) { + try { + limit = this.parseLimit(rawLimit); + } catch (e) { + // Keep the current limit + } + } + const previousOperator = this._operator; + this._operator = operator; + + const previousLimit = this._limit; + this._limit = limit; + + if (previousOperator !== this._operator || previousLimit !== this._limit) { + this.notify(); + } else { + this.visualChange$.notify(); + } + } + + /** + * Parse the given limit into a limit compatible for the current filter + * + * As a default, returns raw limit without modification. Models for specific comparison filter must handle parse here + * + * @param {*} rawLimit the raw value of the limit + * + * @return {T} the parsed limit + * @protected + */ + parseLimit(rawLimit) { + return rawLimit; + } + + /** + * Returns the current operator + * + * @return {string} the operator + */ + get operator() { + return this._operator; + } + + /** + * Returns the current limit + * + * @return {T} the current limit + */ + get limit() { + return this._limit; + } +} diff --git a/lib/public/components/Filters/common/filters/DateTimeLimitsFilterModel.js b/lib/public/components/Filters/common/filters/DateTimeLimitsFilterModel.js new file mode 100644 index 0000000000..b77b9449c2 --- /dev/null +++ b/lib/public/components/Filters/common/filters/DateTimeLimitsFilterModel.js @@ -0,0 +1,150 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import { formatDateForHTMLInput } from '../../../../utilities/formatting/formatDateForHTMLInput.js'; +import { FilterModel } from '../FilterModel.js'; + +/** + * @typedef DateTimeLimits + * @property {string} [fromDate] the limit start date expression (format YYYY-MM-DD) + * @property {string} [fromTime] the limit start time expression (format HH:MM) + * @property {string} [tillDate] the limit end date expression (format YYYY-MM-DD) + * @property {string} [tillTime] the limit end time expression (format HH:MM) + */ + +const DEFAULT_RAW = { + fromDate: '', + fromTime: '', + tillDate: '', + tillTime: '', +}; + +/** + * Model for a date time limits filter + */ +export class DateTimeLimitsFilterModel extends FilterModel { + /** + * Constructor + */ + constructor() { + super(); + + this._raw = { ...DEFAULT_RAW }; + this._fromTimestamp = null; + this._tillTimestamp = null; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + reset() { + this._raw = { ...DEFAULT_RAW }; + this._fromTimestamp = null; + this._tillTimestamp = null; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + get isEmpty() { + return this._fromTimestamp === null && this._tillTimestamp === null; + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + get normalized() { + return { + fromTimestamp: this._fromTimestamp, + tillTimestamp: this._tillTimestamp, + }; + } + + /** + * Updates the values of datetime start and end limits + * + * @param {DateTimeLimits} limits the date time limits + * @param {boolean} inputIsValid flag that states if the given expressions are valid (using HTML input validation) + * + * @return {void} + */ + update({ fromDate, fromTime, tillDate, tillTime }, inputIsValid) { + this._raw = { + fromDate: fromDate !== undefined ? fromDate : this._raw.fromDate, + fromTime: fromTime !== undefined ? fromTime : this._raw.fromTime, + tillDate: tillDate !== undefined ? tillDate : this._raw.tillDate, + tillTime: tillTime !== undefined ? tillTime : this._raw.tillTime, + }; + + const { date: currentDay, time: currentTime } = formatDateForHTMLInput(new Date()); + if (this._raw.fromDate && !this._raw.fromTime) { + this._raw.fromTime = '00:00'; + } + if (this._raw.tillDate && !this._raw.tillTime) { + this._raw.tillTime = this._raw.tillDate === currentDay ? currentTime : '23:59'; + } + + // eslint-disable-next-line require-jsdoc + const extractTimestampFromExpression = (date, time) => { + if (!date || !time) { + return null; + } + return Date.parse(`${date.replace(/\//g, '-')}T${time}:00.000`); + }; + + const fromTimestamp = inputIsValid ? extractTimestampFromExpression(this._raw.fromDate, this._raw.fromTime) : null; + const tillTimestamp = inputIsValid ? extractTimestampFromExpression(this._raw.tillDate, this._raw.tillTime) : null; + + const valuesChanges = inputIsValid && (fromTimestamp !== this._fromTimestamp || tillTimestamp !== this._tillTimestamp); + + if (valuesChanges) { + this._fromTimestamp = fromTimestamp; + this._tillTimestamp = tillTimestamp; + this.notify(); + } else { + this.visualChange$.notify(); + } + } + + /** + * Return the raw limits expressions + * + * @return {DateTimeLimits} raw limits expressions + */ + get raw() { + return this._raw; + } + + /** + * Return the timestamp of the end datetime + * + * @return {number} the end timestamp + */ + get tillTimestamp() { + return this._tillTimestamp; + } + + /** + * Return the timestamp of the start datetime + * + * @return {number} the start timestamp + */ + get fromTimestamp() { + return this._fromTimestamp; + } +} diff --git a/lib/public/components/Filters/common/filters/DurationFilterModel.js b/lib/public/components/Filters/common/filters/DurationFilterModel.js new file mode 100644 index 0000000000..10efe3d8d1 --- /dev/null +++ b/lib/public/components/Filters/common/filters/DurationFilterModel.js @@ -0,0 +1,71 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { ComparisonOperatorFilterModel } from './ComparisonOperatorFilterModel.js'; +import { mergeDuration } from '../../../../utilities/durationUtils.js'; + +/** + * Model representing duration filter + */ +export class DurationFilterModel extends ComparisonOperatorFilterModel { + /** + * Constructor + */ + constructor() { + super(); + } + + // eslint-disable-next-line valid-jsdoc + /** + * @inheritDoc + * @override + */ + parseLimit(rawLimit) { + if (typeof rawLimit !== 'object') { + throw Error('Unable to handle non object limit'); + } + const { hours, minutes, seconds } = rawLimit; + + let ret = { + ...this.limit, + }; + + for (const [key, rawValue] of Object.entries({ hours, minutes, seconds })) { + if (rawValue !== undefined) { + let value = null; + if (rawValue !== '') { + value = parseInt(rawValue, 10); + if (isNaN(value)) { + throw new Error(`Invalid ${key} : ${rawValue}`); + } + } + ret[key] = value; + } + } + + if (ret.hours === null && ret.minutes === null && ret.seconds === null) { + ret = null; + } + + return ret; + } + + /** + * Returns the filter's duration in milliseconds + * + * @return {number} the normalized representation + */ + get normalized() { + return this.limit ? mergeDuration(this.limit) : null; + } +} diff --git a/lib/public/components/Filters/common/filters/commaSeparatedValuesFilter.js b/lib/public/components/Filters/common/filters/commaSeparatedValuesFilter.js new file mode 100644 index 0000000000..5afaca1570 --- /dev/null +++ b/lib/public/components/Filters/common/filters/commaSeparatedValuesFilter.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h } from '/js/src/index.js'; + +const SEPARATOR = ','; + +/** + * @callback commaSeparatedValuesFilterChangeHandler + * + * @param {{raw: string, values: string[]}} newFilter the raw values in the input alongside with the parsed array of values + * + * @return {void} + */ + +/** + * Returns a filter component to filter on a comma separated value list of elements + * + * The coma separated list of elements is returned as an array. Each item is parsed (default parse function simply trim and remove empty strings) + * Any item parsed to null or undefined is removed + * + * @param {CommaSeparatedValuesFilterModel} filterModel the model for this filter + * @param {function} parseValue an optional function applied on every value before to return them. As a default, convert empty strings to null + * + * @return {vnode} the filter component + */ +export const commaSeparatedValuesFilter = (filterModel, parseValue = (value) => value === '' ? null : value) => { + // eslint-disable-next-line require-jsdoc + const handleInput = (e) => { + const { value } = e.target; + filterModel.update( + value, + value.split(SEPARATOR) + .map((item) => parseValue(item.trim())) + .filter((item) => item !== null && item !== undefined), + ); + }; + + return h('input', { + value: filterModel.raw, + oninput: handleInput, + }); +}; diff --git a/lib/public/components/Filters/common/filters/comparisonOperatorFilter.js b/lib/public/components/Filters/common/filters/comparisonOperatorFilter.js index d20dca35dc..70ea8dcef9 100644 --- a/lib/public/components/Filters/common/filters/comparisonOperatorFilter.js +++ b/lib/public/components/Filters/common/filters/comparisonOperatorFilter.js @@ -12,6 +12,7 @@ */ import { h } from '/js/src/index.js'; +import { COMPARISON_OPERATORS } from './ComparisonOperatorFilterModel.js'; /** * Handle an operator change event @@ -39,7 +40,7 @@ export const comparisonOperatorFilter = (limitInput, currentLimit, onOperatorCha ...operatorAttributes, value: currentLimit, onchange: (e) => onOperatorChange(e.target.value), - }, ['<', '<=', '=', '>=', '>'].map((operator) => h('option', { + }, COMPARISON_OPERATORS.map((operator) => h('option', { value: operator, }, operator)))), limitInput, ]); diff --git a/lib/public/components/Filters/common/filters/dateTimeLimitsFilter.js b/lib/public/components/Filters/common/filters/dateTimeLimitsFilter.js new file mode 100644 index 0000000000..01621f310e --- /dev/null +++ b/lib/public/components/Filters/common/filters/dateTimeLimitsFilter.js @@ -0,0 +1,113 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h } from '/js/src/index.js'; +import { formatDateForHTMLInput } from '../../../../utilities/formatting/formatDateForHTMLInput.js'; + +const DATE_FORMAT = 'YYYY-MM-DD'; + +/** + * Returns a component providing a from/to filter + * + * @param {DateTimeLimitsFilterModel} filterModel the model of the filter + * + * @return {vnode} the filter component + */ +export const dateTimeLimitsFilter = (filterModel) => { + const now = new Date(); + + const { date: currentDay, time: currentTime } = formatDateForHTMLInput(now); + let maxFromDate; let maxFromTime; let minTillDate; let minTillTime; + const maxTillDate = currentDay; + const maxTillTime = currentTime; + + if (filterModel.tillTimestamp) { + ({ date: maxFromDate, time: maxFromTime } = formatDateForHTMLInput(new Date(filterModel.tillTimestamp))); + } else { + maxFromDate = currentDay; + maxFromTime = currentTime; + } + + if (filterModel.fromTimestamp) { + ({ date: minTillDate, time: minTillTime } = formatDateForHTMLInput(new Date(filterModel.fromTimestamp))); + } + + const { fromDate, fromTime, tillDate, tillTime } = filterModel.raw; + + return h('', [ + h('.f6', 'Started From:'), + h('input.w-50.mv1.datetime-filter-date.datetime-filter-from-date', { + type: 'date', + placeholder: DATE_FORMAT, + max: maxFromDate, + value: fromDate, + oninput: (e) => filterModel.update({ fromDate: e.target.value }, e.target.validity.valid), + }, ''), + h('input.w-50.mv1.datetime-filter-time.datetime-filter-from-time', { + type: 'time', + max: maxFromTime, + value: fromTime, + oninput: (e) => { + const time = e.target.value; + if (time !== '' && !fromDate) { + filterModel.update( + { + fromDate: currentDay, + fromTime: time, + }, + e.target.validity.valid && e.target.value <= currentTime, + ); + } else { + filterModel.update( + { fromTime: time }, + e.target.validity.valid, + ); + } + }, + }, ''), + h('.f6', 'Started Till:'), + h('input.w-50.mv1.datetime-filter-date.datetime-filter-till-date', { + type: 'date', + placeholder: DATE_FORMAT, + min: minTillDate, + max: maxTillDate, + value: tillDate, + oninput: (e) => filterModel.update({ + tillDate: e.target.value, + }, e.target.validity.valid), + }, ''), + h('input.w-50.mv1.datetime-filter-time.datetime-filter-till-time', { + type: 'time', + min: minTillTime, + max: maxTillTime, + value: tillTime, + oninput: (e) => { + const time = e.target.value; + if (time !== '' && !tillDate) { + filterModel.update( + { + tillDate: currentDay, + tillTime: time, + }, + e.target.validity.valid && e.target.value <= currentTime, + ); + } else { + filterModel.update( + { tillTime: time }, + e.target.validity.valid, + ); + } + }, + }, ''), + ]); +}; diff --git a/lib/public/components/Filters/common/filters/durationFilter.js b/lib/public/components/Filters/common/filters/durationFilter.js new file mode 100644 index 0000000000..3227851f37 --- /dev/null +++ b/lib/public/components/Filters/common/filters/durationFilter.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import { comparisonOperatorFilter } from './comparisonOperatorFilter.js'; +import { h } from '/js/src/index.js'; + +/** + * @callback durationFilterChangeHandler + * @param {{operator: string, limit: (number|number)}} newFilter the new filter value including the limit duration in milliseconds + */ + +/** + * Returns a component which provide a duration filter, allowing to choose a limit and the comparison operator to apply + * + * @param {DurationFilterModel} filterModel the filter's model + * @param {*} options eventual options to configure the filter: use `operatorAttributes` to define the attributes of + * the operator selection component and `limitAttributes` to define the attributes of the limit input component + * + * @return {vnode} the component + */ +export const durationFilter = (filterModel, options) => { + const { operator, limit } = filterModel; + const { operatorAttributes = {}, limitAttributes = {} } = options; + + const { hours, minutes, seconds } = limit || { hours: null, minutes: null, seconds: null }; + + // Number input are not controlled because the e.target.value do not reflect what is actually written in the input + + const durationInput = h( + '.flex-row.flex-grow.g1.items-center', + limitAttributes, + [ + h('input.flex-grow.text-center', { + type: 'number', + min: 0, + placeholder: 'HH', + value: hours, + oninput: (e) => filterModel.update({ limit: { hours: e.target.validity.valid ? e.target.value : undefined } }), + }), + ':', + h('input.flex-grow.text-center', { + type: 'number', + min: 0, + max: 59, + placeholder: 'MM', + value: minutes, + oninput: (e) => filterModel.update({ limit: { minutes: e.target.validity.valid ? e.target.value : undefined } }), + }), + ':', + h('input.flex-grow.text-center', { + type: 'number', + min: 0, + max: 59, + placeholder: 'SS', + value: seconds, + oninput: (e) => filterModel.update({ limit: { seconds: e.target.validity.valid ? e.target.value : undefined } }), + }), + ], + ); + + return comparisonOperatorFilter( + durationInput, + operator, + (newOperator) => filterModel.update({ operator: newOperator }), + operatorAttributes, + ); +}; diff --git a/lib/public/utilities/arrayHasSameContent.js b/lib/public/utilities/arrayHasSameContent.js new file mode 100644 index 0000000000..0d31ba8932 --- /dev/null +++ b/lib/public/utilities/arrayHasSameContent.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Returns true if the two given arrays has strictly equals items at the same index + * + * @param {array} first the first array to compare + * @param {array} second the second array to compare + * + * @return {boolean} true if the arrays has the same content + */ +export const arrayHasSameContent = (first, second) => first.every((item, index) => item === second[index]); diff --git a/lib/public/utilities/formatting/formatDateForHTMLInput.js b/lib/public/utilities/formatting/formatDateForHTMLInput.js new file mode 100644 index 0000000000..297ac16edf --- /dev/null +++ b/lib/public/utilities/formatting/formatDateForHTMLInput.js @@ -0,0 +1,28 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Returns the given date formatted in two parts, YYYY-MM-DD and HH:MM to fill in HTML date and time inputs + * + * @param {Date} date the date to parse + * + * @return {{date: string, time: string}} the date expression + */ +export const formatDateForHTMLInput = (date) => { + const [dateExpression] = date.toISOString().split('T'); + const hours = `${date.getHours()}`.padStart(2, '0'); + const minutes = `${date.getHours()}`.padStart(2, '0'); + const timeExpression = `${hours}:${minutes}`; + + return { date: dateExpression, time: timeExpression }; +}; diff --git a/lib/public/utilities/serializeQueryParameters.js b/lib/public/utilities/serializeQueryParameters.js new file mode 100644 index 0000000000..6a9ba73404 --- /dev/null +++ b/lib/public/utilities/serializeQueryParameters.js @@ -0,0 +1,69 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Given a value and a query param prefix, returns a list of key => values representing the corresponding query parameters (null or undefined + * values are dropped) + * + * for example [1, 3] with prefix 'myPrefix' will result in [{key: 'myPrefix[]', value: 1}, {key: 'myPrefix[]', value: 3] + * for example {foo: 1, bar: 3} with prefix 'myPrefix' will result in [{key: 'myPrefix[foo]', value: 1}, {key: 'myPrefix[bar]', value: 3] + * + * @param {string|boolean|number|null|array|object} parameters the parameter to convert to query param + * @param {string} key the query parameter's key + * @return {({key: string, value: (string|number)}|null)[]} the query parameters definition + */ +export const serializeQueryParameters = (parameters, key) => { + if (parameters === null || parameters === undefined) { + return [null]; + } + + if (Array.isArray(parameters)) { + return parameters.map((parameter) => serializeQueryParameters(parameter, `${key}[]`)).flat(); + } + + switch (typeof parameters) { + case 'boolean': + return [{ key, value: parameters ? 'true' : 'false' }]; + case 'number': + case 'string': + return [{ key, value: parameters }]; + case 'object': + return Object.entries(parameters) + .map(([parameterKey, parameter]) => serializeQueryParameters(parameter, `${key}[${parameterKey}]`)) + .flat(); + default: + return [null]; + } +}; + +/** + * Generate a {URLSearchParams} from an object representing the query parameters + * + * Parameters can be nested ({foo: {bar: 23}}) and values can be an array ({foo: ['bar', 'baz']}) + * + * @param {Object} parameters the query parameters + * @return {URLSearchParams} the generated search params + */ +export const generateURLSearchParams = (parameters) => { + const ret = new URLSearchParams(); + + for (const mainKey in parameters) { + const serializedQueryParameters = serializeQueryParameters(parameters[mainKey], mainKey); + for (const serializedQueryParameter of serializedQueryParameters) { + if (serializedQueryParameter) { + ret.append(serializedQueryParameter.key, serializedQueryParameter.value); + } + } + } + return ret; +};