Skip to content

Commit 2f20875

Browse files
authored
Inspector v2: Back compat for explorerExtensibility v1 option (#17470)
Prior to this PR Inspector v2 supports Scene Explorer "commands" which are shown inline in the tree view item, and as such they _must_ have an icon. For the Inspector v1 `explorerExtensibility` option, it is effectively a Scene Explorer command, but has no icon. In Inspector v1, there is a `...` icon at the end that has the context menu. In Inspector v2, we already have a right click context menu for other things like expand/collapse. Given this, the strategy for Inspector v2 is: - Allow for "inline" vs. "contextMenu" commands for Scene Explorer. "inline" is the default and requires an icon, where as for "contextMenu" the icon is optional. - For commands that are toggle "type" and contextMenu "mode", if an icon is provided it is used, otherwise a checkmark is used, but we don't double up the iconography and have both a checkmark and an icon. With this in place, `explorerExtensibility` is simply adding commands of type "action" and mode "contextMenu" with no icon. <img width="389" height="331" alt="image" src="https://github.com/user-attachments/assets/4d54c40b-8011-4c9e-be98-2de5b7e96106" />
1 parent 92a601a commit 2f20875

File tree

2 files changed

+161
-40
lines changed

2 files changed

+161
-40
lines changed

packages/dev/inspector-v2/src/components/scene/sceneExplorer.tsx

Lines changed: 129 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ScrollToInterface } from "@fluentui-contrib/react-virtualizer";
2-
import type { TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components";
2+
import type { MenuCheckedValueChangeData, MenuCheckedValueChangeEvent, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components";
33
import type { FluentIcon } from "@fluentui/react-icons";
44
import type { ComponentType, FunctionComponent } from "react";
55

@@ -14,7 +14,9 @@ import {
1414
FlatTreeItem,
1515
makeStyles,
1616
Menu,
17+
MenuDivider,
1718
MenuItem,
19+
MenuItemCheckbox,
1820
MenuList,
1921
MenuPopover,
2022
MenuTrigger,
@@ -25,7 +27,7 @@ import {
2527
TreeItemLayout,
2628
treeItemLevelToken,
2729
} from "@fluentui/react-components";
28-
import { ArrowExpandAllRegular, createFluentIcon, FilterRegular, GlobeRegular } from "@fluentui/react-icons";
30+
import { ArrowCollapseAllRegular, ArrowExpandAllRegular, createFluentIcon, FilterRegular, GlobeRegular } from "@fluentui/react-icons";
2931
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3032

3133
import { ToggleButton } from "shared-ui-components/fluent/primitives/toggleButton";
@@ -103,25 +105,33 @@ export type SceneExplorerSection<T extends EntityBase> = Readonly<{
103105
getEntityMovedObservables?: () => readonly IReadonlyObservable<T>[];
104106
}>;
105107

106-
type Command = Partial<IDisposable> &
107-
Readonly<{
108-
/**
109-
* The display name of the command (e.g. "Delete", "Rename", etc.).
110-
*/
111-
displayName: string;
108+
type InlineCommand = {
109+
/**
110+
* An icon component to render for the command. Required for inline commands.
111+
*/
112+
icon: ComponentType;
112113

113-
/**
114-
* An icon component to render for the command.
115-
*/
116-
icon: ComponentType;
114+
/**
115+
* The mode of the command. Inline commands are shown directly in the tree item layout. Inline by default.
116+
*/
117+
mode?: "inline";
118+
};
117119

118-
/**
119-
* An observable that notifies when the command state changes.
120-
*/
121-
onChange?: IReadonlyObservable<unknown>;
122-
}>;
120+
type ContextMenuCommand = {
121+
/**
122+
* An icon component to render for the command. Optional for context menu commands.
123+
*/
124+
icon?: ComponentType;
125+
126+
/**
127+
* The mode of the command. Context menu commands are shown in the context menu for the tree item.
128+
*/
129+
mode: "contextMenu";
130+
};
123131

124-
type ActionCommand = Command & {
132+
type CommandMode = (InlineCommand | ContextMenuCommand)["mode"];
133+
134+
type ActionCommand = {
125135
readonly type: "action";
126136

127137
/**
@@ -130,7 +140,7 @@ type ActionCommand = Command & {
130140
execute(): void;
131141
};
132142

133-
type ToggleCommand = Command & {
143+
type ToggleCommand = {
134144
readonly type: "toggle";
135145

136146
/**
@@ -139,7 +149,22 @@ type ToggleCommand = Command & {
139149
isEnabled: boolean;
140150
};
141151

142-
export type SceneExplorerCommand = ActionCommand | ToggleCommand;
152+
type CommandType = (ActionCommand | ToggleCommand)["type"];
153+
154+
export type SceneExplorerCommand<ModeT extends CommandMode = CommandMode, TypeT extends CommandType = CommandType> = Partial<IDisposable> &
155+
Readonly<{
156+
/**
157+
* The display name of the command (e.g. "Delete", "Rename", etc.).
158+
*/
159+
displayName: string;
160+
161+
/**
162+
* An observable that notifies when the command state changes.
163+
*/
164+
onChange?: IReadonlyObservable<unknown>;
165+
}> &
166+
(ModeT extends "inline" ? InlineCommand : ContextMenuCommand) &
167+
(TypeT extends "action" ? ActionCommand : ToggleCommand);
143168

144169
export type SceneExplorerCommandProvider<T extends EntityBase> = Readonly<{
145170
/**
@@ -224,7 +249,7 @@ const useStyles = makeStyles({
224249
},
225250
});
226251

227-
const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) => {
252+
const ActionCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "action"> }> = (props) => {
228253
const { command } = props;
229254

230255
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -240,7 +265,7 @@ const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) =>
240265
);
241266
};
242267

243-
const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) => {
268+
const ToggleCommand: FunctionComponent<{ command: SceneExplorerCommand<"inline", "toggle"> }> = (props) => {
244269
const { command } = props;
245270

246271
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -256,7 +281,7 @@ const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) =>
256281
// This "placeholder" command has a blank icon and is a no-op. It is used for aside
257282
// alignment when some toggle commands are enabled. See more details on the commands
258283
// for setting the aside state.
259-
const PlaceHolderCommand: ActionCommand = {
284+
const PlaceHolderCommand: SceneExplorerCommand<"inline", "action"> = {
260285
type: "action",
261286
displayName: "",
262287
icon: createFluentIcon("Placeholder", "1em", ""),
@@ -265,7 +290,7 @@ const PlaceHolderCommand: ActionCommand = {
265290
},
266291
};
267292

268-
function MakeCommandElement(command: SceneExplorerCommand, isPlaceholder: boolean): JSX.Element {
293+
function MakeInlineCommandElement(command: SceneExplorerCommand<"inline">, isPlaceholder: boolean): JSX.Element {
269294
if (isPlaceholder) {
270295
// Placeholders are not visible and not interacted with, so they are always ActionCommand
271296
// components, just to ensure the exact right amount of space is taken up.
@@ -409,9 +434,11 @@ const EntityTreeItem: FunctionComponent<{
409434
}, [entityItem.entity, commandProviders])
410435
);
411436

437+
const inlineCommands = useMemo(() => commands.filter((command): command is SceneExplorerCommand<"inline"> => command.mode !== "contextMenu"), [commands]);
438+
412439
// TreeItemLayout actions (totally unrelated to "Action" type commands) are only visible when the item is focused or has pointer hover.
413440
const actions = useMemo(() => {
414-
const defaultCommands: SceneExplorerCommand[] = [];
441+
const defaultCommands: SceneExplorerCommand<"inline">[] = [];
415442
if (hasChildren) {
416443
defaultCommands.push({
417444
type: "action",
@@ -421,8 +448,8 @@ const EntityTreeItem: FunctionComponent<{
421448
});
422449
}
423450

424-
return [...defaultCommands, ...commands].map((command) => MakeCommandElement(command, false));
425-
}, [commands, hasChildren, expandAll]);
451+
return [...defaultCommands, ...inlineCommands].map((command) => MakeInlineCommandElement(command, false));
452+
}, [inlineCommands, hasChildren, expandAll]);
426453

427454
// TreeItemLayout asides are always visible.
428455
const [aside, setAside] = useState<readonly JSX.Element[]>([]);
@@ -433,18 +460,18 @@ const EntityTreeItem: FunctionComponent<{
433460
const updateAside = () => {
434461
let isAnyCommandEnabled = false;
435462
const aside: JSX.Element[] = [];
436-
for (const command of commands) {
463+
for (const command of inlineCommands) {
437464
isAnyCommandEnabled ||= command.type === "toggle" && command.isEnabled;
438465
if (isAnyCommandEnabled) {
439-
aside.push(MakeCommandElement(command, command.type !== "toggle" || !command.isEnabled));
466+
aside.push(MakeInlineCommandElement(command, command.type !== "toggle" || !command.isEnabled));
440467
}
441468
}
442469
setAside(aside);
443470
};
444471

445472
updateAside();
446473

447-
const observers = commands
474+
const observers = inlineCommands
448475
.map((command) => command.onChange)
449476
.filter((onChange) => !!onChange)
450477
.map((onChange) => onChange.add(updateAside));
@@ -454,10 +481,50 @@ const EntityTreeItem: FunctionComponent<{
454481
observer.remove();
455482
}
456483
};
457-
}, [commands]);
484+
}, [inlineCommands]);
485+
486+
const contextMenuCommands = useMemo(() => commands.filter((command): command is SceneExplorerCommand<"contextMenu"> => command.mode === "contextMenu"), [commands]);
487+
488+
const [checkedContextMenuItems, setCheckedContextMenuItems] = useState({ toggleCommands: [] as string[] });
489+
490+
useEffect(() => {
491+
const updateCheckedItems = () => {
492+
const checkedItems: string[] = [];
493+
for (const command of contextMenuCommands) {
494+
if (command.type === "toggle" && command.isEnabled) {
495+
checkedItems.push(command.displayName);
496+
}
497+
}
498+
setCheckedContextMenuItems({ toggleCommands: checkedItems });
499+
};
500+
501+
updateCheckedItems();
502+
503+
const observers = contextMenuCommands
504+
.map((command) => command.onChange)
505+
.filter((onChange) => !!onChange)
506+
.map((onChange) => onChange.add(updateCheckedItems));
507+
508+
return () => {
509+
for (const observer of observers) {
510+
observer.remove();
511+
}
512+
};
513+
}, [contextMenuCommands]);
514+
515+
const onContextMenuCheckedValueChange = useCallback(
516+
(e: MenuCheckedValueChangeEvent, data: MenuCheckedValueChangeData) => {
517+
for (const command of contextMenuCommands) {
518+
if (command.type === "toggle") {
519+
command.isEnabled = data.checkedItems.includes(command.displayName);
520+
}
521+
}
522+
},
523+
[contextMenuCommands]
524+
);
458525

459526
return (
460-
<Menu openOnContext>
527+
<Menu openOnContext checkedValues={checkedContextMenuItems} onCheckedValueChange={onContextMenuCheckedValueChange}>
461528
<MenuTrigger disableButtonEnhancement>
462529
<FlatTreeItem
463530
key={entityItem.entity.uniqueId}
@@ -492,14 +559,37 @@ const EntityTreeItem: FunctionComponent<{
492559
</TreeItemLayout>
493560
</FlatTreeItem>
494561
</MenuTrigger>
495-
<MenuPopover hidden={!hasChildren}>
562+
<MenuPopover hidden={!hasChildren && contextMenuCommands.length === 0}>
496563
<MenuList>
497-
<MenuItem onClick={expandAll}>
498-
<Body1>Expand All</Body1>
499-
</MenuItem>
500-
<MenuItem onClick={collapseAll}>
501-
<Body1>Collapse All</Body1>
502-
</MenuItem>
564+
{hasChildren && (
565+
<>
566+
<MenuItem icon={<ArrowExpandAllRegular />} onClick={expandAll}>
567+
<Body1>Expand All</Body1>
568+
</MenuItem>
569+
<MenuItem icon={<ArrowCollapseAllRegular />} onClick={collapseAll}>
570+
<Body1>Collapse All</Body1>
571+
</MenuItem>
572+
</>
573+
)}
574+
{hasChildren && contextMenuCommands.length > 0 && <MenuDivider />}
575+
{contextMenuCommands.map((command) =>
576+
command.type === "action" ? (
577+
<MenuItem key={command.displayName} icon={command.icon ? <command.icon /> : undefined} onClick={() => command.execute()}>
578+
{command.displayName}
579+
</MenuItem>
580+
) : (
581+
<MenuItemCheckbox
582+
key={command.displayName}
583+
// Don't show both a checkmark and an icon. null means no checkmark, undefined means default (checkmark).
584+
checkmark={command.icon ? null : undefined}
585+
icon={command.icon ? <command.icon /> : undefined}
586+
name="toggleCommands"
587+
value={command.displayName}
588+
>
589+
{command.displayName}
590+
</MenuItemCheckbox>
591+
)
592+
)}
503593
</MenuList>
504594
</MenuPopover>
505595
</Menu>

packages/dev/inspector-v2/src/legacy/inspector.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export function ConvertOptions(v1Options: Partial<InspectorV1Options>): Partial<
3333
// • skipDefaultFontLoading: Probably doesn't make sense for Inspector v2 using Fluent.
3434

3535
// TODO:
36-
// • explorerExtensibility
3736
// • contextMenu
3837
// • contextMenuOverride
3938

@@ -131,6 +130,38 @@ export function ConvertOptions(v1Options: Partial<InspectorV1Options>): Partial<
131130
serviceDefinitions.push(additionalNodesServiceDefinition);
132131
}
133132

133+
if (v1Options.explorerExtensibility && v1Options.explorerExtensibility.length > 0) {
134+
const { explorerExtensibility } = v1Options;
135+
const explorerExtensibilityServiceDefinition: ServiceDefinition<[], [ISceneExplorerService]> = {
136+
friendlyName: "Explorer Extensibility",
137+
consumes: [SceneExplorerServiceIdentity],
138+
factory: (sceneExplorerService) => {
139+
const sceneExplorerCommandRegistrations = explorerExtensibility.flatMap((command) =>
140+
command.entries.map((entry) =>
141+
sceneExplorerService.addCommand({
142+
predicate: (entity): entity is EntityBase => command.predicate(entity),
143+
getCommand: (entity) => {
144+
return {
145+
displayName: entry.label,
146+
type: "action",
147+
mode: "contextMenu",
148+
execute: () => entry.action(entity),
149+
};
150+
},
151+
})
152+
)
153+
);
154+
155+
return {
156+
dispose: () => {
157+
sceneExplorerCommandRegistrations.forEach((registration) => registration.dispose());
158+
},
159+
};
160+
},
161+
};
162+
serviceDefinitions.push(explorerExtensibilityServiceDefinition);
163+
}
164+
134165
const v2Options: Partial<InspectorV2Options> = {
135166
containerElement: v1Options.globalRoot,
136167
layoutMode: v1Options.overlay ? "overlay" : "inline",

0 commit comments

Comments
 (0)