chore(graph): add components to support task graph (#12472)

This commit is contained in:
Philip Fulcher 2022-10-08 15:58:32 -06:00 committed by GitHub
parent 621baaa709
commit 080a8961d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 548 additions and 86 deletions

View File

@ -1,6 +1,7 @@
import { memo } from 'react';
import { ProjectGraphList } from './interfaces';
import { GraphPerfReport } from './machines/interfaces';
import Dropdown from './ui-components/dropdown';
export interface DebuggerPanelProps {
projectGraphs: ProjectGraphList[];
@ -23,20 +24,22 @@ export const DebuggerPanel = memo(function ({
<h4 className="dark:text-sidebar-title-dark mr-4 text-lg font-normal">
Debugger
</h4>
<select
className="flex w-auto items-center rounded-md rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
<Dropdown
data-cy="project-select"
onChange={(event) => projectGraphChange(event.target.value)}
value={selectedProjectGraph}
onChange={(event) => projectGraphChange(event.currentTarget.value)}
>
{projectGraphs.map((projectGraph) => {
return (
<option key={projectGraph.id} value={projectGraph.id}>
<option
key={projectGraph.id}
value={projectGraph.id}
selected={projectGraph.id === selectedProjectGraph}
>
{projectGraph.label}
</option>
);
})}
</select>
</Dropdown>
<p className="text-sm">
Last render took {lastPerfReport.renderTime}ms:{' '}
<b className="text-medium font-mono">{lastPerfReport.numNodes} nodes</b>{' '}

View File

@ -1,3 +1,5 @@
import Tag from './ui-components/tag';
export interface EdgeNodeTooltipProps {
type: 'static' | 'dynamic' | 'implicit';
source: string;
@ -14,7 +16,7 @@ export function EdgeNodeTooltip({
return (
<div>
<h4 className={type !== 'implicit' ? 'mb-3' : ''}>
<span className="tag">{type ?? 'unknown'}</span>
<Tag>{type ?? 'unknown'}</Tag>
{source} &rarr; {target}
</h4>
{type !== 'implicit' ? (

View File

@ -4,6 +4,7 @@ import {
FlagIcon,
MapPinIcon,
} from '@heroicons/react/24/solid';
import Tag from './ui-components/tag';
export interface ProjectNodeToolTipProps {
type: 'app' | 'lib' | 'e2e';
@ -49,7 +50,7 @@ export function ProjectNodeToolTip({
return (
<div>
<h4>
<span className="tag">{type}</span>
<Tag>{type}</Tag>
{id}
</h4>
{tags.length > 0 ? (

View File

@ -0,0 +1,67 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { TaskList, TaskListProps } from './task-list';
const Story: ComponentMeta<typeof TaskList> = {
component: TaskList,
title: 'TaskList',
argTypes: {
selectTask: {
action: 'selectTask',
},
},
};
export default Story;
const Template: ComponentStory<typeof TaskList> = (args) => (
<TaskList {...args} />
);
export const Primary = Template.bind({});
const args: Partial<TaskListProps> = {
projects: [
{
name: 'app1',
type: 'app',
data: {
root: 'apps/app1',
targets: {
build: {
configurations: { production: {}, development: {} },
defaultConfiguration: 'production',
},
},
},
},
{
name: 'nested-app',
type: 'app',
data: {
root: 'apps/nested/app',
targets: { build: { configurations: { production: {} } } },
},
},
{
name: 'app1-e2e',
type: 'e2e',
data: {
root: 'apps/app1-e2e',
targets: { e2e: { configurations: { production: {} } } },
},
},
{
name: 'lib1',
type: 'lib',
data: {
root: 'libs/lib1',
targets: { lint: { configurations: { production: {} } } },
},
},
],
workspaceLayout: {
appsDir: 'apps',
libsDir: 'libs',
},
selectedTask: 'app1:build:production',
};
Primary.args = args;

View File

@ -0,0 +1,277 @@
import { DocumentMagnifyingGlassIcon } from '@heroicons/react/24/solid';
// nx-ignore-next-line
import type { ProjectGraphNode, Task } from '@nrwl/devkit';
import { parseParentDirectoriesFromFilePath } from '../util';
import { WorkspaceLayout } from '../interfaces';
import Tag from '../ui-components/tag';
function getProjectsByType(type: string, projects: ProjectGraphNode[]) {
return projects
.filter((project) => project.type === type)
.sort((a, b) => a.name.localeCompare(b.name));
}
interface SidebarTarget {
targetName: string;
configurations: Array<{
name: string;
isSelected: boolean;
default: boolean;
}>;
}
interface SidebarProjectWithTargets {
projectGraphNode: ProjectGraphNode;
targets: SidebarTarget[];
}
function groupProjectsByDirectory(
projects: ProjectGraphNode[],
workspaceLayout: { appsDir: string; libsDir: string }
): Record<string, ProjectGraphNode[]> {
let groups: Record<string, ProjectGraphNode[]> = {};
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const directories = parseParentDirectoriesFromFilePath(
project.data.root,
workspaceRoot
);
const directory = directories.join('/');
if (!groups.hasOwnProperty(directory)) {
groups[directory] = [];
}
groups[directory].push(project);
});
return groups;
}
function ProjectListItem({
project,
selectTask,
}: {
project: SidebarProjectWithTargets;
selectTask: (
projectName: string,
targetName: string,
configurationName: string
) => void;
}) {
return (
<li className="relative block cursor-default select-none py-1 pl-2 pr-6 text-xs text-slate-600 dark:text-slate-400">
<strong>{project.projectGraphNode.name}</strong>
<br />
{project.targets.map((target) => (
<>
<strong>{target.targetName}</strong>
<br />
{target.configurations.map((configuration) => (
<div className="flex items-center">
<button
data-cy={`focus-button-${configuration.name}`}
type="button"
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 library"
onClick={() =>
selectTask(
project.projectGraphNode.name,
target.targetName,
configuration.name
)
}
>
<DocumentMagnifyingGlassIcon className="h-5 w-5" />
</button>
<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={configuration.name}
title={configuration.name}
data-active={configuration.isSelected.toString()}
>
{configuration.name}
</label>
{configuration.default ? <Tag>default</Tag> : null}
</div>
))}
</>
))}
</li>
);
}
function SubProjectList({
headerText = '',
projects,
selectTask,
}: {
headerText: string;
projects: SidebarProjectWithTargets[];
selectTask: (
projectName: string,
targetName: string,
configurationName: string
) => void;
}) {
let sortedProjects = [...projects];
sortedProjects.sort((a, b) => {
return a.projectGraphNode.name.localeCompare(b.projectGraphNode.name);
});
return (
<>
{headerText !== '' ? (
<h3 className="mt-4 cursor-text py-2 text-sm font-semibold uppercase tracking-wide text-slate-800 dark:text-slate-200 lg:text-xs">
{headerText}
</h3>
) : null}
<ul className="mt-2 -ml-3">
{sortedProjects.map((project) => {
return (
<ProjectListItem
key={project.projectGraphNode.name}
project={project}
selectTask={selectTask}
></ProjectListItem>
);
})}
</ul>
</>
);
}
function mapToSidebarProjectWithTasks(
project: ProjectGraphNode,
selectedTask: string
): SidebarProjectWithTargets {
const targets: SidebarTarget[] = [];
for (const targetName in project.data?.targets) {
const target: SidebarTarget = {
targetName,
configurations: [],
};
for (const configuration in project.data?.targets?.[targetName]
?.configurations) {
target.configurations.push({
name: configuration,
isSelected: configuration === selectedTask,
default:
configuration ===
project.data?.targets?.[targetName]?.defaultConfiguration,
});
}
targets.push(target);
}
return {
projectGraphNode: project,
targets,
};
}
export interface TaskListProps {
projects: ProjectGraphNode[];
taskGraphs: Record<string, Record<string, Record<string, Task>>>;
workspaceLayout: WorkspaceLayout;
selectedTask: string;
selectTask: (
projectName: string,
targetName: string,
configurationName: string
) => void;
}
export function TaskList({
projects,
workspaceLayout,
selectedTask,
selectTask,
}: TaskListProps) {
const appProjects = getProjectsByType('app', projects);
const libProjects = getProjectsByType('lib', projects);
const e2eProjects = getProjectsByType('e2e', projects);
const appDirectoryGroups = groupProjectsByDirectory(
appProjects,
workspaceLayout
);
const libDirectoryGroups = groupProjectsByDirectory(
libProjects,
workspaceLayout
);
const e2eDirectoryGroups = groupProjectsByDirectory(
e2eProjects,
workspaceLayout
);
const sortedAppDirectories = Object.keys(appDirectoryGroups).sort();
const sortedLibDirectories = Object.keys(libDirectoryGroups).sort();
const sortedE2EDirectories = Object.keys(e2eDirectoryGroups).sort();
return (
<div id="project-lists" className="mt-8 border-t border-slate-400/10 px-4">
<h2 className="mt-8 border-b border-solid border-slate-200/10 text-lg font-light text-slate-400 dark:text-slate-500">
app projects
</h2>
{sortedAppDirectories.map((directoryName) => {
return (
<SubProjectList
key={'app-' + directoryName}
headerText={directoryName}
projects={appDirectoryGroups[directoryName].map((project) =>
mapToSidebarProjectWithTasks(project, selectedTask)
)}
selectTask={selectTask}
></SubProjectList>
);
})}
<h2 className="mt-8 border-b border-solid border-slate-200/10 text-lg font-light">
e2e projects
</h2>
{sortedE2EDirectories.map((directoryName) => {
return (
<SubProjectList
key={'e2e-' + directoryName}
headerText={directoryName}
projects={e2eDirectoryGroups[directoryName].map((project) =>
mapToSidebarProjectWithTasks(project, selectedTask)
)}
selectTask={selectTask}
></SubProjectList>
);
})}
<h2 className="mt-8 border-b border-solid border-slate-200/10 text-lg font-light">
lib projects
</h2>
{sortedLibDirectories.map((directoryName) => {
return (
<SubProjectList
key={'lib-' + directoryName}
headerText={directoryName}
projects={libDirectoryGroups[directoryName].map((project) =>
mapToSidebarProjectWithTasks(project, selectedTask)
)}
selectTask={selectTask}
></SubProjectList>
);
})}
</div>
);
}
export default TaskList;

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { useDebounce } from '../hooks/use-debounce';
import { BackspaceIcon, FunnelIcon } from '@heroicons/react/24/outline';
import DebouncedTextInput from '../ui-components/debounced-text-input';
export interface TextFilterPanelProps {
textFilter: string;
@ -18,72 +19,15 @@ export function TextFilterPanel({
toggleIncludeLibsInPathChange,
includePath,
}: TextFilterPanelProps) {
const [currentTextFilter, setCurrentTextFilter] = useState('');
const [debouncedValue, setDebouncedValue] = useDebounce(
currentTextFilter,
500
);
function onTextFilterKeyUp(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
updateTextFilter(event.currentTarget.value);
}
}
function onTextInputChange(change: string) {
if (change === '') {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
} else {
setCurrentTextFilter(change);
}
}
function resetClicked() {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
}
useEffect(() => {
updateTextFilter(debouncedValue);
}, [debouncedValue, updateTextFilter]);
return (
<div>
<div className="mt-10 px-4">
<form
className="group relative flex rounded-md shadow-sm"
onSubmit={(event) => event.preventDefault()}
>
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-50 p-2 dark:border-slate-900 dark:bg-slate-800">
<FunnelIcon className="h-4 w-4"></FunnelIcon>
</span>
<input
type="text"
className={`block w-full flex-1 rounded-none rounded-r-md border border-slate-300 bg-white p-1.5 font-light text-slate-400 placeholder:font-light placeholder:text-slate-400 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700`}
placeholder="lib name, other lib name"
data-cy="textFilterInput"
name="filter"
value={currentTextFilter}
onKeyUp={onTextFilterKeyUp}
onChange={(event) => onTextInputChange(event.currentTarget.value)}
></input>
{currentTextFilter.length > 0 ? (
<button
data-cy="textFilterReset"
type="reset"
onClick={resetClicked}
className="absolute top-1 right-1 inline-block rounded-md bg-slate-50 p-1 text-slate-400 dark:bg-slate-800"
>
<BackspaceIcon className="h-5 w-5"></BackspaceIcon>
</button>
) : null}
</form>
<DebouncedTextInput
resetTextFilter={resetTextFilter}
updateTextFilter={updateTextFilter}
initialText={''}
placeholderText={'lib name, other lib name'}
></DebouncedTextInput>
</div>
<div className="mt-4 px-4">

View File

@ -0,0 +1,26 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { DebouncedTextInput } from './debounced-text-input';
const Story: ComponentMeta<typeof DebouncedTextInput> = {
component: DebouncedTextInput,
title: 'Shared/DebouncedTextInput',
argTypes: {
resetTextFilter: {
action: 'resetTextFilter',
},
updateTextFilter: {
action: 'updateTextFilter',
},
},
};
export default Story;
const Template: ComponentStory<typeof DebouncedTextInput> = (args) => (
<DebouncedTextInput {...args} />
);
export const Primary = Template.bind({});
Primary.args = {
currentText: '',
placeholderText: '',
};

View File

@ -0,0 +1,85 @@
import { KeyboardEvent, useEffect, useState } from 'react';
import { useDebounce } from '../hooks/use-debounce';
import { BackspaceIcon, FunnelIcon } from '@heroicons/react/24/outline';
export interface DebouncedTextInputProps {
initialText: string;
placeholderText: string;
resetTextFilter: () => void;
updateTextFilter: (textFilter: string) => void;
}
export function DebouncedTextInput({
initialText,
placeholderText,
resetTextFilter,
updateTextFilter,
}: DebouncedTextInputProps) {
const [currentTextFilter, setCurrentTextFilter] = useState(initialText ?? '');
const [debouncedValue, setDebouncedValue] = useDebounce(
currentTextFilter,
500
);
function onTextFilterKeyUp(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
updateTextFilter(event.currentTarget.value);
}
}
function onTextInputChange(change: string) {
if (change === '') {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
} else {
setCurrentTextFilter(change);
}
}
function resetClicked() {
setCurrentTextFilter('');
setDebouncedValue('');
resetTextFilter();
}
useEffect(() => {
updateTextFilter(debouncedValue);
}, [debouncedValue, updateTextFilter]);
return (
<form
className="group relative flex rounded-md shadow-sm"
onSubmit={(event) => event.preventDefault()}
>
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-50 p-2 dark:border-slate-900 dark:bg-slate-800">
<FunnelIcon className="h-4 w-4"></FunnelIcon>
</span>
<input
type="text"
className={`block w-full flex-1 rounded-none rounded-r-md border border-slate-300 bg-white p-1.5 font-light text-slate-400 placeholder:font-light placeholder:text-slate-400 dark:border-slate-900 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700`}
placeholder={placeholderText}
data-cy="textFilterInput"
name="filter"
value={currentTextFilter}
onKeyUp={onTextFilterKeyUp}
onChange={(event) => onTextInputChange(event.currentTarget.value)}
></input>
{currentTextFilter.length > 0 ? (
<button
data-cy="textFilterReset"
type="reset"
onClick={resetClicked}
className="absolute top-1 right-1 inline-block rounded-md bg-slate-50 p-1 text-slate-400 dark:bg-slate-800"
>
<BackspaceIcon className="h-5 w-5"></BackspaceIcon>
</button>
) : null}
</form>
);
}
export default DebouncedTextInput;

View File

@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Dropdown } from './dropdown';
export default {
component: Dropdown,
title: 'Shared/Dropdown',
argTypes: {
onChange: { action: 'onChange' },
},
} as ComponentMeta<typeof Dropdown>;
const Template: ComponentStory<typeof Dropdown> = (args) => (
<Dropdown {...args}>
<option value="Option 1">Option 1</option>
<option value="Option 2">Option 2</option>
</Dropdown>
);
export const Primary = Template.bind({});
Primary.args = {};

View File

@ -0,0 +1,20 @@
/* eslint-disable-next-line */
import React, { ReactNode } from 'react';
export type DropdownProps = {
children: ReactNode[];
} & React.HTMLAttributes<HTMLSelectElement>;
export function Dropdown(props: DropdownProps) {
const { className, children, ...rest } = props;
return (
<select
className="flex w-auto items-center rounded-md rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
{...rest}
>
{children}
</select>
);
}
export default Dropdown;

View File

@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Tag } from './tag';
const Story: ComponentMeta<typeof Tag> = {
component: Tag,
title: 'Shared/Tag',
};
export default Story;
const Template: ComponentStory<typeof Tag> = (args) => <Tag>{args.text}</Tag>;
export const Primary = Template.bind({});
Primary.args = {
text: 'tag',
};

View File

@ -0,0 +1,12 @@
/* eslint-disable-next-line */
export interface TagProps {}
export function Tag(props) {
return (
<span className="font- mr-3 inline-block rounded-md bg-slate-300 p-2 font-sans text-xs font-semibold uppercase leading-4 tracking-wide text-slate-700">
{props.children}
</span>
);
}
export default Tag;

View File

@ -71,35 +71,24 @@ canvas {
&[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: $gray;
}
&[data-placement^='left'] > .tippy-arrow::before {
border-left-color: $gray;
}
&[data-placement^='right'] > .tippy-arrow::before {
border-right-color: $gray;
}
}
.tag {
padding: 0.5rem;
font-family: system-ui;
font-size: 0.75rem;
line-height: 1rem;
display: inline-block;
background-color: hsla(213, 27%, 84%, 1);
border-radius: 0.375rem;
text-transform: uppercase;
color: hsla(215, 25%, 27%, 1);
font-weight: 600;
letter-spacing: 0.025em;
margin-right: 0.75rem;
}
.tippy-box[data-theme~='nx'] h4 {
font-family: monospace;
}
.tippy-box[data-theme~='nx'] p {
margin: 0.375rem;
}
.tippy-box[data-theme~='nx'] button {
background-color: rgba(249, 250, 251, 1);
border-color: $gray;
@ -124,6 +113,7 @@ canvas {
border-color: rgb(71, 85, 105, 1);
color: rgb(203, 213, 225, 1);
}
button:hover {
background-color: rgb(51, 65, 85, 1);
}