Commit da813abf authored by Denys Mishunov's avatar Denys Mishunov

Merge branch 'yaml-source-editor-extenstion' into 'master'

Add Yaml Source Editor Extension

See merge request gitlab-org/gitlab!72764
parents 0d51189a fe4f8022
......@@ -36,12 +36,24 @@ export class SourceEditorExtension {
});
}
static highlightLines(instance) {
const { hash } = window.location;
if (!hash) {
return;
}
const [start, end] = hash.replace(hashRegexp, '').split('-');
static removeHighlights(instance) {
Object.assign(instance, {
lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
});
}
/**
* Returns a function that can only be invoked once between
* each browser screen repaint.
* @param {Object} instance - The Source Editor instance
* @param {Array} bounds - The [start, end] array with start
* and end coordinates for highlighting
*/
static highlightLines(instance, bounds = null) {
const [start, end] =
bounds && Array.isArray(bounds)
? bounds
: window.location.hash?.replace(hashRegexp, '').split('-');
let startLine = start ? parseInt(start, 10) : null;
let endLine = end ? parseInt(end, 10) : startLine;
if (endLine < startLine) {
......@@ -51,15 +63,12 @@ export class SourceEditorExtension {
window.requestAnimationFrame(() => {
instance.revealLineInCenter(startLine);
Object.assign(instance, {
lineDecorations: instance.deltaDecorations(
[],
[
{
range: new Range(startLine, 1, endLine, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
],
),
lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], [
{
range: new Range(startLine, 1, endLine, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
]),
});
});
}
......
import { toPath } from 'lodash';
import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml';
import { findPair } from 'yaml/util';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
export class YamlEditorExtension extends SourceEditorExtension {
/**
* Extends the source editor with capabilities for yaml files.
*
* @param { Instance } instance Source Editor Instance
* @param { boolean } enableComments Convert model nodes with the comment
* pattern to comments?
* @param { string } highlightPath Add a line highlight to the
* node specified by this e.g. `"foo.bar[0]"`
* @param { * } model Any JS Object that will be stringified and used as the
* editor's value. Equivalent to using `setDataModel()`
* @param options SourceEditorExtension Options
*/
constructor({
instance,
enableComments = false,
highlightPath = null,
model = null,
...options
} = {}) {
super({
instance,
options: {
...options,
enableComments,
highlightPath,
},
});
if (model) {
YamlEditorExtension.initFromModel(instance, model);
}
instance.onDidChangeModelContent(() => instance.onUpdate());
}
/**
* @private
*/
static initFromModel(instance, model) {
const doc = new Document(model);
if (instance.options.enableComments) {
YamlEditorExtension.transformComments(doc);
}
instance.setValue(doc.toString());
}
/**
* @private
* This wraps long comments to a maximum line length of 80 chars.
*
* The `yaml` package does not currently wrap comments. This function
* is a local workaround and should be deprecated if
* https://github.com/eemeli/yaml/issues/322
* is resolved.
*/
static wrapCommentString(string, level = 0) {
if (!string) {
return null;
}
if (level < 0 || Number.isNaN(parseInt(level, 10))) {
throw Error(`Invalid value "${level}" for variable \`level\``);
}
const maxLineWidth = 80;
const indentWidth = 2;
const commentMarkerWidth = '# '.length;
const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth;
const lines = [[]];
string.split(' ').forEach((word) => {
const currentLine = lines.length - 1;
if ([...lines[currentLine], word].join(' ').length <= maxLength) {
lines[currentLine].push(word);
} else {
lines.push([word]);
}
});
return lines.map((line) => ` ${line.join(' ')}`).join('\n');
}
/**
* @private
*
* This utilizes `yaml`'s `visit` function to transform nodes with a
* comment key pattern to actual comments.
*
* In Objects, a key of '#' will be converted to a comment at the top of a
* property. Any key following the pattern `#|<some key>` will be placed
* right before `<some key>`.
*
* In Arrays, any string that starts with # (including the space), will
* be converted to a comment at the position it was in.
*
* @param { Document } doc
* @returns { Document }
*/
static transformComments(doc) {
const getLevel = (path) => {
const { length } = path.filter((x) => isCollection(x));
return length ? length - 1 : 0;
};
visit(doc, {
Pair(_, pair, path) {
const key = pair.key.value;
// If the key is = '#', we add the value as a comment to the parent
// We can then remove the node.
if (key === '#') {
Object.assign(path[path.length - 1], {
commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)),
});
return visit.REMOVE;
}
// If the key starts with `#|`, we want to add a comment to the
// corresponding property. We can then remove the node.
if (key.startsWith('#|')) {
const targetProperty = key.split('|')[1];
const target = findPair(path[path.length - 1].items, targetProperty);
if (target) {
target.key.commentBefore = YamlEditorExtension.wrapCommentString(
pair.value.value,
getLevel(path),
);
}
return visit.REMOVE;
}
return undefined; // If the node is not a comment, do nothing with it
},
// Sequence is basically an array
Seq(_, node, path) {
let comment = null;
const items = node.items.flatMap((child) => {
if (comment) {
Object.assign(child, { commentBefore: comment });
comment = null;
}
if (
isScalar(child) &&
child.value &&
child.value.startsWith &&
child.value.startsWith('#')
) {
const commentValue = child.value.replace(/^#\s?/, '');
comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path));
return [];
}
return child;
});
Object.assign(node, { items });
// Adding a comment in case the last one is a comment
if (comment) {
Object.assign(node, { comment });
}
},
});
return doc;
}
/**
* Get the editor's value parsed as a `Document` as defined by the `yaml`
* package
* @returns {Document}
*/
getDoc() {
return parseDocument(this.getValue());
}
/**
* Accepts a `Document` as defined by the `yaml` package and
* sets the Editor's value to a stringified version of it.
* @param { Document } doc
*/
setDoc(doc) {
if (this.options.enableComments) {
YamlEditorExtension.transformComments(doc);
}
if (!this.getValue()) {
this.setValue(doc.toString());
} else {
this.updateValue(doc.toString());
}
}
/**
* Returns the parsed value of the Editor's content as JS.
* @returns {*}
*/
getDataModel() {
return this.getDoc().toJS();
}
/**
* Accepts any JS Object and sets the Editor's value to a stringified version
* of that value.
*
* @param value
*/
setDataModel(value) {
this.setDoc(new Document(value));
}
/**
* Method to be executed when the Editor's <TextModel> was updated
*/
onUpdate() {
if (this.options.highlightPath) {
this.highlight(this.options.highlightPath);
}
}
/**
* Set the editors content to the input without recreating the content model.
*
* @param blob
*/
updateValue(blob) {
// Using applyEdits() instead of setValue() ensures that tokens such as
// highlighted lines aren't deleted/recreated which causes a flicker.
const model = this.getModel();
model.applyEdits([
{
// A nice improvement would be to replace getFullModelRange() with
// a range of the actual diff, avoiding re-formatting the document,
// but that's something for a later iteration.
range: model.getFullModelRange(),
text: blob,
},
]);
}
/**
* Add a line highlight style to the node specified by the path.
*
* @param {string|null|false} path A path to a node of the Editor's value,
* e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all
* highlights.
*/
highlight(path) {
if (this.options.highlightPath === path) return;
if (!path) {
SourceEditorExtension.removeHighlights(this);
} else {
const res = this.locate(path);
SourceEditorExtension.highlightLines(this, res);
}
this.options.highlightPath = path || null;
}
/**
* Return the line numbers of a certain node identified by `path` within
* the yaml.
*
* @param {string} path A path to a node, eg. `foo.bar[0]`
* @returns {number[]} Array following the schema `[firstLine, lastLine]`
* (both inclusive)
*
* @throws {Error} Will throw if the path is not found inside the document
*/
locate(path) {
if (!path) throw Error(`No path provided.`);
const blob = this.getValue();
const doc = parseDocument(blob);
const pathArray = toPath(path);
if (!doc.getIn(pathArray)) {
throw Error(`The node ${path} could not be found inside the document.`);
}
const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1));
let startChar;
let endChar;
if (isMap(parentNode)) {
const node = parentNode.items.find(
(item) => item.key.value === pathArray[pathArray.length - 1],
);
[startChar] = node.key.range;
[, , endChar] = node.value.range;
} else {
const node = doc.getIn(pathArray);
[startChar, , endChar] = node.range;
}
const startSlice = blob.slice(0, startChar);
const endSlice = blob.slice(0, endChar);
const startLine = (startSlice.match(/\n/g) || []).length + 1;
const endLine = (endSlice.match(/\n/g) || []).length;
return [startLine, endLine];
}
}
......@@ -148,7 +148,10 @@ describe('The basis for an Source Editor extension', () => {
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
};
const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' };
const defaultDecorationOptions = {
isWholeLine: true,
className: 'active-line-text',
};
useFakeRequestAnimationFrame();
......@@ -157,18 +160,22 @@ describe('The basis for an Source Editor extension', () => {
});
it.each`
desc | hash | shouldReveal | expectedRange
${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]}
${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]}
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]}
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]}
${'does not highlight if there is no hash'} | ${''} | ${false} | ${null}
${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null}
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null}
`('$desc', ({ hash, shouldReveal, expectedRange } = {}) => {
desc | hash | bounds | shouldReveal | expectedRange
${'properly decorates a single line'} | ${'#L10'} | ${undefined} | ${true} | ${[10, 1, 10, 1]}
${'properly decorates multiple lines'} | ${'#L7-42'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${undefined} | ${true} | ${[7, 1, 7, 1]}
${'does not highlight if there is no hash'} | ${''} | ${undefined} | ${false} | ${null}
${'does not highlight if the hash is undefined'} | ${undefined} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${undefined} | ${false} | ${null}
${'highlights lines if bounds are passed'} | ${undefined} | ${[17, 42]} | ${true} | ${[17, 1, 42, 1]}
${'highlights one line if bounds has a single value'} | ${undefined} | ${[17]} | ${true} | ${[17, 1, 17, 1]}
${'does not highlight if bounds is invalid'} | ${undefined} | ${[Number.NaN]} | ${false} | ${null}
${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]}
`('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => {
window.location.hash = hash;
SourceEditorExtension.highlightLines(instance);
SourceEditorExtension.highlightLines(instance, bounds);
if (!shouldReveal) {
expect(revealSpy).not.toHaveBeenCalled();
expect(decorationsSpy).not.toHaveBeenCalled();
......@@ -193,6 +200,43 @@ describe('The basis for an Source Editor extension', () => {
SourceEditorExtension.highlightLines(instance);
expect(instance.lineDecorations).toBe('foo');
});
it('replaces existing line highlights', () => {
const oldLineDecorations = [
{
range: new Range(1, 1, 20, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
const newLineDecorations = [
{
range: new Range(7, 1, 10, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
instance.lineDecorations = oldLineDecorations;
SourceEditorExtension.highlightLines(instance, [7, 10]);
expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations);
});
});
describe('removeHighlights', () => {
const decorationsSpy = jest.fn();
const lineDecorations = [
{
range: new Range(1, 1, 20, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
const instance = {
deltaDecorations: decorationsSpy,
lineDecorations,
};
it('removes all existing decorations', () => {
SourceEditorExtension.removeHighlights(instance);
expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []);
});
});
describe('setupLineLinking', () => {
......
import { Document } from 'yaml';
import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
const getEditorInstance = (editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>');
return new SourceEditor().createInstance({
el: document.getElementById('editor'),
blobPath: '.gitlab-ci.yml',
language: 'yaml',
...editorInstanceOptions,
});
};
const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>');
const instance = getEditorInstance(editorInstanceOptions);
instance.use(new YamlEditorExtension({ instance, ...extensionOptions }));
// Remove the below once
// https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved
if (editorInstanceOptions.value && !extensionOptions.model) {
instance.setValue(editorInstanceOptions.value);
}
return instance;
};
describe('YamlCreatorExtension', () => {
describe('constructor', () => {
it('saves constructor options', () => {
const instance = getEditorInstanceWithExtension({
highlightPath: 'foo',
enableComments: true,
});
expect(instance).toEqual(
expect.objectContaining({
options: expect.objectContaining({
highlightPath: 'foo',
enableComments: true,
}),
}),
);
});
it('dumps values loaded with the model constructor options', () => {
const model = { foo: 'bar' };
const expected = 'foo: bar\n';
const instance = getEditorInstanceWithExtension({ model });
expect(instance.getDoc().get('foo')).toBeDefined();
expect(instance.getValue()).toEqual(expected);
});
it('registers the onUpdate() function', () => {
const instance = getEditorInstance();
const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent');
instance.use(new YamlEditorExtension({ instance }));
expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
});
it("If not provided with a load constructor option, it will parse the editor's value", () => {
const editorValue = 'foo: bar';
const instance = getEditorInstanceWithExtension({}, { value: editorValue });
expect(instance.getDoc().get('foo')).toBeDefined();
});
it("Prefers values loaded with the load constructor option over the editor's existing value", () => {
const editorValue = 'oldValue: this should be overriden';
const model = { thisShould: 'be the actual value' };
const expected = 'thisShould: be the actual value\n';
const instance = getEditorInstanceWithExtension({ model }, { value: editorValue });
expect(instance.getDoc().get('oldValue')).toBeUndefined();
expect(instance.getValue()).toEqual(expected);
});
});
describe('initFromModel', () => {
const model = { foo: 'bar', 1: 2, abc: ['def'] };
const doc = new Document(model);
it('should call transformComments if enableComments is true', () => {
const instance = getEditorInstanceWithExtension({ enableComments: true });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
YamlEditorExtension.initFromModel(instance, model);
expect(transformComments).toHaveBeenCalled();
});
it('should not call transformComments if enableComments is false', () => {
const instance = getEditorInstanceWithExtension({ enableComments: false });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
YamlEditorExtension.initFromModel(instance, model);
expect(transformComments).not.toHaveBeenCalled();
});
it('should call setValue with the stringified model', () => {
const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue');
YamlEditorExtension.initFromModel(instance, model);
expect(setValue).toHaveBeenCalledWith(doc.toString());
});
});
describe('wrapCommentString', () => {
const longString =
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.';
it('should add spaces before each line', () => {
const result = YamlEditorExtension.wrapCommentString(longString);
const lines = result.split('\n');
expect(lines.every((ln) => ln.startsWith(' '))).toBe(true);
});
it('should break long comments into lines of max. 79 chars', () => {
// 79 = 80 char width minus 1 char for the '#' at the start of each line
const result = YamlEditorExtension.wrapCommentString(longString);
const lines = result.split('\n');
expect(lines.every((ln) => ln.length <= 79)).toBe(true);
});
it('should decrease the line width if passed a level by 2 chars per level', () => {
for (let i = 0; i <= 5; i += 1) {
const result = YamlEditorExtension.wrapCommentString(longString, i);
const lines = result.split('\n');
const decreaseLineWidthBy = i * 2;
const maxLineWith = 79 - decreaseLineWidthBy;
const isValidLine = (ln) => {
if (ln.length <= maxLineWith) return true;
// The line may exceed the max line width in case the word is the
// only one in the line and thus cannot be broken further
return ln.split(' ').length <= 1;
};
expect(lines.every(isValidLine)).toBe(true);
}
});
it('return null if passed an invalid string value', () => {
expect(YamlEditorExtension.wrapCommentString(null)).toBe(null);
expect(YamlEditorExtension.wrapCommentString()).toBe(null);
});
it('throw an error if passed an invalid level value', () => {
expect(() => YamlEditorExtension.wrapCommentString('abc', -5)).toThrow(
'Invalid value "-5" for variable `level`',
);
expect(() => YamlEditorExtension.wrapCommentString('abc', 'invalid')).toThrow(
'Invalid value "invalid" for variable `level`',
);
});
});
describe('transformComments', () => {
const getInstanceWithModel = (model) => {
return getEditorInstanceWithExtension({
model,
enableComments: true,
});
};
it('converts comments inside an array', () => {
const model = ['# test comment', 'def', '# foo', 999];
const expected = `# test comment\n- def\n# foo\n- 999\n`;
const instance = getInstanceWithModel(model);
expect(instance.getValue()).toEqual(expected);
});
it('converts generic comments inside an object and places them at the top', () => {
const model = { foo: 'bar', 1: 2, '#': 'test comment' };
const expected = `# test comment\n"1": 2\nfoo: bar\n`;
const instance = getInstanceWithModel(model);
expect(instance.getValue()).toEqual(expected);
});
it('adds specific comments before the mentioned entry of an object', () => {
const model = { foo: 'bar', 1: 2, '#|foo': 'foo comment' };
const expected = `"1": 2\n# foo comment\nfoo: bar\n`;
const instance = getInstanceWithModel(model);
expect(instance.getValue()).toEqual(expected);
});
it('limits long comments to 80 char width, including indentation', () => {
const model = {
'#|foo':
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.',
foo: {
nested1: {
nested2: {
nested3: {
'#|bar':
'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.',
bar: 'baz',
},
},
},
},
};
const expected = `# Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
# eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
# voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
foo:
nested1:
nested2:
nested3:
# Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
# nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
# sed diam voluptua. At vero eos et accusam et justo duo dolores et ea
# rebum.
bar: baz
`;
const instance = getInstanceWithModel(model);
expect(instance.getValue()).toEqual(expected);
});
});
describe('getDoc', () => {
it('returns a yaml `Document` Type', () => {
const instance = getEditorInstanceWithExtension();
expect(instance.getDoc()).toBeInstanceOf(Document);
});
});
describe('setDoc', () => {
const model = { foo: 'bar', 1: 2, abc: ['def'] };
const doc = new Document(model);
it('should call transformComments if enableComments is true', () => {
const spy = jest.spyOn(YamlEditorExtension, 'transformComments');
const instance = getEditorInstanceWithExtension({ enableComments: true });
instance.setDoc(doc);
expect(spy).toHaveBeenCalledWith(doc);
});
it('should not call transformComments if enableComments is false', () => {
const spy = jest.spyOn(YamlEditorExtension, 'transformComments');
const instance = getEditorInstanceWithExtension({ enableComments: false });
instance.setDoc(doc);
expect(spy).not.toHaveBeenCalled();
});
it("should call setValue with the stringified doc if the editor's value is empty", () => {
const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue');
const updateValue = jest.spyOn(instance, 'updateValue');
instance.setDoc(doc);
expect(setValue).toHaveBeenCalledWith(doc.toString());
expect(updateValue).not.toHaveBeenCalled();
});
it("should call updateValue with the stringified doc if the editor's value is not empty", () => {
const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' });
const setValue = jest.spyOn(instance, 'setValue');
const updateValue = jest.spyOn(instance, 'updateValue');
instance.setDoc(doc);
expect(setValue).not.toHaveBeenCalled();
expect(updateValue).toHaveBeenCalledWith(doc.toString());
});
it('should trigger the onUpdate method', () => {
const instance = getEditorInstanceWithExtension();
const onUpdate = jest.spyOn(instance, 'onUpdate');
instance.setDoc(doc);
expect(onUpdate).toHaveBeenCalled();
});
});
describe('getDataModel', () => {
it('returns the model as JS', () => {
const value = 'abc: def\nfoo:\n - bar\n - baz\n';
const expected = { abc: 'def', foo: ['bar', 'baz'] };
const instance = getEditorInstanceWithExtension({}, { value });
expect(instance.getDataModel()).toEqual(expected);
});
});
describe('setDataModel', () => {
it('sets the value to a YAML-representation of the Doc', () => {
const model = {
abc: ['def'],
'#|foo': 'foo comment',
foo: {
'#|abc': 'abc comment',
abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null],
bar: 'baz',
},
};
const expected =
'abc:\n' +
' - def\n' +
'# foo comment\n' +
'foo:\n' +
' # abc comment\n' +
' abc:\n' +
' - def: ghl\n' +
' lorem: ipsum\n' +
' # array comment\n' +
' - null\n' +
' bar: baz\n';
const instance = getEditorInstanceWithExtension({ enableComments: true });
const setValue = jest.spyOn(instance, 'setValue');
instance.setDataModel(model);
expect(setValue).toHaveBeenCalledWith(expected);
});
it('causes the editor value to be updated', () => {
const initialModel = { foo: 'this should be overriden' };
const initialValue = 'foo: this should be overriden\n';
const newValue = { thisShould: 'be the actual value' };
const expected = 'thisShould: be the actual value\n';
const instance = getEditorInstanceWithExtension({ model: initialModel });
expect(instance.getValue()).toEqual(initialValue);
instance.setDataModel(newValue);
expect(instance.getValue()).toEqual(expected);
});
});
describe('onUpdate', () => {
it('calls highlight', () => {
const highlightPath = 'foo';
const instance = getEditorInstanceWithExtension({ highlightPath });
instance.highlight = jest.fn();
instance.onUpdate();
expect(instance.highlight).toHaveBeenCalledWith(highlightPath);
});
});
describe('updateValue', () => {
it("causes the editor's value to be updated", () => {
const oldValue = 'foobar';
const newValue = 'bazboo';
const instance = getEditorInstanceWithExtension({}, { value: oldValue });
instance.updateValue(newValue);
expect(instance.getValue()).toEqual(newValue);
});
});
describe('highlight', () => {
const highlightPathOnSetup = 'abc';
const value = `foo:
bar:
- baz
- boo
abc: def
`;
let instance;
let highlightLinesSpy;
let removeHighlightsSpy;
beforeEach(() => {
instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value });
highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines');
removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights');
});
afterEach(() => {
jest.clearAllMocks();
});
it('saves the highlighted path in highlightPath', () => {
const path = 'foo.bar';
instance.highlight(path);
expect(instance.options.highlightPath).toEqual(path);
});
it('calls highlightLines with a number of lines', () => {
const path = 'foo.bar';
instance.highlight(path);
expect(highlightLinesSpy).toHaveBeenCalledWith(instance, [2, 4]);
});
it('calls removeHighlights if path is null', () => {
instance.highlight(null);
expect(removeHighlightsSpy).toHaveBeenCalledWith(instance);
expect(highlightLinesSpy).not.toHaveBeenCalled();
expect(instance.options.highlightPath).toBeNull();
});
it('throws an error if path is invalid and does not change the highlighted path', () => {
expect(() => instance.highlight('invalidPath[0]')).toThrow(
'The node invalidPath[0] could not be found inside the document.',
);
expect(instance.options.highlightPath).toEqual(highlightPathOnSetup);
expect(highlightLinesSpy).not.toHaveBeenCalled();
expect(removeHighlightsSpy).not.toHaveBeenCalled();
});
});
describe('locate', () => {
const options = {
enableComments: true,
model: {
abc: ['def'],
'#|foo': 'foo comment',
foo: {
'#|abc': 'abc comment',
abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null],
bar: 'baz',
},
},
};
const value =
/* 1 */ 'abc:\n' +
/* 2 */ ' - def\n' +
/* 3 */ '# foo comment\n' +
/* 4 */ 'foo:\n' +
/* 5 */ ' # abc comment\n' +
/* 6 */ ' abc:\n' +
/* 7 */ ' - def: ghl\n' +
/* 8 */ ' lorem: ipsum\n' +
/* 9 */ ' # array comment\n' +
/* 10 */ ' - null\n' +
/* 11 */ ' bar: baz\n';
it('asserts that the test setup is correct', () => {
const instance = getEditorInstanceWithExtension(options);
expect(instance.getValue()).toEqual(value);
});
it('returns the expected line numbers for a path to an object inside the yaml', () => {
const path = 'foo.abc';
const expected = [6, 10];
const instance = getEditorInstanceWithExtension(options);
expect(instance.locate(path)).toEqual(expected);
});
it('throws an error if a path cannot be found inside the yaml', () => {
const path = 'baz[8]';
const instance = getEditorInstanceWithExtension(options);
expect(() => instance.locate(path)).toThrow();
});
it('returns the expected line numbers for a path to an array entry inside the yaml', () => {
const path = 'foo.abc[0]';
const expected = [7, 8];
const instance = getEditorInstanceWithExtension(options);
expect(instance.locate(path)).toEqual(expected);
});
it('returns the expected line numbers for a path that includes a comment inside the yaml', () => {
const path = 'foo';
const expected = [4, 11];
const instance = getEditorInstanceWithExtension(options);
expect(instance.locate(path)).toEqual(expected);
});
});
});
......@@ -12806,6 +12806,11 @@ yaml@^1.10.0:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.0.0-8:
version "2.0.0-8"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-8.tgz#226365f0d804ba7fb8cc2b527a00a7a4a3d8ea5f"
integrity sha512-QaYgJZMfWD6fKN/EYMk6w1oLWPCr1xj9QaPSZW5qkDb3y8nGCXhy2Ono+AF4F+CSL/vGcqswcAT0BaS//pgD2A==
yargs-parser@^13.1.2:
version "13.1.2"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment