Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"packages/*"
],
"scripts": {
"build": "yarn workspaces foreach -At run build",
"test": "yarn workspaces foreach -A run test",
"lint": "yarn workspaces foreach -A run lint",
"lint:fix": "yarn workspaces foreach -A run lint --fix"
}
Expand Down
247 changes: 247 additions & 0 deletions packages/model/src/entities/BlockNode/BlockNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { NODE_TYPE_HIDDEN_PROP } from './consts.js';
import { TextAddedEvent, TuneModifiedEvent, ValueModifiedEvent } from '../../EventBus/events/index.js';
import { EventType } from '../../EventBus/types/EventType.js';
import { createBlockTuneName } from '../BlockTune/index.js';
import { get } from '../../utils/keypath.js';

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed to spy on conditional-typed getter with @jest/globals strict types
const ValueNodeProto = ValueNode.prototype as unknown as {
Expand Down Expand Up @@ -532,6 +533,154 @@ describe('BlockNode', () => {

expect(listener).not.toHaveBeenCalled();
});

it('should create value node at a nested path within an object', () => {
const blockNode = createBlockNodeWithData({});
const key = createDataKey('meta.url');
const value = 'https://editorjs.io';

blockNode.createDataNode(key, value);

expect(get(blockNode.data, 'meta.url')).toBeInstanceOf(ValueNode);
});

it('should create text node at a nested path within an object', () => {
const blockNode = createBlockNodeWithData({});
const key = createDataKey('meta.title');
const value = { [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'hello',
fragments: [] };

blockNode.createDataNode(key, value);

expect(get(blockNode.data, 'meta.title')).toBeInstanceOf(TextNode);
});

it('should create value node at an array index path', () => {
const blockNode = createBlockNodeWithData({});
const key = createDataKey('items.0');
const value = 'first item';

blockNode.createDataNode(key, value);

expect(get(blockNode.data, 'items.0')).toBeInstanceOf(ValueNode);
});

it('should create value node in a nested object inside an array', () => {
const blockNode = createBlockNodeWithData({});
const key = createDataKey('items.0.content');
const value = 'content text';

blockNode.createDataNode(key, value);

expect(get(blockNode.data, 'items.0.content')).toBeInstanceOf(ValueNode);
});

it('should not change the node if a nested key already exists', () => {
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });
const key = createDataKey('meta.url');
const existingNode = get(blockNode.data, 'meta.url');

blockNode.createDataNode(key, 'another value');

expect(get(blockNode.data, 'meta.url')).toStrictEqual(existingNode);
});

it('should emit DataNodeAddedEvent with nested dataKey', async () => {
const blockNode = createBlockNodeWithData({});
const key = createDataKey('meta.url');
const value = 'https://editorjs.io';
const listener = jest.fn();

blockNode.addEventListener(EventType.Changed, listener);

blockNode.createDataNode(key, value);

await Promise.resolve();

expect(listener).toBeCalledWith(expect.objectContaining({
detail: expect.objectContaining({
action: EventAction.Added,
index: expect.objectContaining({ dataKey: key }),
}),
}));
});

it('should splice a new node into an existing array at the given index', () => {
const blockNode = createBlockNodeWithData({
items: [
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'first',
fragments: [] },
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'third',
fragments: [] },
],
});

blockNode.createDataNode(createDataKey('items.1'), {
[NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'second',
fragments: [],
});

const items = (blockNode.data as Record<string, unknown[]>)['items'];
const expectedLength = 3;

expect(items).toHaveLength(expectedLength);
expect(items[1]).toBeInstanceOf(TextNode);
});

it('should shift existing nodes right when splicing into an array', () => {
const blockNode = createBlockNodeWithData({
items: [
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'second',
fragments: [] },
],
});

const originalNode = (blockNode.data as Record<string, unknown[]>)['items'][0];

blockNode.createDataNode(createDataKey('items.0'), {
[NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'first',
fragments: [],
});

const items = (blockNode.data as Record<string, unknown[]>)['items'];

expect(items).toHaveLength(2);
expect(items[1]).toStrictEqual(originalNode);
});

it('should always insert into an array even when an element already exists at that index', async () => {
const blockNode = createBlockNodeWithData({
items: [
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'existing',
fragments: [] },
],
});

const listener = jest.fn();

blockNode.addEventListener(EventType.Changed, listener);

blockNode.createDataNode(createDataKey('items.0'), {
[NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
value: 'new',
fragments: [],
});

await Promise.resolve();

const items = (blockNode.data as Record<string, unknown[]>)['items'];

// new node was spliced in, old node shifted to index 1
expect(items).toHaveLength(2);
expect(listener).toHaveBeenCalled();
});
});

describe('.getDataNode()', () => {
Expand Down Expand Up @@ -630,6 +779,62 @@ describe('BlockNode', () => {

expect(listener).not.toHaveBeenCalled();
});

it('should remove data at a nested object path', () => {
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });

blockNode.removeDataNode(createDataKey('meta.url'));

expect(get(blockNode.data, 'meta.url')).toBeUndefined();
});

it('should not remove sibling properties when removing a nested key', () => {
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io',
title: 'Editor.js' } });

blockNode.removeDataNode(createDataKey('meta.url'));

expect(get(blockNode.data, 'meta.title')).toBeDefined();
});

it('should remove a node at an array index path', () => {
const blockNode = createBlockNodeWithData({ items: ['first', 'second'] });

blockNode.removeDataNode(createDataKey('items.0'));

// After splice, 'second' shifts to index 0
expect((blockNode.data as Record<string, unknown[]>)['items']).toHaveLength(1);
});

it('should emit DataNodeRemovedEvent with a nested dataKey', () => {
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });
const key = createDataKey('meta.url');
const listener = jest.fn();

jest.spyOn(ValueNodeProto, 'serialized', 'get').mockReturnValueOnce('editorjs.io');

blockNode.addEventListener(EventType.Changed, listener);

blockNode.removeDataNode(key);

expect(listener).toBeCalledWith(expect.objectContaining({
detail: expect.objectContaining({
action: EventAction.Removed,
index: expect.objectContaining({ dataKey: key }),
}),
}));
});

it('should not emit DataNodeRemovedEvent if nested key doesnt exist', () => {
const blockNode = createBlockNodeWithData({ meta: {} });
const listener = jest.fn();

blockNode.addEventListener(EventType.Changed, listener);

blockNode.removeDataNode(createDataKey('meta.nonexistent'));

expect(listener).not.toHaveBeenCalled();
});
});

describe('.updateTuneData()', () => {
Expand Down Expand Up @@ -766,6 +971,30 @@ describe('BlockNode', () => {
expect(blockNode.data[dataKey]).toBeInstanceOf(ValueNode);
});

it('should create new ValueNode at a nested path if the node does not exist', () => {
const blockNode = new BlockNode({
name: createBlockToolName('paragraph'),
data: {},
parent: {} as EditorDocument,
});

blockNode.updateValue(createDataKey('meta.url'), 'https://editorjs.io');

expect(get(blockNode.data, 'meta.url')).toBeInstanceOf(ValueNode);
});

it('should create new ValueNode inside an array if the node does not exist', () => {
const blockNode = new BlockNode({
name: createBlockToolName('paragraph'),
data: {},
parent: {} as EditorDocument,
});

blockNode.updateValue(createDataKey('items.0'), 'first item');

expect(get(blockNode.data, 'items.0')).toBeInstanceOf(ValueNode);
});

it('should throw an error if the ValueNode with the passed dataKey is not a ValueNode', () => {
const dataKey = createDataKey('data-key-1a2b');
const value = 'Some value';
Expand Down Expand Up @@ -913,6 +1142,22 @@ describe('BlockNode', () => {
expect(node.data[key]).toBeInstanceOf(TextNode);
});

it('should create new TextNode at a nested path if the node does not exist', () => {
const node = createBlockNodeWithData({});

node.insertText(createDataKey('meta.title'), text);

expect(get(node.data, 'meta.title')).toBeInstanceOf(TextNode);
});

it('should create new TextNode inside an array if the node does not exist', () => {
const node = createBlockNodeWithData({});

node.insertText(createDataKey('items.0'), text);

expect(get(node.data, 'items.0')).toBeInstanceOf(TextNode);
});

it('should throw an error if node is not a TextNode', () => {
const node = new BlockNode({
name: createBlockToolName('header'),
Expand Down Expand Up @@ -1503,6 +1748,8 @@ describe('BlockNode', () => {
tuneKey: key,
tuneName: tuneName,
}));
expect(event)
.toHaveProperty('detail.userId', 'user');
});

it('should not emit Changed event if ValueNode dispatched event that is not a BaseDocumentEvent', () => {
Expand Down
28 changes: 18 additions & 10 deletions packages/model/src/entities/BlockNode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { ValueSerialized } from '../ValueNode/index.js';
import { ValueNode } from '../ValueNode/index.js';
import type { InlineFragment, InlineToolData, InlineToolName, TextNodeSerialized } from '../inline-fragments/index.js';
import { TextNode } from '../inline-fragments/index.js';
import { get, has } from '../../utils/keypath.js';
import { get, has, set, remove, insert } from '../../utils/keypath.js';
import { NODE_TYPE_HIDDEN_PROP } from './consts.js';
import { mapObject } from '../../utils/mapObject.js';
import type { DeepReadonly } from '../../utils/DeepReadonly.js';
Expand Down Expand Up @@ -171,11 +171,19 @@ export class BlockNode extends EventBus {
* @param data - initial data of the node
*/
public createDataNode(dataKey: DataKey, data: BlockNodeDataSerializedValue): void {
if (this.#data[dataKey] !== undefined) {
return;
}
const keys = (dataKey as string).split('.');
const parent = get(this.#data, keys.slice(0, -1));
const mappedData = this.#mapSerializedDataToNodes(data, dataKey as string);

if (Array.isArray(parent)) {
insert(this.#data, dataKey as string, mappedData);
} else {
if (has(this.#data, dataKey as string)) {
return;
}

this.#data[dataKey] = this.#mapSerializedDataToNodes(data, dataKey as string);
set(this.#data, dataKey as string, mappedData);
}

const index = new IndexBuilder()
.addDataKey(dataKey)
Expand All @@ -191,13 +199,13 @@ export class BlockNode extends EventBus {
* @param dataKey - key of the node to remove
*/
public removeDataNode(dataKey: DataKey): void {
if (this.#data[dataKey] === undefined) {
if (!has(this.#data, dataKey as string)) {
return;
}

const nodeData = this.#serializeData(this.#data[dataKey]);
const nodeData = this.#serializeData(get<BlockNodeDataValue>(this.#data, dataKey as string)!);

delete this.#data[dataKey];
remove(this.#data, dataKey as string);

const index = new IndexBuilder()
.addDataKey(dataKey)
Expand Down Expand Up @@ -234,7 +242,7 @@ export class BlockNode extends EventBus {
/**
* In case there is no data key for the value, we need to create a new ValueNode
*/
this.#data[dataKey] = this.#createValueNode(dataKey);
set(this.#data, dataKey as string, this.#createValueNode(dataKey));
}

const node = get(this.#data, dataKey as string) as ValueNode<T>;
Expand Down Expand Up @@ -271,7 +279,7 @@ export class BlockNode extends EventBus {
/**
* In case there is no data key for the text, we need to create a new TextNode
*/
this.#data[dataKey] = this.#createTextNode(dataKey);
set(this.#data, dataKey as string, this.#createTextNode(dataKey));
}

const node = get(this.#data, dataKey as string) as TextNode;
Expand Down
Loading
Loading