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.
*****************************************************************************/
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;

View File

@ -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) {

View File

@ -22,20 +22,57 @@
<template>
<li class="c-inspect-properties__row">
<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 class="c-inspect-properties__label" title="Filter by keyword.">Filters</div>
<div v-if="canEdit" class="c-inspect-properties__value" :class="{ 'form-error': hasError }">
<div class="c-inspect-properties__label" aria-label="Activity Names" title="Filter by keyword.">
Activity Names
</div>
<div
v-if="canEdit"
class="c-inspect-properties__value"
:class="{ 'form-error': hasFilterError }"
>
<textarea
v-model="filterValue"
class="c-input--flex"
type="text"
@keydown.enter.exact.stop="forceBlur($event)"
@keyup="updateForm($event, 'filter')"
@keyup="updateNameFilter($event, 'filter')"
></textarea>
</div>
<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>
</li>
</template>
@ -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);
}
}
};

View File

@ -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 = [];
}

View File

@ -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;