From 080a8961d88456b39857d0d23ac8b51f8e0fda5e Mon Sep 17 00:00:00 2001 From: Philip Fulcher Date: Sat, 8 Oct 2022 15:58:32 -0600 Subject: [PATCH] chore(graph): add components to support task graph (#12472) --- graph/client/src/app/debugger-panel.tsx | 15 +- graph/client/src/app/edge-tooltip.tsx | 4 +- graph/client/src/app/project-node-tooltip.tsx | 3 +- .../src/app/sidebar/task-list.stories.tsx | 67 +++++ graph/client/src/app/sidebar/task-list.tsx | 277 ++++++++++++++++++ .../src/app/sidebar/text-filter-panel.tsx | 70 +---- .../debounced-text-input.stories.tsx | 26 ++ .../ui-components/debounced-text-input.tsx | 85 ++++++ .../app/ui-components/dropdown.stories.tsx | 20 ++ .../client/src/app/ui-components/dropdown.tsx | 20 ++ .../src/app/ui-components/tag.stories.tsx | 15 + graph/client/src/app/ui-components/tag.tsx | 12 + graph/client/src/styles.scss | 20 +- 13 files changed, 548 insertions(+), 86 deletions(-) create mode 100644 graph/client/src/app/sidebar/task-list.stories.tsx create mode 100644 graph/client/src/app/sidebar/task-list.tsx create mode 100644 graph/client/src/app/ui-components/debounced-text-input.stories.tsx create mode 100644 graph/client/src/app/ui-components/debounced-text-input.tsx create mode 100644 graph/client/src/app/ui-components/dropdown.stories.tsx create mode 100644 graph/client/src/app/ui-components/dropdown.tsx create mode 100644 graph/client/src/app/ui-components/tag.stories.tsx create mode 100644 graph/client/src/app/ui-components/tag.tsx diff --git a/graph/client/src/app/debugger-panel.tsx b/graph/client/src/app/debugger-panel.tsx index fcbe41ed6a..08f9f98c5c 100644 --- a/graph/client/src/app/debugger-panel.tsx +++ b/graph/client/src/app/debugger-panel.tsx @@ -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 ({

Debugger

- +

Last render took {lastPerfReport.renderTime}ms:{' '} {lastPerfReport.numNodes} nodes{' '} diff --git a/graph/client/src/app/edge-tooltip.tsx b/graph/client/src/app/edge-tooltip.tsx index 7a98f08db7..25421b8349 100644 --- a/graph/client/src/app/edge-tooltip.tsx +++ b/graph/client/src/app/edge-tooltip.tsx @@ -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 (

- {type ?? 'unknown'} + {type ?? 'unknown'} {source} → {target}

{type !== 'implicit' ? ( diff --git a/graph/client/src/app/project-node-tooltip.tsx b/graph/client/src/app/project-node-tooltip.tsx index 20a7bba81d..5882a010cb 100644 --- a/graph/client/src/app/project-node-tooltip.tsx +++ b/graph/client/src/app/project-node-tooltip.tsx @@ -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 (

- {type} + {type} {id}

{tags.length > 0 ? ( diff --git a/graph/client/src/app/sidebar/task-list.stories.tsx b/graph/client/src/app/sidebar/task-list.stories.tsx new file mode 100644 index 0000000000..4182d63b6b --- /dev/null +++ b/graph/client/src/app/sidebar/task-list.stories.tsx @@ -0,0 +1,67 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { TaskList, TaskListProps } from './task-list'; + +const Story: ComponentMeta = { + component: TaskList, + title: 'TaskList', + argTypes: { + selectTask: { + action: 'selectTask', + }, + }, +}; +export default Story; + +const Template: ComponentStory = (args) => ( + +); + +export const Primary = Template.bind({}); +const args: Partial = { + 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; diff --git a/graph/client/src/app/sidebar/task-list.tsx b/graph/client/src/app/sidebar/task-list.tsx new file mode 100644 index 0000000000..60654c4c87 --- /dev/null +++ b/graph/client/src/app/sidebar/task-list.tsx @@ -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 { + let groups: Record = {}; + + 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 ( +
  • + {project.projectGraphNode.name} +
    + {project.targets.map((target) => ( + <> + {target.targetName} +
    + {target.configurations.map((configuration) => ( +
    + + + + + {configuration.default ? default : null} +
    + ))} + + ))} +
  • + ); +} + +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 !== '' ? ( +

    + {headerText} +

    + ) : null} +
      + {sortedProjects.map((project) => { + return ( + + ); + })} +
    + + ); +} + +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>>; + 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 ( +
    +

    + app projects +

    + + {sortedAppDirectories.map((directoryName) => { + return ( + + mapToSidebarProjectWithTasks(project, selectedTask) + )} + selectTask={selectTask} + > + ); + })} + +

    + e2e projects +

    + + {sortedE2EDirectories.map((directoryName) => { + return ( + + mapToSidebarProjectWithTasks(project, selectedTask) + )} + selectTask={selectTask} + > + ); + })} + +

    + lib projects +

    + + {sortedLibDirectories.map((directoryName) => { + return ( + + mapToSidebarProjectWithTasks(project, selectedTask) + )} + selectTask={selectTask} + > + ); + })} +
    + ); +} + +export default TaskList; diff --git a/graph/client/src/app/sidebar/text-filter-panel.tsx b/graph/client/src/app/sidebar/text-filter-panel.tsx index bc74a7030a..9fae0c6312 100644 --- a/graph/client/src/app/sidebar/text-filter-panel.tsx +++ b/graph/client/src/app/sidebar/text-filter-panel.tsx @@ -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) { - 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 (
    -
    event.preventDefault()} - > - - - - onTextInputChange(event.currentTarget.value)} - > - {currentTextFilter.length > 0 ? ( - - ) : null} -
    +
    diff --git a/graph/client/src/app/ui-components/debounced-text-input.stories.tsx b/graph/client/src/app/ui-components/debounced-text-input.stories.tsx new file mode 100644 index 0000000000..97bb92b64f --- /dev/null +++ b/graph/client/src/app/ui-components/debounced-text-input.stories.tsx @@ -0,0 +1,26 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { DebouncedTextInput } from './debounced-text-input'; + +const Story: ComponentMeta = { + component: DebouncedTextInput, + title: 'Shared/DebouncedTextInput', + argTypes: { + resetTextFilter: { + action: 'resetTextFilter', + }, + updateTextFilter: { + action: 'updateTextFilter', + }, + }, +}; +export default Story; + +const Template: ComponentStory = (args) => ( + +); + +export const Primary = Template.bind({}); +Primary.args = { + currentText: '', + placeholderText: '', +}; diff --git a/graph/client/src/app/ui-components/debounced-text-input.tsx b/graph/client/src/app/ui-components/debounced-text-input.tsx new file mode 100644 index 0000000000..2d69dac492 --- /dev/null +++ b/graph/client/src/app/ui-components/debounced-text-input.tsx @@ -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) { + 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 ( +
    event.preventDefault()} + > + + + + onTextInputChange(event.currentTarget.value)} + > + {currentTextFilter.length > 0 ? ( + + ) : null} +
    + ); +} + +export default DebouncedTextInput; diff --git a/graph/client/src/app/ui-components/dropdown.stories.tsx b/graph/client/src/app/ui-components/dropdown.stories.tsx new file mode 100644 index 0000000000..6a35b01cba --- /dev/null +++ b/graph/client/src/app/ui-components/dropdown.stories.tsx @@ -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; + +const Template: ComponentStory = (args) => ( + + + + +); + +export const Primary = Template.bind({}); +Primary.args = {}; diff --git a/graph/client/src/app/ui-components/dropdown.tsx b/graph/client/src/app/ui-components/dropdown.tsx new file mode 100644 index 0000000000..ddd6801d87 --- /dev/null +++ b/graph/client/src/app/ui-components/dropdown.tsx @@ -0,0 +1,20 @@ +/* eslint-disable-next-line */ +import React, { ReactNode } from 'react'; + +export type DropdownProps = { + children: ReactNode[]; +} & React.HTMLAttributes; + +export function Dropdown(props: DropdownProps) { + const { className, children, ...rest } = props; + return ( + + ); +} + +export default Dropdown; diff --git a/graph/client/src/app/ui-components/tag.stories.tsx b/graph/client/src/app/ui-components/tag.stories.tsx new file mode 100644 index 0000000000..002090528c --- /dev/null +++ b/graph/client/src/app/ui-components/tag.stories.tsx @@ -0,0 +1,15 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { Tag } from './tag'; + +const Story: ComponentMeta = { + component: Tag, + title: 'Shared/Tag', +}; +export default Story; + +const Template: ComponentStory = (args) => {args.text}; + +export const Primary = Template.bind({}); +Primary.args = { + text: 'tag', +}; diff --git a/graph/client/src/app/ui-components/tag.tsx b/graph/client/src/app/ui-components/tag.tsx new file mode 100644 index 0000000000..2e262b5b97 --- /dev/null +++ b/graph/client/src/app/ui-components/tag.tsx @@ -0,0 +1,12 @@ +/* eslint-disable-next-line */ +export interface TagProps {} + +export function Tag(props) { + return ( + + {props.children} + + ); +} + +export default Tag; diff --git a/graph/client/src/styles.scss b/graph/client/src/styles.scss index a1543850c0..370893e67f 100644 --- a/graph/client/src/styles.scss +++ b/graph/client/src/styles.scss @@ -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); }