Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(devtools-core): Render ST's Visualizer to Include Root Field's Allowed Types #23573

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import type { VisualizeChildData, VisualizeSharedObject } from "./DataVisualizat
import {
determineNodeKind,
toVisualTree,
visualizeSharedTreeNodeBySchema,
visualizeSharedTreeBySchema,
} from "./SharedTreeVisualizer.js";
import type { VisualSharedTreeNode } from "./VisualSharedTreeTypes.js";
import {
type FluidObjectNode,
type FluidObjectTreeNode,
Expand Down Expand Up @@ -260,26 +261,30 @@ export const visualizeSharedTree: VisualizeSharedObject = async (

// Root node of the SharedTree's content.
const treeView = sharedTree.exportVerbose();
// TODO: this visualizer doesn't consider the root as a field, and thus does not display the allowed types or handle when it is empty.
// Tracked by https://dev.azure.com/fluidframework/internal/_workitems/edit/26472.

if (treeView === undefined) {
throw new Error("Support for visualizing empty trees is not implemented");
return {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initializing the view with view.initialize({}) will render:

Screenshot 2025-01-17 at 11 59 20

fluidObjectId: sharedTree.id,
typeMetadata: "SharedTree",
nodeKind: VisualNodeKind.FluidTreeNode,
children: {},
};
}

// Schema of the tree node.
const treeSchema = sharedTree.exportSimpleSchema();

// Traverses the SharedTree and generates a visual representation of the tree and its schema.
const visualTreeRepresentation = await visualizeSharedTreeNodeBySchema(
// Create a root field visualization that shows the allowed types at the root
const visualTreeRepresentation: VisualSharedTreeNode = await visualizeSharedTreeBySchema(
treeView,
treeSchema,
visualizeChildData,
true,
);

// Maps the `visualTreeRepresentation` in the format compatible to {@link visualizeChildData} function.
const visualTree = toVisualTree(visualTreeRepresentation);

// TODO: Validate the type casting.
const visualTreeResult: FluidObjectNode = {
...visualTree,
fluidObjectId: sharedTree.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function createToolTipContents(schema: SharedTreeSchemaNode): VisualTreeNode {
}

/**
* Converts the visual representation from {@link visualizeSharedTreeNodeBySchema} to a visual tree compatible with the devtools-view.
* Converts the visual representation from {@link visualizeInternalNodeBySchema} to a visual tree compatible with the devtools-view.
* @param tree - the visual representation of the SharedTree.
* @returns - the visual representation of type {@link VisualChildNode}
*/
Expand Down Expand Up @@ -150,7 +150,7 @@ export function toVisualTree(tree: VisualSharedTreeNode): VisualChildNode {
/**
* Concatenrate allowed types for `ObjectNodeStoredSchema` and `MapNodeStoredSchema`.
*/
function concatenateTypes(fieldTypes: ReadonlySet<string>): string {
export function concatenateTypes(fieldTypes: ReadonlySet<string>): string {
return [...fieldTypes].join(" | ");
}

Expand All @@ -172,16 +172,14 @@ function getObjectAllowedTypes(schema: SimpleObjectNodeSchema): string {
* Returns the schema & fields of the node.
*/
async function visualizeVerboseNodeFields(
tree: VerboseTreeNode,
treeFields: VerboseTree[] | Record<string, VerboseTree>,
treeSchema: SimpleTreeSchema,
visualizeChildData: VisualizeChildData,
): Promise<Record<string, VisualSharedTreeNode>> {
const treeFields = tree.fields;

const fields: Record<string | number, VisualSharedTreeNode> = {};

for (const [fieldKey, childField] of Object.entries(treeFields)) {
fields[fieldKey] = await visualizeSharedTreeNodeBySchema(
fields[fieldKey] = await visualizeSharedTreeBySchema(
childField,
treeSchema,
visualizeChildData,
Expand All @@ -200,12 +198,16 @@ async function visualizeObjectNode(
treeSchema: SimpleTreeSchema,
visualizeChildData: VisualizeChildData,
): Promise<VisualSharedTreeNode> {
const isRootField = treeSchema.allowedTypes.has(tree.type);

return {
schema: {
schemaName: tree.type,
allowedTypes: getObjectAllowedTypes(nodeSchema),
allowedTypes: isRootField
? concatenateTypes(treeSchema.allowedTypes)
: getObjectAllowedTypes(nodeSchema),
},
fields: await visualizeVerboseNodeFields(tree, treeSchema, visualizeChildData),
fields: await visualizeVerboseNodeFields(tree.fields, treeSchema, visualizeChildData),
kind: VisualSharedTreeNodeKind.InternalNode,
};
}
Expand All @@ -224,36 +226,25 @@ async function visualizeMapNode(
schemaName: tree.type,
allowedTypes: `Record<string, ${concatenateTypes(nodeSchema.allowedTypes)}>`,
},
fields: await visualizeVerboseNodeFields(tree, treeSchema, visualizeChildData),
fields: await visualizeVerboseNodeFields(tree.fields, treeSchema, visualizeChildData),
kind: VisualSharedTreeNodeKind.InternalNode,
};
}

/**
* Main recursive helper function to create the visual representation of the SharedTree.
* Processes tree nodes based on their schema type (e.g., ObjectNodeStoredSchema, MapNodeStoredSchema, LeafNodeStoredSchema), producing the visual representation for each type.
* Helper function to create the visual representation of non-leaf SharedTree nodes.
* Processes internal tree nodes based on their schema type (e.g., ObjectNodeStoredSchema, MapNodeStoredSchema, ArrayNodeStoredSchema),
* producing the visual representation for each type.
*
* @see {@link https://fluidframework.com/docs/data-structures/tree/} for more information on the SharedTree schema.
*
* @remarks
*/
export async function visualizeSharedTreeNodeBySchema(
tree: VerboseTree,
async function visualizeInternalNodeBySchema(
tree: VerboseTreeNode,
treeSchema: SimpleTreeSchema,
visualizeChildData: VisualizeChildData,
): Promise<VisualSharedTreeNode> {
const sf = new SchemaFactory(undefined);
if (Tree.is(tree, [sf.boolean, sf.null, sf.number, sf.handle, sf.string])) {
const nodeSchema = Tree.schema(tree);
return {
schema: {
schemaName: nodeSchema.identifier,
},
value: await visualizeChildData(tree),
kind: VisualSharedTreeNodeKind.LeafNode,
};
}

const schema = treeSchema.definitions.get(tree.type);
if (schema === undefined) {
throw new TypeError("Unrecognized schema type.");
Expand Down Expand Up @@ -281,7 +272,7 @@ export async function visualizeSharedTreeNodeBySchema(
}

for (let i = 0; i < children.length; i++) {
fields[i] = await visualizeSharedTreeNodeBySchema(
fields[i] = await visualizeSharedTreeBySchema(
children[i],
treeSchema,
visualizeChildData,
Expand All @@ -293,7 +284,7 @@ export async function visualizeSharedTreeNodeBySchema(
schemaName: tree.type,
allowedTypes: concatenateTypes(schema.allowedTypes),
},
fields: await visualizeVerboseNodeFields(tree, treeSchema, visualizeChildData),
fields: await visualizeVerboseNodeFields(tree.fields, treeSchema, visualizeChildData),
kind: VisualSharedTreeNodeKind.InternalNode,
};
}
Expand All @@ -302,3 +293,44 @@ export async function visualizeSharedTreeNodeBySchema(
}
}
}

/**
* Creates a visual representation of a SharedTree based on its schema.
* @param tree - The {@link VerboseTree} to visualize
* @param treeSchema - The schema that defines the structure and types of the tree
* @param visualizeChildData - Callback function to visualize child node data
* @returns A visual representation of the tree that includes schema information and node values
*
* @remarks
* This function handles both leaf nodes (primitive values, handles) and internal nodes (objects, maps, arrays).
* For leaf nodes, it creates a visual representation with the node's schema and value.
* For internal nodes, it recursively processes the node's fields using {@link visualizeInternalNodeBySchema}.
*/
export async function visualizeSharedTreeBySchema(
tree: VerboseTree,
treeSchema: SimpleTreeSchema,
visualizeChildData: VisualizeChildData,
isRootField?: boolean,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Josmithr

Interested in your thoughts of this optional parameter. This is to display all the allowed types of the "first" rootfield when it is populated by the leaf-node.

Copy link
Contributor

@Josmithr Josmithr Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be needed. Fundamentally, there is no difference between the root field and any other.

Where we're using it below - does this not recreate the same issue we're trying to fix? E.g., if the root field allows number | string, but the tree is of type number, won't that just display "number" when we want "number | string"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, excuse me, I was reading the code backwards. I think the change creates the problem for non-root fields?

Shouldn't we just always call concatenateTypes(treeSchema.allowedTypes)?

Copy link
Contributor Author

@jikim-msft jikim-msft Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I was seeing was that unless I have this condition (just concatenateTypes(treeSchema.allowedTypes)), all the leaf nodes that are at the sub-root-fields display the allowed types under the tree root.

class RootNodeTwoItem extends builder.object("root-node-item", {
			childrenOne: builder.number, // Should display number
			childrenTwo: RootNodeTwoItemTwo,
		}) {}
Screenshot 2025-01-17 at 16 15 12

This is because the SimpleTreeSchema.allowedTypes gives the allowedTypes directly under the root. Thus, I was inclined to add a filter here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the root schema in this example?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one that is in the FluidObject.ts.

This is also why I have the condition in the visualizeObjectNode().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. In that case, an easier fix might just be to take in the allowedTypes instead of treeSchema. Then each place that calls this can just pass in the appropriate items.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I would recommend taking in a list of types, rather than the concatenated string.

): Promise<VisualSharedTreeNode> {
const schemaFactory = new SchemaFactory(undefined);

return Tree.is(tree, [
schemaFactory.boolean,
schemaFactory.null,
schemaFactory.number,
schemaFactory.handle,
schemaFactory.string,
])
? {
schema: {
schemaName: Tree.schema(tree).identifier,
allowedTypes:
isRootField === true
? concatenateTypes(treeSchema.allowedTypes)
: Tree.schema(tree).identifier,
},
value: await visualizeChildData(tree),
kind: VisualSharedTreeNodeKind.LeafNode,
}
: visualizeInternalNodeBySchema(tree, treeSchema, visualizeChildData);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,66 @@ describe("DefaultVisualizers unit tests", () => {
expect(result).to.deep.equal(expected);
});

it.only("SharedTree: Renders multiple allowed types in SharedTree's root field", async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will be removing only() (and the one below), once we agree on the structure of the visualization (especially adding the root layer).

const factory = SharedTree.getFactory();
const builder = new SchemaFactory("shared-tree-test");

const sharedTree = factory.create(
new MockFluidDataStoreRuntime({ idCompressor: createIdCompressor() }),
"test",
);

const view = sharedTree.viewWith(
new TreeViewConfiguration({ schema: [builder.string, builder.number] }),
);
view.initialize(23);

const result = await visualizeSharedTree(
sharedTree as unknown as ISharedObject,
visualizeChildData,
);

const expected = {
children: {
root: {
value: 23,
nodeKind: "ValueNode",
tooltipContents: {
schema: {
nodeKind: "TreeNode",
children: {
name: {
nodeKind: "ValueNode",
value: "com.fluidframework.leaf.number",
},
},
},
},
},
},
nodeKind: "FluidTreeNode",
tooltipContents: {
schema: {
nodeKind: "TreeNode",
children: {
name: {
nodeKind: "ValueNode",
value: "root",
},
allowedTypes: {
value: "com.fluidframework.leaf.string | com.fluidframework.leaf.number",
nodeKind: "ValueNode",
},
},
},
},
fluidObjectId: "test",
typeMetadata: "SharedTree",
};

expect(result).to.deep.equal(expected);
});

it("Unknown SharedObject", async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const unknownObject = {
Expand Down
49 changes: 33 additions & 16 deletions packages/tools/devtools/devtools-test-app/src/FluidObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,29 +189,46 @@ export class AppData extends DataObject {
childData: builder.optional(LeafSchema),
}) {}

class RootNodeSchema extends builder.object("root-item", {
class RootNodeTwoItemTwo extends builder.object("root-node-two-item-two", {
childrenOne: builder.array(ChildSchema),
childrenTwo: builder.number,
}) {}

const config = new TreeViewConfiguration({ schema: RootNodeSchema });
class RootNodeTwoItem extends builder.object("root-node-item", {
childrenOne: builder.number,
childrenTwo: RootNodeTwoItemTwo,
}) {}

class RootNodeOne extends builder.object("root-node-one", {
leafField: [builder.boolean, builder.handle, builder.string],
}) {}

class RootNodeTwo extends builder.object("root-node-two", {
childField: builder.optional(RootNodeTwoItem),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

builder.optional added to ensure rendering undefined object.

}) {}

const config = new TreeViewConfiguration({
schema: [RootNodeOne, RootNodeTwo, builder.string, builder.number],
});
const view = sharedTree.viewWith(config);
view.initialize({
childrenOne: [
{
childField: "Hello world!",
childData: {
leafField: "Hello world again!",
},
childField: {
childrenOne: 42,
childrenTwo: {
childrenOne: [
{
childField: false,
childData: {
leafField: "leaf data",
},
},
{
childField: true,
},
],
childrenTwo: 123,
},
{
childField: true,
childData: {
leafField: false,
},
},
],
childrenTwo: 32,
},
});
}
}
Loading