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 <charlesh88@gmail.com>
This commit is contained in:
Shefali Joshi 2024-01-28 10:04:52 -08:00 committed by GitHub
parent 1fc6056c51
commit 60e1eeba8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 263 additions and 37 deletions

View File

@ -20,6 +20,7 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import _ from 'lodash';
export function getValidatedData(domainObject) { export function getValidatedData(domainObject) {
const sourceMap = domainObject.sourceMap; const sourceMap = domainObject.sourceMap;
const json = getObjectJson(domainObject); const json = getObjectJson(domainObject);
@ -45,6 +46,16 @@ export function getValidatedData(domainObject) {
groupActivity.end = activity[sourceMap.end]; 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]) { if (!mappedJson[groupIdKey]) {
mappedJson[groupIdKey] = []; mappedJson[groupIdKey] = [];
} }
@ -92,7 +103,6 @@ export function getValidatedGroups(domainObject, planData) {
orderedGroupNames = groups; orderedGroupNames = groups;
} }
} }
if (orderedGroupNames === undefined) { if (orderedGroupNames === undefined) {
orderedGroupNames = Object.keys(planData); orderedGroupNames = Object.keys(planData);
} }
@ -100,6 +110,17 @@ export function getValidatedGroups(domainObject, planData) {
return orderedGroupNames; 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) { export function getContrastingColor(hexColor) {
function cutHex(h, start, end) { function cutHex(h, start, end) {
const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h; const hStr = h.charAt(0) === '#' ? h.substring(1, 7) : h;

View File

@ -38,7 +38,7 @@ import { v4 as uuid } from 'uuid';
import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js'; import { TIME_CONTEXT_EVENTS } from '../../api/time/constants.js';
import ListView from '../../ui/components/List/ListView.vue'; import ListView from '../../ui/components/List/ListView.vue';
import { getPreciseDuration } from '../../utils/duration.js'; 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'; import { SORT_ORDER_OPTIONS } from './constants.js';
const SCROLL_TIMEOUT = 10000; const SCROLL_TIMEOUT = 10000;
@ -208,22 +208,22 @@ export default {
this.setViewFromConfig(mutatedObject.configuration); this.setViewFromConfig(mutatedObject.configuration);
}, },
setViewFromConfig(configuration) { setViewFromConfig(configuration) {
this.filterValue = configuration.filter || '';
this.filterMetadataValue = configuration.filterMetadata || '';
if (this.isEditing) { if (this.isEditing) {
this.filterValue = configuration.filter;
this.hideAll = false; this.hideAll = false;
this.listActivities();
} else { } else {
this.filterValue = configuration.filter;
this.setSort(); this.setSort();
this.listActivities();
} }
this.listActivities();
}, },
updateTimestamp(timestamp) { updateTimestamp(timestamp) {
//The clock never stops ticking //The clock never stops ticking
this.updateTimeStampAndListActivities(timestamp); this.updateTimeStampAndListActivities(timestamp);
}, },
setFixedTime() { setFixedTime() {
this.filterValue = this.domainObject.configuration.filter; this.filterValue = this.domainObject.configuration.filter || '';
this.filterMetadataValue = this.domainObject.configuration.filterMetadata || '';
this.isFixedTime = !this.timeContext.isRealTime(); this.isFixedTime = !this.timeContext.isRealTime();
if (this.isFixedTime) { if (this.isFixedTime) {
this.hideAll = false; this.hideAll = false;
@ -326,7 +326,21 @@ export default {
return true; 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) { if (hasFilterMatch === false || this.hideAll === true) {
return false; return false;
} }
@ -354,6 +368,17 @@ export default {
return regex.test(name.toLowerCase()); 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, // Add activity classes, increase activity counts by type,
// set indices of the first occurrences of current and future activities - used for scrolling // set indices of the first occurrences of current and future activities - used for scrolling
styleActivity(activity, index) { styleActivity(activity, index) {

View File

@ -22,20 +22,57 @@
<template> <template>
<li class="c-inspect-properties__row"> <li class="c-inspect-properties__row">
<div v-if="canEdit" class="c-inspect-properties__hint span-all"> <div v-if="canEdit" class="c-inspect-properties__hint span-all">
Filter this view by comma-separated keywords. Filter this view by comma-separated keywords. Filtering uses an 'OR' method.
</div> </div>
<div class="c-inspect-properties__label" title="Filter by keyword.">Filters</div> <div class="c-inspect-properties__label" aria-label="Activity Names" title="Filter by keyword.">
<div v-if="canEdit" class="c-inspect-properties__value" :class="{ 'form-error': hasError }"> Activity Names
</div>
<div
v-if="canEdit"
class="c-inspect-properties__value"
:class="{ 'form-error': hasFilterError }"
>
<textarea <textarea
v-model="filterValue" v-model="filterValue"
class="c-input--flex" class="c-input--flex"
type="text" type="text"
@keydown.enter.exact.stop="forceBlur($event)" @keydown.enter.exact.stop="forceBlur($event)"
@keyup="updateForm($event, 'filter')" @keyup="updateNameFilter($event, 'filter')"
></textarea> ></textarea>
</div> </div>
<div v-else class="c-inspect-properties__value"> <div v-else class="c-inspect-properties__value">
{{ filterValue }} <template v-if="filterValue.length > 0">
{{ filterValue }}
</template>
<template v-else> No filters applied </template>
</div>
</li>
<li class="c-inspect-properties__row">
<div
class="c-inspect-properties__label"
aria-label="Meta-data Properties"
title="Filter by keyword."
>
Meta-data Properties
</div>
<div
v-if="canEdit"
class="c-inspect-properties__value"
:class="{ 'form-error': hasMetadataFilterError }"
>
<textarea
v-model="filterMetadataValue"
class="c-input--flex"
type="text"
@keydown.enter.exact.stop="forceBlur($event)"
@keyup="updateMetadataFilter($event, 'filterMetadata')"
></textarea>
</div>
<div v-else class="c-inspect-properties__value">
<template v-if="filterMetadataValue.length > 0">
{{ filterMetadataValue }}
</template>
<template v-else> No filters applied </template>
</div> </div>
</li> </li>
</template> </template>
@ -48,7 +85,9 @@ export default {
return { return {
isEditing: this.openmct.editor.isEditing(), isEditing: this.openmct.editor.isEditing(),
filterValue: this.domainObject.configuration.filter, filterValue: this.domainObject.configuration.filter,
hasError: false filterMetadataValue: this.domainObject.configuration.filterMetadata,
hasFilterError: false,
hasMetadataFilterError: false
}; };
}, },
computed: { computed: {
@ -65,37 +104,55 @@ export default {
methods: { methods: {
setEditState(isEditing) { setEditState(isEditing) {
this.isEditing = isEditing; this.isEditing = isEditing;
if (!this.isEditing && this.hasError) { if (!this.isEditing) {
this.filterValue = this.domainObject.configuration.filter; if (this.hasFilterError) {
this.hasError = false; this.filterValue = this.domainObject.configuration.filter;
}
if (this.hasMetadataFilterError) {
this.filterMetadataValue = this.domainObject.configuration.filterMetadata;
}
this.hasFilterError = false;
this.hasMetadataFilterError = false;
} }
}, },
forceBlur(event) { forceBlur(event) {
event.target.blur(); event.target.blur();
}, },
updateForm(event, property) { updateNameFilter(event, property) {
if (!this.isValid()) { if (!this.isValid(this.filterValue)) {
this.hasError = true; this.hasFilterError = true;
return; return;
} }
this.hasFilterError = false;
this.hasError = false;
this.$emit('updated', { this.$emit('updated', {
property, property,
value: this.filterValue.replace(/,(\s)*$/, '') 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 // Test for any word character, any whitespace character or comma
if (this.filterValue === '') { if (value === '') {
return true; return true;
} }
const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g); const regex = new RegExp(/^([a-zA-Z0-9_\-\s,])+$/g);
return regex.test(this.filterValue); return regex.test(value);
} }
} }
}; };

View File

@ -38,16 +38,10 @@ export default function () {
initialize: function (domainObject) { initialize: function (domainObject) {
domainObject.configuration = { domainObject.configuration = {
sortOrderIndex: 0, sortOrderIndex: 0,
futureEventsIndex: 1,
futureEventsDurationIndex: 0,
futureEventsDuration: 20,
currentEventsIndex: 1, currentEventsIndex: 1,
currentEventsDurationIndex: 0, filter: '',
currentEventsDuration: 20, filterMetadata: '',
pastEventsIndex: 1, isCompact: false
pastEventsDurationIndex: 0,
pastEventsDuration: 20,
filter: ''
}; };
domainObject.composition = []; domainObject.composition = [];
} }

View File

@ -71,7 +71,10 @@ describe('the plugin', function () {
end: twoHoursFuture, end: twoHoursFuture,
type: 'TEST-GROUP', type: 'TEST-GROUP',
color: 'fuchsia', color: 'fuchsia',
textColor: 'black' textColor: 'black',
properties: {
location: 'garden'
}
}, },
{ {
name: 'Sed ut perspiciatis two', name: 'Sed ut perspiciatis two',
@ -79,7 +82,10 @@ describe('the plugin', function () {
end: threeHoursFuture, end: threeHoursFuture,
type: 'TEST-GROUP', type: 'TEST-GROUP',
color: 'fuchsia', 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 timelistDomainObject;
let timelistView; 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', () => { describe('time filtering - past', () => {
let timelistDomainObject; let timelistDomainObject;
let timelistView; let timelistView;