feat(graph): enable composite graph functionality (#27789)

This PR enables composite graph functionality:
- Experimental feature to enable Composite Graph
- In Composite Graph mode:
  - Nodes are shown by default.
  - Show/Hide All Projects function similarly to regular mode
- Focus a Composite Node renders the inner nodes with up-to 3 additional
containers: Green area contains external nodes that depend on the inner
nodes; Orange area contains external nodes that the inner nodes depend
depend on; Purple area contains external nodes with circular
dependencies with the inner nodes.
    - Focused node can be unfocus/reset.
- Only one node can be focused at one given time. - Show All projects
while having a focused node will unfocus the node.
- Expand a Composite Node renders the inner nodes of the composite node
in-place (i.e: still keep the context of the current graph). Expanded
node can be collapsed to go back.
This commit is contained in:
Chau Tran 2024-09-25 12:20:48 -05:00 committed by GitHub
parent 7e1cf531ca
commit 3c95965e7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 225 additions and 47 deletions

View File

@ -25,15 +25,65 @@ export const compositeGraphStateConfig: ProjectGraphStateNodeConfig = {
),
],
exit: [
send(() => ({ type: 'notifyGraphDisableCompositeGraph' }), {
to: (ctx) => ctx.graphActor,
}),
assign((ctx) => {
ctx.compositeGraph.enabled = false;
ctx.compositeGraph.context = undefined;
}),
send(
(ctx) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
fileMap: ctx.fileMap,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
composite: ctx.compositeGraph,
}),
{
to: (ctx) => ctx.graphActor,
}
),
],
on: {
selectAll: {
actions: [
assign((ctx, event) => {
if (event.type !== 'selectAll') return;
ctx.compositeGraph.enabled = true;
ctx.compositeGraph.context = null;
}),
send((ctx) => ({
type: 'enableCompositeGraph',
context: ctx.compositeGraph.context,
})),
],
},
deselectAll: {
actions: [
assign((ctx, event) => {
if (event.type !== 'deselectAll') return;
ctx.compositeGraph.enabled = true;
}),
send(
() => ({
type: 'notifyGraphHideAllProjects',
}),
{ to: (context) => context.graphActor }
),
],
},
selectAffected: {
actions: [
send(
() => ({
type: 'notifyGraphShowAffectedProjects',
}),
{ to: (context) => context.graphActor }
),
],
},
focusProject: {
actions: [
assign((ctx, event) => {
@ -112,6 +162,7 @@ export const compositeGraphStateConfig: ProjectGraphStateNodeConfig = {
if (event.type !== 'enableCompositeGraph') return;
ctx.compositeGraph.enabled = true;
ctx.compositeGraph.context = event.context || undefined;
ctx.focusedProject = null;
}),
send(
(ctx, event) => ({

View File

@ -8,7 +8,7 @@ export interface CompositeGraphPanelProps {
export const CompositeGraphPanel = memo(
({ compositeEnabled, compositeEnabledChanged }: CompositeGraphPanelProps) => {
return (
<div className="px-4">
<div className="mt-4 px-4">
<div className="flex items-start">
<div className="flex h-5 items-center">
<input
@ -16,7 +16,7 @@ export const CompositeGraphPanel = memo(
name="composite"
value="composite"
type="checkbox"
className="h-4 w-4 accent-purple-500"
className="h-4 w-4 accent-blue-500 dark:accent-sky-500"
onChange={(event) =>
compositeEnabledChanged(event.target.checked)
}

View File

@ -3,11 +3,15 @@ import { CheckboxPanel } from '../../ui-components/checkbox-panel';
export interface DisplayOptionsPanelProps {
groupByFolder: boolean;
groupByFolderChanged: (checked: boolean) => void;
disabled?: boolean;
disabledDescription?: string;
}
export const GroupByFolderPanel = ({
groupByFolder,
groupByFolderChanged,
disabled,
disabledDescription,
}: DisplayOptionsPanelProps) => {
return (
<CheckboxPanel
@ -16,6 +20,8 @@ export const GroupByFolderPanel = ({
name={'groupByFolder'}
label={'Group by folder'}
description={'Visually arrange libraries by folders.'}
disabled={disabled}
disabledDescription={disabledDescription}
/>
);
};

View File

@ -27,6 +27,7 @@ import { getProjectGraphService } from '../machines/get-services';
import { Link, useNavigate, useNavigation } from 'react-router-dom';
import { useRouteConstructor } from '@nx/graph/shared';
import { CompositeNode } from '../interfaces';
import { useMemo } from 'react';
interface SidebarProject {
projectGraphNode: ProjectGraphProjectNode;
@ -249,6 +250,10 @@ function CompositeNodeListItem({
const routeConstructor = useRouteConstructor();
const navigate = useNavigate();
const label = compositeNode.parent
? `${compositeNode.parent}/${compositeNode.label}`
: compositeNode.label;
function toggleProject() {
if (compositeNode.state !== 'hidden') {
projectGraphService.send({
@ -283,13 +288,8 @@ function CompositeNodeListItem({
<div className="flex items-center">
<Link
to={routeConstructor(
{
pathname: `/projects`,
search: `?composite=true&compositeContext=${encodeURIComponent(
compositeNode.id
)}`,
},
false
{ pathname: `/projects`, search: `?composite=${compositeNode.id}` },
true
)}
className="mr-1 flex items-center rounded-md border-slate-300 bg-white p-1 font-medium text-slate-500 shadow-sm ring-1 ring-slate-200 transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-600 hover:dark:bg-slate-700"
title="Focus on this node"
@ -313,11 +313,11 @@ function CompositeNodeListItem({
<label
className="ml-2 block w-full cursor-pointer truncate rounded-md p-2 font-mono font-normal transition hover:bg-slate-50 hover:dark:bg-slate-700"
data-project={compositeNode.id}
title={compositeNode.label}
title={label}
data-active={compositeNode.state !== 'hidden'}
onClick={toggleProject}
>
{compositeNode.label}
{label}
</label>
</div>
@ -339,8 +339,6 @@ function CompositeNodeList({
}: {
compositeNodes: CompositeNode[];
}) {
const projectGraphService = getProjectGraphService();
if (compositeNodes.length === 0) {
return <p>No composite nodes</p>;
}

View File

@ -7,6 +7,8 @@ import { useProjectGraphSelector } from './hooks/use-project-graph-selector';
import { TracingAlgorithmType } from './machines/interfaces';
import {
collapseEdgesSelector,
compositeContextSelector,
compositeGraphEnabledSelector,
focusedProjectNameSelector,
getTracingInfo,
groupByFolderSelector,
@ -40,9 +42,13 @@ import {
} from 'react-router-dom';
import { useCurrentPath } from '../hooks/use-current-path';
import { ProjectDetailsModal } from '../ui-components/project-details-modal';
import { CompositeGraphPanel } from './panels/composite-graph-panel';
import { CompositeContextPanel } from '../ui-components/composite-context-panel';
import { getGraphService } from '../machines/graph.service';
export function ProjectsSidebar(): JSX.Element {
const environmentConfig = useEnvironmentConfig();
const graphService = getGraphService();
const projectGraphService = getProjectGraphService();
const focusedProject = useProjectGraphSelector(focusedProjectNameSelector);
const searchDepthInfo = useProjectGraphSelector(searchDepthSelector);
@ -53,6 +59,11 @@ export function ProjectsSidebar(): JSX.Element {
);
const groupByFolder = useProjectGraphSelector(groupByFolderSelector);
const collapseEdges = useProjectGraphSelector(collapseEdgesSelector);
const compositeEnabled = useProjectGraphSelector(
compositeGraphEnabledSelector
);
const compositeContext = useProjectGraphSelector(compositeContextSelector);
const isTracing = projectGraphService.getSnapshot().matches('tracing');
const tracingInfo = useProjectGraphSelector(getTracingInfo);
@ -75,17 +86,48 @@ export function ProjectsSidebar(): JSX.Element {
navigate(routeConstructor('/projects', true));
}
function resetCompositeContext() {
projectGraphService.send({ type: 'enableCompositeGraph', context: null });
navigate(
routeConstructor(
{ pathname: '/projects', search: '?composite=true' },
true
)
);
}
function showAllProjects() {
navigate(routeConstructor('/projects/all', true));
navigate(
routeConstructor('/projects/all', (searchParams) => {
if (searchParams.has('composite')) {
searchParams.set('composite', 'true');
}
return searchParams;
})
);
}
function hideAllProjects() {
projectGraphService.send({ type: 'deselectAll' });
navigate(routeConstructor('/projects', true));
navigate(
routeConstructor('/projects', (searchParams) => {
if (searchParams.has('composite')) {
searchParams.set('composite', 'true');
}
return searchParams;
})
);
}
function showAffectedProjects() {
navigate(routeConstructor('/projects/affected', true));
navigate(
routeConstructor('/projects/affected', (searchParams) => {
if (searchParams.has('composite')) {
searchParams.set('composite', 'true');
}
return searchParams;
})
);
}
function searchDepthFilterEnabledChange(checked: boolean) {
@ -126,6 +168,19 @@ export function ProjectsSidebar(): JSX.Element {
});
}
function compositeEnabledChanged(checked: boolean) {
navigate(
routeConstructor('/projects', (searchParams) => {
if (checked) {
searchParams.set('composite', 'true');
} else {
searchParams.delete('composite');
}
return searchParams;
})
);
}
function incrementDepthFilter() {
const newSearchDepth = searchDepthInfo.searchDepth + 1;
setSearchParams((currentSearchParams) => {
@ -182,6 +237,19 @@ export function ProjectsSidebar(): JSX.Element {
});
}
useEffect(() => {
return graphService.listen((event) => {
if (event.type === 'CompositeNodeDblClick') {
projectGraphService.send({
type: event.data.expanded
? 'collapseCompositeNode'
: 'expandCompositeNode',
id: event.id,
});
}
});
}, []);
useEffect(() => {
projectGraphService.send({
type: 'setProjects',
@ -224,7 +292,7 @@ export function ProjectsSidebar(): JSX.Element {
projectName: routeParams.endTrace,
});
}
}, [routeParams]);
}, [routeParams, compositeEnabled]);
useEffect(() => {
if (searchParams.has('groupByFolder') && groupByFolder === false) {
@ -251,6 +319,17 @@ export function ProjectsSidebar(): JSX.Element {
});
}
if (searchParams.has('composite')) {
const compositeParam = searchParams.get('composite');
projectGraphService.send({
type: 'enableCompositeGraph',
context: compositeParam === 'true' ? null : compositeParam,
});
} else if (!searchParams.has('composite')) {
projectGraphService.send({ type: 'disableCompositeGraph' });
navigate(routeConstructor('/projects', true));
}
if (searchParams.has('searchDepth')) {
const parsedValue = parseInt(searchParams.get('searchDepth'), 10);
@ -329,6 +408,13 @@ export function ProjectsSidebar(): JSX.Element {
<>
<ProjectDetailsModal />
{compositeEnabled && compositeContext ? (
<CompositeContextPanel
compositeContext={compositeContext}
reset={resetCompositeContext}
/>
) : null}
{focusedProject ? (
<FocusedPanel
focusedLabel={focusedProject}
@ -367,6 +453,8 @@ export function ProjectsSidebar(): JSX.Element {
<GroupByFolderPanel
groupByFolder={groupByFolder}
groupByFolderChanged={groupByFolderChanged}
disabled={compositeEnabled}
disabledDescription="Group by folder is not available when composite graph is enabled"
></GroupByFolderPanel>
<SearchDepth
@ -377,8 +465,13 @@ export function ProjectsSidebar(): JSX.Element {
decrementDepthFilter={decrementDepthFilter}
></SearchDepth>
<CompositeGraphPanel
compositeEnabled={compositeEnabled}
compositeEnabledChanged={compositeEnabledChanged}
></CompositeGraphPanel>
<ExperimentalFeature>
<div className="mx-4 mt-8 rounded-lg border-2 border-dashed border-purple-500 p-4 shadow-lg dark:border-purple-600 dark:bg-[#0B1221]">
<div className="mx-4 mt-8 flex flex-col gap-4 rounded-lg border-2 border-dashed border-purple-500 p-4 shadow-lg dark:border-purple-600 dark:bg-[#0B1221]">
<h3 className="cursor-text px-4 py-2 text-sm font-semibold uppercase tracking-wide text-slate-800 lg:text-xs dark:text-slate-200">
Experimental Features
</h3>

View File

@ -36,4 +36,5 @@ export interface CompositeNode {
id: string;
label: string;
state: 'expanded' | 'collapsed' | 'hidden';
parent?: string;
}

View File

@ -23,7 +23,7 @@ import { Dropdown, Spinner } from '@nx/graph/ui-components';
import { getSystemTheme, Theme, ThemePanel } from '@nx/graph-internal/ui-theme';
import { Tooltip } from '@nx/graph/ui-tooltips';
import classNames from 'classnames';
import { useLayoutEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import {
Outlet,
useNavigate,
@ -43,14 +43,18 @@ import { TooltipDisplay } from './ui-tooltips/graph-tooltip-display';
export function Shell(): JSX.Element {
const projectGraphService = getProjectGraphService();
const projectGraphDataService = getProjectGraphDataService();
const graphService = getGraphService();
const lastPerfReport = useSyncExternalStore(
(callback) => graphService.listen(callback),
() => graphService.lastPerformanceReport
const [lastPerfReport, setLastPerfReport] = useState(
graphService.lastPerformanceReport
);
useEffect(() => {
graphService.listen(() => {
setLastPerfReport(graphService.lastPerformanceReport);
});
}, []);
const nodesVisible = lastPerfReport.numNodes !== 0;
const environment = useEnvironmentConfig();

View File

@ -1,4 +1,5 @@
import { memo } from 'react';
import classNames from 'classnames';
export interface CheckboxPanelProps {
checked: boolean;
@ -6,12 +7,28 @@ export interface CheckboxPanelProps {
name: string;
label: string;
description: string;
disabled?: boolean;
disabledDescription?: string;
}
export const CheckboxPanel = memo(
({ checked, checkChanged, label, description, name }: CheckboxPanelProps) => {
({
checked,
checkChanged,
label,
description,
name,
disabled,
disabledDescription,
}: CheckboxPanelProps) => {
return (
<div className="mt-8 px-4">
<div
className={classNames(
'mt-8 px-4',
disabled ? 'cursor-not-allowed opacity-50' : ''
)}
title={disabled ? disabledDescription : description}
>
<div className="flex items-start">
<div className="flex h-5 items-center">
<input
@ -22,12 +39,16 @@ export const CheckboxPanel = memo(
className="h-4 w-4 accent-blue-500 dark:accent-sky-500"
onChange={(event) => checkChanged(event.target.checked)}
checked={checked}
disabled={disabled}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor={name}
className="cursor-pointer font-medium text-slate-600 dark:text-slate-400"
className={classNames(
' font-medium text-slate-600 dark:text-slate-400',
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
{label}
</label>

View File

@ -41,17 +41,16 @@ export function TooltipDisplay() {
});
break;
case 'focus-node': {
const to =
action.tooltipNodeType === 'compositeNode'
? routeConstructor(
{
pathname: `/projects`,
search: `?composite=true&compositeContext=${action.id}`,
},
false
if (action.tooltipNodeType === 'compositeNode') {
navigate(
routeConstructor(
{ pathname: `/projects`, search: `?composite=${action.id}` },
true
)
: routeConstructor(`/projects/${action.id}`, true);
navigate(to);
);
} else {
navigate(routeConstructor(`/projects/${action.id}`, true));
}
break;
}
case 'collapse-node':
@ -81,7 +80,12 @@ export function TooltipDisplay() {
navigate(
routeConstructor(
`/projects/trace/${encodeURIComponent(start)}/${action.id}`,
true
(searchParams) => {
if (searchParams.has('composite')) {
searchParams.delete('composite');
}
return searchParams;
}
)
);
break;

View File

@ -327,7 +327,7 @@
"@markdoc/markdoc": "0.2.2",
"@monaco-editor/react": "^4.4.6",
"@napi-rs/canvas": "^0.1.52",
"@nx/graph": "0.0.1-alpha.16",
"@nx/graph": "0.0.1-alpha.23",
"@react-spring/three": "^9.7.3",
"@react-three/drei": "^9.108.3",
"@react-three/fiber": "^8.16.8",

10
pnpm-lock.yaml generated
View File

@ -31,8 +31,8 @@ importers:
specifier: ^0.1.52
version: 0.1.55
'@nx/graph':
specifier: 0.0.1-alpha.16
version: 0.0.1-alpha.16(@nx/devkit@19.8.0-beta.2(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11))))(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
specifier: 0.0.1-alpha.23
version: 0.0.1-alpha.23(@nx/devkit@19.8.0-beta.2(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11))))(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@react-spring/three':
specifier: ^9.7.3
version: 9.7.4(@react-three/fiber@8.17.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.166.1))(react@18.3.1)(three@0.166.1)
@ -4575,8 +4575,8 @@ packages:
'@zkochan/js-yaml':
optional: true
'@nx/graph@0.0.1-alpha.16':
resolution: {integrity: sha512-pJVFuTunlRfa8BuO2Vy6wxRtfC2kDT3TKDR6y1KXmMl90xTVT30zU/K9ITXPZzLfo8nLpewIfbv41gGSCY9+Dg==}
'@nx/graph@0.0.1-alpha.23':
resolution: {integrity: sha512-vgP9CxcCLa551AWZkwfN6qkwLGdZazU+GL7KZusIH8L3enRo2GHqyPQaF93fnTenokYM0SphR85/X4QibF3mlA==}
peerDependencies:
'@nx/devkit': '>= 19 < 20'
nx: '>= 19 < 20'
@ -21629,7 +21629,7 @@ snapshots:
- supports-color
- verdaccio
'@nx/graph@0.0.1-alpha.16(@nx/devkit@19.8.0-beta.2(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11))))(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
'@nx/graph@0.0.1-alpha.23(@nx/devkit@19.8.0-beta.2(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11))))(nx@19.8.0-beta.2(@swc-node/register@1.9.1(@swc/core@1.5.7(@swc/helpers@0.5.11))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.5.7(@swc/helpers@0.5.11)))(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react': 0.26.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@headlessui/react': 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)