From b43546a0a93dababeef40e23713f7425cd444fb6 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Tue, 9 Sep 2025 15:40:31 +0200 Subject: [PATCH 01/22] Search/filter layout functionality in Model --- .../public/services/Layout.service.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/QualityControl/public/services/Layout.service.js b/QualityControl/public/services/Layout.service.js index 80550e7b5..dbcfe535f 100644 --- a/QualityControl/public/services/Layout.service.js +++ b/QualityControl/public/services/Layout.service.js @@ -35,6 +35,7 @@ export default class LayoutService { this.list = RemoteData.notAsked(); // List of all existing layouts in QCG; this.userList = RemoteData.notAsked(); // List of layouts owned by current user; + this.filterList = RemoteData.notAsked(); // List of all layouts filtered by filter object; } /** @@ -105,6 +106,44 @@ export default class LayoutService { that.notify(); } + /** + * Method to get all layouts by a given filter object + * Only one type of filter condition exists right now, filter.objectPath. + * Check the controller: 'QualityControl/lib/dtos/LayoutDto.js' line 88 + * for more future filter options. + * @param {string|undefined} fields - comma seperated string values. Represent the fields that should be fetched. + * If left empty all available fields will be fetched + * @param {object} filter - filter information to be parsed by the backend. + * @param {Class} that - Observer requesting data that should be notified of changes + * @returns {undefined} + */ + async getLayoutsByFilter(fields = undefined, filter = undefined, that = this.model) { + this.filterList = RemoteData.loading(); + that.notify(); + if (filter !== undefined) { + const filterSearchParam = new URLSearchParams(); + if (fields !== undefined) { + filterSearchParam.append('fields', fields); + } + + filterSearchParam.append('filter', JSON.stringify(filter)); + filter.objectPath = filter.objectPath.trim(); + const url = `/api/layouts?${filterSearchParam.size > 0 ? `${filterSearchParam.toString()}` : ''}`; + + const { result, ok } = await this.loader.get(url); + if (ok) { + const sortedLayouts = result.sort(this._compareByName); + // My layouts/userlist or should this be about all layouts? + this.filterList = RemoteData.success(sortedLayouts); + } else { + this.filterList = RemoteData.failure(result.error || result.message); + } + } else { + this.filterList = RemoteData.failure('Filter is not defined'); + } + that.notify(); + } + /** * Comparator function to sort layouts alphabetically by their name property * @param {Layout} layout1 - First layout object to compare From 37df26e4754314e0877ee365549cc392b86e6299 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Mon, 15 Sep 2025 16:00:46 +0200 Subject: [PATCH 02/22] Filters work in progress, TODO filter finetuning and UI --- .../public/pages/layoutListView/Filter.js | 100 ++++++++++++++++ .../pages/layoutListView/FilterTypes.js | 73 ++++++++++++ .../pages/layoutListView/LayoutListPage.js | 55 ++++++++- .../components/LayoutListHeader.js | 59 ++++++++-- .../layoutListView/filtersPanelPopover.js | 111 ++++++++++++++++++ .../layoutListView/model/SearchFilterModel.js | 29 +++++ 6 files changed, 416 insertions(+), 11 deletions(-) create mode 100644 QualityControl/public/pages/layoutListView/Filter.js create mode 100644 QualityControl/public/pages/layoutListView/FilterTypes.js create mode 100644 QualityControl/public/pages/layoutListView/filtersPanelPopover.js create mode 100644 QualityControl/public/pages/layoutListView/model/SearchFilterModel.js diff --git a/QualityControl/public/pages/layoutListView/Filter.js b/QualityControl/public/pages/layoutListView/Filter.js new file mode 100644 index 000000000..6cb8d483f --- /dev/null +++ b/QualityControl/public/pages/layoutListView/Filter.js @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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. + */ + +/** + * @typedef {object} FilterModel + * @property {(filter: import("./FilterTypes").Filter) => import("./FilterTypes").Filter} register + * - Register a filter object with a unique key. + * @property {(key: string) => void} unregister - Remove a filter by its key. + * @property {(key: string) => (import("./FilterTypes").Filter|undefined)} get + * - Return the registered filter object for the given key. + * @property {() => import("./FilterTypes").Filter[]} getAll - Return an array of all registered filters. + * @property {(key: string, ...args: any[]) => void} setValue - Call the filter's set(args...) method. + * @property {() => void} resetAll - Call reset() on all filters. + */ + +/** + * create the filtermodel that will store our filters and their states. + * It also provides functionality to modify its filters and getting data from them. + * @returns {FilterModel} - created filtermodel. + */ +export function createFilterModel() { + const filters = new Map(); // key -> filter instance + + /** + * register a filter object. + * @param {Filter} filter - Filter object to register. + * @returns {Filter} - Filter object that was registered. + */ + function register(filter) { + if (!filter?.key) { + throw new Error('Invalid filter'); + } + if (filters.has(filter.key)) { + throw new Error(`Filter already registered: ${filter.key}`); + } + filters.set(filter.key, filter); + return filter; + } + + function unregister(key) { + const filter = filters.get(key); + if (!filter) { + return; + } + return filters.delete(key); + } + + function get(key) { + return filters.get(key); + }; + + function getAll() { + return Array.from(filters.values()); + } + + function setValue(key, ...args) { + const filter = filters.get(key); + if (!filter) { + throw new Error(`Unknown filter: ${key}`); + } + if (typeof filter.set !== 'function') { + // not a proper filter, we need the setter. + throw new Error(`Filter has no set method: ${key}`); + } + filter.set(...args); + } + + function resetAll() { + for (const filter of filters.values()) { + try { + if (typeof filter.reset === 'function') { + filter.reset(); + } + } catch (e) { + // TODO: use the correct logger here, not this one... + console.error(e); + } + } + } + + return { + register, + unregister, + get, + getAll, + setValue, + resetAll, + }; +} diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js new file mode 100644 index 000000000..1334f9cd2 --- /dev/null +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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. + */ + +/** + * @typedef {object} Filter + * @property {string} key - searchable key of the filter. + * @property {Function} isActive - has the filter any active value('s) + * @property {Function} getValue - gets the current value('s) of the filter + * @property {Function} set - set value of the filter + * @property {Function} reset - reset filter to default state + */ + +/** + * creates a key-value filter. + * @param {string} key - key used to save and retrieve value. + * @param {string} value - value associated with key. + * @returns {Filter} key-value filter object. + */ +export function createKeyValueFilter(key, value = '') { + return { + key, + getValue: () => value ? value : null, + // trim checks if value is a string value, test this + isActive: () => Boolean(value && value.trim()), + set: (v) => { + value = v; + }, + reset: () => { + value = ''; + }, + }; +} + +// Multi-value filter +/** + * Creates a multiple value filter, key with array value. + * @param {string} key - key used to save and retrieve value. + * @param {Array} value - values (array) associated with key. + * @returns {Filter} multiple value filter, key with array with values. + */ +export function createMultiValueFilter(key, value = []) { + let values = Array.isArray(value) ? value : []; + return { + key, + getValue: () => values, + isActive: () => values.length > 0, + add: (v) => { + if (!values.includes(v)) { + values.push(v); + } + }, + remove: (v) => { + values = values.filter((x) => x !== v); + }, + set: (arr) => { + values = Array.isArray(arr) ? arr : []; + }, + reset: () => { + values = []; + }, + }; +} diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index 315f6f832..1c3e9070d 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -14,14 +14,61 @@ import FolderComponent from '../../folder/view/FolderComponent.js'; import { h } from '/js/src/index.js'; +import SearchFilterModel from './model/SearchFilterModel.js'; +import { createKeyValueFilter } from './FilterTypes.js'; +import { filtersPanelPopover } from './filtersPanelPopover.js'; /** * Shows a list of layouts grouped by user and more * @param {Array} folderModels - LayoutListModel.folders: The Folders used by LayoutListModel * @returns {vnode} - virtual node element */ -export default function (folderModels) { - return h('.scroll-y.absolute-fill', { - style: 'display: flex; flex-direction: column', - }, Array.from(folderModels.values()).map(FolderComponent)); +export default (folderModels) => { + const searchFilterModel = new SearchFilterModel(); + initializeSearchFilters(searchFilterModel.filterModel); + + return [ + h('.scroll-y.absolute-fill', [ + h( + '.flex-row.text-right.m2', + // h('.btn.btn-primary', 'Filter'), + // eslint-disable-next-line @stylistic/js/array-bracket-newline + [ + filtersPanelPopover(searchFilterModel.filterModel), + h( + 'input.form-control.form-inline.mh1.w-33', + { + placeholder: 'Search', + type: 'text', + value: searchFilterModel.searchInput, + // switch to search(undefined, e.target.value) when searching for objectPath + // oninput: (e) => layoutListModel.search(undefined, e.target.value), + oninput: (e) => { + searchFilterModel.searchInput = e.target.value; + }, + }, + ), + ], + ), + + h('', { + style: 'display: flex; flex-direction: column', + }, Array.from(folderModels.values()).map(FolderComponent)), + ]), + ]; +}; + +/** + * Initializes the filterModel model. + * @param {import('./Filter.js').FilterModel} filterModel - filterModel of the searchFilterModel. + */ +function initializeSearchFilters(filterModel) { + console.log(filterModel.register(createKeyValueFilter('objectPath', 'abc'))); + + // TESTFUNCJASP(filterModel); + return; } + +// function TESTFUNCJASP(filterModel) { +// // filterModel.setValue('objectPath', 'test'); +// } diff --git a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js index f8ce9a27f..ef3108b2b 100644 --- a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js +++ b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js @@ -17,17 +17,62 @@ import { h } from '/js/src/index.js'; /** * Shows header of list of layouts with one search input to filter them * @param {LayoutListModel} layoutListModel - The model handeling the state of the LayoutListPage - * @param {FilterModel} filterModel - The model handeling the filter state * @returns {vnode} - virtual node element */ export default (layoutListModel) => [ h('.w-50.text-center', [h('b.f4', 'Layouts')]), + // eslint-disable-next-line @stylistic/js/array-bracket-newline h('.flex-grow.text-right', [ - h('input.form-control.form-inline.mh1.w-33', { - placeholder: 'Search', - type: 'text', - value: layoutListModel.searchInput, - oninput: (e) => layoutListModel.search(e.target.value), - }), + h(layoutListModel.searchObjectPathMode ? '.btn.btn-primary' : '.btn', 'SearchMode'), + // eslint-disable-next-line @stylistic/js/array-bracket-newline + ], [ + h( + 'input.form-control.form-inline.mh1.w-33', + + layoutListModel.searchObjectPathMode ? + { + placeholder: 'Search', + type: 'text', + value: layoutListModel.searchInput, + // switch to search(undefined, e.target.value) when searching for objectPath + // oninput: (e) => layoutListModel.search(undefined, e.target.value), + oninput: (e) => { + layoutListModel.searchInput = e.target.value; + }, + } + : + { + placeholder: 'Search', + type: 'text', + value: layoutListModel.searchInput, + // switch to search(undefined, e.target.value) when searching for objectPath + oninput: (e) => layoutListModel.search(e.target.value), + }, + ), + // eslint-disable-next-line @stylistic/js/array-bracket-newline ]), ]; + +// /** +// * Function responsible for getting the right input control body element. +// * @param {LayoutListModel} layoutListModel - The model handeling the state of the LayoutListPage +// * @returns {object} - object containing data for body of Mitril element +// */ +// function getSearchInputObject(layoutListModel) { +// return layoutListModel.searchObjectPathMode ? +// { +// placeholder: 'Search', +// type: 'text', +// value: layoutListModel.searchInput, +// // switch to search(undefined, e.target.value) when searching for objectPath +// oninput: (e) => layoutListModel.search(e.target.value), +// } +// : +// { +// placeholder: 'Search', +// type: 'text', +// value: layoutListModel.searchInput, +// // switch to search(undefined, e.target.value) when searching for objectPath +// oninput: (e) => layoutListModel.search(e.target.value), +// }; +// } diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js new file mode 100644 index 000000000..2cc387f7a --- /dev/null +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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. + */ + +// Adopted from Bookkeeping/lib/public/components/Filters/common/filtersPanelPopover.js +import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; +// import { tooltip } from '../../common/popover/tooltip.js'; + +//imports for JSDoc + VSCode navigation: +// eslint-disable-next-line no-unused-vars +import { createFilterModel } from './Filter.js'; + +/** + * Return the filters panel popover trigger + * @return {Component} the button component + */ +const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); + +/** + * Create main header of the filters panel + * @param {import('./Filter').FilterModel} filteringModel {@link createFilterModel} filtering model. + * @returns {Component} main panel header. + */ +const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ + h('.f4', 'Filters'), + h( + 'button#reset-filters.btn.btn-danger', + { + onclick: () => filteringModel.resetAll(), + // TODO fix check for active filters over filteringmodel..... + // ? filteringModel.resetFiltering() + // : filteringModel.reset(true), + // disabled: !filteringModel.isAnyFilterActive(), + disabled: false, + }, + 'Reset all filters', + ), +]); + +/** + * Return the filters panel popover content section + * @param {import('./Filter.js').FilterModel} filterModel the filter model + * @returns {Component} the filters section + */ +export const filtersSection = (filterModel = {}) => + h('.flex-column.g2', [ + 'hello world!!', + filterModel.getAll().forEach((filter)=> { + filter.getValue.toString(); + }), + // Object.entries(filtersConfiguration) + // .filter(([_, column]) => { + // let columnProfiles = column.profiles ?? [profiles.none]; + // if (typeof columnProfiles === 'string') { + // columnProfiles = [columnProfiles]; + // } + // return applyProfile(column, appliedProfile, columnProfiles)?.filter; + // }) + // .map(([columnKey, { name, filterTooltip, filter }]) => + // name + // ? [ + // h(`.flex-row.items-baseline.${columnKey}-filter`, [ + // h('.w-30.f5.flex-row.items-center.g2', [ + // name, + // filterTooltip ? tooltip(info(), filterTooltip) : null, + // ]), + // h('.w-70', typeof filter === 'function' ? filter(filteringModel) : filter), + // ]), + // ] + // : typeof filter === 'function' ? filter(filteringModel) : filter), + ]); + +/** + * Return the filters panel popover content (i.e. the actual filters) + * @param {FilteringModel} filteringModel the filtering model + * @param {object} [configuration] additional configuration + * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column + * @returns {Component} the filters panel + */ +const filtersToggleContent = ( + filteringModel, + configuration = {}, +) => h('.w-l.flex-column.p3.g3', [ + filtersToggleContentHeader(filteringModel), + filtersSection(filteringModel, configuration), +]); + +/** + * Return component composed of the filtering popover and its button trigger + * @param {FilteringModel} filteringModel the filtering model + * @param {object} [configuration] optional configuration + * @returns {Component} the filter component + */ +export const filtersPanelPopover = (filteringModel, configuration) => popover( + filtersToggleTrigger(), + filtersToggleContent(filteringModel, configuration), + { + ...PopoverTriggerPreConfiguration.click, + anchor: PopoverAnchors.RIGHT_START, + }, +); diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js new file mode 100644 index 000000000..2fead57e7 --- /dev/null +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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 { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; +import { createFilterModel } from '../Filter.js'; + +/** + * SearchFilter model to control the search and filter state + * @param {Model} model - The the application model + */ +export default class SearchFilterModel extends BaseViewModel { + constructor(model) { + super(); + this.model = model; + this.searchInput = ''; + this.filterModel = createFilterModel(); + } +} From bbb5b995ed9cac3a59c91550c33fa35bacf38838 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Tue, 16 Sep 2025 17:27:04 +0200 Subject: [PATCH 03/22] WIP Mithril fighting --- .../public/pages/layoutListView/Filter.js | 100 -------------- .../pages/layoutListView/FilterModel.js | 123 ++++++++++++++++++ .../pages/layoutListView/FilterTypes.js | 6 +- .../pages/layoutListView/LayoutListPage.js | 6 +- .../layoutListView/filtersPanelPopover.js | 80 ++++++------ .../layoutListView/model/LayoutListModel.js | 15 ++- .../layoutListView/model/SearchFilterModel.js | 12 +- 7 files changed, 189 insertions(+), 153 deletions(-) delete mode 100644 QualityControl/public/pages/layoutListView/Filter.js create mode 100644 QualityControl/public/pages/layoutListView/FilterModel.js diff --git a/QualityControl/public/pages/layoutListView/Filter.js b/QualityControl/public/pages/layoutListView/Filter.js deleted file mode 100644 index 6cb8d483f..000000000 --- a/QualityControl/public/pages/layoutListView/Filter.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * 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. - */ - -/** - * @typedef {object} FilterModel - * @property {(filter: import("./FilterTypes").Filter) => import("./FilterTypes").Filter} register - * - Register a filter object with a unique key. - * @property {(key: string) => void} unregister - Remove a filter by its key. - * @property {(key: string) => (import("./FilterTypes").Filter|undefined)} get - * - Return the registered filter object for the given key. - * @property {() => import("./FilterTypes").Filter[]} getAll - Return an array of all registered filters. - * @property {(key: string, ...args: any[]) => void} setValue - Call the filter's set(args...) method. - * @property {() => void} resetAll - Call reset() on all filters. - */ - -/** - * create the filtermodel that will store our filters and their states. - * It also provides functionality to modify its filters and getting data from them. - * @returns {FilterModel} - created filtermodel. - */ -export function createFilterModel() { - const filters = new Map(); // key -> filter instance - - /** - * register a filter object. - * @param {Filter} filter - Filter object to register. - * @returns {Filter} - Filter object that was registered. - */ - function register(filter) { - if (!filter?.key) { - throw new Error('Invalid filter'); - } - if (filters.has(filter.key)) { - throw new Error(`Filter already registered: ${filter.key}`); - } - filters.set(filter.key, filter); - return filter; - } - - function unregister(key) { - const filter = filters.get(key); - if (!filter) { - return; - } - return filters.delete(key); - } - - function get(key) { - return filters.get(key); - }; - - function getAll() { - return Array.from(filters.values()); - } - - function setValue(key, ...args) { - const filter = filters.get(key); - if (!filter) { - throw new Error(`Unknown filter: ${key}`); - } - if (typeof filter.set !== 'function') { - // not a proper filter, we need the setter. - throw new Error(`Filter has no set method: ${key}`); - } - filter.set(...args); - } - - function resetAll() { - for (const filter of filters.values()) { - try { - if (typeof filter.reset === 'function') { - filter.reset(); - } - } catch (e) { - // TODO: use the correct logger here, not this one... - console.error(e); - } - } - } - - return { - register, - unregister, - get, - getAll, - setValue, - resetAll, - }; -} diff --git a/QualityControl/public/pages/layoutListView/FilterModel.js b/QualityControl/public/pages/layoutListView/FilterModel.js new file mode 100644 index 000000000..47da22e4d --- /dev/null +++ b/QualityControl/public/pages/layoutListView/FilterModel.js @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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'; + +/** + * Filter model which will store and modify the filters + * @class FilterModel + * @augments Observable + * @import { Filter } from './FilterTypes.js'; + */ +export class FilterModel extends Observable { + constructor() { + super(); + this._filters = new Map(); // key -> filter instance + } + + /** + * register a filter object. + * @param {Filter} filter - Filter object to register. + * @returns {Filter} - Filter object that was registered. + */ + register(filter) { + if (!filter?.key) { + throw new Error('Invalid filter'); + } + if (this.filters.has(filter.key)) { + throw new Error(`Filter already registered: ${filter.key}`); + } + this.filters.set(filter.key, filter); + this.notify(); + return filter; + } + + /** + * Getter + * @returns {Map} filters + */ + get filters() { + return this._filters; + } + + set filters(value) { + this._filters = value; + } + + // eslint-disable-next-line jsdoc/require-returns + /** + * unregisters a specified filter by key. + * @param {string} key - filter key. + */ + unregister(key) { + const filter = this.filters.get(key); + if (!filter) { + return; + } + const result = this.filters.delete(key); + this.notify(); + return result; + } + + /** + * gets a specified filter object by filter.key + * @param {string} key - filter key. + * @returns {Filter} - Filter object + */ + get(key) { + return this.filters.get(key); + } + + /** + * gets all registered filters + * @returns {Array} - Filter object that was registered. + */ + getAll() { + return Array.from(this.filters.values()); + } + + /** + * set filter by key with specified value('s) + * @param {string} key - filter key. + * @param {any[]} args - value('s) to set. + * @returns {void} - void. + */ + setValue(key, ...args) { + const filter = this.filters.get(key); + if (!filter) { + throw new Error(`Unknown filter: ${key}`); + } + if (typeof filter.set !== 'function') { + // not a proper filter, we need the setter. + throw new Error(`Filter has no set method: ${key}`); + } + filter.set(...args); + this.notify(); + return; + } + + resetAll() { + for (const filter of this.filters.values()) { + try { + if (typeof filter.reset === 'function') { + filter.reset(); + } + this.notify(); + } catch (e) { + // TODO: use the correct logger here, not this one... + console.error(e); + } + } + } +} diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index 1334f9cd2..9a615380e 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -15,9 +15,9 @@ /** * @typedef {object} Filter * @property {string} key - searchable key of the filter. - * @property {Function} isActive - has the filter any active value('s) - * @property {Function} getValue - gets the current value('s) of the filter - * @property {Function} set - set value of the filter + * @property {function(): (boolean)} isActive - has the filter any active value('s) + * @property {function(): (string|string[]|null)} getValue - gets the current value('s) of the filter + * @property {function(string): void} set - set value of the filter * @property {Function} reset - reset filter to default state */ diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index 1c3e9070d..a165c8fed 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -31,10 +31,8 @@ export default (folderModels) => { h('.scroll-y.absolute-fill', [ h( '.flex-row.text-right.m2', - // h('.btn.btn-primary', 'Filter'), - // eslint-disable-next-line @stylistic/js/array-bracket-newline [ - filtersPanelPopover(searchFilterModel.filterModel), + filtersPanelPopover(searchFilterModel), h( 'input.form-control.form-inline.mh1.w-33', { @@ -60,7 +58,7 @@ export default (folderModels) => { /** * Initializes the filterModel model. - * @param {import('./Filter.js').FilterModel} filterModel - filterModel of the searchFilterModel. + * @param {import('./FilterModel.js').FilterModel} filterModel - filterModel of the searchFilterModel. */ function initializeSearchFilters(filterModel) { console.log(filterModel.register(createKeyValueFilter('objectPath', 'abc'))); diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 2cc387f7a..1fffeaf68 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -13,30 +13,36 @@ */ // Adopted from Bookkeeping/lib/public/components/Filters/common/filtersPanelPopover.js -import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; +import { h, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; // import { tooltip } from '../../common/popover/tooltip.js'; -//imports for JSDoc + VSCode navigation: -// eslint-disable-next-line no-unused-vars -import { createFilterModel } from './Filter.js'; +/** + * imports for JSDoc + VSCode navigation: + * @import SearchFilterModel from './model/SearchFilterModel.js'; + * @import { FilterModel } from './FilterModel.js'; + */ /** * Return the filters panel popover trigger - * @return {Component} the button component + * @returns {Component} the button component */ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); /** * Create main header of the filters panel - * @param {import('./Filter').FilterModel} filteringModel {@link createFilterModel} filtering model. + * @param {FilterModel} filterModel {@link FilterModel} filtering model. * @returns {Component} main panel header. */ -const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ +const filtersToggleContentHeader = (filterModel) => h('.flex-row.justify-between', [ h('.f4', 'Filters'), h( 'button#reset-filters.btn.btn-danger', { - onclick: () => filteringModel.resetAll(), + onclick: () => { + console.log(filterModel.get('objectPath').getValue()); + filterModel.resetAll(); + console.log(filterModel.get('objectPath').getValue()); + }, // TODO fix check for active filters over filteringmodel..... // ? filteringModel.resetFiltering() // : filteringModel.reset(true), @@ -49,61 +55,49 @@ const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-betw /** * Return the filters panel popover content section - * @param {import('./Filter.js').FilterModel} filterModel the filter model + * @param {SearchFilterModel} searchFilterModel the searchFilter model * @returns {Component} the filters section */ -export const filtersSection = (filterModel = {}) => - h('.flex-column.g2', [ - 'hello world!!', - filterModel.getAll().forEach((filter)=> { - filter.getValue.toString(); - }), - // Object.entries(filtersConfiguration) - // .filter(([_, column]) => { - // let columnProfiles = column.profiles ?? [profiles.none]; - // if (typeof columnProfiles === 'string') { - // columnProfiles = [columnProfiles]; - // } - // return applyProfile(column, appliedProfile, columnProfiles)?.filter; - // }) - // .map(([columnKey, { name, filterTooltip, filter }]) => - // name - // ? [ - // h(`.flex-row.items-baseline.${columnKey}-filter`, [ - // h('.w-30.f5.flex-row.items-center.g2', [ - // name, - // filterTooltip ? tooltip(info(), filterTooltip) : null, - // ]), - // h('.w-70', typeof filter === 'function' ? filter(filteringModel) : filter), - // ]), - // ] - // : typeof filter === 'function' ? filter(filteringModel) : filter), - ]); +export const filtersSection = (searchFilterModel = {}) => [ + searchFilterModel.filters.flatMap((filter) => [ + h('.flex-row.g2', [ + h('.w-30.f5.flex-row.items-center.g2', [filter.key]), + h('.w-70', [ + h('input.form-control.w-100', { + placeholder: 'Search', + type: 'text', + value: filter.getValue(), + onchange: (e) => searchFilterModel.filterModel.setValue(filter.key, e.target.value), + }), + ]), + ]), + ]), +]; /** * Return the filters panel popover content (i.e. the actual filters) - * @param {FilteringModel} filteringModel the filtering model + * @param {FilterModel} filterModel the filtering model * @param {object} [configuration] additional configuration * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column * @returns {Component} the filters panel */ const filtersToggleContent = ( - filteringModel, + searchFilterModel, configuration = {}, ) => h('.w-l.flex-column.p3.g3', [ - filtersToggleContentHeader(filteringModel), - filtersSection(filteringModel, configuration), + filtersToggleContentHeader(searchFilterModel.filterModel), + filtersSection(searchFilterModel, configuration), ]); /** * Return component composed of the filtering popover and its button trigger - * @param {FilteringModel} filteringModel the filtering model + * @param {FilterModel} filterModel the filtering model * @param {object} [configuration] optional configuration * @returns {Component} the filter component */ -export const filtersPanelPopover = (filteringModel, configuration) => popover( +export const filtersPanelPopover = (searchFilterModel, configuration) => popover( filtersToggleTrigger(), - filtersToggleContent(filteringModel, configuration), + filtersToggleContent(searchFilterModel, configuration), { ...PopoverTriggerPreConfiguration.click, anchor: PopoverAnchors.RIGHT_START, diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 6df49f5e1..811db19be 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -30,6 +30,7 @@ export default class LayoutListModel extends BaseViewModel { this.model = model; this._searchInput = ''; this.folders = new Map(); + // this.searchObjectPathMode = false; this._initializeFolders(); } @@ -67,6 +68,18 @@ export default class LayoutListModel extends BaseViewModel { return this._searchInput.trim(); } + // set searchInput(value) { + // this._searchInput = value; + // } + + // /** + // * Toggle objectPath search mode for layout searches in the search bar. + // */ + // toggleObjectPathSearchMode() { + // this.searchObjectPathMode = !this.searchObjectPathMode; + // this.notify(); + // } + /** * Set user's input for search and use a fuzzy algo to filter list of layouts. * Fuzzy allows missing chars "aaa" can find "a/a/a" or "aa/a/bbbbb" @@ -76,7 +89,7 @@ export default class LayoutListModel extends BaseViewModel { */ search(searchInput, objectPath) { if (objectPath === undefined) { - this._searchInput = searchInput; + this.searchInput = searchInput; this.folders.forEach((folder) => { folder.searchInput = new RegExp(searchInput, 'i'); }); diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 2fead57e7..7c1104f48 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -13,7 +13,7 @@ */ import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; -import { createFilterModel } from '../Filter.js'; +import { FilterModel } from '../FilterModel.js'; /** * SearchFilter model to control the search and filter state @@ -24,6 +24,14 @@ export default class SearchFilterModel extends BaseViewModel { super(); this.model = model; this.searchInput = ''; - this.filterModel = createFilterModel(); + this.filterModel = new FilterModel; + this.filters = this.filterModel.getAll(); + this.filterModel.observe(() => { + this.filters = this.filterModel.getAll(); + console.log('Detected model change!!!!'); + console.log(this.filterModel.get('objectPath').getValue()); + console.log(this.filters); + return; + }); } } From f539c5354a0da155a8c1f7507c0123e32bb12df5 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Wed, 17 Sep 2025 15:56:48 +0200 Subject: [PATCH 04/22] old search moved and preserved, tests fixed. Filter popover present, not functional yet --- QualityControl/public/common/header.js | 4 +- .../pages/layoutListView/FilterModel.js | 123 ------------------ .../pages/layoutListView/FilterTypes.js | 9 +- .../pages/layoutListView/LayoutListPage.js | 73 ++++------- .../components/LayoutListHeader.js | 60 +-------- .../layoutListView/filtersPanelPopover.js | 25 ++-- .../layoutListView/model/LayoutListModel.js | 22 ++-- .../layoutListView/model/SearchFilterModel.js | 105 +++++++++++++-- QualityControl/public/view.js | 2 +- .../test/public/pages/layout-list.test.js | 41 +++--- 10 files changed, 172 insertions(+), 292 deletions(-) delete mode 100644 QualityControl/public/pages/layoutListView/FilterModel.js diff --git a/QualityControl/public/common/header.js b/QualityControl/public/common/header.js index a9a6112ff..0619691d4 100644 --- a/QualityControl/public/common/header.js +++ b/QualityControl/public/common/header.js @@ -46,9 +46,9 @@ export default (model) => h('.flex-col', [ * @returns {vnode} - virtual node element */ const headerSpecific = (model) => { - const { layoutListModel, filterModel, layout, object, page } = model; + const { filterModel, layout, object, page } = model; switch (page) { - case 'layoutList': return LayoutListHeader(layoutListModel); + case 'layoutList': return LayoutListHeader(); case 'layoutShow': return layoutViewHeader(layout, filterModel); case 'objectTree': return objectTreeHeader(object, filterModel); case 'objectView': return objectViewHeader(model); diff --git a/QualityControl/public/pages/layoutListView/FilterModel.js b/QualityControl/public/pages/layoutListView/FilterModel.js deleted file mode 100644 index 47da22e4d..000000000 --- a/QualityControl/public/pages/layoutListView/FilterModel.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * 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'; - -/** - * Filter model which will store and modify the filters - * @class FilterModel - * @augments Observable - * @import { Filter } from './FilterTypes.js'; - */ -export class FilterModel extends Observable { - constructor() { - super(); - this._filters = new Map(); // key -> filter instance - } - - /** - * register a filter object. - * @param {Filter} filter - Filter object to register. - * @returns {Filter} - Filter object that was registered. - */ - register(filter) { - if (!filter?.key) { - throw new Error('Invalid filter'); - } - if (this.filters.has(filter.key)) { - throw new Error(`Filter already registered: ${filter.key}`); - } - this.filters.set(filter.key, filter); - this.notify(); - return filter; - } - - /** - * Getter - * @returns {Map} filters - */ - get filters() { - return this._filters; - } - - set filters(value) { - this._filters = value; - } - - // eslint-disable-next-line jsdoc/require-returns - /** - * unregisters a specified filter by key. - * @param {string} key - filter key. - */ - unregister(key) { - const filter = this.filters.get(key); - if (!filter) { - return; - } - const result = this.filters.delete(key); - this.notify(); - return result; - } - - /** - * gets a specified filter object by filter.key - * @param {string} key - filter key. - * @returns {Filter} - Filter object - */ - get(key) { - return this.filters.get(key); - } - - /** - * gets all registered filters - * @returns {Array} - Filter object that was registered. - */ - getAll() { - return Array.from(this.filters.values()); - } - - /** - * set filter by key with specified value('s) - * @param {string} key - filter key. - * @param {any[]} args - value('s) to set. - * @returns {void} - void. - */ - setValue(key, ...args) { - const filter = this.filters.get(key); - if (!filter) { - throw new Error(`Unknown filter: ${key}`); - } - if (typeof filter.set !== 'function') { - // not a proper filter, we need the setter. - throw new Error(`Filter has no set method: ${key}`); - } - filter.set(...args); - this.notify(); - return; - } - - resetAll() { - for (const filter of this.filters.values()) { - try { - if (typeof filter.reset === 'function') { - filter.reset(); - } - this.notify(); - } catch (e) { - // TODO: use the correct logger here, not this one... - console.error(e); - } - } - } -} diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index 9a615380e..badeb898c 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -15,6 +15,7 @@ /** * @typedef {object} Filter * @property {string} key - searchable key of the filter. + * @property {function(): (string)} friendlyName - friendly name of the filter else key. * @property {function(): (boolean)} isActive - has the filter any active value('s) * @property {function(): (string|string[]|null)} getValue - gets the current value('s) of the filter * @property {function(string): void} set - set value of the filter @@ -24,12 +25,14 @@ /** * creates a key-value filter. * @param {string} key - key used to save and retrieve value. + * @param {string|null} friendlyName - friendly name of the filter. * @param {string} value - value associated with key. * @returns {Filter} key-value filter object. */ -export function createKeyValueFilter(key, value = '') { +export function createKeyValueFilter(key, friendlyName = null, value = '') { return { key, + friendlyName: () => friendlyName ? friendlyName : key, getValue: () => value ? value : null, // trim checks if value is a string value, test this isActive: () => Boolean(value && value.trim()), @@ -46,13 +49,15 @@ export function createKeyValueFilter(key, value = '') { /** * Creates a multiple value filter, key with array value. * @param {string} key - key used to save and retrieve value. + * @param {string|null} friendlyName - friendly name of the filter. * @param {Array} value - values (array) associated with key. * @returns {Filter} multiple value filter, key with array with values. */ -export function createMultiValueFilter(key, value = []) { +export function createMultiValueFilter(key, friendlyName = null, value = []) { let values = Array.isArray(value) ? value : []; return { key, + friendlyName: () => friendlyName ? friendlyName : key, getValue: () => values, isActive: () => values.length > 0, add: (v) => { diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index a165c8fed..380003fb0 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -14,59 +14,36 @@ import FolderComponent from '../../folder/view/FolderComponent.js'; import { h } from '/js/src/index.js'; -import SearchFilterModel from './model/SearchFilterModel.js'; -import { createKeyValueFilter } from './FilterTypes.js'; import { filtersPanelPopover } from './filtersPanelPopover.js'; /** * Shows a list of layouts grouped by user and more - * @param {Array} folderModels - LayoutListModel.folders: The Folders used by LayoutListModel + * @param {LayoutListModel} layoutListModel - LayoutListModel which contains the folders and searchfiltermodel. * @returns {vnode} - virtual node element + * @import LayoutListModel from './model/LayoutListModel.js'; */ -export default (folderModels) => { - const searchFilterModel = new SearchFilterModel(); - initializeSearchFilters(searchFilterModel.filterModel); - - return [ - h('.scroll-y.absolute-fill', [ - h( - '.flex-row.text-right.m2', - [ - filtersPanelPopover(searchFilterModel), - h( - 'input.form-control.form-inline.mh1.w-33', - { - placeholder: 'Search', - type: 'text', - value: searchFilterModel.searchInput, - // switch to search(undefined, e.target.value) when searching for objectPath - // oninput: (e) => layoutListModel.search(undefined, e.target.value), - oninput: (e) => { - searchFilterModel.searchInput = e.target.value; - }, +export default (layoutListModel) => [ + h('.scroll-y.absolute-fill', [ + h( + '.flex-row.text-right.m2', + [ + filtersPanelPopover(layoutListModel.searchFilterModel), + h( + 'input.form-control.form-inline.mh1.w-33', + { + placeholder: 'Search', + type: 'text', + value: layoutListModel.searchFilterModel.searchInput, + oninput: (e) => { + layoutListModel.search(e.target.value); }, - ), - ], - ), - - h('', { - style: 'display: flex; flex-direction: column', - }, Array.from(folderModels.values()).map(FolderComponent)), - ]), - ]; -}; - -/** - * Initializes the filterModel model. - * @param {import('./FilterModel.js').FilterModel} filterModel - filterModel of the searchFilterModel. - */ -function initializeSearchFilters(filterModel) { - console.log(filterModel.register(createKeyValueFilter('objectPath', 'abc'))); - - // TESTFUNCJASP(filterModel); - return; -} + }, + ), + ], + ), -// function TESTFUNCJASP(filterModel) { -// // filterModel.setValue('objectPath', 'test'); -// } + h('', { + style: 'display: flex; flex-direction: column', + }, Array.from(layoutListModel.folders.values()).map(FolderComponent)), + ]), +]; diff --git a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js index ef3108b2b..e64f7370e 100644 --- a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js +++ b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js @@ -15,64 +15,10 @@ import { h } from '/js/src/index.js'; /** - * Shows header of list of layouts with one search input to filter them - * @param {LayoutListModel} layoutListModel - The model handeling the state of the LayoutListPage + * Shows header of list of layouts. * @returns {vnode} - virtual node element */ -export default (layoutListModel) => [ +export default () => [ h('.w-50.text-center', [h('b.f4', 'Layouts')]), - // eslint-disable-next-line @stylistic/js/array-bracket-newline - h('.flex-grow.text-right', [ - h(layoutListModel.searchObjectPathMode ? '.btn.btn-primary' : '.btn', 'SearchMode'), - // eslint-disable-next-line @stylistic/js/array-bracket-newline - ], [ - h( - 'input.form-control.form-inline.mh1.w-33', - - layoutListModel.searchObjectPathMode ? - { - placeholder: 'Search', - type: 'text', - value: layoutListModel.searchInput, - // switch to search(undefined, e.target.value) when searching for objectPath - // oninput: (e) => layoutListModel.search(undefined, e.target.value), - oninput: (e) => { - layoutListModel.searchInput = e.target.value; - }, - } - : - { - placeholder: 'Search', - type: 'text', - value: layoutListModel.searchInput, - // switch to search(undefined, e.target.value) when searching for objectPath - oninput: (e) => layoutListModel.search(e.target.value), - }, - ), - // eslint-disable-next-line @stylistic/js/array-bracket-newline - ]), + h('.flex-grow.text-right'), ]; - -// /** -// * Function responsible for getting the right input control body element. -// * @param {LayoutListModel} layoutListModel - The model handeling the state of the LayoutListPage -// * @returns {object} - object containing data for body of Mitril element -// */ -// function getSearchInputObject(layoutListModel) { -// return layoutListModel.searchObjectPathMode ? -// { -// placeholder: 'Search', -// type: 'text', -// value: layoutListModel.searchInput, -// // switch to search(undefined, e.target.value) when searching for objectPath -// oninput: (e) => layoutListModel.search(e.target.value), -// } -// : -// { -// placeholder: 'Search', -// type: 'text', -// value: layoutListModel.searchInput, -// // switch to search(undefined, e.target.value) when searching for objectPath -// oninput: (e) => layoutListModel.search(e.target.value), -// }; -// } diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 1fffeaf68..1e032e08f 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -19,7 +19,6 @@ import { h, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/ /** * imports for JSDoc + VSCode navigation: * @import SearchFilterModel from './model/SearchFilterModel.js'; - * @import { FilterModel } from './FilterModel.js'; */ /** @@ -30,24 +29,18 @@ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primar /** * Create main header of the filters panel - * @param {FilterModel} filterModel {@link FilterModel} filtering model. + * @param {SearchFilterModel} searchFilterModel {@link SearchFilterModel} filtering model. * @returns {Component} main panel header. */ -const filtersToggleContentHeader = (filterModel) => h('.flex-row.justify-between', [ +const filtersToggleContentHeader = (searchFilterModel) => h('.flex-row.justify-between', [ h('.f4', 'Filters'), h( 'button#reset-filters.btn.btn-danger', { onclick: () => { - console.log(filterModel.get('objectPath').getValue()); - filterModel.resetAll(); - console.log(filterModel.get('objectPath').getValue()); + searchFilterModel.resetAll(); }, - // TODO fix check for active filters over filteringmodel..... - // ? filteringModel.resetFiltering() - // : filteringModel.reset(true), - // disabled: !filteringModel.isAnyFilterActive(), - disabled: false, + disabled: searchFilterModel.allInActive() ? true : false, }, 'Reset all filters', ), @@ -59,7 +52,7 @@ const filtersToggleContentHeader = (filterModel) => h('.flex-row.justify-between * @returns {Component} the filters section */ export const filtersSection = (searchFilterModel = {}) => [ - searchFilterModel.filters.flatMap((filter) => [ + searchFilterModel.getAll().flatMap((filter) => [ h('.flex-row.g2', [ h('.w-30.f5.flex-row.items-center.g2', [filter.key]), h('.w-70', [ @@ -67,7 +60,7 @@ export const filtersSection = (searchFilterModel = {}) => [ placeholder: 'Search', type: 'text', value: filter.getValue(), - onchange: (e) => searchFilterModel.filterModel.setValue(filter.key, e.target.value), + onchange: (e) => searchFilterModel.setValue(filter.key, e.target.value), }), ]), ]), @@ -76,7 +69,7 @@ export const filtersSection = (searchFilterModel = {}) => [ /** * Return the filters panel popover content (i.e. the actual filters) - * @param {FilterModel} filterModel the filtering model + * @param {SearchFilterModel} searchFilterModel the filtering model * @param {object} [configuration] additional configuration * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column * @returns {Component} the filters panel @@ -85,13 +78,13 @@ const filtersToggleContent = ( searchFilterModel, configuration = {}, ) => h('.w-l.flex-column.p3.g3', [ - filtersToggleContentHeader(searchFilterModel.filterModel), + filtersToggleContentHeader(searchFilterModel), filtersSection(searchFilterModel, configuration), ]); /** * Return component composed of the filtering popover and its button trigger - * @param {FilterModel} filterModel the filtering model + * @param {SearchFilterModel} searchFilterModel the filtering model * @param {object} [configuration] optional configuration * @returns {Component} the filter component */ diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 811db19be..6a7cebb4e 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -16,6 +16,7 @@ import FolderModel, { FolderType } from '../../../folder/model/FolderModel.js'; import LayoutCardModel from './LayoutCardModel.js'; import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; import { RequestFields } from '../../../common/RequestFields.enum.js'; +import SearchFilterModel from './SearchFilterModel.js'; /** * LayoutListModel namespace to control the layoutCards spread between its folders @@ -28,9 +29,10 @@ export default class LayoutListModel extends BaseViewModel { constructor(model) { super(); this.model = model; - this._searchInput = ''; this.folders = new Map(); - // this.searchObjectPathMode = false; + this.searchFilterModel = new SearchFilterModel(); + // Notify the root model to redraw. + this.searchFilterModel.observe(() => this.notify()); this._initializeFolders(); } @@ -65,20 +67,12 @@ export default class LayoutListModel extends BaseViewModel { * @returns {string} The trimmed search input */ get searchInput() { - return this._searchInput.trim(); + return this.searchFilterModel.searchInput.trim(); } - // set searchInput(value) { - // this._searchInput = value; - // } - - // /** - // * Toggle objectPath search mode for layout searches in the search bar. - // */ - // toggleObjectPathSearchMode() { - // this.searchObjectPathMode = !this.searchObjectPathMode; - // this.notify(); - // } + set searchInput(value) { + this.searchFilterModel.searchInput = value; + } /** * Set user's input for search and use a fuzzy algo to filter list of layouts. diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 7c1104f48..8bd9ec8f1 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -13,25 +13,106 @@ */ import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; -import { FilterModel } from '../FilterModel.js'; +import { createKeyValueFilter } from '../FilterTypes.js'; /** * SearchFilter model to control the search and filter state - * @param {Model} model - The the application model + * @import { Filter } from '../FilterTypes.js'; */ export default class SearchFilterModel extends BaseViewModel { - constructor(model) { + constructor() { super(); - this.model = model; + + /** + * filters storage map + * @type {Map} + */ + this.filters = new Map(); // key -> filter instance this.searchInput = ''; - this.filterModel = new FilterModel; - this.filters = this.filterModel.getAll(); - this.filterModel.observe(() => { - this.filters = this.filterModel.getAll(); - console.log('Detected model change!!!!'); - console.log(this.filterModel.get('objectPath').getValue()); - console.log(this.filters); + this.register(createKeyValueFilter('objectPath', null)); + } + + /** + * register a filter object. + * @param {Filter} filter - Filter object to register. + * @returns {Filter} - Filter object that was registered. + */ + register(filter) { + if (!filter?.key) { + throw new Error('Invalid filter'); + } + if (this.filters.has(filter.key)) { + throw new Error(`Filter already registered: ${filter.key}`); + } + this.filters.set(filter.key, filter); + this.notify(); + return filter; + } + + // eslint-disable-next-line jsdoc/require-returns + /** + * unregisters a specified filter by key. + * @param {string} key - filter key. + */ + unregister(key) { + const filter = this.get(key); + if (!filter) { return; - }); + } + const result = this.filters.delete(key); + this.notify(); + return result; + } + + /** + * gets a specified filter object by filter.key + * @param {string} key - filter key. + * @returns {Filter} - Filter object + */ + get(key) { + return this.filters.get(key); + } + + /** + * gets all registered filters + * @returns {Array} - Filter object that was registered. + */ + getAll() { + return Array.from(this.filters.values()); + } + + /** + * set filter by key with specified value('s) + * @param {string} key - filter key. + * @param {any[]} args - value('s) to set. + * @returns {void} - void. + */ + setValue(key, ...args) { + const filter = this.get(key); + if (!filter) { + throw new Error(`Unknown filter: ${key}`); + } + if (typeof filter.set !== 'function') { + // not a proper filter, we need the setter. + throw new Error(`Filter has no set method: ${key}`); + } + filter.set(...args); + this.notify(); + return; + } + + resetAll() { + this.allInActive(); + for (const filter of this.filters.values()) { + if (typeof filter.reset === 'function') { + filter.reset(); + } + this.notify(); + } + } + + allInActive() { + const activeCount = this.filters.values().filter((filter) => filter.isActive()).toArray().length; + return activeCount > 0 ? false : true; } } diff --git a/QualityControl/public/view.js b/QualityControl/public/view.js index 654140bf5..ac3418cfe 100644 --- a/QualityControl/public/view.js +++ b/QualityControl/public/view.js @@ -51,7 +51,7 @@ export default (model) => [ */ function page(model) { switch (model.page) { - case 'layoutList': return LayoutListPage(model.layoutListModel.folders); + case 'layoutList': return LayoutListPage(model.layoutListModel); case 'layoutShow': return layoutViewPage(model); case 'objectTree': return objectTreePage(model); case 'objectView': return ObjectViewPage(model.objectViewModel); diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index ca1997979..b87994a15 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -24,17 +24,20 @@ const LAYOUT_LIST_PAGE_PARAM = '?page=layoutList'; * @param {object} testParent - Node.js test object which ensures sub-tests are being awaited */ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) => { - const officialLayoutIndex = 1; + const officialLayoutIndex = 2; + const officialLayoutIndex2 = 1; const myLayoutIndex = 2; - const allLayoutIndex = 3; + const allLayoutIndex = 2; + const allLayoutIndex2 = 3; const basePath = (index) => `section > div > div:nth-child(${index})`; - const toggleFolderPath = (index) => `${basePath(index)} div > b`; + const toggleFolderPath = (index, index2) => index2 ? `${basePath(index)} > div:nth-child(${index2}) > div > b` : + `${basePath(index)} div > b`; const cardPath = (index, cardIndex) => `${basePath(index)} .card:nth-child(${cardIndex})`; const cardLayoutLinkPath = (cardPath) => `${cardPath} a`; const cardOfficialButtonPath = (cardPath) => `${cardPath} > .cardHeader > button`; - const filterPath = 'header > div > div:nth-child(1) > div:nth-child(3) > input'; + const filterPath = 'section > div > div:nth-child(1) > input'; await testParent.test('should successfully load layoutList page "/"', { timeout }, async () => { await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); @@ -53,53 +56,57 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) await testParent.test('should have folder for official layouts', async () => { const label = await page.evaluate((path) => - document.querySelector(path).textContent.trim(), toggleFolderPath(officialLayoutIndex)); + document.querySelector(path).textContent.trim(), toggleFolderPath(officialLayoutIndex, officialLayoutIndex2)); strictEqual(label, 'Official'); }); await testParent.test('should have folder for personal layouts', async () => { const label = await page.evaluate((path) => - document.querySelector(path).textContent.trim(), toggleFolderPath(myLayoutIndex)); + document.querySelector(path).textContent.trim(), toggleFolderPath(myLayoutIndex, myLayoutIndex)); strictEqual(label, 'My Layouts'); }); await testParent.test('should be able to close folders', async () => { + let nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); + await page.click(toggleFolderPath(officialLayoutIndex)); // This will close a folder await delay(1000); - let nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); + const nrOfOpenedFolders2 = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); - strictEqual(nrOfOpenedFolders, 1, 'Official Layouts should have closed'); + strictEqual(nrOfOpenedFolders - 1, nrOfOpenedFolders2, 'Official Layouts should have closed'); - await page.click(toggleFolderPath(myLayoutIndex)); // This will close a folder + await page.click(toggleFolderPath(myLayoutIndex, myLayoutIndex)); // This will close a folder await delay(100); nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); - strictEqual(nrOfOpenedFolders, 0, 'My Layouts should have closed'); + strictEqual(nrOfOpenedFolders, nrOfOpenedFolders2 - 1, 'My Layouts should have closed'); }); - await testParent.test('should be able to close folders', async () => { + await testParent.test('should be able to open folders', async () => { + let nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); + await page.click(toggleFolderPath(officialLayoutIndex)); // This will open a folder await delay(100); - let nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); + const nrOfOpenedFolders2 = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); - strictEqual(nrOfOpenedFolders, 1, 'Official Layouts should have opened'); + strictEqual(nrOfOpenedFolders, nrOfOpenedFolders2 - 1, 'Official Layouts should have opened'); - await page.click(toggleFolderPath(myLayoutIndex)); // This will open a folder + await page.click(toggleFolderPath(myLayoutIndex, myLayoutIndex)); // This will open a folder await delay(100); nrOfOpenedFolders = await page.evaluate(() => document.querySelectorAll('.cardGrid').length); - strictEqual(nrOfOpenedFolders, 2, 'My Layouts should have opened'); + strictEqual(nrOfOpenedFolders - 1, nrOfOpenedFolders2, 'My Layouts should have opened'); }); await testParent.test('should have folder for all layouts', async () => { const label = await page.evaluate((path) => - document.querySelector(path)?.textContent.trim(), toggleFolderPath(allLayoutIndex)); + document.querySelector(path)?.textContent.trim(), toggleFolderPath(allLayoutIndex, allLayoutIndex2)); strictEqual(label, 'All Layouts'); }); @@ -161,7 +168,7 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) await testParent.test('should remove official layouts from official folder when made unofficial', async () => { const buttonPath = cardOfficialButtonPath(cardPath(myLayoutIndex, 1)); - const officialLayoutCardPath = cardPath(officialLayoutIndex, 1); + const officialLayoutCardPath = cardPath(officialLayoutIndex - 1, 1); await page.click(buttonPath); await delay(100); // Making a layout official takes a bit. From fd99dac8fcffa0a9504c8377442e74f35af75c59 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Wed, 17 Sep 2025 16:00:40 +0200 Subject: [PATCH 05/22] Friendly name used --- .../public/pages/layoutListView/filtersPanelPopover.js | 2 +- .../public/pages/layoutListView/model/SearchFilterModel.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 1e032e08f..c4d041ecb 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -54,7 +54,7 @@ const filtersToggleContentHeader = (searchFilterModel) => h('.flex-row.justify-b export const filtersSection = (searchFilterModel = {}) => [ searchFilterModel.getAll().flatMap((filter) => [ h('.flex-row.g2', [ - h('.w-30.f5.flex-row.items-center.g2', [filter.key]), + h('.w-30.f5.flex-row.items-center.g2', [filter.friendlyName()]), h('.w-70', [ h('input.form-control.w-100', { placeholder: 'Search', diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 8bd9ec8f1..dbeafe374 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -29,7 +29,7 @@ export default class SearchFilterModel extends BaseViewModel { */ this.filters = new Map(); // key -> filter instance this.searchInput = ''; - this.register(createKeyValueFilter('objectPath', null)); + this.register(createKeyValueFilter('objectPath', 'Object path')); } /** From d74068e4cc946050bcbb7b3a6cb36a041d9f9faf Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Thu, 18 Sep 2025 09:59:29 +0200 Subject: [PATCH 06/22] Filtering system works in conjunction with classic search. Added VSCode launch config for Firefox debugging (extension + Firefox flags needed) --- .vscode/launch.json | 13 +++++++++ .../layoutListView/model/LayoutListModel.js | 27 ++++++++++++++----- .../public/services/Layout.service.js | 18 ++++++++----- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b0cc7401..88a9dc3f3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,19 @@ "windows": { "localRoot": "${workspaceFolder}\\InfoLogger\\" } + }, + { + "name": "Launch firefox", + "type": "firefox", + "request": "attach", + "url": "http://localhost:8080/", + "webRoot": "${workspaceFolder}", + "pathMappings": [ + { + "url": "http://localhost:8080/", + "path": "${workspaceFolder}/QualityControl/public/" + } + ] } ] } diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 6a7cebb4e..34aeab5a3 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -32,7 +32,15 @@ export default class LayoutListModel extends BaseViewModel { this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); // Notify the root model to redraw. - this.searchFilterModel.observe(() => this.notify()); + this.searchFilterModel.observe(() => { + this.notify(); + if (!this.searchFilterModel.allInActive()) { + this.search(undefined, this.searchFilterModel.filters.get('objectPath') + .getValue(), this.model.session.personid); + } else { + this.search(undefined, undefined, this.model.session.personid); + } + }); this._initializeFolders(); } @@ -76,19 +84,24 @@ export default class LayoutListModel extends BaseViewModel { /** * Set user's input for search and use a fuzzy algo to filter list of layouts. - * Fuzzy allows missing chars "aaa" can find "a/a/a" or "aa/a/bbbbb" - * @param {string} searchInput - string input from the user to search by - * @param {string} objectPath - string input from the user to search layouts by objectPath - * @returns {undefined} + * Fuzzy allows missing chars "aaa" can find "a/a/a" or "aa/a/bbbbb". + * If searchInput and objectPath are not included get all non-filtered layouts. + * @param {string} searchInput - string input from the user to search by. + * @param {string} objectPath - string input from the user to search layouts by objectPath. + * @param {string} userid - userId to check if layout is owned by you. */ - search(searchInput, objectPath) { - if (objectPath === undefined) { + search(searchInput, objectPath, userid = undefined) { + if (searchInput === undefined && objectPath === undefined) { + this.model.services.layout.getLayouts(undefined, this.model); + } else if (objectPath === undefined) { + // Normal offline search this.searchInput = searchInput; this.folders.forEach((folder) => { folder.searchInput = new RegExp(searchInput, 'i'); }); this.notify(); } else { + // online search const layoutService = this.model.services.layout; this._searchInput = objectPath; layoutService.getLayouts(RequestFields.LAYOUT_CARD, { objectPath }, this.model); diff --git a/QualityControl/public/services/Layout.service.js b/QualityControl/public/services/Layout.service.js index dbcfe535f..46c90e7eb 100644 --- a/QualityControl/public/services/Layout.service.js +++ b/QualityControl/public/services/Layout.service.js @@ -35,7 +35,6 @@ export default class LayoutService { this.list = RemoteData.notAsked(); // List of all existing layouts in QCG; this.userList = RemoteData.notAsked(); // List of layouts owned by current user; - this.filterList = RemoteData.notAsked(); // List of all layouts filtered by filter object; } /** @@ -111,14 +110,15 @@ export default class LayoutService { * Only one type of filter condition exists right now, filter.objectPath. * Check the controller: 'QualityControl/lib/dtos/LayoutDto.js' line 88 * for more future filter options. + * @param {string} userId - user id for which to query layouts * @param {string|undefined} fields - comma seperated string values. Represent the fields that should be fetched. * If left empty all available fields will be fetched * @param {object} filter - filter information to be parsed by the backend. * @param {Class} that - Observer requesting data that should be notified of changes * @returns {undefined} */ - async getLayoutsByFilter(fields = undefined, filter = undefined, that = this.model) { - this.filterList = RemoteData.loading(); + async getLayoutsByFilter(userId = undefined, fields = undefined, filter = undefined, that = this.model) { + this.list = RemoteData.loading(); that.notify(); if (filter !== undefined) { const filterSearchParam = new URLSearchParams(); @@ -134,13 +134,19 @@ export default class LayoutService { if (ok) { const sortedLayouts = result.sort(this._compareByName); // My layouts/userlist or should this be about all layouts? - this.filterList = RemoteData.success(sortedLayouts); + this.list = RemoteData.success(sortedLayouts); } else { - this.filterList = RemoteData.failure(result.error || result.message); + this.list = RemoteData.failure(result.error || result.message); } } else { - this.filterList = RemoteData.failure('Filter is not defined'); + this.list = RemoteData.failure('Filter is not defined'); } + //unpack remote-data/filter, repack and set + const unpacked = + this.list._payload.filter((layout) => layout.owner_id === userId); + this.userList = RemoteData.success(unpacked); + this.model.layoutListModel.folders.get('My Layouts').list = this.userList; + this.model.layoutListModel.folders.get('All Layouts').list = this.list; that.notify(); } From 98eecce61f4ab81c6a46eb21d133fd0c0017de0c Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Thu, 18 Sep 2025 17:15:33 +0200 Subject: [PATCH 07/22] tests added + fixed --- .../test/public/pages/layout-list.test.js | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index b87994a15..1cb585e62 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -38,6 +38,7 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) const cardOfficialButtonPath = (cardPath) => `${cardPath} > .cardHeader > button`; const filterPath = 'section > div > div:nth-child(1) > input'; + const filterObjectPath = 'input.form-control:nth-child(1)'; await testParent.test('should successfully load layoutList page "/"', { timeout }, async () => { await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); @@ -178,11 +179,46 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) strictEqual(officialLayoutCard, true, 'The official layout folder should have had a card added in previous test'); }); + await testParent.test('should have a folder with one card after object path filtering', async () => { + const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); + strictEqual(preFilterCardCount, 2); + await page.locator('#openFilterToggle').click(); + await delay(100); + await page.locator(filterObjectPath).fill('qc/MCH/QO/'); + await page.locator('#openFilterToggle').click(); + await delay(100); + const postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); + strictEqual(postFilterCardCount, 1); + }); + + await testParent.test('should have a folder with 1 card after object path filtering + regular search', async () => { + // reset page, thus reset filter/search. + await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); + await delay(100); + await page.locator('div.m2:nth-child(3) > div:nth-child(1)').click(); + await delay(100); + const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); + strictEqual(preFilterCardCount, 5); + await page.locator('#openFilterToggle').click(); + await delay(100); + await page.locator(filterObjectPath).fill('object'); + await page.locator('#openFilterToggle').click(); + await delay(100); + let postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); + strictEqual(postFilterCardCount, 3); + await page.locator(filterPath).fill('pdpBeamType'); + await delay(100); + postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); + strictEqual(postFilterCardCount, 1); + }); + await testParent.test('should have a folder with one card after filtering', async () => { + // reset page, thus reset filter/search. + await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); + await delay(100); const preFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); strictEqual(preFilterCardCount, 2); await page.locator(filterPath).fill('a'); - await delay(100); const postFilterCardCount = await page.evaluate(() => document.querySelectorAll('.card').length); strictEqual(postFilterCardCount, 1); From 1606cc9362a797658d97e39c5ad90edc4c79c674 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 09:34:59 +0200 Subject: [PATCH 08/22] Update placeholders --- .../public/pages/layoutListView/FilterTypes.js | 17 +++++++---------- .../pages/layoutListView/LayoutListPage.js | 2 +- .../pages/layoutListView/filtersPanelPopover.js | 2 +- .../layoutListView/model/SearchFilterModel.js | 2 +- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index badeb898c..f9c08c712 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -16,6 +16,7 @@ * @typedef {object} Filter * @property {string} key - searchable key of the filter. * @property {function(): (string)} friendlyName - friendly name of the filter else key. + * @property {function(): (string)} inputPlaceholder - Input element's placeholder, use examples of filter. * @property {function(): (boolean)} isActive - has the filter any active value('s) * @property {function(): (string|string[]|null)} getValue - gets the current value('s) of the filter * @property {function(string): void} set - set value of the filter @@ -26,13 +27,15 @@ * creates a key-value filter. * @param {string} key - key used to save and retrieve value. * @param {string|null} friendlyName - friendly name of the filter. + * @param {string|null} inputPlaceholder - input placeholder text. * @param {string} value - value associated with key. * @returns {Filter} key-value filter object. */ -export function createKeyValueFilter(key, friendlyName = null, value = '') { +export function createKeyValueFilter(key, friendlyName = null, inputPlaceholder = null, value = '') { return { key, friendlyName: () => friendlyName ? friendlyName : key, + inputPlaceholder: () => inputPlaceholder ? inputPlaceholder : 'Conditions', getValue: () => value ? value : null, // trim checks if value is a string value, test this isActive: () => Boolean(value && value.trim()), @@ -51,23 +54,17 @@ export function createKeyValueFilter(key, friendlyName = null, value = '') { * @param {string} key - key used to save and retrieve value. * @param {string|null} friendlyName - friendly name of the filter. * @param {Array} value - values (array) associated with key. + * @param {string|null} inputPlaceholder - input placeholder text. * @returns {Filter} multiple value filter, key with array with values. */ -export function createMultiValueFilter(key, friendlyName = null, value = []) { +export function createMultiValueFilter(key, friendlyName = null, inputPlaceholder = null, value = []) { let values = Array.isArray(value) ? value : []; return { key, friendlyName: () => friendlyName ? friendlyName : key, + inputPlaceholder: () => inputPlaceholder ? inputPlaceholder : 'Conditions', getValue: () => values, isActive: () => values.length > 0, - add: (v) => { - if (!values.includes(v)) { - values.push(v); - } - }, - remove: (v) => { - values = values.filter((x) => x !== v); - }, set: (arr) => { values = Array.isArray(arr) ? arr : []; }, diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index 380003fb0..36ef1a983 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -31,7 +31,7 @@ export default (layoutListModel) => [ h( 'input.form-control.form-inline.mh1.w-33', { - placeholder: 'Search', + placeholder: 'Layout name', type: 'text', value: layoutListModel.searchFilterModel.searchInput, oninput: (e) => { diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index c4d041ecb..bbec97006 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -57,7 +57,7 @@ export const filtersSection = (searchFilterModel = {}) => [ h('.w-30.f5.flex-row.items-center.g2', [filter.friendlyName()]), h('.w-70', [ h('input.form-control.w-100', { - placeholder: 'Search', + placeholder: filter.inputPlaceholder(), type: 'text', value: filter.getValue(), onchange: (e) => searchFilterModel.setValue(filter.key, e.target.value), diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index dbeafe374..a1071dc3d 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -29,7 +29,7 @@ export default class SearchFilterModel extends BaseViewModel { */ this.filters = new Map(); // key -> filter instance this.searchInput = ''; - this.register(createKeyValueFilter('objectPath', 'Object path')); + this.register(createKeyValueFilter('objectPath', 'Object path', 'e.g. TPC')); } /** From fb43fe66b880f200e7e194710b3a22a6f87edc3f Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 11:01:15 +0200 Subject: [PATCH 09/22] Fix rebase --- .../public/pages/layoutListView/model/LayoutListModel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 34aeab5a3..ecfbf1aea 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -86,13 +86,13 @@ export default class LayoutListModel extends BaseViewModel { * Set user's input for search and use a fuzzy algo to filter list of layouts. * Fuzzy allows missing chars "aaa" can find "a/a/a" or "aa/a/bbbbb". * If searchInput and objectPath are not included get all non-filtered layouts. + * All params can be undefined if you want all layouts. * @param {string} searchInput - string input from the user to search by. * @param {string} objectPath - string input from the user to search layouts by objectPath. - * @param {string} userid - userId to check if layout is owned by you. */ - search(searchInput, objectPath, userid = undefined) { + search(searchInput, objectPath) { if (searchInput === undefined && objectPath === undefined) { - this.model.services.layout.getLayouts(undefined, this.model); + this.model.services.layout.getLayouts(undefined, undefined, this.model); } else if (objectPath === undefined) { // Normal offline search this.searchInput = searchInput; From bff7808cfda4673db87306ca368d2c68536ef398 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 13:34:30 +0200 Subject: [PATCH 10/22] Ui now shows active filters --- .../pages/layoutListView/FilterTypes.js | 2 +- .../pages/layoutListView/LayoutListPage.js | 4 +++ .../layoutListView/model/SearchFilterModel.js | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index f9c08c712..b1cc45b67 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -53,8 +53,8 @@ export function createKeyValueFilter(key, friendlyName = null, inputPlaceholder * Creates a multiple value filter, key with array value. * @param {string} key - key used to save and retrieve value. * @param {string|null} friendlyName - friendly name of the filter. - * @param {Array} value - values (array) associated with key. * @param {string|null} inputPlaceholder - input placeholder text. + * @param {Array} value - values (array) associated with key. * @returns {Filter} multiple value filter, key with array with values. */ export function createMultiValueFilter(key, friendlyName = null, inputPlaceholder = null, value = []) { diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index 36ef1a983..24f7cf114 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -39,6 +39,10 @@ export default (layoutListModel) => [ }, }, ), + h( + '.mh1', + layoutListModel.searchFilterModel.stringifyActiveFiltersFriendly(), + ), ], ), diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index a1071dc3d..8300d1f03 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -111,8 +111,40 @@ export default class SearchFilterModel extends BaseViewModel { } } + /** + * Get all the currently active filters. + * @returns {Array} all active filters + */ + getAllActive() { + return this.filters.values().filter((filter) => filter.isActive()).toArray(); + } + + /** + * check if all filters are inactive or not. + * @returns {boolean} all filters are inactive + */ allInActive() { const activeCount = this.filters.values().filter((filter) => filter.isActive()).toArray().length; return activeCount > 0 ? false : true; } + + /** + * return the active filters in a representable way. + * @returns {string} Active filters: filter.friendlyName(). + */ + stringifyActiveFiltersFriendly() { + let activeFilterText = 'Active filters: '; + if (this.allInActive()) { + activeFilterText += 'None.'; + return activeFilterText; + } else { + for (const filter of this.getAllActive()) { + activeFilterText += ` ${filter.friendlyName()},`; + } + activeFilterText = activeFilterText.slice(0, activeFilterText.length - 1); + activeFilterText += '.'; + + return activeFilterText; + } + } } From 710cb66b7a928c481ae7b20f9f7c320e2e8e8c65 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 15:14:54 +0200 Subject: [PATCH 11/22] Create a singular object to send to the backend for filtering --- .../layoutListView/model/LayoutListModel.js | 20 ++++----- .../layoutListView/model/SearchFilterModel.js | 13 ++++++ .../public/services/Layout.service.js | 45 ------------------- 3 files changed, 21 insertions(+), 57 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index ecfbf1aea..f78990fa4 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -31,14 +31,12 @@ export default class LayoutListModel extends BaseViewModel { this.model = model; this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); - // Notify the root model to redraw. this.searchFilterModel.observe(() => { this.notify(); if (!this.searchFilterModel.allInActive()) { - this.search(undefined, this.searchFilterModel.filters.get('objectPath') - .getValue(), this.model.session.personid); + this.search(undefined, this.searchFilterModel.getAllAsObject()); } else { - this.search(undefined, undefined, this.model.session.personid); + this.search(undefined, undefined); } }); @@ -88,12 +86,12 @@ export default class LayoutListModel extends BaseViewModel { * If searchInput and objectPath are not included get all non-filtered layouts. * All params can be undefined if you want all layouts. * @param {string} searchInput - string input from the user to search by. - * @param {string} objectPath - string input from the user to search layouts by objectPath. + * @param {object} filters - filters object contains all filter key value pairs in one object. */ - search(searchInput, objectPath) { - if (searchInput === undefined && objectPath === undefined) { - this.model.services.layout.getLayouts(undefined, undefined, this.model); - } else if (objectPath === undefined) { + search(searchInput, filters) { + if (searchInput === undefined && filters === undefined) { + this.model.services.layout.getLayouts(undefined, undefined); + } else if (filters === undefined) { // Normal offline search this.searchInput = searchInput; this.folders.forEach((folder) => { @@ -102,9 +100,7 @@ export default class LayoutListModel extends BaseViewModel { this.notify(); } else { // online search - const layoutService = this.model.services.layout; - this._searchInput = objectPath; - layoutService.getLayouts(RequestFields.LAYOUT_CARD, { objectPath }, this.model); + this.model.services.layout.getLayouts(RequestFields.LAYOUT_CARD, filters); } } diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 8300d1f03..37446cfc7 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -128,6 +128,19 @@ export default class SearchFilterModel extends BaseViewModel { return activeCount > 0 ? false : true; } + /** + * Returns all filters in a object like so + * This is the object that we can actually search with. + * { + * objectPath: 'TPC', + * pizza: 'Peperoni', + * } + * @returns {object} object containing all key/value pairs from all filters. + */ + getAllAsObject() { + return Object.fromEntries(this.getAll().map((filter) => [filter.key, filter.getValue()])); + } + /** * return the active filters in a representable way. * @returns {string} Active filters: filter.friendlyName(). diff --git a/QualityControl/public/services/Layout.service.js b/QualityControl/public/services/Layout.service.js index 46c90e7eb..80550e7b5 100644 --- a/QualityControl/public/services/Layout.service.js +++ b/QualityControl/public/services/Layout.service.js @@ -105,51 +105,6 @@ export default class LayoutService { that.notify(); } - /** - * Method to get all layouts by a given filter object - * Only one type of filter condition exists right now, filter.objectPath. - * Check the controller: 'QualityControl/lib/dtos/LayoutDto.js' line 88 - * for more future filter options. - * @param {string} userId - user id for which to query layouts - * @param {string|undefined} fields - comma seperated string values. Represent the fields that should be fetched. - * If left empty all available fields will be fetched - * @param {object} filter - filter information to be parsed by the backend. - * @param {Class} that - Observer requesting data that should be notified of changes - * @returns {undefined} - */ - async getLayoutsByFilter(userId = undefined, fields = undefined, filter = undefined, that = this.model) { - this.list = RemoteData.loading(); - that.notify(); - if (filter !== undefined) { - const filterSearchParam = new URLSearchParams(); - if (fields !== undefined) { - filterSearchParam.append('fields', fields); - } - - filterSearchParam.append('filter', JSON.stringify(filter)); - filter.objectPath = filter.objectPath.trim(); - const url = `/api/layouts?${filterSearchParam.size > 0 ? `${filterSearchParam.toString()}` : ''}`; - - const { result, ok } = await this.loader.get(url); - if (ok) { - const sortedLayouts = result.sort(this._compareByName); - // My layouts/userlist or should this be about all layouts? - this.list = RemoteData.success(sortedLayouts); - } else { - this.list = RemoteData.failure(result.error || result.message); - } - } else { - this.list = RemoteData.failure('Filter is not defined'); - } - //unpack remote-data/filter, repack and set - const unpacked = - this.list._payload.filter((layout) => layout.owner_id === userId); - this.userList = RemoteData.success(unpacked); - this.model.layoutListModel.folders.get('My Layouts').list = this.userList; - this.model.layoutListModel.folders.get('All Layouts').list = this.list; - that.notify(); - } - /** * Comparator function to sort layouts alphabetically by their name property * @param {Layout} layout1 - First layout object to compare From 84892d79930366ff51185e2fa333a39e438514df Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 15:33:24 +0200 Subject: [PATCH 12/22] Style fix + add test for active filter text --- .../public/pages/layoutListView/LayoutListPage.js | 10 ++++++---- .../test/public/pages/layout-list.test.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/LayoutListPage.js b/QualityControl/public/pages/layoutListView/LayoutListPage.js index 24f7cf114..996d63adc 100644 --- a/QualityControl/public/pages/layoutListView/LayoutListPage.js +++ b/QualityControl/public/pages/layoutListView/LayoutListPage.js @@ -39,10 +39,12 @@ export default (layoutListModel) => [ }, }, ), - h( - '.mh1', - layoutListModel.searchFilterModel.stringifyActiveFiltersFriendly(), - ), + h('.p1', [ + h( + '.mh1', + layoutListModel.searchFilterModel.stringifyActiveFiltersFriendly(), + ), + ]), ], ), diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 1cb585e62..8df54df11 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -191,6 +191,21 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) strictEqual(postFilterCardCount, 1); }); + await testParent.test('should show the active filter name', async () => { + // reset page, thus reset filter/search. + await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); + await delay(100); + const preFilterText = await page.evaluate(() => document.querySelector('div.mh1').textContent.trim()); + strictEqual(preFilterText, 'Active filters: None.'); + await page.locator('#openFilterToggle').click(); + await delay(100); + await page.locator(filterObjectPath).fill('TPC'); + await page.locator('#openFilterToggle').click(); + await delay(100); + const postFilterText = await page.evaluate(() => document.querySelector('div.mh1').textContent.trim()); + strictEqual(postFilterText, 'Active filters: Object path.'); + }); + await testParent.test('should have a folder with 1 card after object path filtering + regular search', async () => { // reset page, thus reset filter/search. await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); From 9e762f81764f5ca881ade7506a9c83006437e8a8 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 15:39:13 +0200 Subject: [PATCH 13/22] Add extra documentation to getLayouts --- QualityControl/public/services/Layout.service.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/QualityControl/public/services/Layout.service.js b/QualityControl/public/services/Layout.service.js index 80550e7b5..debc99702 100644 --- a/QualityControl/public/services/Layout.service.js +++ b/QualityControl/public/services/Layout.service.js @@ -39,6 +39,9 @@ export default class LayoutService { /** * Method to get all layouts shared between users + * username is used for matching the user to their own layouts. + * This is chosen over user_id due to CERN users sometimes having multiple accounts, + * different user_id but same user_name. * @param {string|undefined} fields - comma separated string values. Represent the fields that should be fetched. * @param {object|undefined} filter - filter information to be parsed by the backend. * If left empty all available fields will be fetched From a05445931786847038beda779f228efda2ca2dc9 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 15:57:41 +0200 Subject: [PATCH 14/22] Code cleanup --- .../pages/layoutListView/FilterTypes.js | 2 +- .../layoutListView/model/LayoutListModel.js | 5 ++-- .../layoutListView/model/SearchFilterModel.js | 30 +++++++++++-------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index b1cc45b67..f4d34fb55 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -48,9 +48,9 @@ export function createKeyValueFilter(key, friendlyName = null, inputPlaceholder }; } -// Multi-value filter /** * Creates a multiple value filter, key with array value. + * Not used in the code yet but serves as an example. * @param {string} key - key used to save and retrieve value. * @param {string|null} friendlyName - friendly name of the filter. * @param {string|null} inputPlaceholder - input placeholder text. diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index f78990fa4..8e5563f46 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -90,7 +90,8 @@ export default class LayoutListModel extends BaseViewModel { */ search(searchInput, filters) { if (searchInput === undefined && filters === undefined) { - this.model.services.layout.getLayouts(undefined, undefined); + // Get all layouts + this.model.services.layout.getLayouts(RequestFields.LAYOUT_CARD, undefined); } else if (filters === undefined) { // Normal offline search this.searchInput = searchInput; @@ -99,7 +100,7 @@ export default class LayoutListModel extends BaseViewModel { }); this.notify(); } else { - // online search + // online search using filters this.model.services.layout.getLayouts(RequestFields.LAYOUT_CARD, filters); } } diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 37446cfc7..790600d35 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -24,7 +24,7 @@ export default class SearchFilterModel extends BaseViewModel { super(); /** - * filters storage map + * Filters storage map * @type {Map} */ this.filters = new Map(); // key -> filter instance @@ -33,7 +33,7 @@ export default class SearchFilterModel extends BaseViewModel { } /** - * register a filter object. + * Register a filter object. * @param {Filter} filter - Filter object to register. * @returns {Filter} - Filter object that was registered. */ @@ -49,10 +49,10 @@ export default class SearchFilterModel extends BaseViewModel { return filter; } - // eslint-disable-next-line jsdoc/require-returns /** - * unregisters a specified filter by key. + * Unregisters a specified filter by key. * @param {string} key - filter key. + * @returns {void|boolean} void when key is not registered or true when the filter was successfully unregistered. */ unregister(key) { const filter = this.get(key); @@ -65,7 +65,7 @@ export default class SearchFilterModel extends BaseViewModel { } /** - * gets a specified filter object by filter.key + * Gets a specified filter object by filter.key * @param {string} key - filter key. * @returns {Filter} - Filter object */ @@ -74,7 +74,7 @@ export default class SearchFilterModel extends BaseViewModel { } /** - * gets all registered filters + * Gets all registered filters * @returns {Array} - Filter object that was registered. */ getAll() { @@ -82,7 +82,7 @@ export default class SearchFilterModel extends BaseViewModel { } /** - * set filter by key with specified value('s) + * Set filter by key with specified value('s) * @param {string} key - filter key. * @param {any[]} args - value('s) to set. * @returns {void} - void. @@ -102,10 +102,13 @@ export default class SearchFilterModel extends BaseViewModel { } resetAll() { - this.allInActive(); - for (const filter of this.filters.values()) { - if (typeof filter.reset === 'function') { - filter.reset(); + if (this.allInActive()) { + return; + } else { + for (const filter of this.filters.values()) { + if (typeof filter.reset === 'function') { + filter.reset(); + } } this.notify(); } @@ -142,8 +145,8 @@ export default class SearchFilterModel extends BaseViewModel { } /** - * return the active filters in a representable way. - * @returns {string} Active filters: filter.friendlyName(). + * Return the active filters in a representable way. + * @returns {string} Active filters: filter.friendlyName(), ... . */ stringifyActiveFiltersFriendly() { let activeFilterText = 'Active filters: '; @@ -154,6 +157,7 @@ export default class SearchFilterModel extends BaseViewModel { for (const filter of this.getAllActive()) { activeFilterText += ` ${filter.friendlyName()},`; } + // Remove trailing comma activeFilterText = activeFilterText.slice(0, activeFilterText.length - 1); activeFilterText += '.'; From f8ce94b6c06ad742c0aeb21a2b2cc21bf845544e Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 16:21:28 +0200 Subject: [PATCH 15/22] Fix bad parameter --- .../public/pages/layoutListView/filtersPanelPopover.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index bbec97006..04d206f20 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -70,16 +70,11 @@ export const filtersSection = (searchFilterModel = {}) => [ /** * Return the filters panel popover content (i.e. the actual filters) * @param {SearchFilterModel} searchFilterModel the filtering model - * @param {object} [configuration] additional configuration - * @param {string} [configuration.profile = profiles.none] profile which filters should be rendered @see Column * @returns {Component} the filters panel */ -const filtersToggleContent = ( - searchFilterModel, - configuration = {}, -) => h('.w-l.flex-column.p3.g3', [ +const filtersToggleContent = (searchFilterModel) => h('.w-l.flex-column.p3.g3', [ filtersToggleContentHeader(searchFilterModel), - filtersSection(searchFilterModel, configuration), + filtersSection(searchFilterModel), ]); /** From b34779b6fc3af5c2f6f054c1b6092d6ed9008e96 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Fri, 19 Sep 2025 16:25:03 +0200 Subject: [PATCH 16/22] Bad parameter 2 --- .../public/pages/layoutListView/filtersPanelPopover.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 04d206f20..6b41b330b 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -80,12 +80,11 @@ const filtersToggleContent = (searchFilterModel) => h('.w-l.flex-column.p3.g3', /** * Return component composed of the filtering popover and its button trigger * @param {SearchFilterModel} searchFilterModel the filtering model - * @param {object} [configuration] optional configuration * @returns {Component} the filter component */ -export const filtersPanelPopover = (searchFilterModel, configuration) => popover( +export const filtersPanelPopover = (searchFilterModel) => popover( filtersToggleTrigger(), - filtersToggleContent(searchFilterModel, configuration), + filtersToggleContent(searchFilterModel), { ...PopoverTriggerPreConfiguration.click, anchor: PopoverAnchors.RIGHT_START, From c5cd6e89597b087fb54761995707bdc834b157f0 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Mon, 22 Sep 2025 09:18:36 +0200 Subject: [PATCH 17/22] Process feedback --- .../pages/layoutListView/FilterTypes.js | 38 +++---------------- .../layoutListView/filtersPanelPopover.js | 6 +-- .../layoutListView/model/LayoutListModel.js | 1 - .../layoutListView/model/SearchFilterModel.js | 10 ++--- .../test/public/pages/layout-list.test.js | 4 +- 5 files changed, 12 insertions(+), 47 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index f4d34fb55..3a9a7965c 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -35,41 +35,13 @@ export function createKeyValueFilter(key, friendlyName = null, inputPlaceholder return { key, friendlyName: () => friendlyName ? friendlyName : key, - inputPlaceholder: () => inputPlaceholder ? inputPlaceholder : 'Conditions', + inputPlaceholder: () => inputPlaceholder ? inputPlaceholder : '', getValue: () => value ? value : null, // trim checks if value is a string value, test this isActive: () => Boolean(value && value.trim()), - set: (v) => { - value = v; - }, - reset: () => { - value = ''; - }, - }; -} - -/** - * Creates a multiple value filter, key with array value. - * Not used in the code yet but serves as an example. - * @param {string} key - key used to save and retrieve value. - * @param {string|null} friendlyName - friendly name of the filter. - * @param {string|null} inputPlaceholder - input placeholder text. - * @param {Array} value - values (array) associated with key. - * @returns {Filter} multiple value filter, key with array with values. - */ -export function createMultiValueFilter(key, friendlyName = null, inputPlaceholder = null, value = []) { - let values = Array.isArray(value) ? value : []; - return { - key, - friendlyName: () => friendlyName ? friendlyName : key, - inputPlaceholder: () => inputPlaceholder ? inputPlaceholder : 'Conditions', - getValue: () => values, - isActive: () => values.length > 0, - set: (arr) => { - values = Array.isArray(arr) ? arr : []; - }, - reset: () => { - values = []; - }, + // eslint-disable-next-line @stylistic/js/brace-style + set: (v) => { value = v; }, + // eslint-disable-next-line @stylistic/js/brace-style + reset: () => { value = ''; }, }; } diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 6b41b330b..e8f52e429 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -37,9 +37,7 @@ const filtersToggleContentHeader = (searchFilterModel) => h('.flex-row.justify-b h( 'button#reset-filters.btn.btn-danger', { - onclick: () => { - searchFilterModel.resetAll(); - }, + onclick: () => searchFilterModel.resetAll(), disabled: searchFilterModel.allInActive() ? true : false, }, 'Reset all filters', @@ -54,7 +52,7 @@ const filtersToggleContentHeader = (searchFilterModel) => h('.flex-row.justify-b export const filtersSection = (searchFilterModel = {}) => [ searchFilterModel.getAll().flatMap((filter) => [ h('.flex-row.g2', [ - h('.w-30.f5.flex-row.items-center.g2', [filter.friendlyName()]), + h('.w-30.f5.flex-row.items-center.g2', filter.friendlyName()), h('.w-70', [ h('input.form-control.w-100', { placeholder: filter.inputPlaceholder(), diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 8e5563f46..174e73b95 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -32,7 +32,6 @@ export default class LayoutListModel extends BaseViewModel { this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); this.searchFilterModel.observe(() => { - this.notify(); if (!this.searchFilterModel.allInActive()) { this.search(undefined, this.searchFilterModel.getAllAsObject()); } else { diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 790600d35..82266db8d 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -151,15 +151,11 @@ export default class SearchFilterModel extends BaseViewModel { stringifyActiveFiltersFriendly() { let activeFilterText = 'Active filters: '; if (this.allInActive()) { - activeFilterText += 'None.'; + activeFilterText = ''; return activeFilterText; } else { - for (const filter of this.getAllActive()) { - activeFilterText += ` ${filter.friendlyName()},`; - } - // Remove trailing comma - activeFilterText = activeFilterText.slice(0, activeFilterText.length - 1); - activeFilterText += '.'; + const activeNames = this.getAllActive().map((filter) => filter.friendlyName()); + activeFilterText += `${activeNames.join(', ')}.`; return activeFilterText; } diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 8bcad2342..70fee4890 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -195,14 +195,14 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); await delay(100); const preFilterText = await page.evaluate(() => document.querySelector('div.mh1').textContent.trim()); - strictEqual(preFilterText, 'Active filters: None.'); + strictEqual(preFilterText, ''); await page.locator('#openFilterToggle').click(); await delay(100); await page.locator(filterObjectPath).fill('TPC'); await page.locator('#openFilterToggle').click(); await delay(100); const postFilterText = await page.evaluate(() => document.querySelector('div.mh1').textContent.trim()); - strictEqual(postFilterText, 'Active filters: Object path.'); + strictEqual(postFilterText, 'Active filters: Object path.'); }); await testParent.test('should have a folder with 1 card after object path filtering + regular search', async () => { From 1df126d807cf0f521201955968df34ec9fef4d1a Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Tue, 23 Sep 2025 10:10:32 +0200 Subject: [PATCH 18/22] Fix bad merge --- .../layoutListView/components/LayoutListHeader.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js index e64f7370e..76cbc8dee 100644 --- a/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js +++ b/QualityControl/public/pages/layoutListView/components/LayoutListHeader.js @@ -15,10 +15,10 @@ import { h } from '/js/src/index.js'; /** - * Shows header of list of layouts. - * @returns {vnode} - virtual node element + * Shows header of list of layouts with one search input to filter them + * @returns {{centerCol: vnode, rightCol: vnode}} - object with virtual node elements */ -export default () => [ - h('.w-50.text-center', [h('b.f4', 'Layouts')]), - h('.flex-grow.text-right'), -]; +export default () => ({ + centerCol: h('.flex-grow.text-center', [h('b.f4', 'Layouts')]), + rightCol: h('.w-33.text-right'), +}); From 02965cca3c31afa3573d04895134f54530829b60 Mon Sep 17 00:00:00 2001 From: Jasper Houweling Date: Tue, 23 Sep 2025 10:59:32 +0200 Subject: [PATCH 19/22] Processed feedback part 2 --- .../public/pages/layoutListView/FilterTypes.js | 10 ++++++---- .../pages/layoutListView/filtersPanelPopover.js | 1 - .../pages/layoutListView/model/LayoutListModel.js | 4 +++- .../pages/layoutListView/model/SearchFilterModel.js | 12 +++++------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/FilterTypes.js b/QualityControl/public/pages/layoutListView/FilterTypes.js index 3a9a7965c..f48408fd8 100644 --- a/QualityControl/public/pages/layoutListView/FilterTypes.js +++ b/QualityControl/public/pages/layoutListView/FilterTypes.js @@ -39,9 +39,11 @@ export function createKeyValueFilter(key, friendlyName = null, inputPlaceholder getValue: () => value ? value : null, // trim checks if value is a string value, test this isActive: () => Boolean(value && value.trim()), - // eslint-disable-next-line @stylistic/js/brace-style - set: (v) => { value = v; }, - // eslint-disable-next-line @stylistic/js/brace-style - reset: () => { value = ''; }, + set: (v) => { + value = v; + }, + reset: () => { + value = ''; + }, }; } diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index e8f52e429..0a4e7b908 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -14,7 +14,6 @@ // Adopted from Bookkeeping/lib/public/components/Filters/common/filtersPanelPopover.js import { h, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; -// import { tooltip } from '../../common/popover/tooltip.js'; /** * imports for JSDoc + VSCode navigation: diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 174e73b95..274a83147 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -17,6 +17,7 @@ import LayoutCardModel from './LayoutCardModel.js'; import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; import { RequestFields } from '../../../common/RequestFields.enum.js'; import SearchFilterModel from './SearchFilterModel.js'; +import { createKeyValueFilter } from '../FilterTypes.js'; /** * LayoutListModel namespace to control the layoutCards spread between its folders @@ -31,9 +32,10 @@ export default class LayoutListModel extends BaseViewModel { this.model = model; this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); + this.searchFilterModel.register(createKeyValueFilter('objectPath', 'Object path', 'e.g. TPC')); this.searchFilterModel.observe(() => { if (!this.searchFilterModel.allInActive()) { - this.search(undefined, this.searchFilterModel.getAllAsObject()); + this.search(undefined, this.searchFilterModel.getAllActiveAsObject()); } else { this.search(undefined, undefined); } diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 82266db8d..8d9c3a7c9 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -13,7 +13,6 @@ */ import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; -import { createKeyValueFilter } from '../FilterTypes.js'; /** * SearchFilter model to control the search and filter state @@ -29,7 +28,6 @@ export default class SearchFilterModel extends BaseViewModel { */ this.filters = new Map(); // key -> filter instance this.searchInput = ''; - this.register(createKeyValueFilter('objectPath', 'Object path', 'e.g. TPC')); } /** @@ -127,21 +125,21 @@ export default class SearchFilterModel extends BaseViewModel { * @returns {boolean} all filters are inactive */ allInActive() { - const activeCount = this.filters.values().filter((filter) => filter.isActive()).toArray().length; + const activeCount = this.getAllActive().length; return activeCount > 0 ? false : true; } /** - * Returns all filters in a object like so + * Returns all active filters in a object like so * This is the object that we can actually search with. * { * objectPath: 'TPC', * pizza: 'Peperoni', * } - * @returns {object} object containing all key/value pairs from all filters. + * @returns {object} object containing all key/value pairs from all active filters. */ - getAllAsObject() { - return Object.fromEntries(this.getAll().map((filter) => [filter.key, filter.getValue()])); + getAllActiveAsObject() { + return Object.fromEntries(this.getAllActive().map((filter) => [filter.key, filter.getValue()])); } /** From 244953155c73548b49f22c8b532716f728f335e8 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 24 Sep 2025 18:03:10 +0200 Subject: [PATCH 20/22] Fix spelling mistake --- .../public/pages/layoutListView/filtersPanelPopover.js | 2 +- .../public/pages/layoutListView/model/LayoutListModel.js | 3 ++- .../public/pages/layoutListView/model/SearchFilterModel.js | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 0a4e7b908..17e4f0084 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -37,7 +37,7 @@ const filtersToggleContentHeader = (searchFilterModel) => h('.flex-row.justify-b 'button#reset-filters.btn.btn-danger', { onclick: () => searchFilterModel.resetAll(), - disabled: searchFilterModel.allInActive() ? true : false, + disabled: searchFilterModel.allInactive() ? true : false, }, 'Reset all filters', ), diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 274a83147..92ef7c907 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -33,8 +33,9 @@ export default class LayoutListModel extends BaseViewModel { this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); this.searchFilterModel.register(createKeyValueFilter('objectPath', 'Object path', 'e.g. TPC')); + this.searchFilterModel.register(createKeyValueFilter('objectPath2', 'Object path', 'e.g. TPC')); this.searchFilterModel.observe(() => { - if (!this.searchFilterModel.allInActive()) { + if (!this.searchFilterModel.allInactive()) { this.search(undefined, this.searchFilterModel.getAllActiveAsObject()); } else { this.search(undefined, undefined); diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 8d9c3a7c9..244de42fb 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -100,7 +100,7 @@ export default class SearchFilterModel extends BaseViewModel { } resetAll() { - if (this.allInActive()) { + if (this.allInactive()) { return; } else { for (const filter of this.filters.values()) { @@ -124,7 +124,7 @@ export default class SearchFilterModel extends BaseViewModel { * check if all filters are inactive or not. * @returns {boolean} all filters are inactive */ - allInActive() { + allInactive() { const activeCount = this.getAllActive().length; return activeCount > 0 ? false : true; } @@ -148,7 +148,7 @@ export default class SearchFilterModel extends BaseViewModel { */ stringifyActiveFiltersFriendly() { let activeFilterText = 'Active filters: '; - if (this.allInActive()) { + if (this.allInactive()) { activeFilterText = ''; return activeFilterText; } else { From fa7b912862b773a687e4dd07d29232dafac1f3c2 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 24 Sep 2025 18:03:59 +0200 Subject: [PATCH 21/22] Do not export as default class --- .../public/pages/layoutListView/filtersPanelPopover.js | 2 +- .../public/pages/layoutListView/model/LayoutListModel.js | 2 +- .../public/pages/layoutListView/model/SearchFilterModel.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js index 17e4f0084..a68b13253 100644 --- a/QualityControl/public/pages/layoutListView/filtersPanelPopover.js +++ b/QualityControl/public/pages/layoutListView/filtersPanelPopover.js @@ -17,7 +17,7 @@ import { h, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/ /** * imports for JSDoc + VSCode navigation: - * @import SearchFilterModel from './model/SearchFilterModel.js'; + * @import { SearchFilterModel } from './model/SearchFilterModel.js'; */ /** diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 92ef7c907..9542542dc 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -16,7 +16,7 @@ import FolderModel, { FolderType } from '../../../folder/model/FolderModel.js'; import LayoutCardModel from './LayoutCardModel.js'; import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; import { RequestFields } from '../../../common/RequestFields.enum.js'; -import SearchFilterModel from './SearchFilterModel.js'; +import { SearchFilterModel } from './SearchFilterModel.js'; import { createKeyValueFilter } from '../FilterTypes.js'; /** diff --git a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js index 244de42fb..7db8309f2 100644 --- a/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js +++ b/QualityControl/public/pages/layoutListView/model/SearchFilterModel.js @@ -18,7 +18,7 @@ import { BaseViewModel } from '../../../common/abstracts/BaseViewModel.js'; * SearchFilter model to control the search and filter state * @import { Filter } from '../FilterTypes.js'; */ -export default class SearchFilterModel extends BaseViewModel { +export class SearchFilterModel extends BaseViewModel { constructor() { super(); From 76ff2084b676e42dc60669760fe11a8afe5d4061 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 24 Sep 2025 18:06:07 +0200 Subject: [PATCH 22/22] Remove unwanted filter registration --- .../public/pages/layoutListView/model/LayoutListModel.js | 1 - 1 file changed, 1 deletion(-) diff --git a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js index 9542542dc..5b541abba 100644 --- a/QualityControl/public/pages/layoutListView/model/LayoutListModel.js +++ b/QualityControl/public/pages/layoutListView/model/LayoutListModel.js @@ -33,7 +33,6 @@ export default class LayoutListModel extends BaseViewModel { this.folders = new Map(); this.searchFilterModel = new SearchFilterModel(); this.searchFilterModel.register(createKeyValueFilter('objectPath', 'Object path', 'e.g. TPC')); - this.searchFilterModel.register(createKeyValueFilter('objectPath2', 'Object path', 'e.g. TPC')); this.searchFilterModel.observe(() => { if (!this.searchFilterModel.allInactive()) { this.search(undefined, this.searchFilterModel.getAllActiveAsObject());