diff --git a/public/devtool/object.css b/public/devtool/object.css new file mode 100644 index 000000000..b0ed7be3a --- /dev/null +++ b/public/devtool/object.css @@ -0,0 +1,147 @@ +.devtool-root-holder, +.devtool-ops-holder { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: scroll; + font-size: 14px; + font-weight: 400; +} + +.devtool-root-holder .object-content { + margin-left: 24px; +} + +.devtool-root-holder .object-key-val, +.devtool-root-holder .object-val { + position: relative; +} + +.devtool-root-holder .object-key-val:not(:last-of-type):before, +.devtool-root-holder .object-val:not(:last-of-type):before { + border: 1px dashed #ddd; + border-width: 0 0 0 1px; + content: ''; + position: absolute; + bottom: -12px; + left: -18px; + top: 12px; +} + +.devtool-root-holder .object-key-val:after, +.devtool-root-holder .object-val:after { + border-bottom: 1px dashed #ddd; + border-left: 1px dashed #ddd; + border-radius: 0 0 0 4px; + border-right: 0 dashed #ddd; + border-top: 0 dashed #ddd; + content: ''; + height: 16px; + position: absolute; + top: 0; + width: 16px; + height: 32px; + top: -16px; + left: -18px; +} + +.devtool-root-holder > .object-key-val:after, +.devtool-root-holder > .object-val:after { + content: none; +} + +.devtool-root-holder .object-val > span, +.devtool-root-holder .object-key-val > span, +.devtool-root-holder .object-val label, +.devtool-root-holder .object-key-val label { + border: 1px solid #ddd; + border-radius: 4px; + display: inline-block; + font-size: 14px; + font-weight: 500; + letter-spacing: 0 !important; + line-height: 1.72; + margin-bottom: 16px; + padding: 6px 8px; +} + +.devtool-root-holder label { + cursor: pointer; +} + +.devtool-root-holder .object-key-val label:before { + content: '▾'; + margin-right: 4px; +} + +.devtool-root-holder input[type='checkbox']:checked + label:before { + content: '▸'; +} + +.devtool-root-holder input[type='checkbox']:checked ~ .object-content { + display: none; +} + +.devtool-root-holder input[type='checkbox'] { + display: none; +} + +.devtool-root-holder .timeticket, +.devtool-ops-holder .timeticket { + border-radius: 4px; + background: #f1f2f3; + font-size: 12px; + font-weight: 400; + padding: 2px 6px; + margin-left: 4px; + letter-spacing: 1px; +} + +.devtool-ops-holder .change { + display: flex; + margin-bottom: 3px; + border-top: 1px solid #ddd; + word-break: break-all; +} +.devtool-ops-holder label { + position: relative; + overflow: hidden; + padding-left: 24px; + cursor: pointer; + line-height: 1.6; +} +.devtool-ops-holder input[type='checkbox']:checked + label { + height: 22px; +} +.devtool-ops-holder input[type='checkbox'] { + display: none; +} +.devtool-ops-holder .count { + position: absolute; + left: 0px; + display: flex; + justify-content: center; + width: 20px; + height: 20px; + font-size: 13px; +} +.devtool-ops-holder .op { + display: block; +} +.devtool-ops-holder .op:first-child { + display: inline-block; +} +.devtool-ops-holder .op .type { + padding: 0 4px; + border-radius: 4px; + background: #e6e6fa; +} +.devtool-ops-holder .op .type.set { + background: #cff7cf; +} +.devtool-ops-holder .op .type.remove { + background: #f9c0c8; +} +.devtool-ops-holder .op .type.add { + background: #add8e6; +} diff --git a/public/devtool/object.js b/public/devtool/object.js new file mode 100644 index 000000000..994786f23 --- /dev/null +++ b/public/devtool/object.js @@ -0,0 +1,121 @@ +const objectDevtool = ( + doc, + { rootHolder, opsHolder, undoOpsHolder, redoOpsHolder }, +) => { + const displayRootObject = () => { + const rootObj = doc.getRoot().toJSForTest(); + rootHolder.innerHTML = ` +
( function fromObject(pbObject: PbJSONElement.JSONObject): CRDTObject { const rht = new ElementRHT(); for (const pbRHTNode of pbObject.getNodesList()) { - // eslint-disable-next-line - rht.set(pbRHTNode.getKey(), fromElement(pbRHTNode.getElement()!)); + const value = fromElement(pbRHTNode.getElement()!); + rht.set(pbRHTNode.getKey(), value, value.getPositionedAt()); } const obj = new CRDTObject(fromTimeTicket(pbObject.getCreatedAt())!, rht); diff --git a/src/document/change/change.ts b/src/document/change/change.ts index 910604512..505eb3827 100644 --- a/src/document/change/change.ts +++ b/src/document/change/change.ts @@ -16,6 +16,7 @@ import { ActorID } from '@yorkie-js-sdk/src/document/time/actor_id'; import { + OpSource, Operation, OperationInfo, } from '@yorkie-js-sdk/src/document/operation/operation'; @@ -137,15 +138,26 @@ export class Change
{
public execute(
root: CRDTRoot,
presences: Map {
presences.delete(this.id.getActorID()!);
}
}
+
return { opInfos: changeOpInfos, reverseOps };
}
diff --git a/src/document/change/context.ts b/src/document/change/context.ts
index d9f726202..e3234922e 100644
--- a/src/document/change/context.ts
+++ b/src/document/change/context.ts
@@ -153,7 +153,7 @@ export class ChangeContext {
}
/**
- * `getReversePresence` returns the reverse presence of this context.
+ * `toReversePresence` returns the reverse presence of this context.
*/
public getReversePresence() {
if (this.reversePresenceKeys.size === 0) return undefined;
diff --git a/src/document/crdt/array.ts b/src/document/crdt/array.ts
index 8170ba18c..9ad8513c2 100644
--- a/src/document/crdt/array.ts
+++ b/src/document/crdt/array.ts
@@ -20,6 +20,7 @@ import {
CRDTElement,
} from '@yorkie-js-sdk/src/document/crdt/element';
import { RGATreeList } from '@yorkie-js-sdk/src/document/crdt/rga_tree_list';
+import * as Devtools from '@yorkie-js-sdk/src/types/devtools_element';
/**
* `CRDTArray` represents an array data type containing `CRDTElement`s.
@@ -75,27 +76,19 @@ export class CRDTArray extends CRDTContainer {
}
/**
- * `get` returns the element of the given createAt.
+ * `get` returns the element of the given index.
*/
- public get(createdAt: TimeTicket): CRDTElement | undefined {
- const node = this.elements.get(createdAt);
- if (!node || node.isRemoved()) {
- return;
- }
-
- return node;
+ public get(index: number): CRDTElement | undefined {
+ const node = this.elements.getByIndex(index);
+ return node?.getValue();
}
/**
- * `getByIndex` returns the element of the given index.
+ * `getByID` returns the element of the given createAt.
*/
- public getByIndex(index: number): CRDTElement | undefined {
- const node = this.elements.getByIndex(index);
- if (!node) {
- return;
- }
-
- return node.getValue();
+ public getByID(createdAt: TimeTicket): CRDTElement | undefined {
+ const node = this.elements.getByID(createdAt);
+ return node?.getValue();
}
/**
@@ -206,6 +199,27 @@ export class CRDTArray extends CRDTContainer {
return JSON.parse(this.toJSON());
}
+ /**
+ * `toJSForTest` returns value with meta data for testing.
+ */
+ public toJSForTest(): Devtools.JSONElement {
+ const values: Devtools.ContainerValue = {};
+ for (let i = 0; i < this.length; i++) {
+ const { id, value, type } = this.get(i)!.toJSForTest();
+ values[i] = {
+ key: String(i),
+ id,
+ value,
+ type,
+ };
+ }
+ return {
+ id: this.getCreatedAt().toTestString(),
+ value: values,
+ type: 'YORKIE_ARRAY',
+ };
+ }
+
/**
* `toSortedJSON` returns the sorted JSON encoding of this array.
*/
diff --git a/src/document/crdt/counter.ts b/src/document/crdt/counter.ts
index dc94a1858..cb33c0fc7 100644
--- a/src/document/crdt/counter.ts
+++ b/src/document/crdt/counter.ts
@@ -23,6 +23,7 @@ import {
PrimitiveType,
} from '@yorkie-js-sdk/src/document/crdt/primitive';
import { removeDecimal } from '@yorkie-js-sdk/src/util/number';
+import type * as Devtools from '@yorkie-js-sdk/src/types/devtools_element';
export enum CounterType {
IntegerCnt,
@@ -119,6 +120,17 @@ export class CRDTCounter extends CRDTElement {
return this.toJSON();
}
+ /**
+ * `toJSForTest` returns value with meta data for testing.
+ */
+ public toJSForTest(): Devtools.JSONElement {
+ return {
+ id: this.getCreatedAt().toTestString(),
+ value: this.value,
+ type: 'YORKIE_COUNTER',
+ };
+ }
+
/**
* `deepcopy` copies itself deeply.
*/
diff --git a/src/document/crdt/element.ts b/src/document/crdt/element.ts
index 2f9607ec6..7cd93f7e8 100644
--- a/src/document/crdt/element.ts
+++ b/src/document/crdt/element.ts
@@ -15,6 +15,7 @@
*/
import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket';
+import type * as Devtools from '@yorkie-js-sdk/src/types/devtools_element';
/**
* `CRDTElement` represents an element that has `TimeTicket`s.
@@ -58,6 +59,18 @@ export abstract class CRDTElement {
return this.removedAt;
}
+ /**
+ * `getPositionedAt` returns the time of this element when it was positioned
+ * in the document by undo/redo or move operation.
+ */
+ public getPositionedAt(): TimeTicket {
+ if (!this.movedAt) {
+ return this.createdAt;
+ }
+
+ return this.movedAt;
+ }
+
/**
* `setMovedAt` sets the move time of this element.
*/
@@ -83,7 +96,7 @@ export abstract class CRDTElement {
public remove(removedAt?: TimeTicket): boolean {
if (
removedAt &&
- removedAt.after(this.createdAt) &&
+ removedAt.after(this.getPositionedAt()) &&
(!this.removedAt || removedAt.after(this.removedAt))
) {
this.removedAt = removedAt;
@@ -102,6 +115,7 @@ export abstract class CRDTElement {
abstract toJSON(): string;
abstract toSortedJSON(): string;
+ abstract toJSForTest(): Devtools.JSONElement;
abstract deepcopy(): CRDTElement;
}
@@ -126,6 +140,19 @@ export abstract class CRDTContainer extends CRDTElement {
abstract getDescendants(
callback: (elem: CRDTElement, parent: CRDTContainer) => boolean,
): void;
+
+ /**
+ * `get` returns the element of the given key or index. This method is called
+ * by users. So it should return undefined if the element is removed.
+ */
+ abstract get(keyOrIndex: string | number): CRDTElement | undefined;
+
+ /**
+ * `getByID` returns the element of the given creation time. This method is
+ * called by internal. So it should return the element even if the element is
+ * removed.
+ */
+ abstract getByID(createdAt: TimeTicket): CRDTElement | undefined;
}
/**
diff --git a/src/document/crdt/element_rht.ts b/src/document/crdt/element_rht.ts
index 79cff5395..d9d3bdadd 100644
--- a/src/document/crdt/element_rht.ts
+++ b/src/document/crdt/element_rht.ts
@@ -89,24 +89,22 @@ export class ElementRHT {
/**
* `set` sets the value of the given key.
*/
- public set(key: string, value: CRDTElement): CRDTElement | undefined {
+ public set(
+ key: string,
+ value: CRDTElement,
+ executedAt: TimeTicket,
+ ): CRDTElement | undefined {
let removed;
const node = this.nodeMapByKey.get(key);
- if (
- node != null &&
- !node.isRemoved() &&
- node.remove(value.getCreatedAt())
- ) {
+ if (node != null && !node.isRemoved() && node.remove(executedAt)) {
removed = node.getValue();
}
const newNode = ElementRHTNode.of(key, value);
this.nodeMapByCreatedAt.set(value.getCreatedAt().toIDString(), newNode);
- if (
- node == null ||
- value.getCreatedAt().after(node.getValue().getCreatedAt())
- ) {
+ if (node == null || executedAt.after(node.getValue().getPositionedAt())) {
this.nodeMapByKey.set(key, newNode);
+ value.setMovedAt(executedAt);
}
return removed;
}
@@ -187,15 +185,22 @@ export class ElementRHT {
}
/**
- * `get` returns the value of the given key.
+ * `getByID` returns the node of the given createdAt.
+ */
+ public getByID(createdAt: TimeTicket): ElementRHTNode | undefined {
+ return this.nodeMapByCreatedAt.get(createdAt.toIDString());
+ }
+
+ /**
+ * `get` returns the node of the given key.
*/
- public get(key: string): CRDTElement | undefined {
+ public get(key: string): ElementRHTNode | undefined {
const node = this.nodeMapByKey.get(key);
- if (node == null) {
+ if (!node || node.isRemoved()) {
return;
}
- return node.getValue();
+ return node;
}
// eslint-disable-next-line jsdoc/require-jsdoc
diff --git a/src/document/crdt/object.ts b/src/document/crdt/object.ts
index 81b9ceb63..86b650a61 100644
--- a/src/document/crdt/object.ts
+++ b/src/document/crdt/object.ts
@@ -20,6 +20,7 @@ import {
CRDTElement,
} from '@yorkie-js-sdk/src/document/crdt/element';
import { ElementRHT } from '@yorkie-js-sdk/src/document/crdt/element_rht';
+import type * as Devtools from '@yorkie-js-sdk/src/types/devtools_element';
/**
* `CRDTObject` represents an object data type, but unlike regular JSON,
@@ -59,8 +60,12 @@ export class CRDTObject extends CRDTContainer {
/**
* `set` sets the given element of the given key.
*/
- public set(key: string, value: CRDTElement): CRDTElement | undefined {
- return this.memberNodes.set(key, value);
+ public set(
+ key: string,
+ value: CRDTElement,
+ executedAt: TimeTicket,
+ ): CRDTElement | undefined {
+ return this.memberNodes.set(key, value, executedAt);
}
/**
@@ -84,7 +89,16 @@ export class CRDTObject extends CRDTContainer {
* `get` returns the value of the given key.
*/
public get(key: string): CRDTElement | undefined {
- return this.memberNodes.get(key);
+ const node = this.memberNodes.get(key);
+ return node?.getValue();
+ }
+
+ /**
+ * `getByID` returns the element of the given createAt.
+ */
+ public getByID(createdAt: TimeTicket): CRDTElement | undefined {
+ const node = this.memberNodes.getByID(createdAt);
+ return node?.getValue();
}
/**
@@ -112,6 +126,27 @@ export class CRDTObject extends CRDTContainer {
return JSON.parse(this.toJSON());
}
+ /**
+ * `toJSForTest` returns value with meta data for testing.
+ */
+ public toJSForTest(): Devtools.JSONElement {
+ const values: Devtools.ContainerValue = {};
+ for (const [key, elem] of this) {
+ const { id, value, type } = elem.toJSForTest();
+ values[key] = {
+ key,
+ id,
+ value,
+ type,
+ };
+ }
+ return {
+ id: this.getCreatedAt().toTestString(),
+ value: values,
+ type: 'YORKIE_OBJECT',
+ };
+ }
+
/**
* `getKeys` returns array of keys in this object.
*/
@@ -135,7 +170,7 @@ export class CRDTObject extends CRDTContainer {
const json = [];
for (const key of keys.sort()) {
- const node = this.memberNodes.get(key);
+ const node = this.memberNodes.get(key)?.getValue();
json.push(`"${key}":${node!.toSortedJSON()}`);
}
@@ -155,7 +190,11 @@ export class CRDTObject extends CRDTContainer {
public deepcopy(): CRDTObject {
const clone = CRDTObject.create(this.getCreatedAt());
for (const node of this.memberNodes) {
- clone.memberNodes.set(node.getStrKey(), node.getValue().deepcopy());
+ clone.memberNodes.set(
+ node.getStrKey(),
+ node.getValue().deepcopy(),
+ this.getPositionedAt(),
+ );
}
clone.remove(this.getRemovedAt());
return clone;
diff --git a/src/document/crdt/primitive.ts b/src/document/crdt/primitive.ts
index fbaf9ccda..b3c4b440c 100644
--- a/src/document/crdt/primitive.ts
+++ b/src/document/crdt/primitive.ts
@@ -19,6 +19,7 @@ import { Code, YorkieError } from '@yorkie-js-sdk/src/util/error';
import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket';
import { CRDTElement } from '@yorkie-js-sdk/src/document/crdt/element';
import { escapeString } from '@yorkie-js-sdk/src/document/json/strings';
+import type * as Devtools from '@yorkie-js-sdk/src/types/devtools_element';
export enum PrimitiveType {
Null,
@@ -117,6 +118,17 @@ export class Primitive extends CRDTElement {
return this.toJSON();
}
+ /**
+ * `toJSForTest` returns value with meta data for testing.
+ */
+ public toJSForTest(): Devtools.JSONElement {
+ return {
+ id: this.getCreatedAt().toTestString(),
+ value: this.value,
+ type: 'YORKIE_PRIMITIVE',
+ };
+ }
+
/**
* `deepcopy` copies itself deeply.
*/
diff --git a/src/document/crdt/rga_tree_list.ts b/src/document/crdt/rga_tree_list.ts
index 3a1add11b..df7bd0854 100644
--- a/src/document/crdt/rga_tree_list.ts
+++ b/src/document/crdt/rga_tree_list.ts
@@ -69,15 +69,11 @@ class RGATreeListNode extends SplayNode
| UnwatchedEvent
@@ -1086,23 +1101,24 @@ export class Document (
context,
deepcopy(this.clone!.presences.get(this.changeID.getActorID()!)!),
@@ -1274,16 +1291,13 @@ export class Document (
context,
deepcopy(this.clone!.presences.get(this.changeID.getActorID()!)!),
@@ -1357,16 +1380,13 @@ export class Document {
/**
* `getUndoStackForTest` returns the undo stack for test.
*/
- public getUndoStackForTest(): Array (
+ op: HistoryOperation ,
+): string {
+ return op instanceof Operation ? op.toTestString() : JSON.stringify(op);
+}
diff --git a/test/integration/counter_test.ts b/test/integration/counter_test.ts
index 7aad05e98..3f8d04af2 100644
--- a/test/integration/counter_test.ts
+++ b/test/integration/counter_test.ts
@@ -1,5 +1,6 @@
import { describe, it, assert } from 'vitest';
import { Document } from '@yorkie-js-sdk/src/document/document';
+import { toStringHistoryOp } from '@yorkie-js-sdk/test/helper/helper';
import {
withTwoClientsAndDocuments,
assertUndoRedo,
@@ -147,17 +148,18 @@ describe('Counter', function () {
root.longCnt.increase(Long.fromString('9223372036854775807')); // 2^63-1
});
assert.equal(doc.toSortedJSON(), `{"cnt":1,"longCnt":9223372036854775807}`);
- assert.equal(
- JSON.stringify(doc.getUndoStackForTest()),
- `[["1:00:2.INCREASE.-9223372036854775807","1:00:1.INCREASE.-1.5"]]`,
- );
+
+ assert.deepEqual(doc.getUndoStackForTest().at(-1)?.map(toStringHistoryOp), [
+ '1:00:2.INCREASE.-9223372036854775807',
+ '1:00:1.INCREASE.-1.5',
+ ]);
doc.history.undo();
assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`);
- assert.equal(
- JSON.stringify(doc.getRedoStackForTest()),
- `[["1:00:1.INCREASE.1.5","1:00:2.INCREASE.9223372036854775807"]]`,
- );
+ assert.deepEqual(doc.getRedoStackForTest().at(-1)?.map(toStringHistoryOp), [
+ '1:00:1.INCREASE.1.5',
+ '1:00:2.INCREASE.9223372036854775807',
+ ]);
});
it('Can undo/redo for increase operation', async function ({ task }) {
diff --git a/test/integration/document_test.ts b/test/integration/document_test.ts
index 0e97e5384..b885c8940 100644
--- a/test/integration/document_test.ts
+++ b/test/integration/document_test.ts
@@ -745,7 +745,7 @@ describe('Document', function () {
}, 'init counter');
assert.equal(doc.toSortedJSON(), '{"counter":100}');
- assert.equal(doc.history.canUndo(), false);
+ assert.equal(doc.history.canUndo(), true);
assert.equal(doc.history.canRedo(), false);
// user increases the counter
@@ -760,7 +760,7 @@ describe('Document', function () {
// user undoes the latest operation
doc.history.undo();
- assert.equal(doc.history.canUndo(), false);
+ assert.equal(doc.history.canUndo(), true);
assert.equal(doc.history.canRedo(), true);
// user redoes the latest undone operation
@@ -779,7 +779,7 @@ describe('Document', function () {
}, 'init counter');
assert.equal(doc.toSortedJSON(), '{"counter":100}');
- assert.equal(doc.history.canUndo(), false);
+ assert.equal(doc.history.canUndo(), true);
assert.equal(doc.history.canRedo(), false);
for (let i = 0; i < 5; i++) {
@@ -839,7 +839,7 @@ describe('Document', function () {
}, 'init counter');
assert.equal(doc.toSortedJSON(), '{"counter":100}');
- assert.equal(doc.history.canUndo(), false);
+ assert.equal(doc.history.canUndo(), true);
assert.equal(doc.history.canRedo(), false);
assert.throws(
@@ -872,7 +872,7 @@ describe('Document', function () {
}, 'init counter');
assert.equal(doc.toSortedJSON(), '{"counter":0}');
- assert.equal(doc.history.canUndo(), false);
+ assert.equal(doc.history.canUndo(), true);
assert.equal(doc.history.canRedo(), false);
for (let i = 0; i < 100; i++) {
diff --git a/test/integration/object_test.ts b/test/integration/object_test.ts
index fafeba262..666f61658 100644
--- a/test/integration/object_test.ts
+++ b/test/integration/object_test.ts
@@ -1,7 +1,13 @@
import { describe, it, assert } from 'vitest';
-import { JSONObject } from '@yorkie-js-sdk/src/yorkie';
+import { JSONObject, Client } from '@yorkie-js-sdk/src/yorkie';
import { Document } from '@yorkie-js-sdk/src/document/document';
-import { withTwoClientsAndDocuments } from '@yorkie-js-sdk/test/integration/integration_helper';
+import {
+ withTwoClientsAndDocuments,
+ assertUndoRedo,
+ toDocKey,
+ testRPCAddr,
+} from '@yorkie-js-sdk/test/integration/integration_helper';
+import { toStringHistoryOp } from '@yorkie-js-sdk/test/helper/helper';
import { YorkieError } from '@yorkie-js-sdk/src/util/error';
describe('Object', function () {
@@ -80,24 +86,31 @@ describe('Object', function () {
'{"k1":{"k1-1":"v1","k1-2":"v3"},"k2":["1","2","3","4",{"k2-5":"v4"}]}',
doc.toSortedJSON(),
);
+
+ // TODO(Hyemmie): test assertUndoRedo after implementing array's reverse operation
});
it('should handle delete operations', function () {
const doc = new Document<{
k1: { 'k1-1'?: string; 'k1-2': string; 'k1-3'?: string };
}>('test-doc');
+ const states: Array