From 60e1eeba8ed863c126b42e35b62e7a6b47d62525 Mon Sep 17 00:00:00 2001 From: Shefali Joshi Date: Sun, 28 Jan 2024 10:04:52 -0800 Subject: [PATCH] Add filtering by metadata (#7388) * Add filtering by metadata. Add new sourceMap property to get a list of properties for metadata filtering. * Change filter label names * Add aria-labels * Closes #7389 - Added a "No filters applied" message for both input areas. - Added additional detail about how it works in the hint text visible while editing. * Restore valid state if there is an error * Fix linting error * Tests for filtering by metadata --------- Co-authored-by: Charles Hacskaylo --- src/plugins/plan/util.js | 23 ++- src/plugins/timelist/TimelistComponent.vue | 39 ++++- .../timelist/inspector/FilteringComponent.vue | 91 +++++++++--- src/plugins/timelist/plugin.js | 12 +- src/plugins/timelist/pluginSpec.js | 135 +++++++++++++++++- 5 files changed, 263 insertions(+), 37 deletions(-) diff --git a/src/plugins/plan/util.js b/src/plugins/plan/util.js index 4f44d52302..7dcefc4b3d 100644 --- a/src/plugins/plan/util.js +++ b/src/plugins/plan/util.js @@ -20,6 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ +import _ from 'lodash'; export function getValidatedData(domainObject) { const sourceMap = domainObject.sourceMap; const json = getObjectJson(domainObject); @@ -45,6 +46,16 @@ export function getValidatedData(domainObject) { groupActivity.end = activity[sourceMap.end]; } + if (Array.isArray(sourceMap.filterMetadata)) { + groupActivity.filterMetadataValues = []; + sourceMap.filterMetadata.forEach((property) => { + const value = _.get(activity, property); + groupActivity.filterMetadataValues.push({ + value + }); + }); + } + if (!mappedJson[groupIdKey]) { mappedJson[groupIdKey] = []; } @@ -92,7 +103,6 @@ export function getValidatedGroups(domainObject, planData) { orderedGroupNames = groups; } } - if (orderedGroupNames === undefined) { orderedGroupNames = Object.keys(planData); } @@ -100,6 +110,17 @@ export function getValidatedGroups(domainObject, planData) { return orderedGroupNames; } +export function getFilteredValues(activity) { + let values = []; + if (Array.isArray(activity.filterMetadataValues)) { + values = activity.filterMetadataValues; + } else if (activity?.properties) { + values = Object.values(activity.properties); + } + + return values; +} + export function getContrastingColor(hexColor) { function cutHex(h, start, end) { const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h; diff --git a/src/plugins/timelist/TimelistComponent.vue b/src/plugins/timelist/TimelistComponent.vue index 729c5b32d4..e6ced4ea09 100644 --- a/src/plugins/timelist/TimelistComponent.vue +++ b/src/plugins/timelist/TimelistComponent.vue @@ -38,7 +38,7 @@ import { v4 as uuid } from 'uuid'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; import ListView from '../../ui/components/List/ListView.vue'; import { getPreciseDuration } from '../../utils/duration.js'; -import { getValidatedData, getValidatedGroups } from '../plan/util.js'; +import { getFilteredValues, getValidatedData, getValidatedGroups } from '../plan/util.js'; import { SORT_ORDER_OPTIONS } from './constants.js'; const SCROLL_TIMEOUT = 10000; @@ -208,22 +208,22 @@ export default { this.setViewFromConfig(mutatedObject.configuration); }, setViewFromConfig(configuration) { + this.filterValue = configuration.filter || ''; + this.filterMetadataValue = configuration.filterMetadata || ''; if (this.isEditing) { - this.filterValue = configuration.filter; this.hideAll = false; - this.listActivities(); } else { - this.filterValue = configuration.filter; this.setSort(); - this.listActivities(); } + this.listActivities(); }, updateTimestamp(timestamp) { //The clock never stops ticking this.updateTimeStampAndListActivities(timestamp); }, setFixedTime() { - this.filterValue = this.domainObject.configuration.filter; + this.filterValue = this.domainObject.configuration.filter || ''; + this.filterMetadataValue = this.domainObject.configuration.filterMetadata || ''; this.isFixedTime = !this.timeContext.isRealTime(); if (this.isFixedTime) { this.hideAll = false; @@ -326,7 +326,21 @@ export default { return true; } - const hasFilterMatch = this.filterByName(activity.name); + let hasNameMatch = false; + let hasMetadataMatch = false; + if (this.filterValue || this.filterMetadataValue) { + if (this.filterValue) { + hasNameMatch = this.filterByName(activity.name); + } + if (this.filterMetadataValue) { + hasMetadataMatch = this.filterByMetadata(activity); + } + } else { + hasNameMatch = true; + hasMetadataMatch = true; + } + + const hasFilterMatch = hasNameMatch || hasMetadataMatch; if (hasFilterMatch === false || this.hideAll === true) { return false; } @@ -354,6 +368,17 @@ export default { return regex.test(name.toLowerCase()); }); }, + filterByMetadata(activity) { + const filters = this.filterMetadataValue.split(','); + + return filters.some((search) => { + const normalized = search.trim().toLowerCase(); + const regex = new RegExp(normalized); + const activityValues = getFilteredValues(activity); + + return regex.test(activityValues.join().toLowerCase()); + }); + }, // Add activity classes, increase activity counts by type, // set indices of the first occurrences of current and future activities - used for scrolling styleActivity(activity, index) { diff --git a/src/plugins/timelist/inspector/FilteringComponent.vue b/src/plugins/timelist/inspector/FilteringComponent.vue index 2ff0f8bbb9..bf8c34a2a3 100644 --- a/src/plugins/timelist/inspector/FilteringComponent.vue +++ b/src/plugins/timelist/inspector/FilteringComponent.vue @@ -22,20 +22,57 @@ @@ -48,7 +85,9 @@ export default { return { isEditing: this.openmct.editor.isEditing(), filterValue: this.domainObject.configuration.filter, - hasError: false + filterMetadataValue: this.domainObject.configuration.filterMetadata, + hasFilterError: false, + hasMetadataFilterError: false }; }, computed: { @@ -65,37 +104,55 @@ export default { methods: { setEditState(isEditing) { this.isEditing = isEditing; - if (!this.isEditing && this.hasError) { - this.filterValue = this.domainObject.configuration.filter; - this.hasError = false; + if (!this.isEditing) { + if (this.hasFilterError) { + this.filterValue = this.domainObject.configuration.filter; + } + if (this.hasMetadataFilterError) { + this.filterMetadataValue = this.domainObject.configuration.filterMetadata; + } + this.hasFilterError = false; + this.hasMetadataFilterError = false; } }, forceBlur(event) { event.target.blur(); }, - updateForm(event, property) { - if (!this.isValid()) { - this.hasError = true; + updateNameFilter(event, property) { + if (!this.isValid(this.filterValue)) { + this.hasFilterError = true; return; } - - this.hasError = false; + this.hasFilterError = false; this.$emit('updated', { property, value: this.filterValue.replace(/,(\s)*$/, '') }); }, - isValid() { + updateMetadataFilter(event, property) { + if (!this.isValid(this.filterMetadataValue)) { + this.hasMetadataFilterError = true; + + return; + } + this.hasMetadataFilterError = false; + + this.$emit('updated', { + property, + value: this.filterMetadataValue.replace(/,(\s)*$/, '') + }); + }, + isValid(value) { // Test for any word character, any whitespace character or comma - if (this.filterValue === '') { + if (value === '') { return true; } const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g); - return regex.test(this.filterValue); + return regex.test(value); } } }; diff --git a/src/plugins/timelist/plugin.js b/src/plugins/timelist/plugin.js index 0e9a37af3b..feb91d9382 100644 --- a/src/plugins/timelist/plugin.js +++ b/src/plugins/timelist/plugin.js @@ -38,16 +38,10 @@ export default function () { initialize: function (domainObject) { domainObject.configuration = { sortOrderIndex: 0, - futureEventsIndex: 1, - futureEventsDurationIndex: 0, - futureEventsDuration: 20, currentEventsIndex: 1, - currentEventsDurationIndex: 0, - currentEventsDuration: 20, - pastEventsIndex: 1, - pastEventsDurationIndex: 0, - pastEventsDuration: 20, - filter: '' + filter: '', + filterMetadata: '', + isCompact: false }; domainObject.composition = []; } diff --git a/src/plugins/timelist/pluginSpec.js b/src/plugins/timelist/pluginSpec.js index 1de13dc59b..69101cadd0 100644 --- a/src/plugins/timelist/pluginSpec.js +++ b/src/plugins/timelist/pluginSpec.js @@ -71,7 +71,10 @@ describe('the plugin', function () { end: twoHoursFuture, type: 'TEST-GROUP', color: 'fuchsia', - textColor: 'black' + textColor: 'black', + properties: { + location: 'garden' + } }, { name: 'Sed ut perspiciatis two', @@ -79,7 +82,10 @@ describe('the plugin', function () { end: threeHoursFuture, type: 'TEST-GROUP', color: 'fuchsia', - textColor: 'black' + textColor: 'black', + properties: { + location: 'hallway' + } } ] }) @@ -305,7 +311,7 @@ describe('the plugin', function () { }); }); - describe('filters', () => { + describe('filters by name', () => { let timelistDomainObject; let timelistView; @@ -379,6 +385,129 @@ describe('the plugin', function () { }); }); + describe('filters by metadata', () => { + let timelistDomainObject; + let timelistView; + + beforeEach(() => { + timelistDomainObject = { + identifier: { + key: 'test-object', + namespace: '' + }, + type: TIMELIST_TYPE, + id: 'test-object', + configuration: { + sortOrderIndex: 2, + futureEventsIndex: 1, + futureEventsDurationIndex: 0, + futureEventsDuration: 0, + currentEventsIndex: 1, + currentEventsDurationIndex: 0, + currentEventsDuration: 0, + pastEventsIndex: 1, + pastEventsDurationIndex: 0, + pastEventsDuration: 0, + filter: '', + filterMetadata: 'hallway,garden' + }, + composition: [ + { + identifier: { + key: 'test-plan-object', + namespace: '' + } + } + ] + }; + + openmct.router.path = [timelistDomainObject]; + + const applicableViews = openmct.objectViews.get(timelistDomainObject, [timelistDomainObject]); + timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view'); + let view = timelistView.view(timelistDomainObject, [timelistDomainObject]); + view.show(child, true); + + return nextTick(); + }); + + it('activities and sorts them correctly', () => { + mockComposition.emit('add', planObject); + + return nextTick(() => { + const timeFormat = openmct.time.timeSystem().timeFormat; + const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter; + + const items = element.querySelectorAll(LIST_ITEM_CLASS); + expect(items.length).toEqual(2); + + const itemValues = items[1].querySelectorAll(LIST_ITEM_VALUE_CLASS); + expect(itemValues[0].innerHTML.trim()).toEqual(timeFormatter.format(now, TIME_FORMAT)); + expect(itemValues[1].innerHTML.trim()).toEqual( + timeFormatter.format(threeHoursFuture, TIME_FORMAT) + ); + expect(itemValues[3].innerHTML.trim()).toEqual('Sed ut perspiciatis two'); + }); + }); + }); + + describe('filters by name and metadata', () => { + let timelistDomainObject; + let timelistView; + + beforeEach(() => { + timelistDomainObject = { + identifier: { + key: 'test-object', + namespace: '' + }, + type: TIMELIST_TYPE, + id: 'test-object', + configuration: { + sortOrderIndex: 2, + currentEventsIndex: 1, + filter: 'two', + filterMetadata: 'garden' + }, + composition: [ + { + identifier: { + key: 'test-plan-object', + namespace: '' + } + } + ] + }; + + openmct.router.path = [timelistDomainObject]; + + const applicableViews = openmct.objectViews.get(timelistDomainObject, [timelistDomainObject]); + timelistView = applicableViews.find((viewProvider) => viewProvider.key === 'timelist.view'); + let view = timelistView.view(timelistDomainObject, [timelistDomainObject]); + view.show(child, true); + + return nextTick(); + }); + + it('activities and sorts them correctly', () => { + mockComposition.emit('add', planObject); + + return nextTick(() => { + const timeFormat = openmct.time.timeSystem().timeFormat; + const timeFormatter = openmct.telemetry.getValueFormatter({ format: timeFormat }).formatter; + + const items = element.querySelectorAll(LIST_ITEM_CLASS); + expect(items.length).toEqual(2); + + const itemValues = items[0].querySelectorAll(LIST_ITEM_VALUE_CLASS); + expect(itemValues[0].innerHTML.trim()).toEqual(timeFormatter.format(now, TIME_FORMAT)); + expect(itemValues[1].innerHTML.trim()).toEqual( + timeFormatter.format(twoHoursFuture, TIME_FORMAT) + ); + }); + }); + }); + describe('time filtering - past', () => { let timelistDomainObject; let timelistView;