Skip to content

Commit 7478236

Browse files
authored
Enable full keypath support for write operations (#132)
* Fix inputs and values attachments to be data-first * Load adapters as plugin * Fix tests * Review comments * (try to) fix test run for core package * Run base branch coverage manually * Fix unit-tests action: use ArtiomTr with pre-generated coverage files * Try just PR branch tests * Try clear before build * Try no cache * Use latest node * Review comments resolved * Enable full keypath support for write operations * feat(model): add array-aware insert to keypath and BlockNode.createDataNode - Add insert() to keypath utils: splices a value into an array at the given index, shifting existing elements right (symmetric with remove()) - Update BlockNode.createDataNode to detect the parent container type: * Array parent → insert() (splice in, no duplicate guard) * Object parent → existing has() guard + set() semantics - Add tests for insert() in keypath.spec.ts (8 cases) - Add tests for array-insert behavior in BlockNode.spec.ts (3 cases) * Review comments * Lint fix * Review comments * Review comments
1 parent 33f2dc0 commit 7478236

7 files changed

Lines changed: 505 additions & 19 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"packages/*"
88
],
99
"scripts": {
10+
"build": "yarn workspaces foreach -At run build",
11+
"test": "yarn workspaces foreach -A run test",
1012
"lint": "yarn workspaces foreach -A run lint",
1113
"lint:fix": "yarn workspaces foreach -A run lint --fix"
1214
}

packages/core/src/components/BlockRenderer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,9 @@ export class BlockRenderer {
152152
tool: data.name,
153153
index: index.blockIndex,
154154
}));
155+
156+
/**
157+
* @todo clear block tool adapter memory
158+
*/
155159
}
156160
}

packages/model/src/entities/BlockNode/BlockNode.spec.ts

Lines changed: 243 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { NODE_TYPE_HIDDEN_PROP } from './consts.js';
1818
import { TextAddedEvent, TuneModifiedEvent, ValueModifiedEvent } from '../../EventBus/events/index.js';
1919
import { EventType } from '../../EventBus/types/EventType.js';
2020
import { createBlockTuneName } from '../BlockTune/index.js';
21+
import { get } from '../../utils/keypath.js';
22+
import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.js';
2123

2224
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed to spy on conditional-typed getter with @jest/globals strict types
2325
const ValueNodeProto = ValueNode.prototype as unknown as {
@@ -508,29 +510,151 @@ describe('BlockNode', () => {
508510
}));
509511
});
510512

511-
it('should not change the node if key already exists', () => {
513+
it('should throw an error if key already exists', () => {
512514
const key = createDataKey('url');
513515
const value = 'https://editorjs.io';
514516
const blockNode = createBlockNodeWithData({ [key]: value });
515517

516518
const currentNode = blockNode.data[key];
517519

518-
blockNode.createDataNode(key, 'another value');
520+
expect(() => {
521+
blockNode.createDataNode(key, 'another value');
522+
}).toThrowError(AlreadyExistingKeyError);
523+
});
524+
525+
it('should create value node at a nested path within an object', () => {
526+
const blockNode = createBlockNodeWithData({});
527+
const key = createDataKey('meta.url');
528+
const value = 'https://editorjs.io';
519529

520-
expect(blockNode.data[key]).toStrictEqual(currentNode);
530+
blockNode.createDataNode(key, value);
531+
532+
expect(get(blockNode.data, 'meta.url')).toBeInstanceOf(ValueNode);
521533
});
522534

523-
it('should not emit DataNodeAddedEvent if key already exists', () => {
524-
const key = createDataKey('url');
535+
it('should create text node at a nested path within an object', () => {
536+
const blockNode = createBlockNodeWithData({});
537+
const key = createDataKey('meta.title');
538+
const value = { [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
539+
value: 'hello',
540+
fragments: [] };
541+
542+
blockNode.createDataNode(key, value);
543+
544+
expect(get(blockNode.data, 'meta.title')).toBeInstanceOf(TextNode);
545+
});
546+
547+
it('should create value node at an array index path', () => {
548+
const blockNode = createBlockNodeWithData({});
549+
const key = createDataKey('items.0');
550+
const value = 'first item';
551+
552+
blockNode.createDataNode(key, value);
553+
554+
expect(get(blockNode.data, 'items.0')).toBeInstanceOf(ValueNode);
555+
});
556+
557+
it('should create value node in a nested object inside an array', () => {
558+
const blockNode = createBlockNodeWithData({});
559+
const key = createDataKey('items.0.content');
560+
const value = 'content text';
561+
562+
blockNode.createDataNode(key, value);
563+
564+
expect(get(blockNode.data, 'items.0.content')).toBeInstanceOf(ValueNode);
565+
});
566+
567+
it('should create text node in a nested object inside an array', () => {
568+
const blockNode = createBlockNodeWithData({});
569+
const key = createDataKey('items.0.content');
570+
const value = {
571+
value: 'text',
572+
fragments: [],
573+
$t: 't',
574+
};
575+
576+
blockNode.createDataNode(key, value);
577+
578+
expect(get(blockNode.data, 'items.0.content')).toBeInstanceOf(TextNode);
579+
});
580+
581+
it('should throw an error if a nested key already exists', () => {
582+
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });
583+
const key = createDataKey('meta.url');
584+
const existingNode = get(blockNode.data, 'meta.url');
585+
586+
expect(() => blockNode.createDataNode(key, 'another value'))
587+
.toThrowError(AlreadyExistingKeyError);
588+
589+
expect(get(blockNode.data, 'meta.url')).toStrictEqual(existingNode);
590+
});
591+
592+
it('should emit DataNodeAddedEvent with nested dataKey', async () => {
593+
const blockNode = createBlockNodeWithData({});
594+
const key = createDataKey('meta.url');
525595
const value = 'https://editorjs.io';
526-
const blockNode = createBlockNodeWithData({ [key]: value });
527596
const listener = jest.fn();
528597

529598
blockNode.addEventListener(EventType.Changed, listener);
530599

531600
blockNode.createDataNode(key, value);
532601

533-
expect(listener).not.toHaveBeenCalled();
602+
await Promise.resolve();
603+
604+
expect(listener).toBeCalledWith(expect.objectContaining({
605+
detail: expect.objectContaining({
606+
action: EventAction.Added,
607+
index: expect.objectContaining({ dataKey: key }),
608+
}),
609+
}));
610+
});
611+
612+
it('should splice a new node into an existing array at the given index', () => {
613+
const blockNode = createBlockNodeWithData({
614+
items: [
615+
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
616+
value: 'first',
617+
fragments: [] },
618+
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
619+
value: 'third',
620+
fragments: [] },
621+
],
622+
});
623+
624+
blockNode.createDataNode(createDataKey('items.1'), {
625+
[NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
626+
value: 'second',
627+
fragments: [],
628+
});
629+
630+
const items = (blockNode.data as Record<string, unknown[]>)['items'];
631+
const expectedLength = 3;
632+
633+
expect(items).toHaveLength(expectedLength);
634+
expect(items[1]).toBeInstanceOf(TextNode);
635+
});
636+
637+
it('should shift existing nodes right when splicing into an array', () => {
638+
const blockNode = createBlockNodeWithData({
639+
items: [
640+
{ [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
641+
value: 'second',
642+
fragments: [] },
643+
],
644+
});
645+
646+
const originalNode = (blockNode.data as Record<string, unknown[]>)['items'][0];
647+
648+
blockNode.createDataNode(createDataKey('items.0'), {
649+
[NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text,
650+
value: 'first',
651+
fragments: [],
652+
});
653+
654+
const items = (blockNode.data as Record<string, unknown[]>)['items'];
655+
656+
expect(items).toHaveLength(2);
657+
expect(items[1]).toStrictEqual(originalNode);
534658
});
535659
});
536660

@@ -544,6 +668,20 @@ describe('BlockNode', () => {
544668
expect(result).toBeUndefined();
545669
});
546670

671+
it('should return undefined if the nested key does not exist', () => {
672+
const blockNode = createBlockNodeWithData({});
673+
const result = blockNode.getDataNode(createDataKey('meta.nonexistent'));
674+
675+
expect(result).toBeUndefined();
676+
});
677+
678+
it('should return undefined if the array index does not exist', () => {
679+
const blockNode = createBlockNodeWithData({});
680+
const result = blockNode.getDataNode(createDataKey('meta.0'));
681+
682+
expect(result).toBeUndefined();
683+
});
684+
547685
it('should return serialized ValueNode for a value key', () => {
548686
const key = createDataKey('url');
549687
const value = 'https://editorjs.io';
@@ -630,6 +768,62 @@ describe('BlockNode', () => {
630768

631769
expect(listener).not.toHaveBeenCalled();
632770
});
771+
772+
it('should remove data at a nested object path', () => {
773+
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });
774+
775+
blockNode.removeDataNode(createDataKey('meta.url'));
776+
777+
expect(get(blockNode.data, 'meta.url')).toBeUndefined();
778+
});
779+
780+
it('should not remove sibling properties when removing a nested key', () => {
781+
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io',
782+
title: 'Editor.js' } });
783+
784+
blockNode.removeDataNode(createDataKey('meta.url'));
785+
786+
expect(get(blockNode.data, 'meta.title')).toBeDefined();
787+
});
788+
789+
it('should remove a node at an array index path', () => {
790+
const blockNode = createBlockNodeWithData({ items: ['first', 'second'] });
791+
792+
blockNode.removeDataNode(createDataKey('items.0'));
793+
794+
// After splice, 'second' shifts to index 0
795+
expect((blockNode.data as Record<string, unknown[]>)['items']).toHaveLength(1);
796+
});
797+
798+
it('should emit DataNodeRemovedEvent with a nested dataKey', () => {
799+
const blockNode = createBlockNodeWithData({ meta: { url: 'editorjs.io' } });
800+
const key = createDataKey('meta.url');
801+
const listener = jest.fn();
802+
803+
jest.spyOn(ValueNodeProto, 'serialized', 'get').mockReturnValueOnce('editorjs.io');
804+
805+
blockNode.addEventListener(EventType.Changed, listener);
806+
807+
blockNode.removeDataNode(key);
808+
809+
expect(listener).toBeCalledWith(expect.objectContaining({
810+
detail: expect.objectContaining({
811+
action: EventAction.Removed,
812+
index: expect.objectContaining({ dataKey: key }),
813+
}),
814+
}));
815+
});
816+
817+
it('should not emit DataNodeRemovedEvent if nested key doesnt exist', () => {
818+
const blockNode = createBlockNodeWithData({ meta: {} });
819+
const listener = jest.fn();
820+
821+
blockNode.addEventListener(EventType.Changed, listener);
822+
823+
blockNode.removeDataNode(createDataKey('meta.nonexistent'));
824+
825+
expect(listener).not.toHaveBeenCalled();
826+
});
633827
});
634828

635829
describe('.updateTuneData()', () => {
@@ -766,6 +960,30 @@ describe('BlockNode', () => {
766960
expect(blockNode.data[dataKey]).toBeInstanceOf(ValueNode);
767961
});
768962

963+
it('should create new ValueNode at a nested path if the node does not exist', () => {
964+
const blockNode = new BlockNode({
965+
name: createBlockToolName('paragraph'),
966+
data: {},
967+
parent: {} as EditorDocument,
968+
});
969+
970+
blockNode.updateValue(createDataKey('meta.url'), 'https://editorjs.io');
971+
972+
expect(get(blockNode.data, 'meta.url')).toBeInstanceOf(ValueNode);
973+
});
974+
975+
it('should create new ValueNode inside an array if the node does not exist', () => {
976+
const blockNode = new BlockNode({
977+
name: createBlockToolName('paragraph'),
978+
data: {},
979+
parent: {} as EditorDocument,
980+
});
981+
982+
blockNode.updateValue(createDataKey('items.0'), 'first item');
983+
984+
expect(get(blockNode.data, 'items.0')).toBeInstanceOf(ValueNode);
985+
});
986+
769987
it('should throw an error if the ValueNode with the passed dataKey is not a ValueNode', () => {
770988
const dataKey = createDataKey('data-key-1a2b');
771989
const value = 'Some value';
@@ -913,6 +1131,22 @@ describe('BlockNode', () => {
9131131
expect(node.data[key]).toBeInstanceOf(TextNode);
9141132
});
9151133

1134+
it('should create new TextNode at a nested path if the node does not exist', () => {
1135+
const node = createBlockNodeWithData({});
1136+
1137+
node.insertText(createDataKey('meta.title'), text);
1138+
1139+
expect(get(node.data, 'meta.title')).toBeInstanceOf(TextNode);
1140+
});
1141+
1142+
it('should create new TextNode inside an array if the node does not exist', () => {
1143+
const node = createBlockNodeWithData({});
1144+
1145+
node.insertText(createDataKey('items.0'), text);
1146+
1147+
expect(get(node.data, 'items.0')).toBeInstanceOf(TextNode);
1148+
});
1149+
9161150
it('should throw an error if node is not a TextNode', () => {
9171151
const node = new BlockNode({
9181152
name: createBlockToolName('header'),
@@ -1503,6 +1737,8 @@ describe('BlockNode', () => {
15031737
tuneKey: key,
15041738
tuneName: tuneName,
15051739
}));
1740+
expect(event)
1741+
.toHaveProperty('detail.userId', 'user');
15061742
});
15071743

15081744
it('should not emit Changed event if ValueNode dispatched event that is not a BaseDocumentEvent', () => {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DataKey } from '../types/index.js';
2+
3+
/**
4+
* Error is thrown on attempt to create data with already existing key
5+
*/
6+
export class AlreadyExistingKeyError extends Error {
7+
/**
8+
* AlreadyExistingKeyError constructor
9+
* @param key - data key existing node
10+
*/
11+
constructor(key: DataKey) {
12+
super(`BlockNode: data with key "${key}" already exists`);
13+
}
14+
}

0 commit comments

Comments
 (0)