diff --git a/packages/dds/tree/api-report/tree.alpha.api.md b/packages/dds/tree/api-report/tree.alpha.api.md index 2ca5f4f2c532..4f47c499dc60 100644 --- a/packages/dds/tree/api-report/tree.alpha.api.md +++ b/packages/dds/tree/api-report/tree.alpha.api.md @@ -918,6 +918,7 @@ export interface RunTransaction { // @alpha @input export interface RunTransactionParams { + readonly label?: unknown; readonly preconditions?: readonly TransactionConstraint[]; } diff --git a/packages/dds/tree/src/core/rebase/types.ts b/packages/dds/tree/src/core/rebase/types.ts index 1a562c8e599b..68fabfca0e67 100644 --- a/packages/dds/tree/src/core/rebase/types.ts +++ b/packages/dds/tree/src/core/rebase/types.ts @@ -201,10 +201,16 @@ export type ChangeMetadata = CommitMetadata & * This is a `SerializedChange` from treeCheckout.ts. */ getChange(): JsonCompatibleReadOnly; + /** + * Optional label provided by the user when commit was created. + * This can be used by undo/redo to group or classify edits. + */ + label?: unknown; } | { readonly isLocal: false; readonly getChange?: undefined; + label?: unknown; } ); diff --git a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts index a2d4136783f1..487abb83b0fb 100644 --- a/packages/dds/tree/src/shared-tree/schematizingTreeView.ts +++ b/packages/dds/tree/src/shared-tree/schematizingTreeView.ts @@ -301,33 +301,43 @@ export class SchematizingSimpleTreeView< addConstraintsToTransaction(this.checkout, constraintsOnRevert, constraints); }; - this.checkout.transaction.start(); - - // Validate preconditions before running the transaction callback. - addConstraints(false /* constraintsOnRevert */, params?.preconditions); - const transactionCallbackStatus = transaction(); - const rollback = transactionCallbackStatus?.rollback; - const value = ( - transactionCallbackStatus as TransactionCallbackStatus - )?.value; - - if (rollback === true) { - this.checkout.transaction.abort(); - return value !== undefined - ? { success: false, value: value as TFailureValue } - : { success: false }; - } + const executeTransaction = (): + | TransactionResultExt + | TransactionResult => { + this.checkout.transaction.start(); - // Validate preconditions on revert after running the transaction callback and was successful. - addConstraints( - true /* constraintsOnRevert */, - transactionCallbackStatus?.preconditionsOnRevert, - ); + // Validate preconditions before running the transaction callback. + addConstraints(false /* constraintsOnRevert */, params?.preconditions); + const transactionCallbackStatus = transaction(); + const rollback = transactionCallbackStatus?.rollback; + const value = ( + transactionCallbackStatus as TransactionCallbackStatus + )?.value; + + if (rollback === true) { + this.checkout.transaction.abort(); + return value !== undefined + ? { success: false, value: value as TFailureValue } + : { success: false }; + } + + // Validate preconditions on revert after running the transaction callback and was successful. + addConstraints( + true /* constraintsOnRevert */, + transactionCallbackStatus?.preconditionsOnRevert, + ); - this.checkout.transaction.commit(); - return value !== undefined - ? { success: true, value: value as TSuccessValue } - : { success: true }; + this.checkout.transaction.commit(); + + return value !== undefined + ? { success: true, value: value as TSuccessValue } + : { success: true }; + }; + + if (params?.label !== undefined) { + return this.checkout.runWithTransactionLabel(params.label, () => executeTransaction()); + } + return executeTransaction(); } private ensureUndisposed(): void { diff --git a/packages/dds/tree/src/shared-tree/treeCheckout.ts b/packages/dds/tree/src/shared-tree/treeCheckout.ts index 883264ecc6eb..020bb62a682a 100644 --- a/packages/dds/tree/src/shared-tree/treeCheckout.ts +++ b/packages/dds/tree/src/shared-tree/treeCheckout.ts @@ -379,6 +379,9 @@ export class TreeCheckout implements ITreeCheckoutFork { private editLock: EditLock; + // User-defined label associated with the transaction whose commit is currently being produced for this checkout. + public transactionLabel?: unknown; + private readonly views = new Set>(); /** @@ -430,6 +433,18 @@ export class TreeCheckout implements ITreeCheckoutFork { this.registerForBranchEvents(); } + public runWithTransactionLabel( + label: TLabel, + fn: (label: TLabel) => TResult, + ): TResult { + this.transactionLabel = label; + try { + return fn(label); + } finally { + this.transactionLabel = undefined; + } + } + public get removedRoots(): ReadOnlyDetachedFieldIndex { return this._removedRoots; } @@ -570,6 +585,7 @@ export class TreeCheckout implements ITreeCheckoutFork { change: encodedChange, } satisfies SerializedChange; }, + label: this.transactionLabel, }; this.#events.emit("changed", metadata, getRevertible); @@ -577,7 +593,12 @@ export class TreeCheckout implements ITreeCheckoutFork { } } else if (this.isRemoteChangeEvent(event)) { // TODO: figure out how to plumb through commit kind info for remote changes - this.#events.emit("changed", { isLocal: false, kind: CommitKind.Default }); + const metaData: ChangeMetadata = { + isLocal: false, + kind: CommitKind.Default, + label: undefined, + }; + this.#events.emit("changed", metaData); } }; diff --git a/packages/dds/tree/src/simple-tree/api/transactionTypes.ts b/packages/dds/tree/src/simple-tree/api/transactionTypes.ts index 757f0b3ddc56..2b11594bc47b 100644 --- a/packages/dds/tree/src/simple-tree/api/transactionTypes.ts +++ b/packages/dds/tree/src/simple-tree/api/transactionTypes.ts @@ -123,4 +123,12 @@ export interface RunTransactionParams { * this client and ignored by all other clients. */ readonly preconditions?: readonly TransactionConstraint[]; + /** + * An optional user-defined label for this transaction. + * + * This label is associated with the commit produced by this transaction, and is surfaced through {@link CommitMetadataAlpha.label}, + * in the `commitApplied` event. + * + */ + readonly label?: unknown; } diff --git a/packages/dds/tree/src/simple-tree/api/tree.ts b/packages/dds/tree/src/simple-tree/api/tree.ts index 2383d639c147..b4aa48b49770 100644 --- a/packages/dds/tree/src/simple-tree/api/tree.ts +++ b/packages/dds/tree/src/simple-tree/api/tree.ts @@ -6,7 +6,6 @@ import type { IFluidLoadable, IDisposable, Listenable } from "@fluidframework/core-interfaces"; import type { - CommitMetadata, ChangeMetadata, RevertibleAlphaFactory, RevertibleFactory, @@ -542,7 +541,7 @@ export interface TreeBranchEvents extends Omit * @param getRevertible - a function provided that allows users to get a revertible for the commit that was applied. If not provided, * this commit is not revertible. */ - commitApplied(data: CommitMetadata, getRevertible?: RevertibleAlphaFactory): void; + commitApplied(data: ChangeMetadata, getRevertible?: RevertibleAlphaFactory): void; } /** @@ -586,7 +585,7 @@ export interface TreeViewEvents { * @param getRevertible - a function provided that allows users to get a revertible for the commit that was applied. If not provided, * this commit is not revertible. */ - commitApplied(data: CommitMetadata, getRevertible?: RevertibleFactory): void; + commitApplied(data: ChangeMetadata, getRevertible?: RevertibleFactory): void; } /** diff --git a/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts b/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts index fbfd47181bcc..8815380f7181 100644 --- a/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts +++ b/packages/dds/tree/src/test/shared-tree/schematizingTreeView.spec.ts @@ -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 }); @@ -1118,4 +1119,205 @@ describe("SchematizingSimpleTreeView", () => { }); }); }); + + describe("transaction labels", () => { + it("exposes label via CommitMetadataAlpha during commitApplied", () => { + const view = getTestObjectView(); + + const labels: unknown[] = []; + + view.events.on("commitApplied", (meta) => { + if (meta.isLocal) { + labels.push(meta.label); + } + }); + + 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]); + }); + + it("CommitMetadataAlpha.label is undefined for unlabeled transactions", () => { + const view = getTestObjectView(); + + const labels: unknown[] = []; + + view.events.on("commitApplied", (meta) => { + if (meta.isLocal) { + labels.push(meta.label); + } + }); + + 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]); + }); + + it("exposes the correct labels for multiple transactions", () => { + const view = getTestObjectView(); + + const labels: unknown[] = []; + + view.events.on("commitApplied", (meta) => { + if (meta.isLocal) { + labels.push(meta.label); + } + }); + + 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]); + }); + }); + + 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 = meta.label; + 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); + }); + }); }); diff --git a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md index 22b8793bd3b1..381f5e3da948 100644 --- a/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md +++ b/packages/framework/fluid-framework/api-report/fluid-framework.alpha.api.md @@ -1288,6 +1288,7 @@ export interface RunTransaction { // @alpha @input export interface RunTransactionParams { + readonly label?: unknown; readonly preconditions?: readonly TransactionConstraint[]; }