diff --git a/lib/public/app.css b/lib/public/app.css index bb67bc66bd..77f2215862 100644 --- a/lib/public/app.css +++ b/lib/public/app.css @@ -718,6 +718,52 @@ label { opacity: 0.5; } +.active-filters-indicator { + border-radius: .25rem; + padding: var(--space-xs) var(--space-s) var(--space-xs) var(--space-s); + margin: 0 0 0 var(--space-s); +} + +.active-filters-indicator:has(+ .clear-filter-icon) { + border-right: 0; + border-radius: .25rem 0 0 .25rem +} + +.clear-filter-icon { + background-color: white; + border-radius: 0 .25rem .25rem 0; + color: var(--color-danger); + padding: var(--space-xs); + font-weight: 700; + cursor: pointer; +} + +.clear-filter-icon:hover { + background-color: var(--color-danger); + color: white; +} + +.inactive { + opacity: 0.5; + pointer-events: none; +} + +.pulse-green { + animation: pulseGreen 2s infinite; +} + +@keyframes pulseGreen { + 0% { + box-shadow: 0 0 0px rgba(102, 255, 7, 0.6); + } + 50% { + box-shadow: 0 0 10px rgba(102, 255, 7, 0.9); + } + 100% { + box-shadow: 0 0 0px rgba(102, 255, 7, 0.6); + } +} + /** * Breakpoints : * small : x < 600 (default styles) diff --git a/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js b/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js index 6dadd9f363..7bc3ae5592 100644 --- a/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js @@ -76,6 +76,13 @@ export class MultiCompositionFilterModel extends FilterModel { return Object.values(this._filters).every((filter) => filter.isEmpty); } + /** + * @inheritDoc + */ + get isInactive() { + return Object.values(this._filters).every((filter) => filter.isInactive); + } + /** * @inheritDoc */ diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js index 2aae7b1a10..d16f1226f7 100644 --- a/lib/public/components/Filters/common/FilterModel.js +++ b/lib/public/components/Filters/common/FilterModel.js @@ -77,6 +77,15 @@ export class FilterModel extends Observable { return this._visualChange$; } + /** + * States if the filter is active. By default this is equivalent to isEmpty + * + * @return {boolean} true if the filter is active + */ + get isInactive() { + return this.isEmpty; + } + /** * Utility function to register a filter model as sub-filter model * diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index 251dbd07a8..174b78f37d 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -95,12 +95,7 @@ export class FilteringModel extends Observable { * @return {boolean} true if at least one filter is active */ isAnyFilterActive() { - for (const model of this._filterModels) { - if (!model.isEmpty) { - return true; - } - } - return false; + return !this._filterModels.every((model) => model.isInactive); } /** diff --git a/lib/public/components/Filters/common/RadioButtonFilterModel.js b/lib/public/components/Filters/common/RadioButtonFilterModel.js index 5e93205bfc..0aaa6e70af 100644 --- a/lib/public/components/Filters/common/RadioButtonFilterModel.js +++ b/lib/public/components/Filters/common/RadioButtonFilterModel.js @@ -22,13 +22,27 @@ export class RadioButtonFilterModel extends SelectionModel { * * @param {SelectionOption[]} [availableOptions] the list of possible operators * @param {function} [setDefault] function that selects the default from the list of available options. Selects first entry by default + * @param {boolean} [defaultIsEmpty] if true, the default selection will be treated as empty */ - constructor(availableOptions, setDefault = (options) => [options[0]]) { + constructor(availableOptions, setDefault = (options) => [options[0]], defaultIsEmpty = true) { super({ availableOptions, defaultSelection: setDefault(availableOptions), multiple: false, allowEmpty: false, }); + + this._defaultIsEmpty = defaultIsEmpty; + } + + /** + * @inheritdoc + */ + get isEmpty() { + if (this._defaultIsEmpty) { + return this.hasOnlyDefaultSelection(); + } + + return false; } } diff --git a/lib/public/components/Filters/common/filters/ToggleFilterModel.js b/lib/public/components/Filters/common/filters/ToggleFilterModel.js index b5a98fd4a4..e964815e83 100644 --- a/lib/public/components/Filters/common/filters/ToggleFilterModel.js +++ b/lib/public/components/Filters/common/filters/ToggleFilterModel.js @@ -60,4 +60,19 @@ export class ToggleFilterModel extends SelectionModel { return false; } + + /** + * Returns if the toggle filter is considered 'inactive' + * If _falseIsEmpty is true, this getter is synonymous with isEmpty + * + * @return {boolean} + */ + get isInactive() { + if (!this._falseIsEmpty) { + return this.isEmpty; + } + + // If the filter has its default selection, it should not be considered 'inactive' + return this.hasOnlyDefaultSelection(); + } } diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 8f0ca39a4b..a2543d9e23 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -11,7 +11,7 @@ * or submit itself to any jurisdiction. */ import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration, DropdownComponent, CopyToClipboardComponent } from '/js/src/index.js'; -import { iconCaretBottom } from '/js/src/icons.js'; +import { iconCaretBottom, iconX } from '/js/src/icons.js'; import { profiles } from '../../common/table/profiles.js'; import { applyProfile } from '../../../utilities/applyProfile.js'; import { tooltip } from '../../common/popover/tooltip.js'; @@ -42,18 +42,21 @@ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primar * Button component that resets all filters upon click * * @param {FilteringModel|OverviewPageModel} filteringModel the FilteringModel + * @param {bool} [filteringModel=false] if the component is rendered as a regular button with text or as a component with an 'X' icon * @returns {Component} the reset button component */ -const resetFiltersButton = (filteringModel) => h( - 'button#reset-filters.btn.btn-danger', - { +const resetFiltersButton = (filteringModel, isIcon = false) => { + const attributes = { disabled: !filteringModel.isAnyFilterActive(), onclick: () => filteringModel.resetFiltering ? filteringModel.resetFiltering(true, true) : filteringModel.reset(true, true), - }, - 'Reset all filters', -); + }; + + return isIcon + ? h('.clear-filter-icon.b1.b-danger.btn-group-item.last-item', attributes, 'X') + : h('button#reset-filters.btn.btn-danger', attributes, 'Reset all filters'); +} /** * Create main header of the filters panel @@ -168,6 +171,28 @@ const pasteButtonOption = (model) => { }, 'Paste filters'); }; +/** + * A indicates if any filters are currently active on the page + * + * @param {FilteringModel} filteringModel the filtering model + * @returns {Component} the active filters indicator + */ +const activeFilterIndicator = (filteringModel) => { + const hasActiveFilters = filteringModel.isAnyFilterActive() + const innerText = `Filters ${hasActiveFilters ? 'Active' : 'Inactive'}`; + + let indicator = '.active-filters-indicator.b1'; + indicator += hasActiveFilters ? '.b-success.success.pulse-green' : '.inactive'; + + const children = [h(indicator, innerText)]; + + if (hasActiveFilters) { + children.push(resetFiltersButton(filteringModel, true)) + } + + return h('.flex-row.items-center', children); +}; + /** * Return component composed of the filter popover button and a dropdown trigger * @@ -195,6 +220,7 @@ export const filtersPanelPopover = (filteringModel, filtersConfiguration, config ], ), ), + activeFilterIndicator(filteringModel), ], ); }; diff --git a/lib/public/components/common/selection/SelectionModel.js b/lib/public/components/common/selection/SelectionModel.js index 9b812eabf5..20e03d4b9e 100644 --- a/lib/public/components/common/selection/SelectionModel.js +++ b/lib/public/components/common/selection/SelectionModel.js @@ -113,6 +113,15 @@ export class SelectionModel extends Observable { return selected.length === defaultSelection.length && selected.every((item) => defaultSelection.includes(item)); } + /** + * States if the filter is active. By default this is equivalent to isEmpty + * + * @return {boolean} true if the filter is active + */ + get isInactive() { + return this.isEmpty; + } + /** * Reset the selection to the default *