5734 synchronization for new tags on notebook entries (#5763)

* trying this again

* wip

* wip

* wip

* one annotation per tag

* fixed too many events firing

* syncing works mostly

* syncing properly across existing annotations

* search with multiple tags

* resolve conflicts between different tag editors

* resolve conflicts

* fix annotation tests

* combine search results

* modify tests

* prevent infinite loop creating annotation

* add modified and deleted

* revert index checkin

* change to standard couch deleted flag

* revert throwing of error

* resolve conflict issues

* work in progress, but load annotations once from notebook

* works to add

* attempt 1

* wip

* last changes

* listening works, though still getting conflicts

* rename to annotationLastCreated

* use local mutable again

* works with new tags syncing

* listeners wont fire if modification is null

* clean up code

* fixed local search

* cleaned up log messages

* remove on more log

* add e2e test for network traffic

* lint

* change to use good old for each

* add some local variables for clarity

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/api/objects/ObjectAPI.js

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* Update src/plugins/notebook/components/Notebook.vue

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* press enter for last entry

* add test explanation of numbers

* fix spread typo

* add some nice jsdoc

* throw some errors

* use really small integer instead

* remove unneeded binding

* make method public and jsdoc it

* use mutables

* clean up tests

* clean up tests

* use aria labels for tests

* add some proper tsdoc to annotation api

* add undelete test

Co-authored-by: John Hill <john.c.hill@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
This commit is contained in:
Scott Bell 2022-09-30 19:32:11 +02:00 committed by GitHub
parent 27c30132d2
commit ce463babff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 568 additions and 296 deletions

View File

@ -15,8 +15,8 @@
## Sections ## Sections
* The [API](api/) document is generated from inline documentation * The [API](api/) uses inline documentation
using [JSDoc](http://usejsdoc.org/), and describes the JavaScript objects and using [TypeScript](https://www.typescriptlang.org) and some legacy [JSDoc](https://jsdoc.app/). It describes the JavaScript objects and
functions that make up the software platform. functions that make up the software platform.
* The [Development Process](process/) document describes the * The [Development Process](process/) document describes the

View File

@ -0,0 +1,232 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2022, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB.
*/
const { test, expect } = require('../../../../baseFixtures');
const { createDomainObjectWithDefaults } = require('../../../../appActions');
test.describe('Notebook Network Request Inspection @couchdb', () => {
let testNotebook;
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'networkidle' });
// Create Notebook
testNotebook = await createDomainObjectWithDefaults(page, {
type: 'Notebook',
name: "TestNotebook"
});
});
test('Inspect Notebook Entry Network Requests', async ({ page }) => {
// Expand sidebar
await page.locator('.c-notebook__toggle-nav-button').click();
// Collect all request events to count and assert after notebook action
let addingNotebookElementsRequests = [];
page.on('request', (request) => addingNotebookElementsRequests.push(request));
let [notebookUrlRequest, allDocsRequest] = await Promise.all([
// Waits for the next request with the specified url
page.waitForRequest(`**/openmct/${testNotebook.uuid}`),
page.waitForRequest('**/openmct/_all_docs?include_docs=true'),
// Triggers the request
page.click('[aria-label="Add Page"]'),
// Ensures that there are no other network requests
page.waitForLoadState('networkidle')
]);
// Assert that only two requests are made
// Network Requests are:
// 1) The actual POST to create the page
// 2) The shared worker event from 👆 request
expect(addingNotebookElementsRequests.length).toBe(2);
// Assert on request object
expect(notebookUrlRequest.postDataJSON().metadata.name).toBe('TestNotebook');
expect(notebookUrlRequest.postDataJSON().model.persisted).toBeGreaterThanOrEqual(notebookUrlRequest.postDataJSON().model.modified);
expect(allDocsRequest.postDataJSON().keys).toContain(testNotebook.uuid);
// Add an entry
// Network Requests are:
// 1) The actual POST to create the entry
// 2) The shared worker event from 👆 POST request
addingNotebookElementsRequests = [];
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"]').click();
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
await page.waitForLoadState('networkidle');
expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2);
// Add some tags
// Network Requests are for each tag creation are:
// 1) Getting the original path of the parent object
// 2) Getting the original path of the grandparent object (recursive call)
// 3) Creating the annotation/tag object
// 4) The shared worker event from 👆 POST request
// 5) Mutate notebook domain object's annotationModified property
// 6) The shared worker event from 👆 POST request
// 7) Notebooks fetching new annotations due to annotationModified changed
// 8) The update of the notebook domain's object's modified property
// 9) The shared worker event from 👆 POST request
// 10) Entry is timestamped
// 11) The shared worker event from 👆 POST request
addingNotebookElementsRequests = [];
await page.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag")`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
addingNotebookElementsRequests = [];
await page.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag")`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
addingNotebookElementsRequests = [];
await page.hover(`button:has-text("Add Tag")`);
await page.locator(`button:has-text("Add Tag")`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(11);
// Delete all the tags
// Network requests are:
// 1) Send POST to mutate _delete property to true on annotation with tag
// 2) The shared worker event from 👆 POST request
// 3) Timestamp update on entry
// 4) The shared worker event from 👆 POST request
// This happens for 3 tags so 12 requests
addingNotebookElementsRequests = [];
await page.hover('[aria-label="Tag"]:has-text("Driving")');
await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")', {state: 'hidden'});
await page.hover('[aria-label="Tag"]:has-text("Drilling")');
await page.locator('[aria-label="Tag"]:has-text("Drilling") ~ .c-completed-tag-deletion').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")', {state: 'hidden'});
page.hover('[aria-label="Tag"]:has-text("Science")');
await page.locator('[aria-label="Tag"]:has-text("Science") ~ .c-completed-tag-deletion').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Science")', {state: 'hidden'});
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(12);
// Add two more pages
await page.click('[aria-label="Add Page"]');
await page.click('[aria-label="Add Page"]');
// Add three entries
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"]').click();
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`);
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`);
// Add three tags
await page.hover(`button:has-text("Add Tag") >> nth=2`);
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
await page.hover(`button:has-text("Add Tag") >> nth=2`);
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
await page.hover(`button:has-text("Add Tag") >> nth=2`);
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
await page.locator('[placeholder="Type to select tag"]').click();
await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
page.waitForLoadState('networkidle');
// Add a fourth entry
// Network requests are:
// 1) Send POST to add new entry
// 2) The shared worker event from 👆 POST request
// 3) Timestamp update on entry
// 4) The shared worker event from 👆 POST request
addingNotebookElementsRequests = [];
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').fill(`Fourth Entry`);
await page.locator('[aria-label="Notebook Entry Input"] >> nth=3').press('Enter');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
// Add a fifth entry
// Network requests are:
// 1) Send POST to add new entry
// 2) The shared worker event from 👆 POST request
// 3) Timestamp update on entry
// 4) The shared worker event from 👆 POST request
addingNotebookElementsRequests = [];
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').fill(`Fifth Entry`);
await page.locator('[aria-label="Notebook Entry Input"] >> nth=4').press('Enter');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
// Add a sixth entry
// 1) Send POST to add new entry
// 2) The shared worker event from 👆 POST request
// 3) Timestamp update on entry
// 4) The shared worker event from 👆 POST request
addingNotebookElementsRequests = [];
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').click();
await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').fill(`Sixth Entry`);
await page.locator('[aria-label="Notebook Entry Input"] >> nth=5').press('Enter');
page.waitForLoadState('networkidle');
expect(filterNonFetchRequests(addingNotebookElementsRequests).length).toBeLessThanOrEqual(4);
});
});
// Try to reduce indeterminism of browser requests by only returning fetch requests.
// Filter out preflight CORS, fetching stylesheets, page icons, etc. that can occur during tests
function filterNonFetchRequests(requests) {
return requests.filter(request => {
return (request.resourceType() === 'fetch');
});
}

View File

@ -81,10 +81,8 @@ test.describe('Tagging in Notebooks @addInit', () => {
test('Can load tags', async ({ page }) => { test('Can load tags', async ({ page }) => {
await createNotebookAndEntry(page); await createNotebookAndEntry(page);
// Click text=To start a new entry, click here or drag and drop any object
await page.locator('button:has-text("Add Tag")').click(); await page.locator('button:has-text("Add Tag")').click();
// Click [placeholder="Type to select tag"]
await page.locator('[placeholder="Type to select tag"]').click(); await page.locator('[placeholder="Type to select tag"]').click();
await expect(page.locator('[aria-label="Autocomplete Options"]')).toContainText("Science"); await expect(page.locator('[aria-label="Autocomplete Options"]')).toContainText("Science");
@ -97,9 +95,7 @@ test.describe('Tagging in Notebooks @addInit', () => {
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science"); await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Driving"); await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Driving");
// Click button:has-text("Add Tag")
await page.locator('button:has-text("Add Tag")').click(); await page.locator('button:has-text("Add Tag")').click();
// Click [placeholder="Type to select tag"]
await page.locator('[placeholder="Type to select tag"]').click(); await page.locator('[placeholder="Type to select tag"]').click();
await expect(page.locator('[aria-label="Autocomplete Options"]')).not.toContainText("Science"); await expect(page.locator('[aria-label="Autocomplete Options"]')).not.toContainText("Science");
@ -108,39 +104,31 @@ test.describe('Tagging in Notebooks @addInit', () => {
}); });
test('Can search for tags', async ({ page }) => { test('Can search for tags', async ({ page }) => {
await createNotebookEntryAndTags(page); await createNotebookEntryAndTags(page);
// Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science"); await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science");
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving"); await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
// Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Sc');
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science"); await expect(page.locator('[aria-label="Search Result"]')).toContainText("Science");
await expect(page.locator('[aria-label="Search Result"]')).toContainText("Driving"); await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
// Click [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click();
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Xq');
await expect(page.locator('[aria-label="Search Result"]')).toBeHidden(); await expect(page.locator('[aria-label="Search Result"]')).toBeHidden();
await expect(page.locator('[aria-label="Search Result"]')).toBeHidden();
}); });
test('Can delete tags', async ({ page }) => { test('Can delete tags', async ({ page }) => {
await createNotebookEntryAndTags(page); await createNotebookEntryAndTags(page);
await page.locator('[aria-label="Notebook Entries"]').click(); await page.locator('[aria-label="Notebook Entries"]').click();
// Delete Driving // Delete Driving
await page.hover('.c-tag__label:has-text("Driving")'); await page.hover('[aria-label="Tag"]:has-text("Driving")');
await page.locator('.c-tag__label:has-text("Driving") ~ .c-completed-tag-deletion').click(); await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science"); await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving"); await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving");
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving"); await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
}); });
@ -153,7 +141,6 @@ test.describe('Tagging in Notebooks @addInit', () => {
await page.locator('button:has-text("OK")').click(); await page.locator('button:has-text("OK")').click();
await page.goto('./', { waitUntil: 'networkidle' }); await page.goto('./', { waitUntil: 'networkidle' });
// Fill [aria-label="OpenMCT Search"] input[type="search"]
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed');
await expect(page.locator('text=No results found')).toBeVisible(); await expect(page.locator('text=No results found')).toBeVisible();
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sci'); await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sci');

View File

@ -22,6 +22,7 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import EventEmitter from 'EventEmitter'; import EventEmitter from 'EventEmitter';
import _ from 'lodash';
/** /**
* @readonly * @readonly
@ -42,19 +43,28 @@ const ANNOTATION_TYPES = Object.freeze({
const ANNOTATION_TYPE = 'annotation'; const ANNOTATION_TYPE = 'annotation';
const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
/** /**
* @typedef {Object} Tag * @typedef {Object} Tag
* @property {String} key a unique identifier for the tag * @property {String} key a unique identifier for the tag
* @property {String} backgroundColor eg. "#cc0000" * @property {String} backgroundColor eg. "#cc0000"
* @property {String} foregroundColor eg. "#ffffff" * @property {String} foregroundColor eg. "#ffffff"
*/ */
export default class AnnotationAPI extends EventEmitter { export default class AnnotationAPI extends EventEmitter {
/**
* @param {OpenMCT} openmct
*/
constructor(openmct) { constructor(openmct) {
super(); super();
this.openmct = openmct; this.openmct = openmct;
this.availableTags = {}; this.availableTags = {};
this.ANNOTATION_TYPES = ANNOTATION_TYPES; this.ANNOTATION_TYPES = ANNOTATION_TYPES;
this.ANNOTATION_TYPE = ANNOTATION_TYPE;
this.ANNOTATION_LAST_CREATED = ANNOTATION_LAST_CREATED;
this.openmct.types.addType(ANNOTATION_TYPE, { this.openmct.types.addType(ANNOTATION_TYPE, {
name: 'Annotation', name: 'Annotation',
@ -63,6 +73,7 @@ export default class AnnotationAPI extends EventEmitter {
cssClass: 'icon-notebook', cssClass: 'icon-notebook',
initialize: function (domainObject) { initialize: function (domainObject) {
domainObject.targets = domainObject.targets || {}; domainObject.targets = domainObject.targets || {};
domainObject._deleted = domainObject._deleted || false;
domainObject.originalContextPath = domainObject.originalContextPath || ''; domainObject.originalContextPath = domainObject.originalContextPath || '';
domainObject.tags = domainObject.tags || []; domainObject.tags = domainObject.tags || [];
domainObject.contentText = domainObject.contentText || ''; domainObject.contentText = domainObject.contentText || '';
@ -112,6 +123,7 @@ export default class AnnotationAPI extends EventEmitter {
namespace namespace
}, },
tags, tags,
_deleted: false,
annotationType, annotationType,
contentText, contentText,
originalContextPath originalContextPath
@ -127,6 +139,7 @@ export default class AnnotationAPI extends EventEmitter {
const success = await this.openmct.objects.save(createdObject); const success = await this.openmct.objects.save(createdObject);
if (success) { if (success) {
this.emit('annotationCreated', createdObject); this.emit('annotationCreated', createdObject);
this.#updateAnnotationModified(domainObject);
return createdObject; return createdObject;
} else { } else {
@ -134,14 +147,32 @@ export default class AnnotationAPI extends EventEmitter {
} }
} }
#updateAnnotationModified(domainObject) {
this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now());
}
/**
* @method defineTag
* @param {String} key a unique identifier for the tag
* @param {Tag} tagsDefinition the definition of the tag to add
*/
defineTag(tagKey, tagsDefinition) { defineTag(tagKey, tagsDefinition) {
this.availableTags[tagKey] = tagsDefinition; this.availableTags[tagKey] = tagsDefinition;
} }
/**
* @method isAnnotation
* @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question
* @returns {Boolean} Returns true if the domain object is an annotation
*/
isAnnotation(domainObject) { isAnnotation(domainObject) {
return domainObject && (domainObject.type === ANNOTATION_TYPE); return domainObject && (domainObject.type === ANNOTATION_TYPE);
} }
/**
* @method getAvailableTags
* @returns {Tag[]} Returns an array of the available tags that have been loaded
*/
getAvailableTags() { getAvailableTags() {
if (this.availableTags) { if (this.availableTags) {
const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => { const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => {
@ -157,18 +188,26 @@ export default class AnnotationAPI extends EventEmitter {
} }
} }
async getAnnotation(query, searchType) { /**
let foundAnnotation = null; * @method getAnnotations
* @param {String} query - The keystring of the domain object to search for annotations for
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query
*/
async getAnnotations(query) {
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, searchType))).flat(); return searchResults;
if (searchResults) {
foundAnnotation = searchResults[0];
}
return foundAnnotation;
} }
async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) { /**
* @method addSingleAnnotationTag
* @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation.
* @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to.
* @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID
* @param {AnnotationType} annotationType - The type of annotation this is for.
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation
*/
async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
if (!existingAnnotation) { if (!existingAnnotation) {
const targets = {}; const targets = {};
const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier); const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier);
@ -186,27 +225,44 @@ export default class AnnotationAPI extends EventEmitter {
return newAnnotation; return newAnnotation;
} else { } else {
const tagArray = [tag, ...existingAnnotation.tags]; if (!existingAnnotation.tags.includes(tag)) {
this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray); throw new Error(`Existing annotation did not contain tag ${tag}`);
}
if (existingAnnotation._deleted) {
this.unDeleteAnnotation(existingAnnotation);
}
return existingAnnotation; return existingAnnotation;
} }
} }
removeAnnotationTag(existingAnnotation, tagToRemove) { /**
if (existingAnnotation && existingAnnotation.tags.includes(tagToRemove)) { * @method deleteAnnotations
const cleanedArray = existingAnnotation.tags.filter(extantTag => extantTag !== tagToRemove); * @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
this.openmct.objects.mutate(existingAnnotation, 'tags', cleanedArray); */
} else { deleteAnnotations(annotations) {
throw new Error(`Asked to remove tag (${tagToRemove}) that doesn't exist`, existingAnnotation); if (!annotations) {
throw new Error('Asked to delete null annotations! 🙅‍♂️');
} }
annotations.forEach(annotation => {
if (!annotation._deleted) {
this.openmct.objects.mutate(annotation, '_deleted', true);
}
});
} }
removeAnnotationTags(existingAnnotation) { /**
// just removes tags on the annotation as we can't really delete objects * @method deleteAnnotations
if (existingAnnotation && existingAnnotation.tags) { * @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false)
this.openmct.objects.mutate(existingAnnotation, 'tags', []); */
unDeleteAnnotation(annotation) {
if (!annotation) {
throw new Error('Asked to undelete null annotation! 🙅‍♂️');
} }
this.openmct.objects.mutate(annotation, '_deleted', false);
} }
#getMatchingTags(query) { #getMatchingTags(query) {
@ -266,16 +322,36 @@ export default class AnnotationAPI extends EventEmitter {
return modelAddedToResults; return modelAddedToResults;
} }
#combineSameTargets(results) {
const combinedResults = [];
results.forEach(currentAnnotation => {
const existingAnnotation = combinedResults.find((annotationToFind) => {
return _.isEqual(currentAnnotation.targets, annotationToFind.targets);
});
if (!existingAnnotation) {
combinedResults.push(currentAnnotation);
} else {
existingAnnotation.tags.push(...currentAnnotation.tags);
}
});
return combinedResults;
}
/** /**
* @method searchForTags * @method searchForTags
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving" * @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
* @param {Object} abortController An optional abort method to stop the query * @param {Object} [abortController] An optional abort method to stop the query
* @returns {Promise} returns a model of matching tags with their target domain objects attached * @returns {Promise} returns a model of matching tags with their target domain objects attached
*/ */
async searchForTags(query, abortController) { async searchForTags(query, abortController) {
const matchingTagKeys = this.#getMatchingTags(query); const matchingTagKeys = this.#getMatchingTags(query);
const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat(); const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat();
const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys); const filteredDeletedResults = searchResults.filter((result) => {
return !(result._deleted);
});
const combinedSameTargets = this.#combineSameTargets(filteredDeletedResults);
const appliedTagSearchResults = this.#addTagMetaInformationToResults(combinedSameTargets, matchingTagKeys);
const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults); const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults);
const resultsWithValidPath = appliedTargetsModels.filter(result => { const resultsWithValidPath = appliedTargetsModels.filter(result => {
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath); return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);

View File

@ -126,34 +126,44 @@ describe("The Annotation API", () => {
describe("Tagging", () => { describe("Tagging", () => {
it("can create a tag", async () => { it("can create a tag", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation'); expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag'); expect(annotationObject.tags).toContain('aWonderfulTag');
}); });
it("can delete a tag", async () => { it("can delete a tag", async () => {
const originalAnnotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
const annotationObject = await openmct.annotation.addAnnotationTag(originalAnnotationObject, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'anotherTagToRemove');
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove'); openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject.tags).toEqual(['aWonderfulTag']); expect(annotationObject._deleted).toBeTrue();
openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag');
expect(annotationObject.tags).toEqual([]);
}); });
it("throws an error if deleting non-existent tag", async () => { it("throws an error if deleting non-existent tag", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(() => { expect(() => {
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist'); openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
}).toThrow(); }).toThrow();
}); });
it("can remove all tags", async () => { it("can remove all tags", async () => {
const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined(); expect(annotationObject).toBeDefined();
expect(() => { expect(() => {
openmct.annotation.removeAnnotationTags(annotationObject); openmct.annotation.deleteAnnotations([annotationObject]);
}).not.toThrow(); }).not.toThrow();
expect(annotationObject.tags).toEqual([]); expect(annotationObject._deleted).toBeTrue();
});
it("can add/delete/add a tag", async () => {
let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
expect(annotationObject._deleted).toBeFalse();
}); });
}); });
@ -175,16 +185,5 @@ describe("The Annotation API", () => {
expect(results).toBeDefined(); expect(results).toBeDefined();
expect(results.length).toEqual(1); expect(results.length).toEqual(1);
}); });
it("can get notebook annotations", async () => {
const targetKeyString = openmct.objects.makeKeyString(mockDomainObject.identifier);
const query = {
targetKeyString,
entryId: 'fooBarEntry'
};
const results = await openmct.annotation.getAnnotation(query, openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
expect(results).toBeDefined();
expect(results.tags.length).toEqual(2);
});
}); });
}); });

View File

@ -199,7 +199,7 @@ define([
if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) { if (this.returnMutables && this.publicAPI.objects.supportsMutation(child.identifier)) {
let keyString = this.publicAPI.objects.makeKeyString(child.identifier); let keyString = this.publicAPI.objects.makeKeyString(child.identifier);
child = this.publicAPI.objects._toMutable(child); child = this.publicAPI.objects.toMutable(child);
this.mutables[keyString] = child; this.mutables[keyString] = child;
} }

View File

@ -42,7 +42,6 @@ class InMemorySearchProvider {
this.openmct = openmct; this.openmct = openmct;
this.indexedIds = {}; this.indexedIds = {};
this.indexedCompositions = {}; this.indexedCompositions = {};
this.indexedTags = {};
this.idsToIndex = []; this.idsToIndex = [];
this.pendingIndex = {}; this.pendingIndex = {};
this.pendingRequests = 0; this.pendingRequests = 0;
@ -61,7 +60,6 @@ class InMemorySearchProvider {
this.localSearchForObjects = this.localSearchForObjects.bind(this); this.localSearchForObjects = this.localSearchForObjects.bind(this);
this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this); this.localSearchForAnnotations = this.localSearchForAnnotations.bind(this);
this.localSearchForTags = this.localSearchForTags.bind(this); this.localSearchForTags = this.localSearchForTags.bind(this);
this.localSearchForNotebookAnnotations = this.localSearchForNotebookAnnotations.bind(this);
this.onAnnotationCreation = this.onAnnotationCreation.bind(this); this.onAnnotationCreation = this.onAnnotationCreation.bind(this);
this.onCompositionAdded = this.onCompositionAdded.bind(this); this.onCompositionAdded = this.onCompositionAdded.bind(this);
this.onCompositionRemoved = this.onCompositionRemoved.bind(this); this.onCompositionRemoved = this.onCompositionRemoved.bind(this);
@ -93,7 +91,7 @@ class InMemorySearchProvider {
this.searchTypes = this.openmct.objects.SEARCH_TYPES; this.searchTypes = this.openmct.objects.SEARCH_TYPES;
this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS]; this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS];
this.scheduleForIndexing(rootObject.identifier); this.scheduleForIndexing(rootObject.identifier);
@ -163,8 +161,6 @@ class InMemorySearchProvider {
return this.localSearchForObjects(queryId, query, maxResults); return this.localSearchForObjects(queryId, query, maxResults);
} else if (searchType === this.searchTypes.ANNOTATIONS) { } else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.localSearchForAnnotations(queryId, query, maxResults); return this.localSearchForAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
return this.localSearchForNotebookAnnotations(queryId, query, maxResults);
} else if (searchType === this.searchTypes.TAGS) { } else if (searchType === this.searchTypes.TAGS) {
return this.localSearchForTags(queryId, query, maxResults); return this.localSearchForTags(queryId, query, maxResults);
} else { } else {
@ -281,13 +277,6 @@ class InMemorySearchProvider {
provider.index(domainObject); provider.index(domainObject);
} }
onTagMutation(domainObject, newTags) {
domainObject.tags = newTags;
const provider = this;
provider.index(domainObject);
}
onCompositionAdded(newDomainObjectToIndex) { onCompositionAdded(newDomainObjectToIndex) {
const provider = this; const provider = this;
// The object comes in as a mutable domain object, which has functions, // The object comes in as a mutable domain object, which has functions,
@ -342,14 +331,6 @@ class InMemorySearchProvider {
composition.on('remove', this.onCompositionRemoved); composition.on('remove', this.onCompositionRemoved);
this.indexedCompositions[keyString] = composition; this.indexedCompositions[keyString] = composition;
} }
if (domainObject.type === 'annotation') {
this.indexedTags[keyString] = this.openmct.objects.observe(
domainObject,
'tags',
this.onTagMutation.bind(this, domainObject)
);
}
} }
if ((keyString !== 'ROOT')) { if ((keyString !== 'ROOT')) {
@ -581,43 +562,6 @@ class InMemorySearchProvider {
this.onWorkerMessage(eventToReturn); this.onWorkerMessage(eventToReturn);
} }
/**
* A local version of the same SharedWorker function
* if we don't have SharedWorkers available (e.g., iOS)
*/
localSearchForNotebookAnnotations(queryId, {entryId, targetKeyString}, maxResults) {
// This results dictionary will have domain object ID keys which
// point to the value the domain object's score.
let results = [];
const message = {
request: 'searchForNotebookAnnotations',
results: [],
total: 0,
queryId
};
const matchingAnnotations = this.localIndexedAnnotationsByDomainObject[targetKeyString];
if (matchingAnnotations) {
results = matchingAnnotations.filter(matchingAnnotation => {
if (!matchingAnnotation.targets) {
return false;
}
const target = matchingAnnotation.targets[targetKeyString];
return (target && target.entryId && (target.entryId === entryId));
});
}
message.total = results.length;
message.results = results
.slice(0, maxResults);
const eventToReturn = {
data: message
};
this.onWorkerMessage(eventToReturn);
}
destroyObservers(observers) { destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => { Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') { if (typeof unobserve === 'function') {

View File

@ -43,8 +43,6 @@
port.postMessage(searchForAnnotations(event.data)); port.postMessage(searchForAnnotations(event.data));
} else if (requestType === 'TAGS') { } else if (requestType === 'TAGS') {
port.postMessage(searchForTags(event.data)); port.postMessage(searchForTags(event.data));
} else if (requestType === 'NOTEBOOK_ANNOTATIONS') {
port.postMessage(searchForNotebookAnnotations(event.data));
} else { } else {
throw new Error(`Unknown request ${event.data.request}`); throw new Error(`Unknown request ${event.data.request}`);
} }
@ -204,33 +202,4 @@
return message; return message;
} }
function searchForNotebookAnnotations(data) {
let results = [];
const message = {
request: 'searchForNotebookAnnotations',
results: {},
total: 0,
queryId: data.queryId
};
const matchingAnnotations = indexedAnnotationsByDomainObject[data.input.targetKeyString];
if (matchingAnnotations) {
results = matchingAnnotations.filter(matchingAnnotation => {
if (!matchingAnnotation.targets) {
return false;
}
const target = matchingAnnotation.targets[data.input.targetKeyString];
return (target && target.entryId && (target.entryId === data.input.entryId));
});
}
message.total = results.length;
message.results = results
.slice(0, data.maxResults);
return message;
}
}()); }());

View File

@ -64,6 +64,15 @@ import InMemorySearchProvider from './InMemorySearchProvider';
* to load domain objects * to load domain objects
* @memberof module:openmct * @memberof module:openmct
*/ */
/**
* @readonly
* @enum {String} SEARCH_TYPES
* @property {String} OBJECTS Search for objects
* @property {String} ANNOTATIONS Search for annotations
* @property {String} TAGS Search for tags
*/
/** /**
* Utilities for loading, saving, and manipulating domain objects. * Utilities for loading, saving, and manipulating domain objects.
* @interface ObjectAPI * @interface ObjectAPI
@ -76,7 +85,6 @@ export default class ObjectAPI {
this.SEARCH_TYPES = Object.freeze({ this.SEARCH_TYPES = Object.freeze({
OBJECTS: 'OBJECTS', OBJECTS: 'OBJECTS',
ANNOTATIONS: 'ANNOTATIONS', ANNOTATIONS: 'ANNOTATIONS',
NOTEBOOK_ANNOTATIONS: 'NOTEBOOK_ANNOTATIONS',
TAGS: 'TAGS' TAGS: 'TAGS'
}); });
this.eventEmitter = new EventEmitter(); this.eventEmitter = new EventEmitter();
@ -188,7 +196,6 @@ export default class ObjectAPI {
* @returns {Promise} a promise which will resolve when the domain object * @returns {Promise} a promise which will resolve when the domain object
* has been saved, or be rejected if it cannot be saved * has been saved, or be rejected if it cannot be saved
*/ */
get(identifier, abortSignal) { get(identifier, abortSignal) {
let keystring = this.makeKeyString(identifier); let keystring = this.makeKeyString(identifier);
@ -223,7 +230,7 @@ export default class ObjectAPI {
if (result.isMutable) { if (result.isMutable) {
result.$refresh(result); result.$refresh(result);
} else { } else {
let mutableDomainObject = this._toMutable(result); let mutableDomainObject = this.toMutable(result);
mutableDomainObject.$refresh(result); mutableDomainObject.$refresh(result);
} }
@ -300,7 +307,7 @@ export default class ObjectAPI {
} }
return this.get(identifier).then((object) => { return this.get(identifier).then((object) => {
return this._toMutable(object); return this.toMutable(object);
}); });
} }
@ -490,7 +497,7 @@ export default class ObjectAPI {
} else { } else {
//Creating a temporary mutable domain object allows other mutable instances of the //Creating a temporary mutable domain object allows other mutable instances of the
//object to be kept in sync. //object to be kept in sync.
let mutableDomainObject = this._toMutable(domainObject); let mutableDomainObject = this.toMutable(domainObject);
//Mutate original object //Mutate original object
MutableDomainObject.mutateObject(domainObject, path, value); MutableDomainObject.mutateObject(domainObject, path, value);
@ -510,15 +517,19 @@ export default class ObjectAPI {
} }
/** /**
* @private * Create a mutable domain object from an existing domain object
* @param {module:openmct.DomainObject} domainObject the object to make mutable
* @returns {MutableDomainObject} a mutable domain object that will automatically sync
* @method toMutable
* @memberof module:openmct.ObjectAPI#
*/ */
_toMutable(object) { toMutable(domainObject) {
let mutableObject; let mutableObject;
if (object.isMutable) { if (domainObject.isMutable) {
mutableObject = object; mutableObject = domainObject;
} else { } else {
mutableObject = MutableDomainObject.createMutable(object, this.eventEmitter); mutableObject = MutableDomainObject.createMutable(domainObject, this.eventEmitter);
// Check if provider supports realtime updates // Check if provider supports realtime updates
let identifier = utils.parseKeyString(mutableObject.identifier); let identifier = utils.parseKeyString(mutableObject.identifier);
@ -526,9 +537,11 @@ export default class ObjectAPI {
if (provider !== undefined if (provider !== undefined
&& provider.observe !== undefined && provider.observe !== undefined
&& this.SYNCHRONIZED_OBJECT_TYPES.includes(object.type)) { && this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
let unobserve = provider.observe(identifier, (updatedModel) => { let unobserve = provider.observe(identifier, (updatedModel) => {
if (updatedModel.persisted > mutableObject.modified) { // modified can sometimes be undefined, so make it 0 in this case
const mutableObjectModification = mutableObject.modified ?? Number.MIN_SAFE_INTEGER;
if (updatedModel.persisted > mutableObjectModification) {
//Don't replace with a stale model. This can happen on slow connections when multiple mutations happen //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. //in rapid succession and intermediate persistence states are returned by the observe function.
updatedModel = this.applyGetInterceptors(identifier, updatedModel); updatedModel = this.applyGetInterceptors(identifier, updatedModel);
@ -582,7 +595,7 @@ export default class ObjectAPI {
if (domainObject.isMutable) { if (domainObject.isMutable) {
return domainObject.$observe(path, callback); return domainObject.$observe(path, callback);
} else { } else {
let mutable = this._toMutable(domainObject); let mutable = this.toMutable(domainObject);
mutable.$observe(path, callback); mutable.$observe(path, callback);
return () => mutable.$destroy(); return () => mutable.$destroy();
@ -675,8 +688,10 @@ export default class ObjectAPI {
} }
#hasAlreadyBeenPersisted(domainObject) { #hasAlreadyBeenPersisted(domainObject) {
// modified can sometimes be undefined, so make it 0 in this case
const modified = domainObject.modified ?? Number.MIN_SAFE_INTEGER;
const result = domainObject.persisted !== undefined const result = domainObject.persisted !== undefined
&& domainObject.persisted >= domainObject.modified; && domainObject.persisted >= modified;
return result; return result;
} }

View File

@ -320,7 +320,7 @@ describe("The Object API", () => {
beforeEach(function () { beforeEach(function () {
// Duplicate object to guarantee we are not sharing object instance, which would invalidate test // Duplicate object to guarantee we are not sharing object instance, which would invalidate test
testObjectDuplicate = JSON.parse(JSON.stringify(testObject)); testObjectDuplicate = JSON.parse(JSON.stringify(testObject));
mutableSecondInstance = objectAPI._toMutable(testObjectDuplicate); mutableSecondInstance = objectAPI.toMutable(testObjectDuplicate);
}); });
afterEach(() => { afterEach(() => {

View File

@ -151,6 +151,7 @@
:key="entry.id" :key="entry.id"
:entry="entry" :entry="entry"
:domain-object="domainObject" :domain-object="domainObject"
:notebook-annotations="notebookAnnotations[entry.id]"
:selected-page="selectedPage" :selected-page="selectedPage"
:selected-section="selectedSection" :selected-section="selectedSection"
:read-only="false" :read-only="false"
@ -219,10 +220,12 @@ export default {
isRestricted: this.domainObject.type === RESTRICTED_NOTEBOOK_TYPE, isRestricted: this.domainObject.type === RESTRICTED_NOTEBOOK_TYPE,
search: '', search: '',
searchResults: [], searchResults: [],
lastLocalAnnotationCreation: 0,
showTime: this.domainObject.configuration.showTime || 0, showTime: this.domainObject.configuration.showTime || 0,
showNav: false, showNav: false,
sidebarCoversEntries: false, sidebarCoversEntries: false,
filteredAndSortedEntries: [] filteredAndSortedEntries: [],
notebookAnnotations: {}
}; };
}, },
computed: { computed: {
@ -289,7 +292,8 @@ export default {
this.getSearchResults = debounce(this.getSearchResults, 500); this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100); this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
}, },
mounted() { async mounted() {
await this.loadAnnotations();
this.formatSidebar(); this.formatSidebar();
this.setSectionAndPageFromUrl(); this.setSectionAndPageFromUrl();
@ -307,6 +311,13 @@ export default {
this.unobserveEntries(); this.unobserveEntries();
} }
Object.keys(this.notebookAnnotations).forEach(entryID => {
const notebookAnnotationsForEntry = this.notebookAnnotations[entryID];
notebookAnnotationsForEntry.forEach(notebookAnnotation => {
this.openmct.objects.destroyMutable(notebookAnnotation);
});
});
window.removeEventListener('orientationchange', this.formatSidebar); window.removeEventListener('orientationchange', this.formatSidebar);
window.removeEventListener('hashchange', this.setSectionAndPageFromUrl); window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
}, },
@ -338,6 +349,32 @@ export default {
} }
}); });
}, },
async loadAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) {
return;
}
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const query = this.openmct.objects.makeKeyString(this.domainObject.identifier);
const foundAnnotations = await this.openmct.annotation.getAnnotations(query);
foundAnnotations.forEach((foundAnnotation) => {
const targetId = Object.keys(foundAnnotation.targets)[0];
const entryId = foundAnnotation.targets[targetId].entryId;
if (!this.notebookAnnotations[entryId]) {
this.$set(this.notebookAnnotations, entryId, []);
}
const annotationExtant = this.notebookAnnotations[entryId].some((existingAnnotation) => {
return this.openmct.objects.areIdsEqual(existingAnnotation.identifier, foundAnnotation.identifier);
});
if (!annotationExtant) {
const annotationArray = this.notebookAnnotations[entryId];
const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation);
annotationArray.push(mutableAnnotation);
}
});
},
filterAndSortEntries() { filterAndSortEntries() {
const filterTime = Date.now(); const filterTime = Date.now();
const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || []; const pageEntries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage) || [];
@ -350,6 +387,10 @@ export default {
this.filteredAndSortedEntries = this.defaultSort === 'oldest' this.filteredAndSortedEntries = this.defaultSort === 'oldest'
? filteredPageEntriesByTime ? filteredPageEntriesByTime
: [...filteredPageEntriesByTime].reverse(); : [...filteredPageEntriesByTime].reverse();
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
this.loadAnnotations();
}
}, },
changeSelectedSection({ sectionId, pageId }) { changeSelectedSection({ sectionId, pageId }) {
const sections = this.sections.map(s => { const sections = this.sections.map(s => {
@ -473,14 +514,8 @@ export default {
] ]
}); });
}, },
async removeAnnotations(entryId) { removeAnnotations(entryId) {
const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier); this.openmct.annotation.deleteAnnotations(this.notebookAnnotations[entryId]);
const query = {
targetKeyString,
entryId
};
const existingAnnotation = await this.openmct.annotation.getAnnotation(query, this.openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS);
this.openmct.annotation.removeAnnotationTags(existingAnnotation);
}, },
checkEntryPos(entry) { checkEntryPos(entry) {
const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage); const entryPos = getEntryPosById(entry.id, this.domainObject, this.selectedSection, this.selectedPage);

View File

@ -84,9 +84,8 @@
<TagEditor <TagEditor
:domain-object="domainObject" :domain-object="domainObject"
:annotation-query="annotationQuery" :annotations="notebookAnnotations"
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK" :annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
:annotation-search-type="openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS"
:target-specific-details="{entryId: entry.id}" :target-specific-details="{entryId: entry.id}"
@tags-updated="timestampAndUpdate" @tags-updated="timestampAndUpdate"
/> />
@ -163,6 +162,12 @@ export default {
return {}; return {};
} }
}, },
notebookAnnotations: {
type: Array,
default() {
return [];
}
},
entry: { entry: {
type: Object, type: Object,
default() { default() {
@ -204,15 +209,6 @@ export default {
createdOnDate() { createdOnDate() {
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD'); return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
}, },
annotationQuery() {
const targetKeyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
return {
targetKeyString,
entryId: this.entry.id,
modified: this.entry.modified
};
},
createdOnTime() { createdOnTime() {
return this.formatTime(this.entry.createdOn, 'HH:mm:ss'); return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
}, },

View File

@ -6,6 +6,7 @@
<span class="c-sidebar__header-label">{{ sectionTitle }}</span> <span class="c-sidebar__header-label">{{ sectionTitle }}</span>
<button <button
class="c-icon-button c-icon-button--major icon-plus" class="c-icon-button c-icon-button--major icon-plus"
aria-label="Add Section"
@click="addSection" @click="addSection"
> >
<span class="c-list-button__label">Add</span> <span class="c-list-button__label">Add</span>
@ -33,6 +34,7 @@
<button <button
class="c-icon-button c-icon-button--major icon-plus" class="c-icon-button c-icon-button--major icon-plus"
aria-label="Add Page"
@click="addPage" @click="addPage"
> >
<span class="c-icon-button__label">Add</span> <span class="c-icon-button__label">Add</span>

View File

@ -1,22 +1,23 @@
import { isNotebookType } from './notebook-constants'; import { isAnnotationType, isNotebookType, isNotebookOrAnnotationType } from './notebook-constants';
import _ from 'lodash';
export default function (openmct) { export default function (openmct) {
const apiSave = openmct.objects.save.bind(openmct.objects); const apiSave = openmct.objects.save.bind(openmct.objects);
openmct.objects.save = async (domainObject) => { openmct.objects.save = async (domainObject) => {
if (!isNotebookType(domainObject)) { if (!isNotebookOrAnnotationType(domainObject)) {
return apiSave(domainObject); return apiSave(domainObject);
} }
const isNewMutable = !domainObject.isMutable; const isNewMutable = !domainObject.isMutable;
const localMutable = openmct.objects._toMutable(domainObject); const localMutable = openmct.objects.toMutable(domainObject);
let result; let result;
try { try {
result = await apiSave(localMutable); result = await apiSave(localMutable);
} catch (error) { } catch (error) {
if (error instanceof openmct.objects.errors.Conflict) { if (error instanceof openmct.objects.errors.Conflict) {
result = resolveConflicts(localMutable, openmct); result = await resolveConflicts(domainObject, localMutable, openmct);
} else { } else {
result = Promise.reject(error); result = Promise.reject(error);
} }
@ -30,16 +31,56 @@ export default function (openmct) {
}; };
} }
function resolveConflicts(localMutable, openmct) { function resolveConflicts(domainObject, localMutable, openmct) {
const localEntries = JSON.parse(JSON.stringify(localMutable.configuration.entries)); if (isNotebookType(domainObject)) {
return resolveNotebookEntryConflicts(localMutable, openmct);
} else if (isAnnotationType(domainObject)) {
return resolveNotebookTagConflicts(localMutable, openmct);
}
}
return openmct.objects.getMutable(localMutable.identifier).then((remoteMutable) => { async function resolveNotebookTagConflicts(localAnnotation, openmct) {
applyLocalEntries(remoteMutable, localEntries, openmct); const localClonedAnnotation = structuredClone(localAnnotation);
const remoteMutable = await openmct.objects.getMutable(localClonedAnnotation.identifier);
openmct.objects.destroyMutable(remoteMutable); // should only be one annotation per targetID, entryID, and tag; so for sanity, ensure we have the
// same targetID, entryID, and tags for this conflict
if (!(_.isEqual(remoteMutable.tags, localClonedAnnotation.tags))) {
throw new Error('Conflict on annotation\'s tag has different tags than remote');
}
return true; Object.keys(localClonedAnnotation.targets).forEach(targetKey => {
if (!remoteMutable.targets[targetKey]) {
throw new Error(`Conflict on annotation's target is missing ${targetKey}`);
}
const remoteMutableTarget = remoteMutable.targets[targetKey];
const localMutableTarget = localClonedAnnotation.targets[targetKey];
if (remoteMutableTarget.entryId !== localMutableTarget.entryId) {
throw new Error(`Conflict on annotation's entryID ${remoteMutableTarget.entryId} has a different entry Id ${localMutableTarget.entryId}`);
}
}); });
if (remoteMutable._deleted && (remoteMutable._deleted !== localClonedAnnotation._deleted)) {
// not deleting wins 😘
openmct.objects.mutate(remoteMutable, '_deleted', false);
}
openmct.objects.destroyMutable(remoteMutable);
return true;
}
async function resolveNotebookEntryConflicts(localMutable, openmct) {
if (localMutable.configuration.entries) {
const localEntries = structuredClone(localMutable.configuration.entries);
const remoteMutable = await openmct.objects.getMutable(localMutable.identifier);
applyLocalEntries(remoteMutable, localEntries, openmct);
openmct.objects.destroyMutable(remoteMutable);
}
return true;
} }
function applyLocalEntries(mutable, entries, openmct) { function applyLocalEntries(mutable, entries, openmct) {

View File

@ -1,5 +1,6 @@
export const NOTEBOOK_TYPE = 'notebook'; export const NOTEBOOK_TYPE = 'notebook';
export const RESTRICTED_NOTEBOOK_TYPE = 'restricted-notebook'; export const RESTRICTED_NOTEBOOK_TYPE = 'restricted-notebook';
export const ANNOTATION_TYPE = 'annotation';
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';
@ -9,10 +10,18 @@ export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED';
export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED'; export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED';
// these only deals with constants, figured this could skip going into a utils file // these only deals with constants, figured this could skip going into a utils file
export function isNotebookOrAnnotationType(domainObject) {
return (isNotebookType(domainObject) || isAnnotationType(domainObject));
}
export function isNotebookType(domainObject) { export function isNotebookType(domainObject) {
return [NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE].includes(domainObject.type); return [NOTEBOOK_TYPE, RESTRICTED_NOTEBOOK_TYPE].includes(domainObject.type);
} }
export function isAnnotationType(domainObject) {
return [ANNOTATION_TYPE].includes(domainObject.type);
}
export function isNotebookViewType(view) { export function isNotebookViewType(view) {
return [NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_VIEW_TYPE].includes(view); return [NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_VIEW_TYPE].includes(view);
} }

View File

@ -27,7 +27,7 @@ export default class AbstractStatusIndicator {
#configuration; #configuration;
/** /**
* @param {*} openmct the Open MCT API (proper jsdoc to come) * @param {*} openmct the Open MCT API (proper typescript doc to come)
* @param {import('@/api/user/UserAPI').UserAPIConfiguration} configuration Per-deployment status styling. See the type definition in UserAPI * @param {import('@/api/user/UserAPI').UserAPIConfiguration} configuration Per-deployment status styling. See the type definition in UserAPI
*/ */
constructor(openmct, configuration) { constructor(openmct, configuration) {

View File

@ -23,7 +23,7 @@
import CouchDocument from "./CouchDocument"; import CouchDocument from "./CouchDocument";
import CouchObjectQueue from "./CouchObjectQueue"; import CouchObjectQueue from "./CouchObjectQueue";
import { PENDING, CONNECTED, DISCONNECTED, UNKNOWN } from "./CouchStatusIndicator"; import { PENDING, CONNECTED, DISCONNECTED, UNKNOWN } from "./CouchStatusIndicator";
import { isNotebookType } from '../../notebook/notebook-constants.js'; import { isNotebookOrAnnotationType } from '../../notebook/notebook-constants.js';
const REV = "_rev"; const REV = "_rev";
const ID = "_id"; const ID = "_id";
@ -71,7 +71,7 @@ class CouchObjectProvider {
} }
onSharedWorkerMessageError(event) { onSharedWorkerMessageError(event) {
console.log('Error', event); console.error('Error', event);
} }
isSynchronizedObject(object) { isSynchronizedObject(object) {
@ -290,7 +290,7 @@ class CouchObjectProvider {
this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]); this.objectQueue[key] = new CouchObjectQueue(undefined, response[REV]);
} }
if (isNotebookType(object) || object.type === 'annotation') { if (isNotebookOrAnnotationType(object)) {
//Temporary measure until object sync is supported for all object types //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. //Always update notebook revision number because we have realtime sync, so always assume it's the latest.
this.objectQueue[key].updateRevision(response[REV]); this.objectQueue[key].updateRevision(response[REV]);

View File

@ -31,7 +31,7 @@ class CouchSearchProvider {
constructor(couchObjectProvider) { constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider; this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.NOTEBOOK_ANNOTATIONS, this.searchTypes.TAGS]; this.supportedSearchTypes = [this.searchTypes.OBJECTS, this.searchTypes.ANNOTATIONS, this.searchTypes.TAGS];
} }
supportsSearchType(searchType) { supportsSearchType(searchType) {
@ -43,8 +43,6 @@ class CouchSearchProvider {
return this.searchForObjects(query, abortSignal); return this.searchForObjects(query, abortSignal);
} else if (searchType === this.searchTypes.ANNOTATIONS) { } else if (searchType === this.searchTypes.ANNOTATIONS) {
return this.searchForAnnotations(query, abortSignal); return this.searchForAnnotations(query, abortSignal);
} else if (searchType === this.searchTypes.NOTEBOOK_ANNOTATIONS) {
return this.searchForNotebookAnnotations(query, abortSignal);
} else if (searchType === this.searchTypes.TAGS) { } else if (searchType === this.searchTypes.TAGS) {
return this.searchForTags(query, abortSignal); return this.searchForTags(query, abortSignal);
} else { } else {
@ -91,38 +89,6 @@ class CouchSearchProvider {
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
} }
searchForNotebookAnnotations({targetKeyString, entryId}, abortSignal) {
const filter = {
"selector": {
"$and": [
{
"model.type": {
"$eq": "annotation"
}
},
{
"model.annotationType": {
"$eq": "NOTEBOOK"
}
},
{
"model": {
"targets": {
}
}
}
]
}
};
filter.selector.$and[2].model.targets[targetKeyString] = {
"entryId": {
"$eq": entryId
}
};
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
searchForTags(tagsArray, abortSignal) { searchForTags(tagsArray, abortSignal) {
const filter = { const filter = {
"selector": { "selector": {
@ -130,7 +96,8 @@ class CouchSearchProvider {
{ {
"model.tags": { "model.tags": {
"$elemMatch": { "$elemMatch": {
"$eq": `${tagsArray[0]}` "$or": [
]
} }
} }
}, },
@ -142,6 +109,11 @@ class CouchSearchProvider {
] ]
} }
}; };
tagsArray.forEach(tag => {
filter.selector.$and[0]["model.tags"].$elemMatch.$or.push({
"$eq": `${tag}`
});
});
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
} }

View File

@ -244,7 +244,7 @@ define(
if (context.item && context.item.isMutable !== true) { if (context.item && context.item.isMutable !== true) {
removeMutable = true; removeMutable = true;
context.item = this.openmct.objects._toMutable(context.item); context.item = this.openmct.objects.toMutable(context.item);
} }
if (select) { if (select) {

View File

@ -51,18 +51,14 @@ export default {
}, },
inject: ['openmct'], inject: ['openmct'],
props: { props: {
annotationQuery: { annotations: {
type: Object, type: Array,
required: true required: true
}, },
annotationType: { annotationType: {
type: String, type: String,
required: true required: true
}, },
annotationSearchType: {
type: String,
required: true
},
targetSpecificDetails: { targetSpecificDetails: {
type: Object, type: Object,
required: true required: true
@ -76,7 +72,6 @@ export default {
}, },
data() { data() {
return { return {
annontation: null,
addedTags: [], addedTags: [],
userAddingTag: false userAddingTag: false
}; };
@ -92,57 +87,50 @@ export default {
} }
}, },
watch: { watch: {
annotation: { annotations: {
handler() { handler() {
this.tagsChanged(this.annotation.tags); this.annotationsChanged();
},
deep: true
},
annotationQuery: {
handler() {
this.unloadAnnotation();
this.loadAnnotation();
}, },
deep: true deep: true
} }
}, },
mounted() { mounted() {
this.loadAnnotation(); this.annotationsChanged();
},
destroyed() {
if (this.removeTagsListener) {
this.removeTagsListener();
}
}, },
methods: { methods: {
addAnnotationListener(annotation) { annotationsChanged() {
if (annotation && !this.removeTagsListener) { if (this.annotations && this.annotations.length) {
this.removeTagsListener = this.openmct.objects.observe(annotation, '*', (newAnnotation) => { this.tagsChanged();
this.tagsChanged(newAnnotation.tags);
this.annotation = newAnnotation;
});
} }
}, },
async loadAnnotation() { annotationDeletionListener(changedAnnotation) {
this.annotation = await this.openmct.annotation.getAnnotation(this.annotationQuery, this.annotationSearchType); const matchingAnnotation = this.annotations.find((possibleMatchingAnnotation) => {
this.addAnnotationListener(this.annotation); return this.openmct.objects.areIdsEqual(possibleMatchingAnnotation.identifier, changedAnnotation.identifier);
if (this.annotation && this.annotation.tags) { });
this.tagsChanged(this.annotation.tags); if (matchingAnnotation) {
matchingAnnotation._deleted = changedAnnotation._deleted;
this.userAddingTag = false;
this.tagsChanged();
} }
}, },
unloadAnnotation() { tagsChanged() {
if (this.removeTagsListener) { // gather tags from annotations
this.removeTagsListener(); const tagsFromAnnotations = this.annotations.flatMap((annotation) => {
this.removeTagsListener = undefined; if (annotation._deleted) {
} return [];
}, } else {
tagsChanged(newTags) { return annotation.tags;
if (newTags.length < this.addedTags.length) { }
this.addedTags = this.addedTags.slice(0, newTags.length); }).filter((tag, index, array) => {
return array.indexOf(tag) === index;
});
if (tagsFromAnnotations.length !== this.addedTags.length) {
this.addedTags = this.addedTags.slice(0, tagsFromAnnotations.length);
} }
for (let index = 0; index < newTags.length; index += 1) { for (let index = 0; index < tagsFromAnnotations.length; index += 1) {
this.$set(this.addedTags, index, newTags[index]); this.$set(this.addedTags, index, tagsFromAnnotations[index]);
} }
}, },
addTag() { addTag() {
@ -153,23 +141,27 @@ export default {
this.userAddingTag = true; this.userAddingTag = true;
}, },
async tagRemoved(tagToRemove) { async tagRemoved(tagToRemove) {
const result = await this.openmct.annotation.removeAnnotationTag(this.annotation, tagToRemove); // Soft delete annotations that match tag instead
this.$emit('tags-updated'); const annotationsToDelete = this.annotations.filter((annotation) => {
return annotation.tags.includes(tagToRemove);
});
const result = await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
this.$emit('tags-updated', annotationsToDelete);
return result; return result;
}, },
async tagAdded(newTag) { async tagAdded(newTag) {
const annotationWasCreated = this.annotation === null || this.annotation === undefined; // Either undelete an annotation, or create one (1) new annotation
this.annotation = await this.openmct.annotation.addAnnotationTag(this.annotation, const existingAnnotation = this.annotations.find((annotation) => {
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag); return annotation.tags.includes(newTag);
if (annotationWasCreated) { });
this.addAnnotationListener(this.annotation);
} const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation,
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
this.tagsChanged(this.annotation.tags);
this.userAddingTag = false; this.userAddingTag = false;
this.$emit('tags-updated'); this.$emit('tags-updated', createdAnnotation);
} }
} }
}; };

View File

@ -37,7 +37,10 @@
class="c-tag" class="c-tag"
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }" :style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
> >
<div class="c-tag__label">{{ selectedTagLabel }} </div> <div
class="c-tag__label"
aria-label="Tag"
>{{ selectedTagLabel }} </div>
<button <button
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle" class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
@click="removeTag" @click="removeTag"

View File

@ -19,7 +19,7 @@ module.exports = merge(common, {
// See: https://webpack.js.org/configuration/watch/#watchoptions-exclude // See: https://webpack.js.org/configuration/watch/#watchoptions-exclude
ignored: [ ignored: [
'**/{node_modules,dist,docs,e2e}', // All files in node_modules, dist, docs, e2e, '**/{node_modules,dist,docs,e2e}', // All files in node_modules, dist, docs, e2e,
'**/{*.yml,Procfile,webpack*.js,babel*.js,package*.json,tsconfig.json,jsdoc.json}', // Config files '**/{*.yml,Procfile,webpack*.js,babel*.js,package*.json,tsconfig.json}', // Config files
'**/*.{sh,md,png,ttf,woff,svg}', // Non source files '**/*.{sh,md,png,ttf,woff,svg}', // Non source files
'**/.*' // dotfiles and dotfolders '**/.*' // dotfiles and dotfolders
] ]