mirror of
https://github.com/nasa/openmct.git
synced 2025-01-20 19:49:17 +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.
|
* 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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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 = [];
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user