Add markdown to notebook entries (#7084)

* try marked out

* fix url validation

* now rendering blockquotes properly

* add abbrv, link titles, and strikethrough

* fix tests and lint

* Closes #6060
- CSS resets and styling for markdown-related HTML markup in Notebook entries.
- Better styling and cursor affordances for Notebook entry selection and editing interaction flow.

* add line breaks option

* Closes #6060
- Tab

* Closes #6060
- Conversion of contenteditable-div to textarea started.
- Stubbed in textarea with styles.

* have it markdown with a textarea and adjust size automatically

* Closes #6060
- Padding added back to text `div` area.

* Closes #6060
- Styles added to support Shift Log and hover behavior for entries on locked pages.
- Removed `--major` styling from Shift Log Commit Entries button
to reduce confusion with entry commit button.
- CSS code cleanups.

* two step focus/edit. also scroll into view for editing

* add markdown, strip all tags, and truncate

* lint

* remove unneeded code

* fix notebook entry, selected page may also be null

* fix existing notebook tests

* lint

* fix whitelist

* readd whitelist

* lint

* fix link tests

* fix tests

* fix tagging test

* add some markdown test

* get rid of pause

* add another sanitization step

---------

Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
This commit is contained in:
Scott Bell 2023-10-03 00:28:02 +02:00 committed by GitHub
parent 2243381d52
commit 6947c912a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 960 additions and 740 deletions

View File

@ -34,7 +34,8 @@ async function enterTextEntry(page, text) {
await page.locator(NOTEBOOK_DROP_AREA).click();
// enter text
await page.locator('[aria-label="Notebook Entry"].is-selected div.c-ne__text').fill(text);
await page.getByLabel('Notebook Entry Display').last().click();
await page.getByLabel('Notebook Entry Input').last().fill(text);
await commitEntry(page);
}
@ -52,7 +53,6 @@ async function dragAndDropEmbed(page, notebookObject) {
await page.click('button[title="Show selected item in tree"]');
// Drag and drop the SWG into the notebook
await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA);
await commitEntry(page);
}
/**

View File

@ -279,8 +279,8 @@ test.describe('Notebook entry tests', () => {
// Click .c-notebook__drag-area
await page.locator('.c-notebook__drag-area').click();
await expect(page.locator('[aria-label="Notebook Entry Input"]')).toBeVisible();
await expect(page.locator('[aria-label="Notebook Entry"]')).toHaveClass(/is-selected/);
await expect(page.getByLabel('Notebook Entry Display')).toBeVisible();
await expect(page.getByLabel('Notebook Entry', { exact: true })).toHaveClass(/is-selected/);
});
test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({
page
@ -369,6 +369,8 @@ test.describe('Notebook entry tests', () => {
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await validLink.count()).toBe(1);
// Start waiting for popup before clicking. Note no await.
const popupPromise = page.waitForEvent('popup');
@ -378,8 +380,6 @@ test.describe('Notebook entry tests', () => {
// Wait for the popup to load.
await popup.waitForLoadState();
expect.soft(popup.url()).toContain('www.google.com');
expect(await validLink.count()).toBe(1);
});
test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({
page
@ -447,6 +447,8 @@ test.describe('Notebook entry tests', () => {
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await validLink.count()).toBe(1);
// Start waiting for popup before clicking. Note no await.
const popupPromise = page.waitForEvent('popup');
@ -456,8 +458,6 @@ test.describe('Notebook entry tests', () => {
// Wait for the popup to load.
await popup.waitForLoadState();
expect.soft(popup.url()).toContain('www.google.com');
expect(await validLink.count()).toBe(1);
});
test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({
page
@ -482,4 +482,42 @@ test.describe('Notebook entry tests', () => {
expect.soft(await sanitizedLink.count()).toBe(1);
expect(await unsanitizedLink.count()).toBe(0);
});
test('Can add markdown to a notebook entry', async ({ page }) => {
await page.goto(notebookObject.url);
// Headers
const headerMarkdown = `# Big Header\n## Large Header\n### Medium Header\n#### Small Header`;
await nbUtils.enterTextEntry(page, headerMarkdown);
await expect(page.getByRole('heading', { name: 'Big Header' })).toBeVisible();
// Text markup
const markupText =
'**This is bold.** _This is italic_. `This is code`. ~This is strikethrough~';
await nbUtils.enterTextEntry(page, markupText);
await expect(page.locator('strong:has-text("This is bold.")')).toBeVisible();
// Tables
const tablesText = '|Col 1|Col 2|Col3|\n|-|-|-|\n |Value 1|Value 2|Value 3|\n';
await nbUtils.enterTextEntry(page, tablesText);
await expect(page.getByRole('cell', { name: 'Value 2' })).toBeVisible();
// Links
const linksText =
'Raw links https://www.google.com and Markdown links like [Google](https://www.google.com) work';
await nbUtils.enterTextEntry(page, linksText);
await expect(page.getByRole('link', { name: 'https://www.google.com' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Google', exact: true })).toBeVisible();
// Lists
const listsText = '- List item 1\n - Item 1A \n- List Item 2\n 1. Order 1\n 1. Order 2\n';
await nbUtils.enterTextEntry(page, listsText);
const childItem = page.locator('li:has-text("List Item 2") ol li:has-text("Order 2")');
await expect(childItem).toBeVisible();
// Blocks
const blockTest = '```javascript\nconst foo = "bar";\nconst bar = "foo";\n```';
await nbUtils.enterTextEntry(page, blockTest);
const codeBlock = page.locator('code.language-javascript:has-text("const foo = \\"bar\\";")');
await expect(codeBlock).toBeVisible();
});
});

View File

@ -192,8 +192,6 @@ test.describe('Snapshot image tests', () => {
// be sure that entry was created
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
// click on image (need to click twice to focus)
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
// expect large image to be displayed

View File

@ -147,16 +147,14 @@ test.describe('Tagging in Notebooks @addInit', () => {
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5823'
});
await createNotebookEntryAndTags(page);
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = 1`;
await page.locator(entryLocator).click();
await page.locator(entryLocator).fill(`An entry without tags`);
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter');
await page.getByLabel('Notebook Entry Display').last().click();
await page.getByLabel('Notebook Entry Input').fill(`An entry without tags`);
await page.locator('.c-ne__save-button > button').click();
await page.hover('[aria-label="Notebook Entry Input"] >> nth=1');
await page.hover('[aria-label="Notebook Entry Display"] >> nth=1');
await page.locator('button[title="Delete this entry"]').last().click();
await expect(
page.locator('text=This action will permanently delete this entry. Do you wish to continue?')

View File

@ -131,8 +131,9 @@ test.describe('Performance tests', () => {
await page.evaluate(() => window.performance.mark('new-notebook-entry-created'));
// Enter Notebook Entry text
await page.locator('div.c-ne__text').last().fill('New Entry');
await page.keyboard.press('Enter');
await page.getByLabel('Notebook Entry').last().click();
await page.getByLabel('Notebook Entry Input').last().fill('New Entry');
await page.locator('.c-ne__save-button').click();
await page.evaluate(() => window.performance.mark('new-notebook-entry-filled'));
//Individual Notebook Entry Search

View File

@ -52,6 +52,7 @@
"karma-webpack": "5.0.0",
"location-bar": "3.0.1",
"lodash": "4.17.21",
"marked": "9.0.3",
"mini-css-extract-plugin": "2.7.6",
"moment": "2.29.4",
"moment-duration-format": "2.3.2",

View File

@ -19,7 +19,6 @@
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-notebook" :class="[{ 'c-notebook--restricted': isRestricted }]">
<div class="c-notebook__head">
@ -62,7 +61,13 @@
@selectSection="selectSection"
@toggleNav="toggleNav"
/>
<div class="c-notebook__page-view">
<div
class="c-notebook__page-view"
:class="{
'c-notebook--page-locked': selectedPage?.isLocked,
'c-notebook--page-unlocked': !selectedPage?.isLocked
}"
>
<div class="c-notebook__page-view__header">
<button
class="c-notebook__toggle-nav-button c-icon-button c-icon-button--major icon-menu-hamburger"
@ -107,9 +112,9 @@
class="c-telemetry-table__progress-bar"
:model="{ progressPerc: null }"
/>
<div v-if="selectedPage && selectedPage.isLocked" class="c-notebook__page-locked">
<div v-if="selectedPage && selectedPage.isLocked" class="c-notebook__page-locked-message">
<div class="icon-lock"></div>
<div class="c-notebook__page-locked__message">
<div class="c-notebook__page-locked-message-text">
This page has been committed and cannot be modified or removed
</div>
</div>
@ -142,7 +147,7 @@
class="c-notebook__commit-entries-control"
>
<button
class="c-button c-button--major commit-button icon-lock"
class="c-button commit-button icon-lock"
title="Commit entries and lock this page from further changes"
@click="lockPage()"
>
@ -185,6 +190,7 @@ import {
import NotebookEntry from './NotebookEntry.vue';
import SearchResults from './SearchResults.vue';
import Sidebar from './Sidebar.vue';
function objectCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}

View File

@ -23,6 +23,7 @@
<template>
<div
ref="entry"
class="c-notebook__entry c-ne has-local-controls"
aria-label="Notebook Entry"
:class="{ locked: isLocked, 'is-selected': isSelectedEntry, 'is-editing': editMode }"
@ -56,7 +57,7 @@
<template v-if="readOnly && result">
<div :id="entry.id" class="c-ne__text highlight" tabindex="0">
<TextHighlight
:text="formatValidUrls(entry.text)"
:text="convertMarkDownToHtml(entry.text)"
:highlight="highlightText"
:highlight-class="'search-highlight'"
/>
@ -64,18 +65,26 @@
</template>
<template v-else-if="!isLocked">
<div
v-if="!editMode"
v-bind.prop="formattedText"
:id="entry.id"
class="c-ne__text c-ne__input"
tabindex="-1"
aria-label="Notebook Entry Display"
class="c-ne__text"
@mouseover="checkEditability($event)"
@click="editingEntry($event)"
></div>
<textarea
v-else
:id="entry.id"
ref="entryInput"
v-model="entry.text"
class="c-ne__input"
aria-label="Notebook Entry Input"
tabindex="-1"
:contenteditable="canEdit"
@mouseover="checkEditability($event)"
@mouseleave="canEdit = true"
@mousedown="preventFocusIfNotSelected($event)"
@focus="editingEntry()"
@blur="updateEntryValue($event)"
></div>
></textarea>
<div v-if="editMode" class="c-ne__save-button">
<button class="c-button c-button--major icon-check"></button>
</div>
@ -139,6 +148,7 @@
<script>
import _ from 'lodash';
import { Marked } from 'marked';
import Moment from 'moment';
import sanitizeHtml from 'sanitize-html';
@ -151,11 +161,49 @@ import {
import NotebookEmbed from './NotebookEmbed.vue';
const SANITIZATION_SCHEMA = {
allowedTags: [],
allowedAttributes: {}
allowedTags: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'p',
'a',
'ul',
'ol',
'li',
'b',
'i',
'strong',
'em',
's',
'strike',
'code',
'hr',
'br',
'div',
'table',
'thead',
'caption',
'tbody',
'tr',
'th',
'td',
'pre',
'del',
'ins',
'mark',
'abbr'
],
allowedAttributes: {
a: ['href', 'target', 'class', 'title'],
code: ['class'],
abbr: ['title']
}
};
const URL_REGEX =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
const UNKNOWN_USER = 'Unknown';
export default {
@ -236,16 +284,15 @@ export default {
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
},
formattedText() {
// remove ANY tags
const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
const text = this.entry.text;
if (this.editMode || this.urlWhitelist.length === 0) {
if (this.editMode) {
return { innerText: text };
}
const html = this.formatValidUrls(text);
const markDownHtml = this.convertMarkDownToHtml(text);
return { innerHTML: html };
return { innerHTML: markDownHtml };
},
isSelectedEntry() {
return this.selectedEntryId === this.entry.id;
@ -276,7 +323,23 @@ export default {
return text;
}
},
watch: {
editMode() {
this.$nextTick(() => {
// waiting for textarea to be rendered
this.$refs.entryInput?.focus();
this.adjustTextareaHeight();
});
}
},
beforeMount() {
this.marked = new Marked();
this.renderer = new this.marked.Renderer();
},
mounted() {
const originalLinkRenderer = this.renderer.link;
this.renderer.link = this.validateLink.bind(this, originalLinkRenderer);
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
if (this.$refs.embedsWrapper) {
@ -309,6 +372,41 @@ export default {
this.manageEmbedLayout();
},
convertMarkDownToHtml(text) {
let markDownHtml = this.marked.parse(text, {
breaks: true,
renderer: this.renderer
});
markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA);
return markDownHtml;
},
adjustTextareaHeight() {
if (this.$refs.entryInput) {
this.$refs.entryInput.style.height = 'auto';
this.$refs.entryInput.style.height = `${this.$refs?.entryInput.scrollHeight}px`;
this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
},
validateLink(originalLinkRenderer, href, title, text) {
try {
const domain = new URL(href).hostname;
const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => {
return domain.endsWith(partialDomain);
});
if (!urlIsWhitelisted) {
return text;
}
const linkHtml = originalLinkRenderer.call(this.renderer, href, title, text);
const linkHtmlWithTarget = linkHtml.replace(
/^<a /,
'<a class="c-hyperlink" target="_blank"'
);
return linkHtmlWithTarget;
} catch (error) {
// had error parsing this URL, just return the text
return text;
}
},
cancelEditMode(event) {
const isEditing = this.openmct.editor.isEditing();
if (isEditing) {
@ -333,22 +431,6 @@ export default {
deleteEntry() {
this.$emit('deleteEntry', this.entry.id);
},
formatValidUrls(text) {
return text.replace(URL_REGEX, (match) => {
const url = new URL(match);
const domain = url.hostname;
let result = match;
let isMatch = this.urlWhitelist.find((partialDomain) => {
return domain.endsWith(partialDomain);
});
if (isMatch) {
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
}
return result;
});
},
manageEmbedLayout() {
if (this.$refs.embeds) {
const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;
@ -417,9 +499,6 @@ export default {
return position;
},
forceBlur(event) {
event.target.blur();
},
formatTime(unixTime, timeFormat) {
return Moment.utc(unixTime).format(timeFormat);
},
@ -474,29 +553,26 @@ export default {
this.$emit('updateEntry', this.entry);
},
preventFocusIfNotSelected($event) {
if (!this.isSelectedEntry) {
$event.preventDefault();
// blur the previous focused entry if clicking on non selected entry input
const focusedElementId = document.activeElement?.id;
if (focusedElementId !== this.entry.id) {
document.activeElement.blur();
}
editingEntry(event) {
this.selectAndEmitEntry(event, this.entry);
if (this.isSelectedEntry) {
// selected and click, so we're ready to edit
this.selectAndEmitEntry(event, this.entry);
this.editMode = true;
this.adjustTextareaHeight();
this.$emit('editingEntry');
}
},
editingEntry() {
this.editMode = true;
this.$emit('editingEntry');
},
updateEntryValue($event) {
this.editMode = false;
const value = $event.target.innerText;
this.entry.text = sanitizeHtml(value, SANITIZATION_SCHEMA);
const rawEntryValue = $event.target.value;
const sanitizeInput = sanitizeHtml(rawEntryValue, { allowedAttributes: [], allowedTags: [] });
this.entry.text = sanitizeInput;
this.timestampAndUpdate();
},
selectAndEmitEntry(event, entry) {
selectEntry({
element: event.currentTarget,
element: this.$refs.entry,
entryId: entry.id,
domainObject: this.domainObject,
openmct: this.openmct,

View File

@ -220,7 +220,7 @@ describe('Notebook plugin:', () => {
const notebookEntryElements = element.querySelectorAll('.c-notebook__entry');
const firstEntryText = getEntryText(0);
expect(notebookEntryElements.length).toBe(2);
expect(firstEntryText.innerText).toBe('First Test Entry');
expect(firstEntryText.innerText.trim()).toBe('First Test Entry');
});
describe('synchronization', () => {
@ -232,13 +232,13 @@ describe('Notebook plugin:', () => {
});
it('updates an entry when another user modifies it', () => {
expect(getEntryText(0).innerText).toBe('First Test Entry');
expect(getEntryText(0).innerText.trim()).toBe('First Test Entry');
objectCloneToSyncFrom.configuration.entries['test-section-1']['test-page-1'][0].text =
'Modified entry text';
objectProviderObserver(objectCloneToSyncFrom);
return Vue.nextTick().then(() => {
expect(getEntryText(0).innerText).toBe('Modified entry text');
expect(getEntryText(0).innerText.trim()).toBe('Modified entry text');
});
});

File diff suppressed because it is too large Load Diff

View File

@ -53,6 +53,9 @@
</template>
<script>
import { Marked } from 'marked';
import sanitizeHtml from 'sanitize-html';
import { identifierToString } from '../../../../src/tools/url';
import ObjectPath from '../../components/ObjectPath.vue';
import PreviewAction from '../../preview/PreviewAction';
@ -81,30 +84,13 @@ export default {
},
getResultName() {
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK) {
const targetID = Object.keys(this.result.targets)[0];
const entryIdToFind = this.result.targets[targetID].entryId;
const notebookModel = this.result.targetModels[0].configuration.entries;
const sections = Object.values(notebookModel);
for (const section of sections) {
const pages = Object.values(section);
for (const entries of pages) {
for (const entry of entries) {
if (entry.id === entryIdToFind) {
return entry.text;
}
}
}
}
return 'Could not find any matching Notebook entries';
const previewText = this.getNotebookPreviewText(this.result);
return previewText;
} else if (
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
) {
const targetID = Object.keys(this.result.targets)[0];
const { layerName, name } = this.result.targets[targetID];
return layerName ? `${layerName} - ${name}` : name;
const previewText = this.getGeospatialPreviewText(this.result);
return previewText;
} else {
return this.result.targetModels[0].name;
}
@ -119,6 +105,9 @@ export default {
return this.result.fullTagModels[0].foregroundColor;
}
},
beforeMount() {
this.marked = new Marked();
},
mounted() {
this.previewAction = new PreviewAction(this.openmct);
this.previewAction.on('isVisible', this.togglePreviewState);
@ -129,6 +118,48 @@ export default {
this.openmct.selection.off('change', this.fireAnnotationSelection);
},
methods: {
getNotebookEntryTextById(entryIdToFind, notebookModel) {
const sections = Object.values(notebookModel);
for (const section of sections) {
const pages = Object.values(section);
for (const entries of pages) {
for (const entry of entries) {
if (entry.id === entryIdToFind) {
return entry.text;
}
}
}
}
return null;
},
getNotebookPreviewText(result) {
const targetID = Object.keys(this.result.targets)[0];
const entryIdToFind = this.result.targets[targetID].entryId;
const notebookModel = this.result.targetModels[0].configuration.entries;
const entryText = this.getNotebookEntryTextById(entryIdToFind, notebookModel);
if (entryText === null) {
return 'Could not find any matching Notebook entries';
}
const markDownHtml = this.marked.parse(entryText, {
breaks: true
});
// strip everything
const cleanedHtml = sanitizeHtml(markDownHtml, { allowedAttributes: [], allowedTags: [] });
// strip to 64 characters
let truncatedText = cleanedHtml.substring(0, 64);
// add ellipsis if necessary
if (truncatedText.length < entryText.length) {
truncatedText = `${truncatedText}...`;
}
return truncatedText;
},
getGeospatialPreviewText(result) {
const targetID = Object.keys(this.result.targets)[0];
const { layerName, name } = this.result.targets[targetID];
return layerName ? `${layerName} - ${name}` : name;
},
clickedResult(event) {
const objectPath = this.domainObject.originalPath;
if (this.openmct.editor.isEditing()) {