Support for remote mutation of Notebooks with Couch DB (#3887)

* Update notebook automatically when modified by another user
* Don't persist selected and default page and section IDs on notebook object
* Fixing object synchronization bugs
* Adding unit tests
* Synchronize notebooks AND plans
* Removed observeEnabled flag

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
This commit is contained in:
Andrew Henry 2021-06-21 10:25:17 -07:00 committed by GitHub
parent 333e8b5583
commit 6755ef4641
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 547 additions and 290 deletions

View File

@ -45,6 +45,8 @@ function ObjectAPI(typeRegistry, openmct) {
this.rootProvider = new RootObjectProvider(this.rootRegistry); this.rootProvider = new RootObjectProvider(this.rootRegistry);
this.cache = {}; this.cache = {};
this.interceptorRegistry = new InterceptorRegistry(); this.interceptorRegistry = new InterceptorRegistry();
this.SYNCHRONIZED_OBJECT_TYPES = ['notebook', 'plan'];
} }
/** /**
@ -404,11 +406,16 @@ ObjectAPI.prototype._toMutable = function (object) {
let provider = this.getProvider(identifier); let provider = this.getProvider(identifier);
if (provider !== undefined if (provider !== undefined
&& provider.observe !== undefined) { && provider.observe !== undefined
&& this.SYNCHRONIZED_OBJECT_TYPES.includes(object.type)) {
let unobserve = provider.observe(identifier, (updatedModel) => { let unobserve = provider.observe(identifier, (updatedModel) => {
mutableObject.$refresh(updatedModel); if (updatedModel.persisted > mutableObject.modified) {
//Don't replace with a stale model. This can happen on slow connections when multiple mutations happen
//in rapid succession and intermediate persistence states are returned by the observe function.
mutableObject.$refresh(updatedModel);
}
}); });
mutableObject.$on('$destroy', () => { mutableObject.$on('$_destroy', () => {
unobserve(); unobserve();
}); });
} }

View File

@ -163,14 +163,22 @@ describe("The Object API", () => {
key: 'test-key' key: 'test-key'
}, },
name: 'test object', name: 'test object',
type: 'notebook',
otherAttribute: 'other-attribute-value', otherAttribute: 'other-attribute-value',
modified: 0,
persisted: 0,
objectAttribute: { objectAttribute: {
embeddedObject: { embeddedObject: {
embeddedKey: 'embedded-value' embeddedKey: 'embedded-value'
} }
} }
}; };
updatedTestObject = Object.assign({otherAttribute: 'changed-attribute-value'}, testObject); updatedTestObject = Object.assign({
otherAttribute: 'changed-attribute-value'
}, testObject);
updatedTestObject.modified = 1;
updatedTestObject.persisted = 1;
mockProvider = jasmine.createSpyObj("mock provider", [ mockProvider = jasmine.createSpyObj("mock provider", [
"get", "get",
"create", "create",
@ -182,6 +190,8 @@ describe("The Object API", () => {
mockProvider.observeObjectChanges.and.callFake(() => { mockProvider.observeObjectChanges.and.callFake(() => {
callbacks[0](updatedTestObject); callbacks[0](updatedTestObject);
callbacks.splice(0, 1); callbacks.splice(0, 1);
return () => {};
}); });
mockProvider.observe.and.callFake((id, callback) => { mockProvider.observe.and.callFake((id, callback) => {
if (callbacks.length === 0) { if (callbacks.length === 0) {
@ -189,6 +199,8 @@ describe("The Object API", () => {
} else { } else {
callbacks[0] = callback; callbacks[0] = callback;
} }
return () => {};
}); });
objectAPI.addProvider(TEST_NAMESPACE, mockProvider); objectAPI.addProvider(TEST_NAMESPACE, mockProvider);

View File

@ -31,7 +31,7 @@
</div> </div>
<SearchResults v-if="search.length" <SearchResults v-if="search.length"
ref="searchResults" ref="searchResults"
:domain-object="internalDomainObject" :domain-object="domainObject"
:results="searchResults" :results="searchResults"
@changeSectionPage="changeSelectedSection" @changeSectionPage="changeSelectedSection"
@updateEntries="updateEntries" @updateEntries="updateEntries"
@ -43,15 +43,18 @@
class="c-notebook__nav c-sidebar c-drawer c-drawer--align-left" class="c-notebook__nav c-sidebar c-drawer c-drawer--align-left"
:class="[{'is-expanded': showNav}, {'c-drawer--push': !sidebarCoversEntries}, {'c-drawer--overlays': sidebarCoversEntries}]" :class="[{'is-expanded': showNav}, {'c-drawer--push': !sidebarCoversEntries}, {'c-drawer--overlays': sidebarCoversEntries}]"
:default-page-id="defaultPageId" :default-page-id="defaultPageId"
:selected-page-id="selectedPageId"
:default-section-id="defaultSectionId" :default-section-id="defaultSectionId"
:domain-object="internalDomainObject" :selected-section-id="selectedSectionId"
:page-title="internalDomainObject.configuration.pageTitle" :domain-object="domainObject"
:section-title="internalDomainObject.configuration.sectionTitle" :page-title="domainObject.configuration.pageTitle"
:section-title="domainObject.configuration.sectionTitle"
:sections="sections" :sections="sections"
:selected-section="selectedSection"
:sidebar-covers-entries="sidebarCoversEntries" :sidebar-covers-entries="sidebarCoversEntries"
@pagesChanged="pagesChanged" @pagesChanged="pagesChanged"
@selectPage="selectPage"
@sectionsChanged="sectionsChanged" @sectionsChanged="sectionsChanged"
@selectSection="selectSection"
@toggleNav="toggleNav" @toggleNav="toggleNav"
/> />
<div class="c-notebook__page-view"> <div class="c-notebook__page-view">
@ -61,10 +64,10 @@
></button> ></button>
<div class="c-notebook__page-view__path c-path"> <div class="c-notebook__page-view__path c-path">
<span class="c-notebook__path__section c-path__item"> <span class="c-notebook__path__section c-path__item">
{{ getSelectedSection() ? getSelectedSection().name : '' }} {{ selectedSection ? selectedSection.name : '' }}
</span> </span>
<span class="c-notebook__path__page c-path__item"> <span class="c-notebook__path__page c-path__item">
{{ getSelectedPage() ? getSelectedPage().name : '' }} {{ selectedPage ? selectedPage.name : '' }}
</span> </span>
</div> </div>
<div class="c-notebook__page-view__controls"> <div class="c-notebook__page-view__controls">
@ -115,9 +118,9 @@
<NotebookEntry v-for="entry in filteredAndSortedEntries" <NotebookEntry v-for="entry in filteredAndSortedEntries"
:key="entry.id" :key="entry.id"
:entry="entry" :entry="entry"
:domain-object="internalDomainObject" :domain-object="domainObject"
:selected-page="getSelectedPage()" :selected-page="selectedPage"
:selected-section="getSelectedSection()" :selected-section="selectedSection"
:read-only="false" :read-only="false"
@deleteEntry="deleteEntry" @deleteEntry="deleteEntry"
@updateEntry="updateEntry" @updateEntry="updateEntry"
@ -152,14 +155,19 @@ export default {
SearchResults, SearchResults,
Sidebar Sidebar
}, },
inject: ['openmct', 'domainObject', 'snapshotContainer'], inject: ['openmct', 'snapshotContainer'],
props: {
domainObject: {
type: Object,
required: true
}
},
data() { data() {
return { return {
defaultPageId: getDefaultNotebook() ? getDefaultNotebook().page.id : '', selectedSectionId: this.getDefaultSectionId(),
defaultSectionId: getDefaultNotebook() ? getDefaultNotebook().section.id : '', selectedPageId: this.getDefaultPageId(),
defaultSort: this.domainObject.configuration.defaultSort, defaultSort: this.domainObject.configuration.defaultSort,
focusEntryId: null, focusEntryId: null,
internalDomainObject: this.domainObject,
search: '', search: '',
searchResults: [], searchResults: [],
showTime: 0, showTime: 0,
@ -168,9 +176,15 @@ export default {
}; };
}, },
computed: { computed: {
defaultPageId() {
return this.getDefaultPageId();
},
defaultSectionId() {
return this.getDefaultSectionId();
},
filteredAndSortedEntries() { filteredAndSortedEntries() {
const filterTime = Date.now(); const filterTime = Date.now();
const pageEntries = getNotebookEntries(this.internalDomainObject, this.selectedSection, this.selectedPage) || []; const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
const hours = parseInt(this.showTime, 10); const hours = parseInt(this.showTime, 10);
const filteredPageEntriesByTime = hours const filteredPageEntriesByTime = hours
@ -185,22 +199,28 @@ export default {
return this.getPages() || []; return this.getPages() || [];
}, },
sections() { sections() {
return this.internalDomainObject.configuration.sections || []; return this.getSections();
}, },
selectedPage() { selectedPage() {
const pages = this.getPages(); const pages = this.getPages();
if (!pages) { const selectedPage = pages.find(page => page.id === this.selectedPageId);
return {};
if (selectedPage) {
return selectedPage;
} }
return pages.find(page => page.isSelected); if (!selectedPage && !pages.length) {
return undefined;
}
return pages[0];
}, },
selectedSection() { selectedSection() {
if (!this.sections.length) { if (!this.sections.length) {
return {}; return null;
} }
return this.sections.find(section => section.isSelected); return this.sections.find(section => section.id === this.selectedSectionId);
} }
}, },
watch: { watch: {
@ -210,16 +230,14 @@ export default {
}, },
beforeMount() { beforeMount() {
this.getSearchResults = debounce(this.getSearchResults, 500); this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
}, },
mounted() { mounted() {
this.unlisten = this.openmct.objects.observe(this.internalDomainObject, '*', this.updateInternalDomainObject);
this.formatSidebar(); this.formatSidebar();
this.setSectionAndPageFromUrl();
window.addEventListener('orientationchange', this.formatSidebar); window.addEventListener('orientationchange', this.formatSidebar);
window.addEventListener("hashchange", this.navigateToSectionPage, false); window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
this.openmct.router.on('change:params', this.changeSectionPage);
this.navigateToSectionPage();
}, },
beforeDestroy() { beforeDestroy() {
if (this.unlisten) { if (this.unlisten) {
@ -227,8 +245,7 @@ export default {
} }
window.removeEventListener('orientationchange', this.formatSidebar); window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener("hashchange", this.navigateToSectionPage); window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
this.openmct.router.off('change:params', this.changeSectionPage);
}, },
updated: function () { updated: function () {
this.$nextTick(() => { this.$nextTick(() => {
@ -284,14 +301,21 @@ export default {
this.sectionsChanged({ sections }); this.sectionsChanged({ sections });
this.resetSearch(); this.resetSearch();
}, },
setSectionAndPageFromUrl() {
let sectionId = this.getSectionIdFromUrl() || this.selectedSectionId;
let pageId = this.getPageIdFromUrl() || this.selectedPageId;
this.selectSection(sectionId);
this.selectPage(pageId);
},
createNotebookStorageObject() { createNotebookStorageObject() {
const notebookMeta = { const notebookMeta = {
name: this.internalDomainObject.name, name: this.domainObject.name,
identifier: this.internalDomainObject.identifier, identifier: this.domainObject.identifier,
link: this.getLinktoNotebook() link: this.getLinktoNotebook()
}; };
const page = this.getSelectedPage(); const page = this.selectedPage;
const section = this.getSelectedSection(); const section = this.selectedSection;
return { return {
notebookMeta, notebookMeta,
@ -300,8 +324,7 @@ export default {
}; };
}, },
deleteEntry(entryId) { deleteEntry(entryId) {
const self = this; const entryPos = getEntryPosById(entryId, this.domainObject, this.selectedSection, this.selectedPage);
const entryPos = getEntryPosById(entryId, this.internalDomainObject, this.selectedSection, this.selectedPage);
if (entryPos === -1) { if (entryPos === -1) {
this.openmct.notifications.alert('Warning: unable to delete entry'); this.openmct.notifications.alert('Warning: unable to delete entry');
console.error(`unable to delete entry ${entryId} from section ${this.selectedSection}, page ${this.selectedPage}`); console.error(`unable to delete entry ${entryId} from section ${this.selectedSection}, page ${this.selectedPage}`);
@ -317,9 +340,9 @@ export default {
label: "Ok", label: "Ok",
emphasis: true, emphasis: true,
callback: () => { callback: () => {
const entries = getNotebookEntries(self.internalDomainObject, self.selectedSection, self.selectedPage); const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
entries.splice(entryPos, 1); entries.splice(entryPos, 1);
self.updateEntries(entries); this.updateEntries(entries);
dialog.dismiss(); dialog.dismiss();
} }
}, },
@ -395,6 +418,37 @@ export default {
const sidebarCoversEntries = (isPhone || (isTablet && isPortrait) || isInLayout); const sidebarCoversEntries = (isPhone || (isTablet && isPortrait) || isInLayout);
this.sidebarCoversEntries = sidebarCoversEntries; this.sidebarCoversEntries = sidebarCoversEntries;
}, },
getDefaultPageId() {
let defaultPageId;
if (this.isDefaultNotebook()) {
defaultPageId = getDefaultNotebook().page.id;
} else {
const firstSection = this.getSections()[0];
defaultPageId = firstSection && firstSection.pages[0].id;
}
return defaultPageId;
},
isDefaultNotebook() {
const defaultNotebook = getDefaultNotebook();
const defaultNotebookIdentifier = defaultNotebook && defaultNotebook.notebookMeta.identifier;
return defaultNotebookIdentifier !== null
&& this.openmct.objects.areIdsEqual(defaultNotebookIdentifier, this.domainObject.identifier);
},
getDefaultSectionId() {
let defaultSectionId;
if (this.isDefaultNotebook()) {
defaultSectionId = getDefaultNotebook().section.id;
} else {
const firstSection = this.getSections()[0];
defaultSectionId = firstSection && firstSection.id;
}
return defaultSectionId;
},
getDefaultNotebookObject() { getDefaultNotebookObject() {
const oldNotebookStorage = getDefaultNotebook(); const oldNotebookStorage = getDefaultNotebook();
if (!oldNotebookStorage) { if (!oldNotebookStorage) {
@ -423,14 +477,17 @@ export default {
getSection(id) { getSection(id) {
return this.sections.find(s => s.id === id); return this.sections.find(s => s.id === id);
}, },
getSections() {
return this.domainObject.configuration.sections || [];
},
getSearchResults() { getSearchResults() {
if (!this.search.length) { if (!this.search.length) {
return []; return [];
} }
const output = []; const output = [];
const sections = this.internalDomainObject.configuration.sections; const sections = this.domainObject.configuration.sections;
const entries = this.internalDomainObject.configuration.entries; const entries = this.domainObject.configuration.entries;
const searchTextLower = this.search.toLowerCase(); const searchTextLower = this.search.toLowerCase();
const originalSearchText = this.search; const originalSearchText = this.search;
let sectionTrackPageHit; let sectionTrackPageHit;
@ -509,77 +566,25 @@ export default {
this.searchResults = output; this.searchResults = output;
}, },
getPages() { getPages() {
const selectedSection = this.getSelectedSection(); const selectedSection = this.selectedSection;
if (!selectedSection || !selectedSection.pages.length) { if (!selectedSection || !selectedSection.pages.length) {
return []; return [];
} }
return selectedSection.pages; return selectedSection.pages;
}, },
getSelectedPage() {
const pages = this.getPages();
if (!pages) {
return null;
}
const selectedPage = pages.find(page => page.isSelected);
if (selectedPage) {
return selectedPage;
}
if (!selectedPage && !pages.length) {
return null;
}
pages[0].isSelected = true;
return pages[0];
},
getSelectedSection() {
if (!this.sections.length) {
return null;
}
return this.sections.find(section => section.isSelected);
},
navigateToSectionPage() {
let { pageId, sectionId } = this.openmct.router.getParams();
if (!pageId || !sectionId) {
sectionId = this.selectedSection.id;
pageId = this.selectedPage.id;
}
const sections = this.sections.map(s => {
s.isSelected = false;
if (s.id === sectionId) {
s.isSelected = true;
s.pages.forEach(p => p.isSelected = (p.id === pageId));
}
return s;
});
const selectedSectionId = this.selectedSection && this.selectedSection.id;
const selectedPageId = this.selectedPage && this.selectedPage.id;
if (selectedPageId === pageId && selectedSectionId === sectionId) {
return;
}
this.sectionsChanged({ sections });
},
newEntry(embed = null) { newEntry(embed = null) {
this.resetSearch(); this.resetSearch();
const notebookStorage = this.createNotebookStorageObject(); const notebookStorage = this.createNotebookStorageObject();
this.updateDefaultNotebook(notebookStorage); this.updateDefaultNotebook(notebookStorage);
const id = addNotebookEntry(this.openmct, this.internalDomainObject, notebookStorage, embed); const id = addNotebookEntry(this.openmct, this.domainObject, notebookStorage, embed);
this.focusEntryId = id; this.focusEntryId = id;
}, },
orientationChange() { orientationChange() {
this.formatSidebar(); this.formatSidebar();
}, },
pagesChanged({ pages = [], id = null}) { pagesChanged({ pages = [], id = null}) {
const selectedSection = this.getSelectedSection(); const selectedSection = this.selectedSection;
if (!selectedSection) { if (!selectedSection) {
return; return;
} }
@ -594,7 +599,6 @@ export default {
}); });
this.sectionsChanged({ sections }); this.sectionsChanged({ sections });
this.updateDefaultNotebookPage(pages, id);
}, },
removeDefaultClass(domainObject) { removeDefaultClass(domainObject) {
if (!domainObject) { if (!domainObject) {
@ -613,10 +617,10 @@ export default {
async updateDefaultNotebook(notebookStorage) { async updateDefaultNotebook(notebookStorage) {
const defaultNotebookObject = await this.getDefaultNotebookObject(); const defaultNotebookObject = await this.getDefaultNotebookObject();
if (!defaultNotebookObject) { if (!defaultNotebookObject) {
setDefaultNotebook(this.openmct, notebookStorage, this.internalDomainObject); setDefaultNotebook(this.openmct, notebookStorage, this.domainObject);
} else if (objectUtils.makeKeyString(defaultNotebookObject.identifier) !== objectUtils.makeKeyString(notebookStorage.notebookMeta.identifier)) { } else if (objectUtils.makeKeyString(defaultNotebookObject.identifier) !== objectUtils.makeKeyString(notebookStorage.notebookMeta.identifier)) {
this.removeDefaultClass(defaultNotebookObject); this.removeDefaultClass(defaultNotebookObject);
setDefaultNotebook(this.openmct, notebookStorage, this.internalDomainObject); setDefaultNotebook(this.openmct, notebookStorage, this.domainObject);
} }
if (this.defaultSectionId && this.defaultSectionId.length === 0 || this.defaultSectionId !== notebookStorage.section.id) { if (this.defaultSectionId && this.defaultSectionId.length === 0 || this.defaultSectionId !== notebookStorage.section.id) {
@ -636,7 +640,7 @@ export default {
const notebookStorage = getDefaultNotebook(); const notebookStorage = getDefaultNotebook();
if (!notebookStorage if (!notebookStorage
|| notebookStorage.notebookMeta.identifier.key !== this.internalDomainObject.identifier.key) { || notebookStorage.notebookMeta.identifier.key !== this.domainObject.identifier.key) {
return; return;
} }
@ -645,7 +649,7 @@ export default {
if (!page && defaultNotebookPage.id === id) { if (!page && defaultNotebookPage.id === id) {
this.defaultSectionId = null; this.defaultSectionId = null;
this.defaultPageId = null; this.defaultPageId = null;
this.removeDefaultClass(this.internalDomainObject); this.removeDefaultClass(this.domainObject);
clearDefaultNotebook(); clearDefaultNotebook();
return; return;
@ -664,7 +668,7 @@ export default {
const notebookStorage = getDefaultNotebook(); const notebookStorage = getDefaultNotebook();
if (!notebookStorage if (!notebookStorage
|| notebookStorage.notebookMeta.identifier.key !== this.internalDomainObject.identifier.key) { || notebookStorage.notebookMeta.identifier.key !== this.domainObject.identifier.key) {
return; return;
} }
@ -673,7 +677,7 @@ export default {
if (!section && defaultNotebookSection.id === id) { if (!section && defaultNotebookSection.id === id) {
this.defaultSectionId = null; this.defaultSectionId = null;
this.defaultPageId = null; this.defaultPageId = null;
this.removeDefaultClass(this.internalDomainObject); this.removeDefaultClass(this.domainObject);
clearDefaultNotebook(); clearDefaultNotebook();
return; return;
@ -686,50 +690,46 @@ export default {
setDefaultNotebookSection(section); setDefaultNotebookSection(section);
}, },
updateEntry(entry) { updateEntry(entry) {
const entries = getNotebookEntries(this.internalDomainObject, this.selectedSection, this.selectedPage); const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
const entryPos = getEntryPosById(entry.id, this.internalDomainObject, this.selectedSection, this.selectedPage); const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);
entries[entryPos] = entry; entries[entryPos] = entry;
this.updateEntries(entries); this.updateEntries(entries);
}, },
updateEntries(entries) { updateEntries(entries) {
const configuration = this.internalDomainObject.configuration; const configuration = this.domainObject.configuration;
const notebookEntries = configuration.entries || {}; const notebookEntries = configuration.entries || {};
notebookEntries[this.selectedSection.id][this.selectedPage.id] = entries; notebookEntries[this.selectedSection.id][this.selectedPage.id] = entries;
mutateObject(this.openmct, this.internalDomainObject, 'configuration.entries', notebookEntries); mutateObject(this.openmct, this.domainObject, 'configuration.entries', notebookEntries);
}, },
updateInternalDomainObject(domainObject) { getPageIdFromUrl() {
this.internalDomainObject = domainObject; return this.openmct.router.getParams().pageId;
}, },
updateParams(sections) { getSectionIdFromUrl() {
const selectedSection = sections.find(s => s.isSelected); return this.openmct.router.getParams().sectionId;
if (!selectedSection) { },
return; syncUrlWithPageAndSection() {
}
const selectedPage = selectedSection.pages.find(p => p.isSelected);
if (!selectedPage) {
return;
}
const sectionId = selectedSection.id;
const pageId = selectedPage.id;
if (!sectionId || !pageId) {
return;
}
this.openmct.router.updateParams({ this.openmct.router.updateParams({
sectionId, pageId: this.selectedPageId,
pageId sectionId: this.selectedSectionId
}); });
}, },
sectionsChanged({ sections, id = null }) { sectionsChanged({ sections, id = null }) {
mutateObject(this.openmct, this.internalDomainObject, 'configuration.sections', sections); mutateObject(this.openmct, this.domainObject, 'configuration.sections', sections);
this.updateParams(sections);
this.updateDefaultNotebookSection(sections, id); this.updateDefaultNotebookSection(sections, id);
},
selectPage(pageId) {
this.selectedPageId = pageId;
this.syncUrlWithPageAndSection();
},
selectSection(sectionId) {
this.selectedSectionId = sectionId;
const defaultPageId = this.selectedSection.pages[0].id;
this.selectPage(defaultPageId);
this.syncUrlWithPageAndSection();
} }
} }
}; };

View File

@ -6,6 +6,7 @@
> >
<Page ref="pageComponent" <Page ref="pageComponent"
:default-page-id="defaultPageId" :default-page-id="defaultPageId"
:selected-page-id="selectedPageId"
:page="page" :page="page"
:page-title="pageTitle" :page-title="pageTitle"
@deletePage="deletePage" @deletePage="deletePage"
@ -33,11 +34,13 @@ export default {
return ''; return '';
} }
}, },
selectedPageId: {
type: String,
required: true
},
domainObject: { domainObject: {
type: Object, type: Object,
default() { required: true
return {};
}
}, },
pages: { pages: {
type: Array, type: Array,
@ -66,7 +69,17 @@ export default {
} }
} }
}, },
watch: {
pages() {
if (!this.containsPage(this.selectedPageId)) {
this.selectPage(this.pages[0].id);
}
}
},
methods: { methods: {
containsPage(pageId) {
return this.pages.some(page => page.id === pageId);
},
deletePage(id) { deletePage(id) {
const selectedSection = this.sections.find(s => s.isSelected); const selectedSection = this.sections.find(s => s.isSelected);
const page = this.pages.find(p => p.id === id); const page = this.pages.find(p => p.id === id);
@ -78,37 +91,29 @@ export default {
const isPageSelected = selectedPage && selectedPage.id === id; const isPageSelected = selectedPage && selectedPage.id === id;
const isPageDefault = defaultpage && defaultpage.id === id; const isPageDefault = defaultpage && defaultpage.id === id;
const pages = this.pages.filter(s => s.id !== id); const pages = this.pages.filter(s => s.id !== id);
let selectedPageId;
if (isPageSelected && defaultpage) { if (isPageSelected && defaultpage) {
pages.forEach(s => { pages.forEach(s => {
s.isSelected = false; s.isSelected = false;
if (defaultpage && defaultpage.id === s.id) { if (defaultpage && defaultpage.id === s.id) {
s.isSelected = true; selectedPageId = s.id;
} }
}); });
} }
if (pages.length && isPageSelected && (!defaultpage || isPageDefault)) { if (pages.length && isPageSelected && (!defaultpage || isPageDefault)) {
pages[0].isSelected = true; selectedPageId = pages[0].id;
} }
this.$emit('updatePage', { this.$emit('updatePage', {
pages, pages,
id id
}); });
this.$emit('selectPage', selectedPageId);
}, },
selectPage(id) { selectPage(id) {
const pages = this.pages.map(page => { this.$emit('selectPage', id);
const isSelected = page.id === id;
page.isSelected = isSelected;
return page;
});
this.$emit('updatePage', {
pages,
id
});
// Add test here for whether or not to toggle the nav // Add test here for whether or not to toggle the nav
if (this.sidebarCoversEntries) { if (this.sidebarCoversEntries) {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="c-list__item js-list__item" <div class="c-list__item js-list__item"
:class="[{ 'is-selected': page.isSelected, 'is-notebook-default' : (defaultPageId === page.id) }]" :class="[{ 'is-selected': isSelected, 'is-notebook-default' : (defaultPageId === page.id) }]"
:data-id="page.id" :data-id="page.id"
@click="selectPage" @click="selectPage"
> >
@ -29,6 +29,10 @@ export default {
return ''; return '';
} }
}, },
selectedPageId: {
type: String,
required: true
},
page: { page: {
type: Object, type: Object,
required: true required: true
@ -46,6 +50,11 @@ export default {
removeActionString: `Delete ${this.pageTitle}` removeActionString: `Delete ${this.pageTitle}`
}; };
}, },
computed: {
isSelected() {
return this.selectedPageId === this.page.id;
}
},
watch: { watch: {
page(newPage) { page(newPage) {
this.toggleContentEditable(newPage); this.toggleContentEditable(newPage);
@ -73,7 +82,7 @@ export default {
this.$emit('deletePage', this.page.id); this.$emit('deletePage', this.page.id);
}, },
getRemoveDialog() { getRemoveDialog() {
const message = 'This action will delete this page and all of its entries. Do you want to continue?'; const message = 'Other users may be editing entries in this page, and deleting it is permanent. Do you want to continue?';
const options = { const options = {
name: this.removeActionString, name: this.removeActionString,
callback: this.deletePage.bind(this), callback: this.deletePage.bind(this),

View File

@ -4,13 +4,14 @@
:key="section.id" :key="section.id"
class="c-list__item-h" class="c-list__item-h"
> >
<sectionComponent ref="sectionComponent" <NotebookSection ref="sectionComponent"
:default-section-id="defaultSectionId" :default-section-id="defaultSectionId"
:section="section" :selected-section-id="selectedSectionId"
:section-title="sectionTitle" :section="section"
@deleteSection="deleteSection" :section-title="sectionTitle"
@renameSection="updateSection" @deleteSection="deleteSection"
@selectSection="selectSection" @renameSection="updateSection"
@selectSection="selectSection"
/> />
</li> </li>
</ul> </ul>
@ -19,11 +20,11 @@
<script> <script>
import { deleteNotebookEntries } from '../utils/notebook-entries'; import { deleteNotebookEntries } from '../utils/notebook-entries';
import { getDefaultNotebook } from '../utils/notebook-storage'; import { getDefaultNotebook } from '../utils/notebook-storage';
import sectionComponent from './SectionComponent.vue'; import SectionComponent from './SectionComponent.vue';
export default { export default {
components: { components: {
sectionComponent NotebookSection: SectionComponent
}, },
inject: ['openmct'], inject: ['openmct'],
props: { props: {
@ -33,6 +34,10 @@ export default {
return ''; return '';
} }
}, },
selectedSectionId: {
type: String,
required: true
},
domainObject: { domainObject: {
type: Object, type: Object,
default() { default() {
@ -53,12 +58,22 @@ export default {
} }
} }
}, },
watch: {
sections() {
if (!this.containsSection(this.selectedSectionId)) {
this.selectSection(this.sections[0].id);
}
}
},
methods: { methods: {
containsSection(sectionId) {
return this.sections.some(section => section.id === sectionId);
},
deleteSection(id) { deleteSection(id) {
const section = this.sections.find(s => s.id === id); const section = this.sections.find(s => s.id === id);
deleteNotebookEntries(this.openmct, this.domainObject, section); deleteNotebookEntries(this.openmct, this.domainObject, section);
const selectedSection = this.sections.find(s => s.isSelected); const selectedSection = this.sections.find(s => s.id === this.selectedSectionId);
const defaultNotebook = getDefaultNotebook(); const defaultNotebook = getDefaultNotebook();
const defaultSection = defaultNotebook && defaultNotebook.section; const defaultSection = defaultNotebook && defaultNotebook.section;
const isSectionSelected = selectedSection && selectedSection.id === id; const isSectionSelected = selectedSection && selectedSection.id === id;
@ -83,18 +98,8 @@ export default {
id id
}); });
}, },
selectSection(id, newSections) { selectSection(id) {
const currentSections = newSections || this.sections; this.$emit('selectSection', id);
const sections = currentSections.map(section => {
const isSelected = section.id === id;
section.isSelected = isSelected;
return section;
});
this.$emit('updateSection', {
sections,
id
});
}, },
updateSection(newSection) { updateSection(newSection) {
const id = newSection.id; const id = newSection.id;

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="c-list__item js-list__item" <div class="c-list__item js-list__item"
:class="[{ 'is-selected': section.isSelected, 'is-notebook-default' : (defaultSectionId === section.id) }]" :class="[{ 'is-selected': isSelected, 'is-notebook-default' : (defaultSectionId === section.id) }]"
:data-id="section.id" :data-id="section.id"
@click="selectSection" @click="selectSection"
> >
@ -13,9 +13,6 @@
</div> </div>
</template> </template>
<style lang="scss">
</style>
<script> <script>
import PopupMenu from './PopupMenu.vue'; import PopupMenu from './PopupMenu.vue';
import RemoveDialog from '../utils/removeDialog'; import RemoveDialog from '../utils/removeDialog';
@ -32,6 +29,10 @@ export default {
return ''; return '';
} }
}, },
selectedSectionId: {
type: String,
required: true
},
section: { section: {
type: Object, type: Object,
required: true required: true
@ -49,6 +50,11 @@ export default {
removeActionString: `Delete ${this.sectionTitle}` removeActionString: `Delete ${this.sectionTitle}`
}; };
}, },
computed: {
isSelected() {
return this.selectedSectionId === this.section.id;
}
},
watch: { watch: {
section(newSection) { section(newSection) {
this.toggleContentEditable(newSection); this.toggleContentEditable(newSection);
@ -76,7 +82,7 @@ export default {
this.$emit('deleteSection', this.section.id); this.$emit('deleteSection', this.section.id);
}, },
getRemoveDialog() { getRemoveDialog() {
const message = 'This action will delete this section and all of its pages and entries. Do you want to continue?'; const message = 'Other users may be editing entries in this section, and deleting it is permanent. Do you want to continue?';
const options = { const options = {
name: this.removeActionString, name: this.removeActionString,
callback: this.deleteSection.bind(this), callback: this.deleteSection.bind(this),

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="c-sidebar c-drawer c-drawer--align-left"> <div class="c-sidebar c-drawer c-drawer--align-left">
<div class="c-sidebar__pane"> <div class="c-sidebar__pane js-sidebar-sections">
<div class="c-sidebar__header-w"> <div class="c-sidebar__header-w">
<div class="c-sidebar__header"> <div class="c-sidebar__header">
<span class="c-sidebar__header-label">{{ sectionTitle }}</span> <span class="c-sidebar__header-label">{{ sectionTitle }}</span>
@ -15,14 +15,16 @@
</button> </button>
<SectionCollection class="c-sidebar__contents" <SectionCollection class="c-sidebar__contents"
:default-section-id="defaultSectionId" :default-section-id="defaultSectionId"
:selected-section-id="selectedSectionId"
:domain-object="domainObject" :domain-object="domainObject"
:sections="sections" :sections="sections"
:section-title="sectionTitle" :section-title="sectionTitle"
@updateSection="sectionsChanged" @updateSection="sectionsChanged"
@selectSection="selectSection"
/> />
</div> </div>
</div> </div>
<div class="c-sidebar__pane"> <div class="c-sidebar__pane js-sidebar-pages">
<div class="c-sidebar__header-w"> <div class="c-sidebar__header-w">
<div class="c-sidebar__header"> <div class="c-sidebar__header">
<span class="c-sidebar__header-label">{{ pageTitle }}</span> <span class="c-sidebar__header-label">{{ pageTitle }}</span>
@ -42,6 +44,7 @@
<PageCollection ref="pageCollection" <PageCollection ref="pageCollection"
class="c-sidebar__contents" class="c-sidebar__contents"
:default-page-id="defaultPageId" :default-page-id="defaultPageId"
:selected-page-id="selectedPageId"
:domain-object="domainObject" :domain-object="domainObject"
:pages="pages" :pages="pages"
:sections="sections" :sections="sections"
@ -49,6 +52,7 @@
:page-title="pageTitle" :page-title="pageTitle"
@toggleNav="toggleNav" @toggleNav="toggleNav"
@updatePage="pagesChanged" @updatePage="pagesChanged"
@selectPage="selectPage"
/> />
</div> </div>
</div> </div>
@ -73,12 +77,24 @@ export default {
return ''; return '';
} }
}, },
selectedPageId: {
type: String,
default() {
return '';
}
},
defaultSectionId: { defaultSectionId: {
type: String, type: String,
default() { default() {
return ''; return '';
} }
}, },
selectedSectionId: {
type: String,
default() {
return '';
}
},
domainObject: { domainObject: {
type: Object, type: Object,
default() { default() {
@ -113,7 +129,7 @@ export default {
}, },
computed: { computed: {
pages() { pages() {
const selectedSection = this.sections.find(section => section.isSelected); const selectedSection = this.sections.find(section => section.id === this.selectedSectionId);
return selectedSection && selectedSection.pages || []; return selectedSection && selectedSection.pages || [];
} }
@ -144,6 +160,7 @@ export default {
pages, pages,
id: newPage.id id: newPage.id
}); });
this.$emit('selectPage', newPage.id);
}, },
addSection() { addSection() {
const newSection = this.createNewSection(); const newSection = this.createNewSection();
@ -153,6 +170,8 @@ export default {
sections, sections,
id: newSection.id id: newSection.id
}); });
this.$emit('selectSection', newSection.id);
}, },
addNewPage(page) { addNewPage(page) {
const pages = this.pages.map(p => { const pages = this.pages.map(p => {
@ -208,6 +227,12 @@ export default {
id id
}); });
}, },
selectPage(pageId) {
this.$emit('selectPage', pageId);
},
selectSection(sectionId) {
this.$emit('selectSection', sectionId);
},
sectionsChanged({ sections, id }) { sectionsChanged({ sections, id }) {
this.$emit('sectionsChanged', { this.$emit('sectionsChanged', {
sections, sections,

View File

@ -1,3 +1,4 @@
export const NOTEBOOK_TYPE = 'notebook';
export const EVENT_SNAPSHOTS_UPDATED = 'SNAPSHOTS_UPDATED'; export const EVENT_SNAPSHOTS_UPDATED = 'SNAPSHOTS_UPDATED';
export const NOTEBOOK_DEFAULT = 'DEFAULT'; export const NOTEBOOK_DEFAULT = 'DEFAULT';
export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT'; export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT';

View File

@ -2,18 +2,17 @@ import CopyToNotebookAction from './actions/CopyToNotebookAction';
import Notebook from './components/Notebook.vue'; import Notebook from './components/Notebook.vue';
import NotebookSnapshotIndicator from './components/NotebookSnapshotIndicator.vue'; import NotebookSnapshotIndicator from './components/NotebookSnapshotIndicator.vue';
import SnapshotContainer from './snapshot-container'; import SnapshotContainer from './snapshot-container';
import {NOTEBOOK_TYPE} from './notebook-constants';
import Vue from 'vue'; import Vue from 'vue';
let installed = false;
export default function NotebookPlugin() { export default function NotebookPlugin() {
return function install(openmct) { return function install(openmct) {
if (installed) { if (openmct._NOTEBOOK_PLUGIN_INSTALLED) {
return; return;
} else {
openmct._NOTEBOOK_PLUGIN_INSTALLED = true;
} }
installed = true;
openmct.actions.register(new CopyToNotebookAction(openmct)); openmct.actions.register(new CopyToNotebookAction(openmct));
const notebookType = { const notebookType = {
@ -84,7 +83,7 @@ export default function NotebookPlugin() {
} }
] ]
}; };
openmct.types.addType('notebook', notebookType); openmct.types.addType(NOTEBOOK_TYPE, notebookType);
const snapshotContainer = new SnapshotContainer(openmct); const snapshotContainer = new SnapshotContainer(openmct);
const notebookSnapshotIndicator = new Vue ({ const notebookSnapshotIndicator = new Vue ({
@ -123,10 +122,14 @@ export default function NotebookPlugin() {
}, },
provide: { provide: {
openmct, openmct,
domainObject,
snapshotContainer snapshotContainer
}, },
template: '<Notebook></Notebook>' data() {
return {
domainObject
};
},
template: '<Notebook :domain-object="domainObject"></Notebook>'
}); });
}, },
destroy() { destroy() {

View File

@ -21,29 +21,32 @@
*****************************************************************************/ *****************************************************************************/
import { createOpenMct, createMouseEvent, resetApplicationState } from 'utils/testing'; import { createOpenMct, createMouseEvent, resetApplicationState } from 'utils/testing';
import NotebookPlugin from './plugin'; import notebookPlugin from './plugin';
import Vue from 'vue'; import Vue from 'vue';
let openmct;
let notebookDefinition;
let notebookPlugin;
let element;
let child;
let appHolder;
const notebookDomainObject = {
identifier: {
key: 'notebook',
namespace: ''
},
type: 'notebook'
};
describe("Notebook plugin:", () => { describe("Notebook plugin:", () => {
beforeAll(done => { let openmct;
let notebookDefinition;
let element;
let child;
let appHolder;
let objectProviderObserver;
let notebookDomainObject;
beforeEach((done) => {
notebookDomainObject = {
identifier: {
key: 'notebook',
namespace: 'test-namespace'
},
type: 'notebook'
};
appHolder = document.createElement('div'); appHolder = document.createElement('div');
appHolder.style.width = '640px'; appHolder.style.width = '640px';
appHolder.style.height = '480px'; appHolder.style.height = '480px';
document.body.appendChild(appHolder);
openmct = createOpenMct(); openmct = createOpenMct();
@ -51,19 +54,16 @@ describe("Notebook plugin:", () => {
child = document.createElement('div'); child = document.createElement('div');
element.appendChild(child); element.appendChild(child);
notebookPlugin = new NotebookPlugin(); openmct.install(notebookPlugin());
openmct.install(notebookPlugin);
notebookDefinition = openmct.types.get('notebook').definition; notebookDefinition = openmct.types.get('notebook').definition;
notebookDefinition.initialize(notebookDomainObject); notebookDefinition.initialize(notebookDomainObject);
openmct.on('start', done); openmct.on('start', done);
openmct.start(appHolder); openmct.start(appHolder);
document.body.append(appHolder);
}); });
afterAll(() => { afterEach(() => {
appHolder.remove(); appHolder.remove();
return resetApplicationState(openmct); return resetApplicationState(openmct);
@ -80,39 +80,96 @@ describe("Notebook plugin:", () => {
describe("Notebook view:", () => { describe("Notebook view:", () => {
let notebookViewProvider; let notebookViewProvider;
let notebookView; let notebookView;
let notebookViewObject;
let mutableNotebookObject;
beforeEach(() => { beforeEach(() => {
const notebookViewObject = { notebookViewObject = {
...notebookDomainObject, ...notebookDomainObject,
id: "test-object", id: "test-object",
name: 'Notebook', name: 'Notebook',
configuration: { configuration: {
defaultSort: 'oldest', defaultSort: 'oldest',
entries: {}, entries: {
"test-section-1": {
"test-page-1": [{
"id": "entry-0",
"createdOn": 0,
"text": "First Test Entry",
"embeds": []
}, {
"id": "entry-1",
"createdOn": 0,
"text": "Second Test Entry",
"embeds": []
}]
}
},
pageTitle: 'Page', pageTitle: 'Page',
sections: [], sections: [{
"id": "test-section-1",
"isDefault": false,
"isSelected": false,
"name": "Test Section",
"pages": [{
"id": "test-page-1",
"isDefault": false,
"isSelected": false,
"name": "Test Page 1",
"pageTitle": "Page"
}, {
"id": "test-page-2",
"isDefault": false,
"isSelected": false,
"name": "Test Page 2",
"pageTitle": "Page"
}]
}, {
"id": "test-section-2",
"isDefault": false,
"isSelected": false,
"name": "Test Section 2",
"pages": [{
"id": "test-page-3",
"isDefault": false,
"isSelected": false,
"name": "Test Page 3",
"pageTitle": "Page"
}]
}],
sectionTitle: 'Section', sectionTitle: 'Section',
type: 'General' type: 'General'
} }
}; };
const testObjectProvider = jasmine.createSpyObj('testObjectProvider', [
'get',
'create',
'update',
'observe'
]);
const notebookObject = { const applicableViews = openmct.objectViews.get(notebookViewObject, [notebookViewObject]);
name: 'Notebook View', notebookViewProvider = applicableViews.find(viewProvider => viewProvider.key === 'notebook-vue');
key: 'notebook-vue',
creatable: true
};
const applicableViews = openmct.objectViews.get(notebookViewObject, []); testObjectProvider.get.and.returnValue(Promise.resolve(notebookViewObject));
notebookViewProvider = applicableViews.find(viewProvider => viewProvider.key === notebookObject.key); openmct.objects.addProvider('test-namespace', testObjectProvider);
notebookView = notebookViewProvider.view(notebookViewObject); testObjectProvider.observe.and.returnValue(() => {});
notebookView.show(child); return openmct.objects.getMutable(notebookViewObject.identifier).then((mutableObject) => {
mutableNotebookObject = mutableObject;
objectProviderObserver = testObjectProvider.observe.calls.mostRecent().args[1];
notebookView = notebookViewProvider.view(mutableNotebookObject);
notebookView.show(child);
return Vue.nextTick();
});
return Vue.nextTick();
}); });
afterEach(() => { afterEach(() => {
notebookView.destroy(); notebookView.destroy();
openmct.objects.destroyMutable(mutableNotebookObject);
}); });
it("provides notebook view", () => { it("provides notebook view", () => {
@ -133,6 +190,114 @@ describe("Notebook plugin:", () => {
expect(hasMajorElements).toBe(true); expect(hasMajorElements).toBe(true);
}); });
it("renders a row for each entry", () => {
const notebookEntryElements = element.querySelectorAll('.c-notebook__entry');
const firstEntryText = getEntryText(0);
expect(notebookEntryElements.length).toBe(2);
expect(firstEntryText.innerText).toBe('First Test Entry');
});
describe("synchronization", () => {
it("updates an entry when another user modifies it", () => {
expect(getEntryText(0).innerText).toBe("First Test Entry");
notebookViewObject.configuration.entries["test-section-1"]["test-page-1"][0].text = "Modified entry text";
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(getEntryText(0).innerText).toBe("Modified entry text");
});
});
it("shows new entry when another user adds one", () => {
expect(allNotebookEntryElements().length).toBe(2);
notebookViewObject.configuration.entries["test-section-1"]["test-page-1"].push({
"id": "entry-3",
"createdOn": 0,
"text": "Third Test Entry",
"embeds": []
});
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookEntryElements().length).toBe(3);
});
});
it("removes an entry when another user removes one", () => {
expect(allNotebookEntryElements().length).toBe(2);
let entries = notebookViewObject.configuration.entries["test-section-1"]["test-page-1"];
notebookViewObject.configuration.entries["test-section-1"]["test-page-1"] = entries.splice(0, 1);
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookEntryElements().length).toBe(1);
});
});
it("updates the notebook when a user adds a page", () => {
const newPage = {
"id": "test-page-4",
"isDefault": false,
"isSelected": false,
"name": "Test Page 4",
"pageTitle": "Page"
};
expect(allNotebookPageElements().length).toBe(2);
notebookViewObject.configuration.sections[0].pages.push(newPage);
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookPageElements().length).toBe(3);
});
});
it("updates the notebook when a user removes a page", () => {
expect(allNotebookPageElements().length).toBe(2);
notebookViewObject.configuration.sections[0].pages.splice(0, 1);
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookPageElements().length).toBe(1);
});
});
it("updates the notebook when a user adds a section", () => {
const newSection = {
"id": "test-section-3",
"isDefault": false,
"isSelected": false,
"name": "Test Section 3",
"pages": [{
"id": "test-page-4",
"isDefault": false,
"isSelected": false,
"name": "Test Page 4",
"pageTitle": "Page"
}]
};
expect(allNotebookSectionElements().length).toBe(2);
notebookViewObject.configuration.sections.push(newSection);
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookSectionElements().length).toBe(3);
});
});
it("updates the notebook when a user removes a section", () => {
expect(allNotebookSectionElements().length).toBe(2);
notebookViewObject.configuration.sections.splice(0, 1);
objectProviderObserver(notebookViewObject);
return Vue.nextTick().then(() => {
expect(allNotebookSectionElements().length).toBe(1);
});
});
});
}); });
describe("Notebook Snapshots view:", () => { describe("Notebook Snapshots view:", () => {
@ -147,16 +312,22 @@ describe("Notebook plugin:", () => {
button.dispatchEvent(clickEvent); button.dispatchEvent(clickEvent);
} }
beforeAll(() => { beforeEach(() => {
snapshotIndicator = openmct.indicators.indicatorObjects snapshotIndicator = openmct.indicators.indicatorObjects
.find(indicator => indicator.key === 'notebook-snapshot-indicator').element; .find(indicator => indicator.key === 'notebook-snapshot-indicator').element;
element.append(snapshotIndicator); element.append(snapshotIndicator);
return Vue.nextTick(); return Vue.nextTick().then(() => {
drawerElement = document.querySelector('.l-shell__drawer');
});
}); });
afterAll(() => { afterEach(() => {
if (drawerElement) {
drawerElement.classList.remove('is-expanded');
}
snapshotIndicator.remove(); snapshotIndicator.remove();
snapshotIndicator = undefined; snapshotIndicator = undefined;
@ -166,16 +337,6 @@ describe("Notebook plugin:", () => {
} }
}); });
beforeEach(() => {
drawerElement = document.querySelector('.l-shell__drawer');
});
afterEach(() => {
if (drawerElement) {
drawerElement.classList.remove('is-expanded');
}
});
it("has Snapshots indicator", () => { it("has Snapshots indicator", () => {
const hasSnapshotIndicator = snapshotIndicator !== null && snapshotIndicator !== undefined; const hasSnapshotIndicator = snapshotIndicator !== null && snapshotIndicator !== undefined;
expect(hasSnapshotIndicator).toBe(true); expect(hasSnapshotIndicator).toBe(true);
@ -219,4 +380,20 @@ describe("Notebook plugin:", () => {
expect(snapshotsText).toBe('Notebook Snapshots'); expect(snapshotsText).toBe('Notebook Snapshots');
}); });
}); });
function getEntryText(entryNumber) {
return element.querySelectorAll('.c-notebook__entry .c-ne__text')[entryNumber];
}
function allNotebookEntryElements() {
return element.querySelectorAll('.c-notebook__entry');
}
function allNotebookSectionElements() {
return element.querySelectorAll('.js-sidebar-sections .js-list__item');
}
function allNotebookPageElements() {
return element.querySelectorAll('.js-sidebar-pages .js-list__item');
}
}); });

View File

@ -22,6 +22,7 @@
import CouchDocument from "./CouchDocument"; import CouchDocument from "./CouchDocument";
import CouchObjectQueue from "./CouchObjectQueue"; import CouchObjectQueue from "./CouchObjectQueue";
import NOTEBOOK_TYPE from '../../notebook/notebook-constants.js';
const REV = "_rev"; const REV = "_rev";
const ID = "_id"; const ID = "_id";
@ -29,24 +30,14 @@ const HEARTBEAT = 50000;
const ALL_DOCS = "_all_docs?include_docs=true"; const ALL_DOCS = "_all_docs?include_docs=true";
export default class CouchObjectProvider { export default class CouchObjectProvider {
// options {
// url: couchdb url,
// disableObserve: disable auto feed from couchdb to keep objects in sync,
// filter: selector to find objects to sync in couchdb
// }
constructor(openmct, options, namespace) { constructor(openmct, options, namespace) {
options = this._normalize(options); options = this._normalize(options);
this.openmct = openmct; this.openmct = openmct;
this.url = options.url; this.url = options.url;
this.namespace = namespace; this.namespace = namespace;
this.objectQueue = {}; this.objectQueue = {};
this.observeEnabled = options.disableObserve !== true;
this.observers = {}; this.observers = {};
this.batchIds = []; this.batchIds = [];
if (this.observeEnabled) {
this.observeObjectChanges(options.filter);
}
} }
//backwards compatibility, options used to be a url. Now it's an object //backwards compatibility, options used to be a url. Now it's an object
@ -133,8 +124,12 @@ export default class CouchObjectProvider {
this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]); this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]);
} }
//Sometimes CouchDB returns the old rev which fetching the object if there is a document update in progress if (object.type === NOTEBOOK_TYPE) {
if (!this.objectQueue[key].pending) { //Temporary measure until object sync is supported for all object types
//Always update notebook revision number because we have realtime sync, so always assume it's the latest.
this.objectQueue[key].updateRevision(response[REV]);
} else if (!this.objectQueue[key].pending) {
//Sometimes CouchDB returns the old rev which fetching the object if there is a document update in progress
this.objectQueue[key].updateRevision(response[REV]); this.objectQueue[key].updateRevision(response[REV]);
} }
@ -313,49 +308,63 @@ export default class CouchObjectProvider {
} }
observe(identifier, callback) { observe(identifier, callback) {
if (!this.observeEnabled) {
return;
}
const keyString = this.openmct.objects.makeKeyString(identifier); const keyString = this.openmct.objects.makeKeyString(identifier);
this.observers[keyString] = this.observers[keyString] || []; this.observers[keyString] = this.observers[keyString] || [];
this.observers[keyString].push(callback); this.observers[keyString].push(callback);
if (!this.isObservingObjectChanges()) {
this.observeObjectChanges();
}
return () => { return () => {
this.observers[keyString] = this.observers[keyString].filter(observer => observer !== callback); this.observers[keyString] = this.observers[keyString].filter(observer => observer !== callback);
if (this.observers[keyString].length === 0) {
delete this.observers[keyString];
if (Object.keys(this.observers).length === 0) {
this.stopObservingObjectChanges();
}
}
}; };
} }
/** isObservingObjectChanges() {
* @private return this.stopObservingObjectChanges !== undefined;
*/
abortGetChanges() {
if (this.controller) {
this.controller.abort();
this.controller = undefined;
}
return true;
} }
/** /**
* @private * @private
*/ */
async observeObjectChanges(filter) { async observeObjectChanges() {
let intermediateResponse = this.getIntermediateResponse();
if (!this.observeEnabled) {
intermediateResponse.reject('Observe for changes is disabled');
}
const controller = new AbortController(); const controller = new AbortController();
const signal = controller.signal; const signal = controller.signal;
let filter = {selector: {}};
if (this.controller) { if (this.openmct.objects.SYNCHRONIZED_OBJECT_TYPES.length > 1) {
this.abortGetChanges(); filter.selector.$or = this.openmct.objects.SYNCHRONIZED_OBJECT_TYPES
.map(type => {
return {
'model': {
type
}
};
});
} else {
filter.selector.model = {
type: this.openmct.objects.SYNCHRONIZED_OBJECT_TYPES[0]
};
} }
this.controller = controller; let error = false;
if (typeof this.stopObservingObjectChanges === 'function') {
this.stopObservingObjectChanges();
}
this.stopObservingObjectChanges = () => {
controller.abort();
delete this.stopObservingObjectChanges;
};
// feed=continuous maintains an indefinitely open connection with a keep-alive of HEARTBEAT milliseconds until this client closes the connection // feed=continuous maintains an indefinitely open connection with a keep-alive of HEARTBEAT milliseconds until this client closes the connection
// style=main_only returns only the current winning revision of the document // style=main_only returns only the current winning revision of the document
let url = `${this.url}/_changes?feed=continuous&style=main_only&heartbeat=${HEARTBEAT}`; let url = `${this.url}/_changes?feed=continuous&style=main_only&heartbeat=${HEARTBEAT}`;
@ -374,14 +383,20 @@ export default class CouchObjectProvider {
}, },
body body
}); });
const reader = response.body.getReader();
let completed = false;
while (!completed) { let reader;
if (response.body === undefined) {
error = true;
} else {
reader = response.body.getReader();
}
while (!error) {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
//done is true when we lose connection with the provider //done is true when we lose connection with the provider
if (done) { if (done) {
completed = true; error = true;
} }
if (value) { if (value) {
@ -414,11 +429,9 @@ export default class CouchObjectProvider {
} }
//We're done receiving from the provider. No more chunks. if (error && Object.keys(this.observers).length > 0) {
intermediateResponse.resolve(true); this.observeObjectChanges();
}
return intermediateResponse.promise;
} }
/** /**

View File

@ -27,8 +27,6 @@ import {
describe('the plugin', () => { describe('the plugin', () => {
let openmct; let openmct;
let element;
let child;
let provider; let provider;
let testPath = '/test/db'; let testPath = '/test/db';
let options; let options;
@ -36,6 +34,8 @@ describe('the plugin', () => {
let mockDomainObject; let mockDomainObject;
beforeEach((done) => { beforeEach((done) => {
spyOnBuiltins(['fetch'], window);
mockDomainObject = { mockDomainObject = {
identifier: { identifier: {
namespace: '', namespace: '',
@ -51,8 +51,6 @@ describe('the plugin', () => {
}; };
openmct = createOpenMct(false); openmct = createOpenMct(false);
spyOnBuiltins(['fetch'], window);
openmct.$injector = jasmine.createSpyObj('$injector', ['get']); openmct.$injector = jasmine.createSpyObj('$injector', ['get']);
mockIdentifierService = jasmine.createSpyObj( mockIdentifierService = jasmine.createSpyObj(
'identifierService', 'identifierService',
@ -70,10 +68,6 @@ describe('the plugin', () => {
openmct.types.addType('mock-type', {creatable: true}); openmct.types.addType('mock-type', {creatable: true});
element = document.createElement('div');
child = document.createElement('div');
element.appendChild(child);
openmct.on('start', done); openmct.on('start', done);
openmct.startHeadless(); openmct.startHeadless();