Compare commits

...

9 Commits

Author SHA1 Message Date
Jesse Mazzella
510215e2da style: make width consistent across both trees 2022-12-30 13:37:36 -08:00
Jesse Mazzella
0b2dc1e8ab refactor(WIP): move common tree logic into a mixin 2022-12-30 13:37:36 -08:00
Jesse Mazzella
bb516d668b feat(WIP): enforce max number of recent items 2022-12-30 13:33:49 -08:00
Jesse Mazzella
eb150a892c feat(WIP): no duplicates in recent objects 2022-12-30 13:33:49 -08:00
Jesse Mazzella
b8b6b0f792 fix: emit after changing hash 2022-12-30 13:33:49 -08:00
Jesse Mazzella
83723065e5 refactor: fix typo 2022-12-30 13:33:49 -08:00
Jesse Mazzella
39e9d2a9c4 feat(WIP): first cut of RecentObjects component 2022-12-30 13:33:49 -08:00
Jesse Mazzella
b5a2194c36 feat: add method getRelativeObjectPath() 2022-12-30 13:33:49 -08:00
Jesse Mazzella
298e9eb361 feat: add pane for recently viewed 2022-12-30 13:32:11 -08:00
7 changed files with 385 additions and 204 deletions

View File

@@ -740,6 +740,30 @@ export default class ObjectAPI {
}
}
/**
* Parse and construct an objectPath from the object's navigation path.
*
* @param {string} navigationPath
* @returns {DomainObject[]} objectPath
*/
async getRelativeObjectPath(navigationPath) {
const identifierRegexp = /mine|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
const identifiers = navigationPath.split('?')[0].match(identifierRegexp);
if (!identifiers) {
return [];
}
identifiers.unshift('ROOT');
const objectPath = (await Promise.all(
identifiers.map(
identifier => this.get(utils.parseKeyString(identifier))
)
)).reverse();
return objectPath;
}
isObjectPathToALink(domainObject, objectPath) {
return objectPath !== undefined
&& objectPath.length > 1

View File

@@ -53,8 +53,8 @@
type="horizontal"
>
<pane
id="tree-pane"
class="l-shell__pane-tree"
style="width: 300px;"
handle="after"
label="Browse"
hide-param="hideTree"
@@ -75,11 +75,27 @@
@click="handleSyncTreeNavigation"
>
</button>
<mct-tree
:sync-tree-navigation="triggerSync"
:reset-tree-navigation="triggerReset"
class="l-shell__tree"
/>
<multipane
type="vertical"
>
<pane
id="tree-pane"
>
<mct-tree
:sync-tree-navigation="triggerSync"
:reset-tree-navigation="triggerReset"
class="l-shell__tree"
/>
</pane>
<pane
handle="before"
label="Recently Viewed"
>
<RecentObjects
class="l-shell__tree"
/>
</pane>
</multipane>
</pane>
<pane class="l-shell__pane-main">
<browse-bar
@@ -134,6 +150,7 @@ import Toolbar from '../toolbar/Toolbar.vue';
import AppLogo from './AppLogo.vue';
import Indicators from './status-bar/Indicators.vue';
import NotificationBanner from './status-bar/NotificationBanner.vue';
import RecentObjects from './RecentObjects.vue';
export default {
components: {
@@ -148,7 +165,8 @@ export default {
Toolbar,
AppLogo,
Indicators,
NotificationBanner
NotificationBanner,
RecentObjects
},
inject: ['openmct'],
data: function () {

View File

@@ -0,0 +1,105 @@
<template>
<div
class="c-tree-and-search l-shell__tree"
>
<div
class="c-tree-and-search__tree c-tree c-tree__scrollable"
>
<tree-item
v-for="(recentItem, index) in treeItems"
:key="`${recentItem.navigationPath}-recent-${index}`"
:node="recentItem"
:is-selector-tree="false"
:selected-item="selectedItem"
:left-offset="recentItem.leftOffset"
:is-new="recentItem.isNew"
:item-offset="itemOffset"
:item-index="index"
:item-height="itemHeight"
:open-items="openTreeItems"
:loading-items="treeItemLoading"
/>
<!-- @tree-item-mounted="scrollToCheck($event)"
@tree-item-action="treeItemAction(recentItem, $event)"
@tree-item-destroyed="removeCompositionListenerFor($event)"
@tree-item-selection="recentItemSelection(recentItem)" -->
</div>
</div>
</template>
<script>
import treeItem from './tree-item.vue';
import treeMixin from '../mixins/tree-mixin.js';
const MAX_RECENT_ITEMS = 20;
const LOCAL_STORAGE_KEY__RECENT_OBJECTS = 'mct-recent-objects';
export default {
name: 'RecentObjects',
components: {
treeItem
},
mixins: [treeMixin],
inject: ['openmct'],
props: {
},
data() {
return {
};
},
async mounted() {
this.openmct.router.on('change:hash', this.onHashChange);
this.treeResizeObserver = new ResizeObserver(this.handleTreeResize);
this.treeResizeObserver.observe(this.$el);
await this.calculateHeights();
},
created() {
this.handleTreeResize = _.debounce(this.handleTreeResize, 300);
},
destroyed() {
this.openmct.router.off('change:hash', this.onHashChange);
},
methods: {
async onHashChange(hash) {
const objectPath = await this.openmct.objects.getRelativeObjectPath(hash);
if (!objectPath.length) {
return;
}
const navigationPath = `/browse/${this.openmct.objects.getRelativePath(objectPath.slice(0, -1))}`;
const foundIndex = this.treeItems.findIndex((item) => {
return navigationPath === item.navigationPath;
});
if (foundIndex > -1) {
const removedItem = this.treeItems.splice(foundIndex, 1);
this.selectedItem = removedItem[0];
} else {
this.selectedItem = {
id: objectPath[0].identifier,
object: objectPath[0],
objectPath,
navigationPath
};
}
this.treeItems.unshift(this.selectedItem);
while (this.treeItems.length > MAX_RECENT_ITEMS) {
this.treeItems.pop();
}
},
async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
let collection = this.openmct.composition.get(domainObject);
let composition = await collection.load(abortSignal);
return composition.map((object) => {
return this.buildTreeItem(object, parentObjectPath);
});
}
}
};
</script>
<style>
</style>

View File

@@ -289,7 +289,7 @@
}
&__pane-tree {
width: 300px;
width: 100%;
padding-left: nth($shellPanePad, 2);
}

View File

@@ -118,12 +118,13 @@
<script>
import _ from 'lodash';
import treeItem from './tree-item.vue';
import treeMixin from '../mixins/tree-mixin.js';
import search from '../components/search.vue';
const ITEM_BUFFER = 25;
const LOCAL_STORAGE_KEY__TREE_EXPANDED = 'mct-tree-expanded';
const SORT_MY_ITEMS_ALPH_ASC = true;
const TREE_ITEM_INDENT_PX = 18;
const SORT_MY_ITEMS_ALPH_ASC = true;
const LOCATOR_ITEM_COUNT_HEIGHT = 10; // how many tree items to make the locator selection box show
export default {
@@ -132,6 +133,7 @@ export default {
search,
treeItem
},
mixins: [treeMixin],
inject: ['openmct'],
props: {
isSelectorTree: {
@@ -214,21 +216,6 @@ export default {
}
},
watch: {
syncTreeNavigation() {
this.searchValue = '';
// if there is an abort controller, then a search is in progress and will need to be canceled
if (this.abortSearchController) {
this.abortSearchController.abort();
delete this.abortSearchController;
}
if (!this.openmct.router.path) {
return;
}
this.$nextTick(this.showCurrentPathInTree);
},
resetTreeNavigation() {
[...this.openTreeItems].reverse().map(this.closeTreeItemByPath);
},
@@ -344,58 +331,6 @@ export default {
return;
},
closeTreeItemByPath(path) {
// if actively loading, abort
if (this.isItemLoading(path)) {
this.abortItemLoad(path);
}
let pathIndex = this.openTreeItems.indexOf(path);
if (pathIndex === -1) {
return;
}
this.treeItems = this.treeItems.filter((checkItem) => {
if (checkItem.navigationPath !== path
&& checkItem.navigationPath.includes(path)) {
this.destroyObserverByPath(checkItem.navigationPath);
this.destroyMutableByPath(checkItem.navigationPath);
return false;
}
return true;
});
this.openTreeItems.splice(pathIndex, 1);
this.removeCompositionListenerFor(path);
},
closeTreeItem(item) {
this.closeTreeItemByPath(item.navigationPath);
},
// returns an AbortController signal to be passed on to requests
startItemLoad(path) {
if (this.isItemLoading(path)) {
this.abortItemLoad(path);
}
this.$set(this.treeItemLoading, path, new AbortController());
return this.treeItemLoading[path].signal;
},
endItemLoad(path) {
this.$set(this.treeItemLoading, path, undefined);
delete this.treeItemLoading[path];
},
abortItemLoad(path) {
if (this.treeItemLoading[path]) {
this.treeItemLoading[path].abort();
this.endItemLoad(path);
}
},
isItemLoading(path) {
return this.treeItemLoading[path] instanceof AbortController;
},
showCurrentPathInTree() {
const currentPath = this.buildNavigationPath(this.openmct.router.path);
@@ -547,69 +482,6 @@ export default {
// determine if any part of the parent's path includes a key value of mine; aka My Items
return Boolean(parentObjectPath.find(path => path.identifier.key === 'mine'));
},
async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
let collection = this.openmct.composition.get(domainObject);
let composition = await collection.load(abortSignal);
if (SORT_MY_ITEMS_ALPH_ASC && this.isSortable(parentObjectPath)) {
const sortedComposition = composition.slice().sort(this.sortNameAscending);
composition = sortedComposition;
}
if (parentObjectPath.length && !this.isSelectorTree) {
let navigationPath = this.buildNavigationPath(parentObjectPath);
if (this.compositionCollections[navigationPath]) {
this.removeCompositionListenerFor(navigationPath);
}
this.compositionCollections[navigationPath] = {};
this.compositionCollections[navigationPath].collection = collection;
this.compositionCollections[navigationPath].addHandler = this.compositionAddHandler(navigationPath);
this.compositionCollections[navigationPath].removeHandler = this.compositionRemoveHandler(navigationPath);
this.compositionCollections[navigationPath].collection.on('add',
this.compositionCollections[navigationPath].addHandler);
this.compositionCollections[navigationPath].collection.on('remove',
this.compositionCollections[navigationPath].removeHandler);
}
return composition.map((object) => {
// Only add observers and mutables if this is NOT a selector tree
if (!this.isSelectorTree) {
if (this.openmct.objects.supportsMutation(object.identifier)) {
object = this.openmct.objects.toMutable(object);
this.addMutable(object, parentObjectPath);
}
this.addTreeItemObserver(object, parentObjectPath);
}
return this.buildTreeItem(object, parentObjectPath);
});
},
buildTreeItem(domainObject, parentObjectPath, isNew = false) {
let objectPath = [domainObject].concat(parentObjectPath);
let navigationPath = this.buildNavigationPath(objectPath);
return {
id: this.openmct.objects.makeKeyString(domainObject.identifier),
object: domainObject,
leftOffset: ((objectPath.length - 1) * TREE_ITEM_INDENT_PX) + 'px',
isNew,
objectPath,
navigationPath
};
},
addMutable(mutableDomainObject, parentObjectPath) {
const objectPath = [mutableDomainObject].concat(parentObjectPath);
const navigationPath = this.buildNavigationPath(objectPath);
// If the mutable already exists, destroy it.
this.destroyMutableByPath(navigationPath);
this.mutables[navigationPath] = () => this.openmct.objects.destroyMutable(mutableDomainObject);
},
addTreeItemObserver(domainObject, parentObjectPath) {
const objectPath = [domainObject].concat(parentObjectPath);
const navigationPath = this.buildNavigationPath(objectPath);
@@ -662,11 +534,6 @@ export default {
// Splice in all of the sorted descendants
this.treeItems.splice(this.treeItems.indexOf(parentItem) + 1, sortedTreeItems.length, ...sortedTreeItems);
},
buildNavigationPath(objectPath) {
return '/browse/' + [...objectPath].reverse()
.map((object) => this.openmct.objects.makeKeyString(object.identifier))
.join('/');
},
compositionAddHandler(navigationPath) {
return (domainObject) => {
const parentItem = this.getTreeItemByPath(navigationPath);
@@ -881,44 +748,6 @@ export default {
return Math.ceil(scrollBottom / this.itemHeight);
},
calculateHeights() {
const RECHECK = 100;
return new Promise((resolve, reject) => {
let checkHeights = () => {
let treeTopMargin = this.getElementStyleValue(this.$refs.mainTree, 'marginTop');
let paddingOffset = 0;
if (
this.$el
&& this.$refs.search
&& this.$refs.mainTree
&& this.$refs.treeContainer
&& this.$refs.dummyItem
&& this.$el.offsetHeight !== 0
&& treeTopMargin > 0
) {
if (this.isSelectorTree) {
paddingOffset = this.getElementStyleValue(this.$refs.treeContainer, 'padding');
}
this.mainTreeTopMargin = treeTopMargin;
this.mainTreeHeight = this.$el.offsetHeight
- this.$refs.search.offsetHeight
- this.mainTreeTopMargin
- (paddingOffset * 2);
this.itemHeight = this.getElementStyleValue(this.$refs.dummyItem, 'height');
resolve();
} else {
setTimeout(checkHeights, RECHECK);
}
};
checkHeights();
});
},
getTreeItemByPath(path) {
return this.treeItems.find(item => item.navigationPath === path);
},
@@ -941,22 +770,6 @@ export default {
&& childItem.navigationPath.includes(parentPath);
});
},
isTreeItemOpen(item) {
return this.isTreeItemPathOpen(item.navigationPath);
},
isTreeItemPathOpen(path) {
return this.openTreeItems.includes(path);
},
getElementStyleValue(el, style) {
if (!el) {
return;
}
let styleString = window.getComputedStyle(el)[style];
let index = styleString.indexOf('px');
return Number(styleString.slice(0, index));
},
getSavedOpenItems() {
if (this.isSelectorTree) {
return;
@@ -972,9 +785,6 @@ export default {
localStorage.setItem(LOCAL_STORAGE_KEY__TREE_EXPANDED, JSON.stringify(this.openTreeItems));
},
handleTreeResize() {
this.calculateHeights();
},
/**
* Destroy an observer for the given navigationPath.
*/

224
src/ui/mixins/tree-mixin.js Normal file
View File

@@ -0,0 +1,224 @@
export const SORT_MY_ITEMS_ALPH_ASC = true;
export const TREE_ITEM_INDENT_PX = 18;
export default {
data: function () {
return {
isLoading: false,
treeItems: [],
openTreeItems: [],
visibleItems: [],
updatingView: false,
treeItemLoading: {},
compositionCollections: {},
selectedItem: {},
observers: {},
itemHeight: 27,
itemOffset: 0
};
},
watch: {
syncTreeNavigation() {
this.searchValue = '';
// if there is an abort controller, then a search is in progress and will need to be canceled
if (this.abortSearchController) {
this.abortSearchController.abort();
delete this.abortSearchController;
}
if (!this.openmct.router.path) {
return;
}
this.$nextTick(this.showCurrentPathInTree);
}
},
methods: {
abortItemLoad(path) {
if (this.treeItemLoading[path]) {
this.treeItemLoading[path].abort();
this.endItemLoad(path);
}
},
buildNavigationPath(objectPath) {
return '/browse/' + [...objectPath].reverse()
.map((object) => this.openmct.objects.makeKeyString(object.identifier))
.join('/');
},
buildTreeItem(domainObject, parentObjectPath, isNew = false) {
let objectPath = [domainObject].concat(parentObjectPath);
let navigationPath = this.buildNavigationPath(objectPath);
return {
id: this.openmct.objects.makeKeyString(domainObject.identifier),
object: domainObject,
leftOffset: ((objectPath.length - 1) * TREE_ITEM_INDENT_PX) + 'px',
isNew,
objectPath,
navigationPath
};
},
calculateHeights() {
const RECHECK = 100;
return new Promise((resolve, reject) => {
let checkHeights = () => {
let treeTopMargin = this.getElementStyleValue(this.$refs.mainTree, 'marginTop');
let paddingOffset = 0;
if (
this.$el
&& this.$refs.search
&& this.$refs.mainTree
&& this.$refs.treeContainer
&& this.$refs.dummyItem
&& this.$el.offsetHeight !== 0
&& treeTopMargin > 0
) {
this.mainTreeTopMargin = treeTopMargin;
this.mainTreeHeight = this.$el.offsetHeight
- this.$refs.search.offsetHeight
- this.mainTreeTopMargin
- (paddingOffset * 2);
this.itemHeight = this.getElementStyleValue(this.$refs.dummyItem, 'height');
resolve();
} else {
setTimeout(checkHeights, RECHECK);
}
};
checkHeights();
});
},
closeTreeItem(item) {
this.closeTreeItemByPath(item.navigationPath);
},
closeTreeItemByPath(path) {
// if actively loading, abort
if (this.isItemLoading(path)) {
this.abortItemLoad(path);
}
let pathIndex = this.openTreeItems.indexOf(path);
if (pathIndex === -1) {
return;
}
this.treeItems = this.treeItems.filter((checkItem) => {
return checkItem.navigationPath === path
|| !checkItem.navigationPath.includes(path);
});
this.openTreeItems.splice(pathIndex, 1);
// this.removeCompositionListenerFor(path);
},
endItemLoad(path) {
this.$set(this.treeItemLoading, path, undefined);
delete this.treeItemLoading[path];
},
getElementStyleValue(el, style) {
if (!el) {
return;
}
let styleString = window.getComputedStyle(el)[style];
let index = styleString.indexOf('px');
return Number(styleString.slice(0, index));
},
handleTreeResize() {
this.calculateHeights();
},
isItemLoading(path) {
return this.treeItemLoading[path] instanceof AbortController;
},
isTreeItemOpen(item) {
return this.isTreeItemPathOpen(item.navigationPath);
},
isTreeItemPathOpen(path) {
return this.openTreeItems.includes(path);
},
async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
let collection = this.openmct.composition.get(domainObject);
let composition = await collection.load(abortSignal);
if (SORT_MY_ITEMS_ALPH_ASC && this.isSortable(parentObjectPath)) {
const sortedComposition = composition.slice().sort(this.sortNameAscending);
composition = sortedComposition;
}
if (parentObjectPath.length) {
let navigationPath = this.buildNavigationPath(parentObjectPath);
if (this.compositionCollections[navigationPath]) {
this.removeCompositionListenerFor(navigationPath);
}
this.compositionCollections[navigationPath] = {};
this.compositionCollections[navigationPath].collection = collection;
this.compositionCollections[navigationPath].addHandler = this.compositionAddHandler(navigationPath);
this.compositionCollections[navigationPath].removeHandler = this.compositionRemoveHandler(navigationPath);
this.compositionCollections[navigationPath].collection.on('add',
this.compositionCollections[navigationPath].addHandler);
this.compositionCollections[navigationPath].collection.on('remove',
this.compositionCollections[navigationPath].removeHandler);
}
return composition.map((object) => {
this.addTreeItemObserver(object, parentObjectPath);
return this.buildTreeItem(object, parentObjectPath);
});
},
async openTreeItem(parentItem) {
let parentPath = parentItem.navigationPath;
this.startItemLoad(parentPath);
// pass in abort signal when functional
let childrenItems = await this.loadAndBuildTreeItemsFor(parentItem.object, parentItem.objectPath);
let parentIndex = this.treeItems.indexOf(parentItem);
// if it's not loading, it was aborted
if (!this.isItemLoading(parentPath) || parentIndex === -1) {
return;
}
this.endItemLoad(parentPath);
this.treeItems.splice(parentIndex + 1, 0, ...childrenItems);
if (!this.isTreeItemOpen(parentItem)) {
this.openTreeItems.push(parentPath);
}
for (let item of childrenItems) {
if (this.isTreeItemOpen(item)) {
this.openTreeItem(item);
}
}
return;
},
// returns an AbortController signal to be passed on to requests
startItemLoad(path) {
if (this.isItemLoading(path)) {
this.abortItemLoad(path);
}
this.$set(this.treeItemLoading, path, new AbortController());
return this.treeItemLoading[path].signal;
},
treeItemAction(parentItem, type) {
if (type === 'close') {
this.closeTreeItem(parentItem);
} else {
this.openTreeItem(parentItem);
}
}
}
};

View File

@@ -227,7 +227,7 @@ class ApplicationRouter extends EventEmitter {
this.started = true;
this.locationBar.onChange(p => this.hashChaged(p));
this.locationBar.onChange(p => this.hashChanged(p));
this.locationBar.start({
root: location.pathname
});
@@ -390,9 +390,9 @@ class ApplicationRouter extends EventEmitter {
*
* @param {string} hash new hash for url
*/
hashChaged(hash) {
this.emit('change:hash', hash);
hashChanged(hash) {
this.handleLocationChange(hash);
this.emit('change:hash', hash);
}
/**