chore(graph): add group by project option to task graph (#13239)

This commit is contained in:
Philip Fulcher 2022-11-17 15:09:36 -07:00 committed by GitHub
parent 558b99c3c6
commit e42da40438
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 337 additions and 185 deletions

View File

@ -14,7 +14,7 @@ export const getCheckedProjectItems = () => cy.get('[data-active="true"]');
export const getUncheckedProjectItems = () => cy.get('[data-active="false"]'); export const getUncheckedProjectItems = () => cy.get('[data-active="false"]');
export const getGroupByFolderCheckbox = () => export const getGroupByFolderCheckbox = () =>
cy.get('input[name=displayOptions][value=groupByFolder]'); cy.get('input[name=groupByFolder]');
export const getSearchDepthCheckbox = () => export const getSearchDepthCheckbox = () =>
cy.get('input[name=depthFilter][value=depthFilterActivated]'); cy.get('input[name=depthFilter][value=depthFilterActivated]');

View File

@ -8,6 +8,12 @@ export class ExternalApi {
} }
enableExperimentalFeatures() { enableExperimentalFeatures() {
localStorage.setItem('showExperimentalFeatures', 'true');
window.appConfig.showExperimentalFeatures = true; window.appConfig.showExperimentalFeatures = true;
} }
disableExperimentalFeatures() {
localStorage.setItem('showExperimentalFeatures', 'false');
window.appConfig.showExperimentalFeatures = false;
}
} }

View File

@ -1,17 +0,0 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { GroupByFolderPanel } from './group-by-folder-panel';
export default {
component: GroupByFolderPanel,
title: 'Project Graph/GroupByFolderPanel',
argTypes: { groupByFolderChanged: { action: 'groupByFolderChanged' } },
} as ComponentMeta<typeof GroupByFolderPanel>;
const Template: ComponentStory<typeof GroupByFolderPanel> = (args) => (
<GroupByFolderPanel {...args} />
);
export const Primary = Template.bind({});
Primary.args = {
groupByFolder: false,
};

View File

@ -1,41 +1,24 @@
import { memo } from 'react'; import { memo } from 'react';
import CheckboxPanel from '../../ui-components/checkbox-panel';
export interface DisplayOptionsPanelProps { export interface DisplayOptionsPanelProps {
groupByFolder: boolean; groupByFolder: boolean;
groupByFolderChanged: (checked: boolean) => void; groupByFolderChanged: (checked: boolean) => void;
} }
export const GroupByFolderPanel = memo( export const GroupByFolderPanel = ({
({ groupByFolder, groupByFolderChanged }: DisplayOptionsPanelProps) => { groupByFolder,
return ( groupByFolderChanged,
<div className="mt-8 px-4"> }: DisplayOptionsPanelProps) => {
<div className="flex items-start"> return (
<div className="flex h-5 items-center"> <CheckboxPanel
<input checked={groupByFolder}
id="displayOptions" checkChanged={groupByFolderChanged}
name="displayOptions" name={'groupByFolder'}
value="groupByFolder" label={'Group by folder'}
type="checkbox" description={'Visually arrange libraries by folders.'}
className="h-4 w-4 accent-blue-500 dark:accent-sky-500" />
onChange={(event) => groupByFolderChanged(event.target.checked)} );
checked={groupByFolder} };
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="displayOptions"
className="cursor-pointer font-medium text-slate-600 dark:text-slate-400"
>
Group by folder
</label>
<p className="text-slate-400 dark:text-slate-500">
Visually arrange libraries by folders with different colors.
</p>
</div>
</div>
</div>
);
}
);
export default GroupByFolderPanel; export default GroupByFolderPanel;

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import ExperimentalFeature from '../ui-components/experimental-feature'; import ExperimentalFeature from '../ui-components/experimental-feature';
import { useProjectGraphSelector } from './hooks/use-project-graph-selector'; import { useProjectGraphSelector } from './hooks/use-project-graph-selector';
import { import {
@ -22,6 +22,11 @@ import TracingPanel from './panels/tracing-panel';
import { useEnvironmentConfig } from '../hooks/use-environment-config'; import { useEnvironmentConfig } from '../hooks/use-environment-config';
import { TracingAlgorithmType } from './machines/interfaces'; import { TracingAlgorithmType } from './machines/interfaces';
import { getProjectGraphService } from '../machines/get-services'; import { getProjectGraphService } from '../machines/get-services';
import { useIntervalWhen } from '../hooks/use-interval-when';
// nx-ignore-next-line
import { ProjectGraphClientResponse } from 'nx/src/command-line/dep-graph';
import { useParams, useRouteLoaderData } from 'react-router-dom';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
export function ProjectsSidebar(): JSX.Element { export function ProjectsSidebar(): JSX.Element {
const environmentConfig = useEnvironmentConfig(); const environmentConfig = useEnvironmentConfig();
@ -38,6 +43,12 @@ export function ProjectsSidebar(): JSX.Element {
const isTracing = projectGraphService.state.matches('tracing'); const isTracing = projectGraphService.state.matches('tracing');
const tracingInfo = useProjectGraphSelector(getTracingInfo); const tracingInfo = useProjectGraphSelector(getTracingInfo);
const projectGraphDataService = getProjectGraphDataService();
const selectedProjectRouteData = useRouteLoaderData(
'selectedWorkspace'
) as ProjectGraphClientResponse;
const params = useParams();
function resetFocus() { function resetFocus() {
projectGraphService.send({ type: 'unfocusProject' }); projectGraphService.send({ type: 'unfocusProject' });
@ -110,6 +121,45 @@ export function ProjectsSidebar(): JSX.Element {
}); });
} }
useEffect(() => {
projectGraphService.send({
type: 'setProjects',
projects: selectedProjectRouteData.projects,
dependencies: selectedProjectRouteData.dependencies,
affectedProjects: selectedProjectRouteData.affected,
workspaceLayout: selectedProjectRouteData.layout,
});
}, [selectedProjectRouteData]);
useIntervalWhen(
() => {
const selectedWorkspaceId =
params.selectedProjectId ??
environmentConfig.appConfig.defaultWorkspaceId;
const projectInfo = environmentConfig.appConfig.workspaces.find(
(graph) => graph.id === selectedWorkspaceId
);
const fetchProjectGraph = async () => {
const project: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph(
projectInfo.projectGraphUrl
);
projectGraphService.send({
type: 'updateGraph',
projects: project.projects,
dependencies: project.dependencies,
});
};
fetchProjectGraph();
},
5000,
environmentConfig.watch
);
const updateTextFilter = useCallback( const updateTextFilter = useCallback(
(textFilter: string) => { (textFilter: string) => {
projectGraphService.send({ type: 'filterByText', search: textFilter }); projectGraphService.send({ type: 'filterByText', search: textFilter });

View File

@ -1,5 +1,10 @@
import TaskList from './task-list'; import TaskList from './task-list';
import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; import {
useNavigate,
useParams,
useRouteLoaderData,
useSearchParams,
} from 'react-router-dom';
// nx-ignore-next-line // nx-ignore-next-line
import type { import type {
ProjectGraphClientResponse, ProjectGraphClientResponse,
@ -8,24 +13,35 @@ import type {
import { getGraphService } from '../machines/graph.service'; import { getGraphService } from '../machines/graph.service';
import { useEffect } from 'react'; import { useEffect } from 'react';
import FocusedPanel from '../ui-components/focused-panel'; import FocusedPanel from '../ui-components/focused-panel';
import CheckboxPanel from '../ui-components/checkbox-panel';
export function TasksSidebar() { export function TasksSidebar() {
const graphService = getGraphService(); const graphService = getGraphService();
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams(); const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const selectedProjectRouteData = useRouteLoaderData( const selectedProjectRouteData = useRouteLoaderData(
'SelectedProject' 'selectedWorkspace'
) as ProjectGraphClientResponse; ) as ProjectGraphClientResponse;
const projects = selectedProjectRouteData.projects;
const workspaceLayout = selectedProjectRouteData.layout; const workspaceLayout = selectedProjectRouteData.layout;
const routeData = useRouteLoaderData( const routeData = useRouteLoaderData(
'selectedTask' 'selectedTask'
) as TaskGraphClientResponse; ) as TaskGraphClientResponse;
const { taskGraphs } = routeData; const { taskGraphs } = routeData;
const projects = selectedProjectRouteData.projects;
const selectedTask = params['selectedTask']; // const projects = selectedProjectRouteData.projects.filter((project) => {
// return (
// Object.keys(project.data.targets).filter((target) => {
// const taskName = `${project.name}:${target}`;
// return (
// taskGraphs[taskName]?.dependencies[taskName]?.length > 0
// );
// }).length > 0
// );
// });
const selectedTask = params['selectedTaskId'];
useEffect(() => { useEffect(() => {
graphService.handleTaskEvent({ graphService.handleTaskEvent({
@ -48,6 +64,22 @@ export function TasksSidebar() {
} }
}, [params]); }, [params]);
const groupByProject = searchParams.get('groupByProject') === 'true';
useEffect(() => {
if (groupByProject) {
graphService.handleTaskEvent({
type: 'setGroupByProject',
groupByProject: true,
});
} else {
graphService.handleTaskEvent({
type: 'setGroupByProject',
groupByProject: false,
});
}
}, [searchParams]);
function selectTask(taskId: string) { function selectTask(taskId: string) {
if (selectedTask) { if (selectedTask) {
navigate(`../${taskId}`); navigate(`../${taskId}`);
@ -60,6 +92,18 @@ export function TasksSidebar() {
navigate('..'); navigate('..');
} }
function groupByProjectChanged(checked) {
setSearchParams((currentSearchParams) => {
if (checked) {
currentSearchParams.set('groupByProject', 'true');
} else {
currentSearchParams.delete('groupByProject');
}
return currentSearchParams;
});
}
return ( return (
<> <>
{selectedTask ? ( {selectedTask ? (
@ -68,6 +112,15 @@ export function TasksSidebar() {
resetFocus={resetFocus} resetFocus={resetFocus}
></FocusedPanel> ></FocusedPanel>
) : null} ) : null}
<CheckboxPanel
checked={groupByProject}
checkChanged={groupByProjectChanged}
name={'groupByProject'}
label={'Group by project'}
description={'Visually arrange tasks by project.'}
/>
<TaskList <TaskList
projects={projects} projects={projects}
workspaceLayout={workspaceLayout} workspaceLayout={workspaceLayout}

View File

@ -24,7 +24,13 @@ export function getEnvironmentConfig() {
localMode: window.localMode, localMode: window.localMode,
projectGraphResponse: window.projectGraphResponse, projectGraphResponse: window.projectGraphResponse,
environment: window.environment, environment: window.environment,
appConfig: window.appConfig, appConfig: {
...window.appConfig,
showExperimentalFeatures:
localStorage.getItem('showExperimentalFeatures') === 'true'
? true
: window.appConfig.showExperimentalFeatures,
},
useXstateInspect: window.useXstateInspect, useXstateInspect: window.useXstateInspect,
}; };
} }

View File

@ -4,7 +4,7 @@ import type {
TaskGraphClientResponse, TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph'; } from 'nx/src/command-line/dep-graph';
export interface GraphListItem { export interface WorkspaceData {
id: string; id: string;
label: string; label: string;
projectGraphUrl: string; projectGraphUrl: string;
@ -29,8 +29,8 @@ export interface Environment {
export interface AppConfig { export interface AppConfig {
showDebugger: boolean; showDebugger: boolean;
showExperimentalFeatures: boolean; showExperimentalFeatures: boolean;
projects: GraphListItem[]; workspaces: WorkspaceData[];
defaultProject: string; defaultWorkspaceId: string;
} }
export interface GraphPerfReport { export interface GraphPerfReport {

View File

@ -6,7 +6,8 @@ let projectGraphService = interpret(projectGraphMachine, {
}); });
export function getProjectGraphService() { export function getProjectGraphService() {
if (projectGraphService.status === InterpreterStatus.Stopped) { if (projectGraphService.status === InterpreterStatus.NotStarted) {
projectGraphService.start();
} }
return projectGraphService; return projectGraphService;

View File

@ -6,7 +6,6 @@ import { getEnvironmentConfig } from './hooks/use-environment-config';
// nx-ignore-next-line // nx-ignore-next-line
import { ProjectGraphClientResponse } from 'nx/src/command-line/dep-graph'; import { ProjectGraphClientResponse } from 'nx/src/command-line/dep-graph';
import { getProjectGraphDataService } from './hooks/get-project-graph-data-service'; import { getProjectGraphDataService } from './hooks/get-project-graph-data-service';
import { getProjectGraphService } from './machines/get-services';
const { appConfig } = getEnvironmentConfig(); const { appConfig } = getEnvironmentConfig();
const projectGraphDataService = getProjectGraphDataService(); const projectGraphDataService = getProjectGraphDataService();
@ -19,19 +18,21 @@ export function getRoutesForEnvironment() {
} }
} }
const projectDataLoader = async (selectedProjectId: string) => { const workspaceDataLoader = async (selectedWorkspaceId: string) => {
const projectInfo = appConfig.projects.find( const workspaceInfo = appConfig.workspaces.find(
(graph) => graph.id === selectedProjectId (graph) => graph.id === selectedWorkspaceId
); );
const projectGraph: ProjectGraphClientResponse = const projectGraph: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph(projectInfo.projectGraphUrl); await projectGraphDataService.getProjectGraph(
workspaceInfo.projectGraphUrl
);
return projectGraph; return projectGraph;
}; };
const taskDataLoader = async (selectedProjectId: string) => { const taskDataLoader = async (selectedProjectId: string) => {
const projectInfo = appConfig.projects.find( const projectInfo = appConfig.workspaces.find(
(graph) => graph.id === selectedProjectId (graph) => graph.id === selectedProjectId
); );
@ -41,9 +42,7 @@ const taskDataLoader = async (selectedProjectId: string) => {
const childRoutes: RouteObject[] = [ const childRoutes: RouteObject[] = [
{ {
path: 'projects', path: 'projects',
loader: () => { loader: () => {},
getProjectGraphService().start();
},
element: <ProjectsSidebar />, element: <ProjectsSidebar />,
}, },
{ {
@ -54,10 +53,8 @@ const childRoutes: RouteObject[] = [
return redirect(`/projects`); return redirect(`/projects`);
} }
getProjectGraphService().stop();
const selectedProjectId = const selectedProjectId =
params.selectedProjectId ?? appConfig.defaultProject; params.selectedProjectId ?? appConfig.defaultWorkspaceId;
return taskDataLoader(selectedProjectId); return taskDataLoader(selectedProjectId);
}, },
path: 'tasks', path: 'tasks',
@ -68,7 +65,7 @@ const childRoutes: RouteObject[] = [
element: <TasksSidebar />, element: <TasksSidebar />,
}, },
{ {
path: ':selectedTask', path: ':selectedTaskId',
element: <TasksSidebar />, element: <TasksSidebar />,
}, },
], ],
@ -84,17 +81,17 @@ export const devRoutes: RouteObject[] = [
loader: async ({ request, params }) => { loader: async ({ request, params }) => {
const { search } = new URL(request.url); const { search } = new URL(request.url);
return redirect(`/${appConfig.defaultProject}/projects${search}`); return redirect(`/${appConfig.defaultWorkspaceId}/projects${search}`);
}, },
}, },
{ {
path: ':selectedProjectId', path: ':selectedProjectId',
id: 'SelectedProject', id: 'selectedWorkspace',
element: <Shell />, element: <Shell />,
loader: async ({ request, params }) => { loader: async ({ request, params }) => {
const selectedProjectId = const selectedProjectId =
params.selectedProjectId ?? appConfig.defaultProject; params.selectedProjectId ?? appConfig.defaultWorkspaceId;
return projectDataLoader(selectedProjectId); return workspaceDataLoader(selectedProjectId);
}, },
children: childRoutes, children: childRoutes,
}, },
@ -105,18 +102,19 @@ export const devRoutes: RouteObject[] = [
export const releaseRoutes: RouteObject[] = [ export const releaseRoutes: RouteObject[] = [
{ {
path: '/', path: '/',
id: 'SelectedProject', id: 'selectedWorkspace',
loader: async ({ request, params }) => { loader: async ({ request, params }) => {
const selectedProjectId = const selectedWorkspaceId = appConfig.defaultWorkspaceId;
params.selectedProjectId ?? appConfig.defaultProject; return workspaceDataLoader(selectedWorkspaceId);
return projectDataLoader(selectedProjectId);
}, },
element: <Shell />, element: <Shell />,
children: [ children: [
{ {
index: true, index: true,
loader: () => { loader: ({ request }) => {
return redirect(`/projects/`); const { search } = new URL(request.url);
return redirect(`/projects${search}`);
}, },
}, },
...childRoutes, ...childRoutes,

View File

@ -20,7 +20,12 @@ import {
projectIsSelectedSelector, projectIsSelectedSelector,
} from './feature-projects/machines/selectors'; } from './feature-projects/machines/selectors';
import { selectValueByThemeStatic } from './theme-resolver'; import { selectValueByThemeStatic } from './theme-resolver';
import { Outlet, useLoaderData, useNavigate } from 'react-router-dom'; import {
Outlet,
useLoaderData,
useNavigate,
useParams,
} from 'react-router-dom';
import ThemePanel from './feature-projects/panels/theme-panel'; import ThemePanel from './feature-projects/panels/theme-panel';
import Dropdown from './ui-components/dropdown'; import Dropdown from './ui-components/dropdown';
import { useCurrentPath } from './hooks/use-current-path'; import { useCurrentPath } from './hooks/use-current-path';
@ -32,20 +37,15 @@ import TooltipDisplay from './ui-tooltips/graph-tooltip-display';
export function Shell(): JSX.Element { export function Shell(): JSX.Element {
const projectGraphService = getProjectGraphService(); const projectGraphService = getProjectGraphService();
const projectGraphDataService = getProjectGraphDataService();
const environment = useEnvironmentConfig(); const environment = useEnvironmentConfig();
const lastPerfReport = useProjectGraphSelector(lastPerfReportSelector); const lastPerfReport = useProjectGraphSelector(lastPerfReportSelector);
const projectIsSelected = useProjectGraphSelector(projectIsSelectedSelector); const projectIsSelected = useProjectGraphSelector(projectIsSelectedSelector);
const taskIsSelected = true;
const environmentConfig = useEnvironmentConfig(); const environmentConfig = useEnvironmentConfig();
const [selectedProjectId, setSelectedProjectId] = useState<string>(
environment.appConfig.defaultProject
);
const navigate = useNavigate(); const navigate = useNavigate();
const currentPath = useCurrentPath(); const currentPath = useCurrentPath();
const { selectedProjectId, selectedTaskId } = useParams();
const taskIsSelected = !!selectedTaskId;
const currentRoute = currentPath.currentPath; const currentRoute = currentPath.currentPath;
const topLevelRoute = currentRoute.startsWith('/tasks') const topLevelRoute = currentRoute.startsWith('/tasks')
@ -67,40 +67,6 @@ export function Shell(): JSX.Element {
} }
const routeData = useLoaderData() as ProjectGraphClientResponse; const routeData = useLoaderData() as ProjectGraphClientResponse;
useEffect(() => {
projectGraphService.send({
type: 'setProjects',
projects: routeData.projects,
dependencies: routeData.dependencies,
affectedProjects: routeData.affected,
workspaceLayout: routeData.layout,
});
}, [routeData]);
useIntervalWhen(
() => {
const projectInfo = environment.appConfig.projects.find(
(graph) => graph.id === selectedProjectId
);
const fetchProjectGraph = async () => {
const project: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph(
projectInfo.projectGraphUrl
);
projectGraphService.send({
type: 'updateGraph',
projects: project.projects,
dependencies: project.dependencies,
});
};
fetchProjectGraph();
},
5000,
environment.watch
);
function downloadImage() { function downloadImage() {
const graph = getGraphService(); const graph = getGraphService();
@ -202,7 +168,7 @@ export function Shell(): JSX.Element {
> >
{environment.appConfig.showDebugger ? ( {environment.appConfig.showDebugger ? (
<DebuggerPanel <DebuggerPanel
projects={environment.appConfig.projects} projects={environment.appConfig.workspaces}
selectedProject={selectedProjectId} selectedProject={selectedProjectId}
lastPerfReport={lastPerfReport} lastPerfReport={lastPerfReport}
selectedProjectChange={projectChange} selectedProjectChange={projectChange}

View File

@ -0,0 +1,20 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import CheckboxPanel from './checkbox-panel';
export default {
component: CheckboxPanel,
title: 'Shared/CheckboxPanel',
argTypes: { checkChanged: { action: 'checkChanged' } },
} as ComponentMeta<typeof CheckboxPanel>;
const Template: ComponentStory<typeof CheckboxPanel> = (args) => (
<CheckboxPanel {...args} />
);
export const Primary = Template.bind({});
Primary.args = {
checked: false,
name: 'option-to-check',
label: 'Option to check',
description: 'You can check this option.',
};

View File

@ -0,0 +1,42 @@
import { memo } from 'react';
export interface CheckboxPanelProps {
checked: boolean;
checkChanged: (checked: boolean) => void;
name: string;
label: string;
description: string;
}
export const CheckboxPanel = memo(
({ checked, checkChanged, label, description, name }: CheckboxPanelProps) => {
return (
<div className="mt-8 px-4">
<div className="flex items-start">
<div className="flex h-5 items-center">
<input
id={name}
name={name}
value={name}
type="checkbox"
className="h-4 w-4 accent-blue-500 dark:accent-sky-500"
onChange={(event) => checkChanged(event.target.checked)}
checked={checked}
/>
</div>
<div className="ml-3 text-sm">
<label
htmlFor={name}
className="cursor-pointer font-medium text-slate-600 dark:text-slate-400"
>
{label}
</label>
<p className="text-slate-400 dark:text-slate-500">{description}</p>
</div>
</div>
</div>
);
}
);
export default CheckboxPanel;

View File

@ -1,9 +1,9 @@
import { memo } from 'react'; import { memo } from 'react';
import { GraphListItem, GraphPerfReport } from '../interfaces'; import { WorkspaceData, GraphPerfReport } from '../interfaces';
import Dropdown from './dropdown'; import Dropdown from './dropdown';
export interface DebuggerPanelProps { export interface DebuggerPanelProps {
projects: GraphListItem[]; projects: WorkspaceData[];
selectedProject: string; selectedProject: string;
selectedProjectChange: (projectName: string) => void; selectedProjectChange: (projectName: string) => void;
lastPerfReport: GraphPerfReport; lastPerfReport: GraphPerfReport;

View File

@ -6,7 +6,7 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: true, showDebugger: true,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projects: [ workspaces: [
{ {
id: 'e2e', id: 'e2e',
label: 'e2e', label: 'e2e',
@ -20,5 +20,5 @@ window.appConfig = {
taskGraphUrl: 'assets/task-graphs/affected.json', taskGraphUrl: 'assets/task-graphs/affected.json',
}, },
], ],
defaultProject: 'e2e', defaultWorkspaceId: 'e2e',
}; };

View File

@ -6,7 +6,7 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projects: [ workspaces: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
@ -14,5 +14,5 @@ window.appConfig = {
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProject: 'local', defaultWorkspaceId: 'local',
}; };

View File

@ -7,7 +7,7 @@ window.localMode = 'build';
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projects: [ workspaces: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
@ -15,7 +15,7 @@ window.appConfig = {
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProject: 'local', defaultWorkspaceId: 'local',
}; };
window.projectGraphResponse = { window.projectGraphResponse = {

View File

@ -6,7 +6,7 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projects: [ workspaces: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
@ -14,5 +14,5 @@ window.appConfig = {
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProject: 'local', defaultWorkspaceId: 'local',
}; };

View File

@ -6,7 +6,7 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projects: [ workspaces: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
@ -14,5 +14,5 @@ window.appConfig = {
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProject: 'local', defaultWorkspaceId: 'local',
}; };

View File

@ -216,6 +216,12 @@ export class GraphService {
break; break;
case 'notifyTaskGraphDeselectTask': case 'notifyTaskGraphDeselectTask':
elementsToSendToRender = this.taskTraversalGraph.deselectTask(); elementsToSendToRender = this.taskTraversalGraph.deselectTask();
break;
case 'setGroupByProject':
elementsToSendToRender = this.taskTraversalGraph.setGroupByProject(
event.groupByProject
);
break; break;
} }

View File

@ -124,4 +124,8 @@ export type TaskGraphRenderEvents =
} }
| { | {
type: 'notifyTaskGraphDeselectTask'; type: 'notifyTaskGraphDeselectTask';
}
| {
type: 'setGroupByProject';
groupByProject: boolean;
}; };

View File

@ -91,6 +91,13 @@ const highlightedNodes: Stylesheet = {
}, },
}; };
const taskNodes: Stylesheet = {
selector: 'node.task',
style: {
label: 'data(label)',
},
};
const transparentProjectNodes: Stylesheet = { const transparentProjectNodes: Stylesheet = {
selector: 'node.transparent:childless', selector: 'node.transparent:childless',
style: { opacity: 0.5 }, style: { opacity: 0.5 },
@ -113,4 +120,5 @@ export const nodeStyles = [
highlightedNodes, highlightedNodes,
transparentProjectNodes, transparentProjectNodes,
transparentParentNodes, transparentParentNodes,
taskNodes,
]; ];

View File

@ -46,29 +46,12 @@ export class RenderGraph {
set theme(theme: 'light' | 'dark') { set theme(theme: 'light' | 'dark') {
this._theme = theme; this._theme = theme;
this.render();
if (this.cy) {
this.cy.unmount();
const useDarkMode = theme === 'dark';
this.cy.scratch(darkModeScratchKey, useDarkMode);
this.cy.elements().scratch(darkModeScratchKey, useDarkMode);
this.cy.mount(this.activeContainer);
}
} }
set rankDir(rankDir: 'LR' | 'TB') { set rankDir(rankDir: 'LR' | 'TB') {
this._rankDir = rankDir; this._rankDir = rankDir;
if (this.cy) { this.render();
const elements = this.cy.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
} }
get activeContainer() { get activeContainer() {

View File

@ -4,26 +4,33 @@ import * as cy from 'cytoscape';
export interface TaskNodeDataDefinition extends cy.NodeDataDefinition { export interface TaskNodeDataDefinition extends cy.NodeDataDefinition {
id: string; id: string;
label: string;
executor: string; executor: string;
} }
export class TaskNode { export class TaskNode {
constructor(private task: Task, private project: ProjectGraphProjectNode) {} constructor(private task: Task, private project: ProjectGraphProjectNode) {}
getCytoscapeNodeDef(): cy.NodeDefinition { getCytoscapeNodeDef(groupByProject: boolean): cy.NodeDefinition {
return { return {
group: 'nodes', group: 'nodes',
data: this.getData(), classes: 'task',
data: this.getData(groupByProject),
selectable: false, selectable: false,
grabbable: false, grabbable: false,
pannable: true, pannable: true,
}; };
} }
private getData(): TaskNodeDataDefinition { private getData(groupByProject: boolean): TaskNodeDataDefinition {
const label = groupByProject
? this.task.id.split(':').slice(1).join(':')
: this.task.id;
return { return {
id: this.task.id, id: this.task.id,
executor: 'placeholder', label,
executor: this.project.data.targets[this.task.target.target].executor,
parent: groupByProject ? this.task.target.project : null,
}; };
} }
} }

View File

@ -4,22 +4,44 @@ import { TaskGraphRecord } from '../interfaces';
import { TaskNode } from './task-node'; import { TaskNode } from './task-node';
import { TaskEdge } from './task-edge'; import { TaskEdge } from './task-edge';
import cytoscape, { Core } from 'cytoscape'; import cytoscape, { Core } from 'cytoscape';
import { ParentNode } from './parent-node';
export class TaskTraversalGraph { export class TaskTraversalGraph {
private projects: ProjectGraphProjectNode[] = []; private projects: ProjectGraphProjectNode[] = [];
private taskGraphs: TaskGraphRecord = {}; private taskGraphs: TaskGraphRecord = {};
private cy: Core; private cy: Core;
private selectedTask: string;
private groupByProject: boolean = false;
setProjects( setProjects(
projects: ProjectGraphProjectNode[], projects: ProjectGraphProjectNode[],
taskGraphs: TaskGraphRecord taskGraphs: TaskGraphRecord
) { ) {
this.selectedTask = null;
this.projects = projects; this.projects = projects;
this.taskGraphs = taskGraphs; this.taskGraphs = taskGraphs;
} }
selectTask(taskId: string) { selectTask(taskId: string) {
this.createElements(taskId); this.selectedTask = taskId;
this.createElements(taskId, this.groupByProject);
return this.cy.elements();
}
setGroupByProject(groupByProject: boolean) {
this.groupByProject = groupByProject;
if (this.selectedTask) {
this.createElements(this.selectedTask, groupByProject);
} else {
this.cy = cytoscape({
headless: true,
elements: [],
});
return this.cy.elements();
}
return this.cy.elements(); return this.cy.elements();
} }
@ -33,31 +55,43 @@ export class TaskTraversalGraph {
return this.cy.elements(); return this.cy.elements();
} }
private createElements(taskId: string) { private createElements(taskId: string, groupByFolder: boolean) {
const [projectName, target, configuration] = taskId.split(':');
const taskGraph = this.taskGraphs[taskId]; const taskGraph = this.taskGraphs[taskId];
if (taskGraph === undefined) { if (taskGraph === undefined) {
throw new Error(`Could not find task graph for ${taskId}`); throw new Error(`Could not find task graph for ${taskId}`);
} }
const project = this.projects.find(
(project) => project.name === projectName
);
if (project === undefined) {
throw new Error(`Could not find project ${projectName}`);
}
const taskElements = []; const taskElements = [];
const parents: Record<
string,
{ id: string; parentId: string; label: string }
> = {};
for (let taskName in taskGraph.tasks) { for (let taskName in taskGraph.tasks) {
taskElements.push( const task = taskGraph.tasks[taskName];
new TaskNode( const project = this.projects.find(
taskGraph.tasks[taskName], (project) => project.name === task.target.project
this.projects[taskGraph.tasks[taskName].target.project]
)
); );
if (project === undefined) {
throw new Error(`Could not find project ${project.name}`);
}
taskElements.push(new TaskNode(taskGraph.tasks[taskName], project));
if (groupByFolder) {
parents[project.name] = {
id: project.name,
parentId: null,
label: project.name,
};
}
}
for (let parent in parents) {
taskElements.push(new ParentNode(parents[parent]));
} }
for (let topDep in taskGraph.dependencies) { for (let topDep in taskGraph.dependencies) {
@ -68,7 +102,9 @@ export class TaskTraversalGraph {
this.cy = cytoscape({ this.cy = cytoscape({
headless: true, headless: true,
elements: taskElements.map((element) => element.getCytoscapeNodeDef()), elements: taskElements.map((element) =>
element.getCytoscapeNodeDef(groupByFolder)
),
boxSelectionEnabled: false, boxSelectionEnabled: false,
}); });
} }

View File

@ -73,7 +73,7 @@ function buildEnvironmentJs(
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projects: [ workspaces: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
@ -81,7 +81,7 @@ function buildEnvironmentJs(
taskGraphUrl: 'task-graph.json' taskGraphUrl: 'task-graph.json'
} }
], ],
defaultProject: 'local', defaultWorkspaceId: 'local',
}; };
`; `;
@ -408,7 +408,7 @@ async function startServer(
params.append('groupByFolder', 'true'); params.append('groupByFolder', 'true');
} }
open(`${url}?${params.toString()}`); open(`${url}/projects?${params.toString()}`);
} }
} }

View File

@ -4,7 +4,7 @@ import { readdirSync, writeFileSync } from 'fs';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
function generateFileContent( function generateFileContent(
projects: { id: string; label: string; url: string }[] workspaces: { id: string; label: string; url: string }[]
) { ) {
return ` return `
window.exclude = []; window.exclude = [];
@ -15,8 +15,8 @@ function generateFileContent(
window.appConfig = { window.appConfig = {
showDebugger: true, showDebugger: true,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projects: ${JSON.stringify(projects)}, workspaces: ${JSON.stringify(workspaces)},
defaultProject: '${projects[0].id}', defaultWorkspaceId: '${workspaces[0].id}',
}; };
`; `;
} }