diff --git a/package.json b/package.json index 4dc481d0..552128ee 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts index 1221830b..ecf81a9c 100644 --- a/packages/core/src/components/BlockRenderer.ts +++ b/packages/core/src/components/BlockRenderer.ts @@ -152,5 +152,9 @@ export class BlockRenderer { tool: data.name, index: index.blockIndex, })); + + /** + * @todo clear block tool adapter memory + */ } } diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index 2c917b5e..b90ab8c5 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -18,6 +18,8 @@ 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'; +import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.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 { @@ -508,29 +510,151 @@ describe('BlockNode', () => { })); }); - it('should not change the node if key already exists', () => { + it('should throw an error if key already exists', () => { const key = createDataKey('url'); const value = 'https://editorjs.io'; const blockNode = createBlockNodeWithData({ [key]: value }); const currentNode = blockNode.data[key]; - blockNode.createDataNode(key, 'another value'); + expect(() => { + blockNode.createDataNode(key, 'another value'); + }).toThrowError(AlreadyExistingKeyError); + }); + + 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'; - expect(blockNode.data[key]).toStrictEqual(currentNode); + blockNode.createDataNode(key, value); + + expect(get(blockNode.data, 'meta.url')).toBeInstanceOf(ValueNode); }); - it('should not emit DataNodeAddedEvent if key already exists', () => { - const key = createDataKey('url'); + 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 create text node in a nested object inside an array', () => { + const blockNode = createBlockNodeWithData({}); + const key = createDataKey('items.0.content'); + const value = { + value: 'text', + fragments: [], + $t: 't', + }; + + blockNode.createDataNode(key, value); + + expect(get(blockNode.data, 'items.0.content')).toBeInstanceOf(TextNode); + }); + + it('should throw an error 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'); + + expect(() => blockNode.createDataNode(key, 'another value')) + .toThrowError(AlreadyExistingKeyError); + + 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 blockNode = createBlockNodeWithData({ [key]: value }); const listener = jest.fn(); blockNode.addEventListener(EventType.Changed, listener); blockNode.createDataNode(key, value); - expect(listener).not.toHaveBeenCalled(); + 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)['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)['items'][0]; + + blockNode.createDataNode(createDataKey('items.0'), { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value: 'first', + fragments: [], + }); + + const items = (blockNode.data as Record)['items']; + + expect(items).toHaveLength(2); + expect(items[1]).toStrictEqual(originalNode); }); }); @@ -544,6 +668,20 @@ describe('BlockNode', () => { expect(result).toBeUndefined(); }); + it('should return undefined if the nested key does not exist', () => { + const blockNode = createBlockNodeWithData({}); + const result = blockNode.getDataNode(createDataKey('meta.nonexistent')); + + expect(result).toBeUndefined(); + }); + + it('should return undefined if the array index does not exist', () => { + const blockNode = createBlockNodeWithData({}); + const result = blockNode.getDataNode(createDataKey('meta.0')); + + expect(result).toBeUndefined(); + }); + it('should return serialized ValueNode for a value key', () => { const key = createDataKey('url'); const value = 'https://editorjs.io'; @@ -630,6 +768,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)['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()', () => { @@ -766,6 +960,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'; @@ -913,6 +1131,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'), @@ -1503,6 +1737,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', () => { diff --git a/packages/model/src/entities/BlockNode/errors/AlreadyExistingKeyError.ts b/packages/model/src/entities/BlockNode/errors/AlreadyExistingKeyError.ts new file mode 100644 index 00000000..4764ff92 --- /dev/null +++ b/packages/model/src/entities/BlockNode/errors/AlreadyExistingKeyError.ts @@ -0,0 +1,14 @@ +import type { DataKey } from '../types/index.js'; + +/** + * Error is thrown on attempt to create data with already existing key + */ +export class AlreadyExistingKeyError extends Error { + /** + * AlreadyExistingKeyError constructor + * @param key - data key existing node + */ + constructor(key: DataKey) { + super(`BlockNode: data with key "${key}" already exists`); + } +} diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 9008db84..13c4b698 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -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'; @@ -36,6 +36,7 @@ import { import type { Constructor } from '../../utils/types.js'; import type { TextNodeEvents } from '../../EventBus/types/EventMap.js'; import { BaseDocumentEvent } from '../../EventBus/events/BaseEvent.js'; +import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.js'; /** * BlockNode class represents a node in a tree-like structure used to store and manipulate Blocks in an editor document. @@ -171,11 +172,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)) { + throw new AlreadyExistingKeyError(dataKey); + } - this.#data[dataKey] = this.#mapSerializedDataToNodes(data, dataKey as string); + set(this.#data, dataKey as string, mappedData); + } const index = new IndexBuilder() .addDataKey(dataKey) @@ -198,13 +207,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(this.#data, dataKey as string)!); - delete this.#data[dataKey]; + remove(this.#data, dataKey as string); const index = new IndexBuilder() .addDataKey(dataKey) @@ -241,7 +250,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; @@ -278,7 +287,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; diff --git a/packages/model/src/utils/keypath.spec.ts b/packages/model/src/utils/keypath.spec.ts index c073ec91..aee7eb9b 100644 --- a/packages/model/src/utils/keypath.spec.ts +++ b/packages/model/src/utils/keypath.spec.ts @@ -1,9 +1,74 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { get, has, set } from './keypath.js'; +import { get, has, insert, remove, set } from './keypath.js'; describe('keypath util', () => { const value = 'value'; + describe('insert()', () => { + it('should do nothing if no key passed', () => { + const object: Record = { a: ['x'] }; + + insert(object, [], 'y'); + + expect(object.a).toEqual(['x']); + }); + + it('should do nothing if the parent at the path is not an array', () => { + const object: Record = { a: { b: 'x' } }; + + insert(object, 'a.0', 'y'); + + expect(object.a).toEqual({ b: 'x' }); + }); + + it('should do nothing if the parent path does not exist', () => { + const object: Record = {}; + + expect(() => insert(object, 'a.0', 'y')).not.toThrow(); + expect(object).toEqual({}); + }); + + it('should prepend a value into an array at index 0', () => { + const object: Record = { a: ['second', 'third'] }; + + insert(object, 'a.0', 'first'); + + expect(object.a).toEqual(['first', 'second', 'third']); + }); + + it('should insert a value at a middle index and shift existing elements right', () => { + const object: Record = { a: ['first', 'third'] }; + + insert(object, 'a.1', 'second'); + + expect(object.a).toEqual(['first', 'second', 'third']); + }); + + it('should append a value when index equals array length', () => { + const object: Record = { a: ['first', 'second'] }; + + insert(object, 'a.2', 'third'); + + expect(object.a).toEqual(['first', 'second', 'third']); + }); + + it('should insert into a nested array', () => { + const object: Record = { a: { b: ['x', 'z'] } }; + + insert(object, 'a.b.1', 'y'); + + expect(object.a.b).toEqual(['x', 'y', 'z']); + }); + + it('should accept keys as an array', () => { + const object: Record = { a: ['x', 'z'] }; + + insert(object, ['a', '1'], 'y'); + + expect(object.a).toEqual(['x', 'y', 'z']); + }); + }); + describe('set()', () => { it('should do nothing if no key passed', () => { const object = {}; @@ -242,4 +307,90 @@ describe('keypath util', () => { expect(result).toEqual(true); }); }); + + describe('remove()', () => { + it('should do nothing if no key passed', () => { + const object: Record = { a: value }; + + remove(object, []); + + expect(object).toEqual({ a: value }); + }); + + it('should not delete the "undefined" property when empty keys array is passed', () => { + const object: Record = { undefined: value }; + + remove(object, []); + + expect(object).toHaveProperty('undefined', value); + }); + + it('should remove a root-level property from an object', () => { + const object: Record = { a: value }; + + remove(object, 'a'); + + expect(object).not.toHaveProperty('a'); + }); + + it('should remove a nested property from an object', () => { + const object: Record = { a: { b: { c: value } } }; + + remove(object, 'a.b.c'); + + expect(object.a.b).not.toHaveProperty('c'); + }); + + it('should not affect sibling properties when removing a nested property', () => { + const object: Record = { + a: { + b: value, + c: 'sibling', + }, + }; + + remove(object, 'a.b'); + + expect(object.a).toEqual({ c: 'sibling' }); + }); + + it('should splice an element out of an array', () => { + const object: Record = { a: ['first', 'second', 'third'] }; + + remove(object, 'a.1'); + + expect(object.a).toEqual(['first', 'third']); + }); + + it('should remove the first element of an array and shift remaining elements', () => { + const object: Record = { a: ['first', 'second'] }; + + remove(object, 'a.0'); + + expect(object.a).toEqual(['second']); + }); + + it('should do nothing if the path does not exist', () => { + const object: Record = { a: value }; + + remove(object, 'a.b.c'); + + expect(object).toEqual({ a: value }); + }); + + it('should do nothing if an intermediate value in the path is null', () => { + const object: Record = { a: null }; + + expect(() => remove(object, 'a.b')).not.toThrow(); + expect(object.a).toBeNull(); + }); + + it('should remove keys passed as an array', () => { + const object: Record = { a: { b: value } }; + + remove(object, ['a', 'b']); + + expect(object.a).not.toHaveProperty('b'); + }); + }); }); diff --git a/packages/model/src/utils/keypath.ts b/packages/model/src/utils/keypath.ts index 82d1eb20..72dbb994 100644 --- a/packages/model/src/utils/keypath.ts +++ b/packages/model/src/utils/keypath.ts @@ -25,7 +25,8 @@ export function get(data: Record, ke * @param keys - keypath to a value * @param value - value to set */ -export function set(data: Record, keys: string | string[], value: T): void { +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unknown can't be used as data parameter is used for recursion +export function set(data: Record, keys: string | string[], value: T): void { const parsedKeys = Array.isArray(keys) ? keys : keys.split('.'); const key = parsedKeys.shift(); @@ -55,3 +56,72 @@ export function set(data: Record, keys: string | s export function has(data: Record, keys: string | string[]): boolean { return get(data, keys) !== undefined; } + +/** + * Insert a value into an array at the index specified by the last key segment. + * Does nothing if the path does not exist or the parent is not an array. + * @param data - root object + * @param keys - keypath where the last segment is the target index + * @param value - value to splice in + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unknown can't be used as data parameter is used for recursion +export function insert(data: Record, keys: string | string[], value: T): void { + const parsedKeys = Array.isArray(keys) ? [...keys] : keys.split('.'); + + if (parsedKeys.length === 0) { + return; + } + + const lastKey = parsedKeys.pop()!; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unknown can't be used as data parameter is used for recursion + const parent = get(data, parsedKeys); + + if (!Array.isArray(parent)) { + return; + } + + const index = Number(lastKey); + + if (!Number.isInteger(index) || index < 0) { + return; + } + + parent.splice(index, 0, value); +} + +/** + * Remove value from object by keypath. + * For array parents the element is spliced out (removes the slot entirely). + * For object parents the property is deleted. + * Does nothing if the path does not exist. + * @param data - object to remove value from + * @param keys - keypath to the value to remove + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- unknown can't be used as data parameter is used for recursion +export function remove(data: Record, keys: string | string[]): void { + const parsedKeys = Array.isArray(keys) ? [...keys] : keys.split('.'); + + if (parsedKeys.length === 0) { + return; + } + + const lastKey = parsedKeys.pop()!; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unknown can't be used as data parameter is used for recursion + const parent = get>(data, parsedKeys); + + if (parent === undefined || parent === null) { + return; + } + + if (Array.isArray(parent)) { + const index = Number(lastKey); + + if (!Number.isInteger(index) || index < 0) { + return; + } + + parent.splice(index, 1); + } else { + delete parent[lastKey]; + } +}