mirror of
https://github.com/nasa/openmct.git
synced 2024-12-18 20:57:53 +00:00
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:
parent
1fc6056c51
commit
60e1eeba8e
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -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 = [];
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user