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
1 change: 1 addition & 0 deletions packages/dds/tree/api-report/tree.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ export interface RunTransaction {

// @alpha @input
export interface RunTransactionParams {
readonly label?: unknown;
readonly preconditions?: readonly TransactionConstraint[];
}

Expand Down
23 changes: 22 additions & 1 deletion packages/dds/tree/src/shared-tree/schematizingTreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ export class SchematizingSimpleTreeView<
in out TRootSchema extends ImplicitFieldSchema | UnsafeUnknownSchema,
> implements TreeViewAlpha<TRootSchema>, WithBreakable
{
/**
* Optional user-provided label associated with the transaction as a commit is being applied, if provided.
* This value is intended to be read within event handlers like `commitApplied`.
* This value is cleared after each transaction to prevent providing stale/incorrect labels.
*/
private static _currentTransactionLabel: unknown | undefined;

public static get currentTransactionLabel(): unknown | undefined {
return this._currentTransactionLabel;
}

public static setCurrentTransactionLabel(label: unknown | undefined): void {
this._currentTransactionLabel = label;
}

/**
* This is set to undefined when this object is disposed or the view schema does not support viewing the document's stored schema.
*
Expand Down Expand Up @@ -319,7 +334,13 @@ export class SchematizingSimpleTreeView<
transactionCallbackStatus?.preconditionsOnRevert,
);

this.checkout.transaction.commit();
// Set the label before commit, and clear the label after commit.
SchematizingSimpleTreeView.setCurrentTransactionLabel(params?.label);
try {
this.checkout.transaction.commit();
} finally {
SchematizingSimpleTreeView.setCurrentTransactionLabel(undefined);
}
return value !== undefined
? { success: true, value: value as TSuccessValue }
: { success: true };
Expand Down
5 changes: 5 additions & 0 deletions packages/dds/tree/src/simple-tree/api/transactionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,9 @@ export interface RunTransactionParams {
* this client and ignored by all other clients.
*/
readonly preconditions?: readonly TransactionConstraint[];
/**
* An optional user-defined label for this transaction.
* This can be used for grouping logic for Undo/Redo operations.
*/
readonly label?: unknown;
}
211 changes: 211 additions & 0 deletions packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { brand } from "../../util/index.js";
import { UnhydratedFlexTreeNode } from "../../simple-tree/core/unhydratedFlexTree.js";
import { testDocumentIndependentView } from "../testTrees.js";
import { fieldJsonCursor } from "../json/index.js";
import { CommitKind } from "../../core/index.js";

const schema = new SchemaFactoryAlpha("com.example");
const config = new TreeViewConfiguration({ schema: schema.number });
Expand Down Expand Up @@ -1118,4 +1119,214 @@ describe("SchematizingSimpleTreeView", () => {
});
});
});

describe("transaction labels", () => {
it("exposes label via currentTransactionLabel during commitApplied", () => {
const view = getTestObjectView();

const labels: unknown[] = [];

view.events.on("commitApplied", (meta) => {
if (meta.isLocal) {
labels.push(SchematizingSimpleTreeView.currentTransactionLabel);
}
});

const testLabel = "testLabel";
const runTransactionResult = view.runTransaction(
() => {
view.root.content = 0;
},
{ label: testLabel },
);

// Check that transaction was applied.
assert.equal(runTransactionResult.success, true);

// Check that correct label was exposed.
assert.deepEqual(labels, [testLabel]);

// Check that transaction label has been cleared.
assert.equal(SchematizingSimpleTreeView.currentTransactionLabel, undefined);
});

it("currentTransactionLabel is undefined for unlabeled transactions", () => {
const view = getTestObjectView();

const labels: unknown[] = [];

view.events.on("commitApplied", (meta) => {
if (meta.isLocal) {
labels.push(SchematizingSimpleTreeView.currentTransactionLabel);
}
});

const runTransactionResult = view.runTransaction(() => {
view.root.content = 0;
});

// Check that transaction was applied.
assert.equal(runTransactionResult.success, true);
assert.equal(view.root.content, 0);

// Check that correct label was exposed.
assert.deepEqual(labels, [undefined]);

// Check that transaction label has been cleared.
assert.equal(SchematizingSimpleTreeView.currentTransactionLabel, undefined);
});

it("exposes the correct labels for multiple transactions", () => {
const view = getTestObjectView();

const labels: unknown[] = [];

view.events.on("commitApplied", (meta) => {
if (meta.isLocal) {
labels.push(SchematizingSimpleTreeView.currentTransactionLabel);
}
});

const testLabel1 = "testLabel1";
const runTransactionResult1 = view.runTransaction(
() => {
view.root.content = 0;
},
{ label: testLabel1 },
);

// run second transaction with no label
const runTransactionResult2 = view.runTransaction(() => {
view.root.content = 1;
});

const testLabel3 = "testLabel3";
const runTransactionResult3 = view.runTransaction(
() => {
view.root.content = 2;
},
{ label: testLabel3 },
);
// Check that transactions were applied.
assert.equal(runTransactionResult1.success, true);
assert.equal(runTransactionResult2.success, true);
assert.equal(runTransactionResult3.success, true);

// Check that correct label was exposed.
assert.deepEqual(labels, [testLabel1, undefined, testLabel3]);

// Check that transaction label has been cleared.
assert.equal(SchematizingSimpleTreeView.currentTransactionLabel, undefined);
});
});

describe("label-based grouping for undo", () => {
it("groups multiple transactions with the same label into a single undo group", () => {
const view = getTestObjectView();

interface LabeledGroup {
label: unknown;
revertibles: { revert(): void }[];
}

const undoGroups: LabeledGroup[] = [];

view.events.on("commitApplied", (meta, getRevertible) => {
// Omit remote, Undo/Redo commits
if (meta.isLocal && getRevertible !== undefined && meta.kind === CommitKind.Default) {
const label = SchematizingSimpleTreeView.currentTransactionLabel;
const revertible = getRevertible();

// Check if the latest group contains the same label.
const latestGroup = undoGroups[undoGroups.length - 1];
if (
label !== undefined &&
latestGroup !== undefined &&
label === latestGroup.label
) {
latestGroup.revertibles.push(revertible);
} else {
undoGroups.push({ label, revertibles: [revertible] });
}
}
});

const undoLatestGroup = () => {
const latestGroup = undoGroups.pop() ?? fail("There are currently no undo groups.");
for (const revertible of latestGroup.revertibles.reverse()) {
revertible.revert();
}
};

const initialRootContent = view.root.content;

// Edit group 1
const testLabel1 = "testLabel1";

const runTransactionResult1 = view.runTransaction(
() => {
view.root.content = 1;
},
{ label: testLabel1 },
);
assert.equal(runTransactionResult1.success, true);
assert.equal(view.root.content, 1);

const runTransactionResult2 = view.runTransaction(
() => {
view.root.content = 2;
},
{ label: testLabel1 },
);
assert.equal(runTransactionResult2.success, true);
assert.equal(view.root.content, 2);

const runTransactionResult3 = view.runTransaction(
() => {
view.root.content = 3;
},
{ label: testLabel1 },
);
assert.equal(runTransactionResult3.success, true);
assert.equal(view.root.content, 3);

// Edit group 2
const testLabel2 = "testLabel2";

const runTransactionResult4 = view.runTransaction(
() => {
view.root.content = 4;
},
{ label: testLabel2 },
);
assert.equal(runTransactionResult4.success, true);
assert.equal(view.root.content, 4);

const runTransactionResult5 = view.runTransaction(
() => {
view.root.content = 5;
},
{ label: testLabel2 },
);
assert.equal(runTransactionResult5.success, true);
assert.equal(view.root.content, 5);

const runTransactionResult6 = view.runTransaction(
() => {
view.root.content = 6;
},
{ label: testLabel2 },
);
assert.equal(runTransactionResult6.success, true);
assert.equal(view.root.content, 6);

// This should undo all the edits from group 2.
undoLatestGroup();
assert.equal(view.root.content, 3);

// This should undo all the edits from group 1
undoLatestGroup();
assert.equal(view.root.content, initialRootContent);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ export interface RunTransaction {

// @alpha @input
export interface RunTransactionParams {
readonly label?: unknown;
readonly preconditions?: readonly TransactionConstraint[];
}

Expand Down
Loading