diff --git a/python_modules/dagit/dagit/webapp/package.json b/python_modules/dagit/dagit/webapp/package.json index 771d3a6a171c1..c4fedc25205b5 100644 --- a/python_modules/dagit/dagit/webapp/package.json +++ b/python_modules/dagit/dagit/webapp/package.json @@ -5,6 +5,7 @@ "dependencies": { "@blueprintjs/core": "^3.0.1", "@blueprintjs/icons": "^3.0.0", + "@blueprintjs/select": "^3.2.0", "@types/dagre": "^0.7.40", "@types/prop-types": "^15.5.4", "@types/react-router": "^4.0.30", @@ -16,11 +17,11 @@ "@vx/responsive": "^0.0.165", "@vx/scale": "^0.0.165", "@vx/shape": "^0.0.170", + "amator": "^1.1.0", "apollo-boost": "^0.1.12", "d3-hierarchy": "^1.1.6", "dagre": "^0.8.2", "graphql-tag": "^2.9.2", - "panzoom": "^6.1.3", "react": "^16.4.2", "react-apollo": "^2.1.9", "react-dom": "^16.4.2", @@ -61,12 +62,20 @@ "watch-types": "apollo codegen:generate --queries \"./src/**/*.tsx\" --target typescript types --schema ./src/schema.json --watch" }, "jest": { - "roots": ["/src"], + "roots": [ + "/src" + ], "transform": { "^.+\\.(ts|tsx)$": "ts-jest", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest/fileTransformer.js" }, - "moduleFileExtensions": ["js", "jsx", "json", "ts", "tsx"], + "moduleFileExtensions": [ + "js", + "jsx", + "json", + "ts", + "tsx" + ], "testRegex": "/__tests__/.*\\.test\\.(ts|tsx)$", "testURL": "http://localhost" } diff --git a/python_modules/dagit/dagit/webapp/src/App.tsx b/python_modules/dagit/dagit/webapp/src/App.tsx index 7b140f61758cc..b5303119ba2a7 100644 --- a/python_modules/dagit/dagit/webapp/src/App.tsx +++ b/python_modules/dagit/dagit/webapp/src/App.tsx @@ -3,35 +3,70 @@ import gql from "graphql-tag"; import { Query, QueryResult } from "react-apollo"; import { Route } from "react-router"; import { BrowserRouter } from "react-router-dom"; -import Page from "./Page"; import Loading from "./Loading"; -import Pipelines from "./Pipelines"; +import PipelineExplorer from "./PipelineExplorer"; +import PipelineJumpBar from "./PipelineJumpBar"; import { AppQuery } from "./types/AppQuery"; +import { Alignment, Navbar, NonIdealState } from "@blueprintjs/core"; +import navBarImage from "./images/nav-logo.png"; + export default class App extends React.Component { public render() { return ( - - - {({ match, history }) => ( - - {(queryResult: QueryResult) => ( - - - {data => ( - - )} - - - )} - - )} - - + + {(queryResult: QueryResult) => ( + + {data => ( + + + {({ match, history }) => { + const selectedPipeline = data.pipelines.find( + p => (match ? p.name === match.params.pipeline : false) + ); + const selectedSolid = + selectedPipeline && + selectedPipeline.solids.find( + s => (match ? s.name === match.params.solid : false) + ); + + return ( + <> + + + + + + + + + + {selectedPipeline ? ( + + ) : ( + + )} + + ); + }} + + + )} + + )} + ); } } @@ -39,9 +74,9 @@ export default class App extends React.Component { export const APP_QUERY = gql` query AppQuery { pipelines { - ...PipelinesFragment + ...PipelineFragment } } - ${Pipelines.fragments.PipelinesFragment} + ${PipelineExplorer.fragments.PipelineFragment} `; diff --git a/python_modules/dagit/dagit/webapp/src/Breadcrumbs.tsx b/python_modules/dagit/dagit/webapp/src/Breadcrumbs.tsx deleted file mode 100644 index dce7f8fec661c..0000000000000 --- a/python_modules/dagit/dagit/webapp/src/Breadcrumbs.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from "react"; -import styled from "styled-components"; -import { Classes } from "@blueprintjs/core"; - -interface IBreadcrumbsProps { - className?: string; -} - -export class Breadcrumbs extends React.Component { - static defaultProps = { - className: "" - }; - - render() { - return ( -
    - {this.props.children} -
- ); - } -} - -interface IBreadcrumbProps { - className?: string; - current?: boolean; -} - -export class Breadcrumb extends React.Component { - static defaultProps = { - className: "", - current: false - }; - - render() { - return ( -
  • - {this.props.children} -
  • - ); - } -} diff --git a/python_modules/dagit/dagit/webapp/src/Config.tsx b/python_modules/dagit/dagit/webapp/src/Config.tsx index 12ddabead7965..bd60d27e2b287 100644 --- a/python_modules/dagit/dagit/webapp/src/Config.tsx +++ b/python_modules/dagit/dagit/webapp/src/Config.tsx @@ -1,14 +1,13 @@ import * as React from "react"; import gql from "graphql-tag"; import styled from "styled-components"; -import { UL, H6, Colors } from "@blueprintjs/core"; -import SpacedCard from "./SpacedCard"; +import { UL, Colors } from "@blueprintjs/core"; import TypeWithTooltip from "./TypeWithTooltip"; import Description from "./Description"; import { ConfigFragment } from "./types/ConfigFragment"; interface ConfigProps { - config: ConfigFragment | null; + config: ConfigFragment; } export default class Config extends React.Component { @@ -61,27 +60,16 @@ export default class Config extends React.Component { } public render() { - if (this.props.config) { - return ( - -
    Config
    - - {this.renderFields(this.props.config)} -
    - ); - } else { - return null; - } + return ( +
    + + {this.renderFields(this.props.config)} +
    + ); } } -const ConfigCard = styled(SpacedCard)` - width: 400px; - margin-bottom: 10px; -`; - const DescriptionWrapper = styled.div` - max-width: 400px; margin-top: 10px; margin-bottom: 10px; color: ${Colors.GRAY2}; diff --git a/python_modules/dagit/dagit/webapp/src/Description.tsx b/python_modules/dagit/dagit/webapp/src/Description.tsx index 7426ec9ce12e2..b79ce10db7e09 100644 --- a/python_modules/dagit/dagit/webapp/src/Description.tsx +++ b/python_modules/dagit/dagit/webapp/src/Description.tsx @@ -1,7 +1,5 @@ import * as React from "react"; -import gql from "graphql-tag"; import styled from "styled-components"; -import { H1, H2, H3, H4, H5, H6, Text, Code, UL, Pre } from "@blueprintjs/core"; import * as ReactMarkdown from "react-markdown"; interface IDescriptionProps { diff --git a/python_modules/dagit/dagit/webapp/src/Page.tsx b/python_modules/dagit/dagit/webapp/src/Page.tsx deleted file mode 100644 index beefc6c9d299b..0000000000000 --- a/python_modules/dagit/dagit/webapp/src/Page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; -import { Alignment, Navbar } from "@blueprintjs/core"; -import navBarImage from "./images/nav-logo.png"; - -export default class Page extends React.Component { - public render() { - return ( - <> - - - - - - - - - {this.props.children} - - ); - } -} diff --git a/python_modules/dagit/dagit/webapp/src/PanelDivider.tsx b/python_modules/dagit/dagit/webapp/src/PanelDivider.tsx new file mode 100644 index 0000000000000..6941211ef8fc4 --- /dev/null +++ b/python_modules/dagit/dagit/webapp/src/PanelDivider.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import styled from "styled-components"; +import { Colors } from "@blueprintjs/core"; + +interface IDividerProps { + onMove: (vw: number) => void; +} +interface IDividerState { + down: boolean; +} + +export class PanelDivider extends React.Component< + IDividerProps, + IDividerState +> { + state = { + down: false + }; + + onMouseDown = (event: React.MouseEvent) => { + this.setState({ down: true }); + const onMouseMove = (event: MouseEvent) => { + let vx = (event.clientX * 100) / window.innerWidth; + this.props.onMove(vx); + }; + const onMouseUp = (event: MouseEvent) => { + this.setState({ down: false }); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + render() { + return ( + + + + ); + } +} + +const DividerWrapper = styled.div<{ down: boolean }>` + width: 4px; + background: ${Colors.WHITE}; + border-left: 1px solid ${p => (p.down ? Colors.GRAY5 : Colors.LIGHT_GRAY2)}; + border-right: 1px solid ${p => (p.down ? Colors.GRAY3 : Colors.GRAY5)}; + overflow: visible; + position: relative; +`; + +const DividerHitArea = styled.div` + width: 17px; + height: 100%; + z-index: 2; + cursor: ew-resize; + position: relative; + left: -8px; +`; diff --git a/python_modules/dagit/dagit/webapp/src/Pipeline.tsx b/python_modules/dagit/dagit/webapp/src/Pipeline.tsx deleted file mode 100644 index 39f69c329ff64..0000000000000 --- a/python_modules/dagit/dagit/webapp/src/Pipeline.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import * as React from "react"; -import gql from "graphql-tag"; -import styled from "styled-components"; -import { History } from "history"; -import { Switch, Route, match } from "react-router"; -import { Link } from "react-router-dom"; -import { Card, H3, H5, Text, Code, UL, H6 } from "@blueprintjs/core"; -import SpacedCard from "./SpacedCard"; -import Config from "./Config"; -import Solid from "./Solid"; -import PipelineGraph from "./graph/PipelineGraph"; -import { Breadcrumbs, Breadcrumb } from "./Breadcrumbs"; -import SolidListItem from "./SolidListItem"; -import Description from "./Description"; -import { - PipelineFragment, - PipelineFragment_solids -} from "./types/PipelineFragment"; - -interface IPipelineProps { - pipeline: PipelineFragment; -} - -export default class Pipeline extends React.Component { - static fragments = { - PipelineFragment: gql` - fragment PipelineFragment on Pipeline { - name - description - solids { - ...SolidFragment - ...SolidListItemFragment - } - contexts { - name - description - config { - ...ConfigFragment - } - } - ...PipelineGraphFragment - } - - ${Solid.fragments.SolidFragment} - ${PipelineGraph.fragments.PipelineGraphFragment} - ${SolidListItem.fragments.SolidListItemFragment} - ${Config.fragments.ConfigFragment} - ` - }; - - handleClickSolid = ( - history: History, - activeSolid: string | null, - solidName: string - ) => { - if (solidName === activeSolid) { - history.push(`/${this.props.pipeline.name}`); - } else { - history.push(`/${this.props.pipeline.name}/${solidName}`); - } - }; - - renderBreadcrumbs() { - return ( - - - ( - <> - - - {this.props.pipeline.name} - - - - {match.params.solid} - - - )} - /> - ( - <> - - {this.props.pipeline.name} - - - )} - /> - - - ); - } - - renderContext() { - return this.props.pipeline.contexts.map((context: any, i: number) => ( - -
    - {context.name} -
    - - - - -
    - )); - } - - renderSolids() { - return this.props.pipeline.solids.map((solid: any, i: number) => ( - - )); - } - - renderBody = ({ - history, - match - }: { - history: History; - match: match<{ pipeline: string; solid?: string }>; - }) => { - const solidName = match.params.solid; - let solid: PipelineFragment_solids | undefined; - if (solidName) { - solid = this.props.pipeline.solids.find(({ name }) => name === solidName); - } - let body; - if (solid) { - body = ; - } else { - body = this.renderSolidList(); - } - return ( - <> - - - this.handleClickSolid( - history, - solid ? solid.name : null, - solidName - ) - } - /> - - {body} - - ); - }; - - renderSolidList = () => { - return ( - <> -
    -

    Context

    - {this.renderContext()} -
    -
    -

    Solids

    - {this.renderSolids()} -
    - - ); - }; - - public render() { - return ( - - {this.renderBreadcrumbs()} - - - - - - ); - } -} - -const BreadcrumbText = styled.h2` - font-family: "Source Code Pro", monospace; - font-weight: 500; -`; - -const Section = styled.div` - margin-bottom: 30px; -`; - -const PipelineGraphWrapper = styled(Section)` - height: 500px; - width: 100%; - display: flex; -`; - -const PipelineCard = styled(Card)` - flex: 1 1; - overflow: hidden; -`; - -const SpacedWrapper = styled.div` - margin-bottom: 10px; -`; - -const ContextCards = styled.div` - display: flex; - flex-wrap: wrap; - align-items: stretch; -`; - -const ContextCard = styled(SpacedCard)` - width: 400px; - margin-bottom: 10px; -`; - -const DescriptionWrapper = styled(SpacedWrapper)` - max-width: 500px; -`; diff --git a/python_modules/dagit/dagit/webapp/src/PipelineExplorer.tsx b/python_modules/dagit/dagit/webapp/src/PipelineExplorer.tsx new file mode 100644 index 0000000000000..ac499e28d0b70 --- /dev/null +++ b/python_modules/dagit/dagit/webapp/src/PipelineExplorer.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import gql from "graphql-tag"; +import styled from "styled-components"; +import { History } from "history"; +import { Colors } from "@blueprintjs/core"; +import { + PipelinesFragment, + PipelinesFragment_solids +} from "./types/PipelinesFragment"; +import PipelineGraph from "./graph/PipelineGraph"; +import { getDagrePipelineLayout } from "./graph/getFullSolidLayout"; +import { PanelDivider } from "./PanelDivider"; +import Config from "./Config"; +import SidebarSolidInfo from "./SidebarSolidInfo"; +import SidebarPipelineInfo from "./SidebarPipelineInfo"; + +interface IPipelineExplorerProps { + history: History; + pipeline: PipelinesFragment; + solid: PipelinesFragment_solids | undefined; +} + +interface IPipelineExplorerState { + filter: string; + graphVW: number; +} + +export default class PipelineExplorer extends React.Component< + IPipelineExplorerProps, + IPipelineExplorerState +> { + static fragments = { + PipelineFragment: gql` + fragment PipelineFragment on Pipeline { + name + description + solids { + ...SolidFragment + } + contexts { + name + description + config { + ...ConfigFragment + } + } + ...PipelineGraphFragment + } + + ${SidebarSolidInfo.fragments.SolidFragment} + ${PipelineGraph.fragments.PipelineGraphFragment} + ${Config.fragments.ConfigFragment} + ` + }; + + state = { + filter: "", + graphVW: 70 + }; + + handleClickSolid = (solidName: string) => { + const { history, pipeline } = this.props; + history.push(`/${pipeline.name}/${solidName}`); + }; + + handleClickBackground = () => { + const { history, pipeline } = this.props; + history.push(`/${pipeline.name}`); + }; + + public render() { + const { pipeline, solid } = this.props; + const { filter, graphVW } = this.state; + + return ( + + + + this.setState({ filter: e.target.value })} + /> + + filter && s.name.includes(filter) + )} + /> + + this.setState({ graphVW: vw })} /> + + {solid ? ( + + ) : ( + + )} + + + ); + } +} + +const PipelinesContainer = styled.div` + flex: 1 1; + display: flex; + width: 100%; + height: 100vh; + top: 0; + position: absolute; + padding-top: 50px; +`; + +const PipelinePanel = styled.div` + height: 100%; + position: relative; +`; + +const RightInfoPanel = styled.div` + height: 100%; + overflow-y: scroll; + background: ${Colors.WHITE}; +`; + +const SearchOverlay = styled.div` + background: rgba(0, 0, 0, 0.2); + z-index: 2; + padding: 7px; + display: inline-block; + width: 150px; + position: absolute; + right: 0; +`; diff --git a/python_modules/dagit/dagit/webapp/src/PipelineJumpBar.tsx b/python_modules/dagit/dagit/webapp/src/PipelineJumpBar.tsx new file mode 100644 index 0000000000000..861a405319a88 --- /dev/null +++ b/python_modules/dagit/dagit/webapp/src/PipelineJumpBar.tsx @@ -0,0 +1,162 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { Select } from "@blueprintjs/select"; +import { Button, MenuItem } from "@blueprintjs/core"; +import styled from "styled-components"; +import { History } from "history"; +import gql from "graphql-tag"; +import PipelineExplorer from "./PipelineExplorer"; +import { PipelinesFragment } from "./types/PipelinesFragment"; +import { + PipelineFragment, + PipelineFragment_solids +} from "./types/PipelineFragment"; + +interface IPipelinesProps { + history: History; + pipelines: Array; + selectedPipeline: PipelinesFragment | undefined; + selectedSolid: PipelineFragment_solids | undefined; +} + +const PipelineSelect = Select.ofType(); +const SolidSelect = Select.ofType(); + +const BasicNamePredicate = (text: string, items: any) => + items + .filter((i: any) => i.name.toLowerCase().includes(text.toLowerCase())) + .slice(0, 20); + +const BasicNameRenderer = ( + item: { name: string }, + options: { handleClick: any; modifiers: any } +) => ( + +); + +function activateSelect(select: Select | null) { + if (!select) return; + const selectEl = ReactDOM.findDOMNode(select) as HTMLElement; + const btnEl = selectEl.querySelector("button"); + if (btnEl) { + btnEl.click(); + } +} + +export default class PipelineJumpBar extends React.Component< + IPipelinesProps, + {} +> { + static fragments = { + PipelinesFragment: gql` + fragment PipelinesFragment on Pipeline { + ...PipelineFragment + } + + ${PipelineExplorer.fragments.PipelineFragment} + ` + }; + + solidSelect: React.RefObject< + Select + > = React.createRef(); + + pipelineSelect: React.RefObject< + Select + > = React.createRef(); + + componentDidMount() { + window.addEventListener("keydown", this.onGlobalKeydown); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.onGlobalKeydown); + } + + onGlobalKeydown = (event: KeyboardEvent) => { + const { history, selectedPipeline } = this.props; + const { key, target } = event; + + if (target && (target as HTMLElement).nodeName === "INPUT") { + return; + } + if (key === "s") { + activateSelect(this.solidSelect.current); + } + if (key === "p") { + activateSelect(this.pipelineSelect.current); + } + if (key === "Escape" && selectedPipeline) { + history.push(`/${selectedPipeline.name}`); + } + }; + + onSelectPipeline = (pipeline: PipelineFragment) => { + this.props.history.push(`/${pipeline.name}`); + }; + + onSelectSolid = (solid: PipelineFragment_solids) => { + const { history, selectedPipeline } = this.props; + + if (selectedPipeline) { + history.push(`/${selectedPipeline.name}/${solid.name}`); + } + }; + + render() { + const { pipelines, selectedPipeline, selectedSolid } = this.props; + + return ( + + } + onItemSelect={this.onSelectPipeline} + > + + + + -
    , +
    +

    -

    - No pipeline selected -

    -
    - Select a pipeline in the sidebar on the left -
    + No pipeline selected + +
    + Select a pipeline in the sidebar on the left
    , ] diff --git a/python_modules/dagit/dagit/webapp/src/graph/PanAndZoom.tsx b/python_modules/dagit/dagit/webapp/src/graph/PanAndZoom.tsx index 5c1afbc9f43d6..28ab8e56e84a4 100644 --- a/python_modules/dagit/dagit/webapp/src/graph/PanAndZoom.tsx +++ b/python_modules/dagit/dagit/webapp/src/graph/PanAndZoom.tsx @@ -1,48 +1,209 @@ import * as React from "react"; -import panzoom from "panzoom"; +import animate from "amator"; +import { Colors } from "@blueprintjs/core"; interface PanAndZoomProps { className?: string; graphWidth: number; graphHeight: number; + children: (state: PanAndZoomState) => React.ReactNode; } -export default class PanAndZoom extends React.Component { +interface PanAndZoomState { + x: number; + y: number; + scale: number; + minScale: number; +} + +interface Point { + x: number; + y: number; +} + +const DETAIL_ZOOM = 1; +const MAX_OVERVIEW_ZOOM = 0.39; + +export default class PanAndZoom extends React.Component< + PanAndZoomProps, + PanAndZoomState +> { element: React.RefObject = React.createRef(); panzoom: any; + _animation: any = null; + _lastWheelTime: number = 0; + _lastWheelDir: number = 0; + + state = { + x: 0, + y: 0, + scale: 1, + minScale: 0 + }; + componentDidMount() { - if (this.element.current) { - const el = this.element.current; - const elWidth = el.clientWidth; - const elHeight = el.clientHeight; - const maxZoom = 1; - let minZoom; - minZoom = Math.min( - el.clientWidth / this.props.graphWidth, - el.clientHeight / this.props.graphHeight, - 1 - ); - - this.panzoom = (panzoom(this.element.current, { - smoothScroll: false, - minZoom, - maxZoom - }) as any).zoomAbs(0, 0, minZoom); + this.autocenter(); + } + + autocenter(animate: boolean = false) { + const el = this.element.current!; + const ownerRect = el.getBoundingClientRect(); + + var dw = ownerRect.width / this.props.graphWidth; + var dh = ownerRect.height / this.props.graphHeight; + var scale = Math.min(dw, dh, MAX_OVERVIEW_ZOOM); + + const target = { + x: -(this.props.graphWidth / 2) * scale + ownerRect.width / 2, + y: -(this.props.graphHeight / 2) * scale + ownerRect.height / 2, + scale: scale + }; + + if (animate) { + this.smoothZoom(target); + } else { + this.setState(Object.assign(target, { minScale: scale })); } } - componentWillUnmount() { - if (this.panzoom) { - this.panzoom.dispose(); + screenToSVGCoords({ x, y }: Point): Point { + const el = this.element.current!; + var { width, height } = el.getBoundingClientRect(); + return { + x: (-(this.state.x - width / 2) + x - width / 2) / this.state.scale, + y: (-(this.state.y - height / 2) + y - height / 2) / this.state.scale + }; + } + + getOffsetXY(e: MouseEvent | React.MouseEvent): Point { + const el = this.element.current!; + var ownerRect = el.getBoundingClientRect(); + return { x: e.clientX - ownerRect.left, y: e.clientY - ownerRect.top }; + } + + public smoothZoomToSVGCoords(x: number, y: number, targetScale: number) { + const el = this.element.current!; + var ownerRect = el.getBoundingClientRect(); + this.smoothZoom({ + x: -x * targetScale + ownerRect.width / 2, + y: -y * targetScale + ownerRect.height / 2, + scale: targetScale + }); + } + + public smoothZoom(to: { x: number; y: number; scale: number }) { + var from = { scale: this.state.scale, x: this.state.x, y: this.state.y }; + + if (this._animation) { + this._animation.cancel(); } + + this._animation = animate(from, to, { + step: (v: any) => { + this.setState({ + x: v.x, + y: v.y, + scale: v.scale + }); + }, + done: () => { + this._animation = null; + } + }); } + onZoomAndCenter = (event: React.MouseEvent) => { + var offset = this.screenToSVGCoords(this.getOffsetXY(event)); + if (Math.abs(1 - this.state.scale) < 0.01) { + this.smoothZoomToSVGCoords(offset.x, offset.y, this.state.minScale); + } else { + this.smoothZoomToSVGCoords(offset.x, offset.y, 1); + } + }; + + onMouseDown = (event: React.MouseEvent) => { + if (this._animation) { + this._animation.cancel(); + } + + const start = this.getOffsetXY(event); + let lastX: number = start.x; + let lastY: number = start.y; + + const onMove = (e: MouseEvent) => { + const offset = this.getOffsetXY(e); + const delta = { x: offset.x - lastX, y: offset.y - lastY }; + this.setState({ + x: this.state.x + delta.x, + y: this.state.y + delta.y + }); + lastX = offset.x; + lastY = offset.y; + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + event.stopPropagation(); + }; + + onWheel = (event: React.WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + + // Because of inertial scrolling on macOS, we receive wheel events for ~1000ms + // after we trigger the zoom and this can cause a second zoom. + const wheelWasIdle = Date.now() - this._lastWheelTime > 2000; + const wheelChangedDir = this._lastWheelDir !== Math.sign(event.deltaY); + if (wheelWasIdle || wheelChangedDir) { + if (event.deltaY > 0) { + this.onZoomAndCenter(event); + } else { + this.autocenter(true); + } + } + + this._lastWheelTime = Date.now(); + this._lastWheelDir = Math.sign(event.deltaY); + }; + render() { + const { children } = this.props; + const { x, y, scale } = this.state; + return ( -
    - {this.props.children} +
    +
    + {children(this.state)} +
    ); } } + +/* +BG: Not using styled-components here because I need a `ref` to an actual DOM element. +Styled-component with a ref returns a React component we need to findDOMNode to use. +*/ +const PanAndZoomStyles: React.CSSProperties = { + width: "100%", + height: "100%", + position: "relative", + overflow: "hidden", + userSelect: "none", + backgroundColor: Colors.LIGHT_GRAY5 +}; diff --git a/python_modules/dagit/dagit/webapp/src/graph/PipelineColorScale.ts b/python_modules/dagit/dagit/webapp/src/graph/PipelineColorScale.ts index e34f220fde06d..22204cd9a1f66 100644 --- a/python_modules/dagit/dagit/webapp/src/graph/PipelineColorScale.ts +++ b/python_modules/dagit/dagit/webapp/src/graph/PipelineColorScale.ts @@ -6,8 +6,8 @@ const PipelineColorScale = scaleOrdinal({ range: [ Colors.TURQUOISE5, Colors.TURQUOISE3, - Colors.GRAY5, - Colors.ORANGE3, + "#DBE6EE", + Colors.BLUE3, Colors.ORANGE5 ] }); diff --git a/python_modules/dagit/dagit/webapp/src/graph/PipelineGraph.tsx b/python_modules/dagit/dagit/webapp/src/graph/PipelineGraph.tsx index db265e32ad1ec..aec9df78a5ae1 100644 --- a/python_modules/dagit/dagit/webapp/src/graph/PipelineGraph.tsx +++ b/python_modules/dagit/dagit/webapp/src/graph/PipelineGraph.tsx @@ -2,19 +2,79 @@ import * as React from "react"; import gql from "graphql-tag"; import styled from "styled-components"; import { Colors } from "@blueprintjs/core"; -import { LinkHorizontal as Link } from "@vx/shape"; +import { LinkVertical as Link } from "@vx/shape"; import PanAndZoom from "./PanAndZoom"; import SolidNode from "./SolidNode"; +import { IPoint, IFullPipelineLayout } from "./getFullSolidLayout"; import { - getDagrePipelineLayout, - IFullPipelineLayout -} from "./getFullSolidLayout"; -import { PipelineGraphFragment } from "./types/PipelineGraphFragment"; + PipelineGraphFragment, + PipelineGraphFragment_solids +} from "./types/PipelineGraphFragment"; interface IPipelineGraphProps { pipeline: PipelineGraphFragment; - selectedSolid?: string; + layout: IFullPipelineLayout; + selectedSolid?: PipelineGraphFragment_solids; + highlightedSolids: Array; onClickSolid?: (solidName: string) => void; + onDoubleClickSolid?: (solidName: string) => void; + onClickBackground?: () => void; +} + +interface IPipelineContentsProps extends IPipelineGraphProps { + minified: boolean; + layout: IFullPipelineLayout; +} + +class PipelineGraphContents extends React.PureComponent< + IPipelineContentsProps +> { + render() { + const { + layout, + minified, + pipeline, + onClickSolid, + onDoubleClickSolid, + highlightedSolids, + selectedSolid + } = this.props; + + return ( + + + {layout.connections.map(({ from, to }, i) => ( + d.x} + y={(d: IPoint) => d.y} + data={{ + // can also use from.point for the "Dagre" closest point on node + source: + layout.solids[from.solidName].outputs[from.edgeName].port, + target: layout.solids[to.solidName].inputs[to.edgeName].port + }} + /> + ))} + + {pipeline.solids.map(solid => ( + 0 && + highlightedSolids.indexOf(solid) == -1 + } + /> + ))} + + ); + } } export default class PipelineGraph extends React.Component< @@ -34,112 +94,59 @@ export default class PipelineGraph extends React.Component< ` }; - renderSolids(layout: IFullPipelineLayout) { - return this.props.pipeline.solids.map(solid => { - const solidLayout = layout.solids[solid.name]; - return ( - - ); - }); - } - - renderConnections(layout: IFullPipelineLayout) { - const connections: Array<{ - from: { solidName: string; outputName: string }; - to: { solidName: string; inputName: string }; - }> = []; - - this.props.pipeline.solids.forEach(solid => { - solid.inputs.forEach(input => { - if (input.dependsOn) { - connections.push({ - from: { - solidName: input.dependsOn.solid.name, - outputName: input.dependsOn.definition.name - }, - to: { - solidName: solid.name, - inputName: input.definition.name - } - }); - } - }); - }); + viewportEl: React.RefObject = React.createRef(); - const links = connections.map( - ( - { - from: { solidName: outputSolidName, outputName }, - to: { solidName: inputSolidName, inputName } - }, - i - ) => ( - d.x} - y={(d: { x: number; y: number }) => d.y} - /> - ) - ); + focusOnSolid = (solidName: string) => { + const solidLayout = this.props.layout.solids[solidName]; + if (!solidLayout) { + return; + } + const cx = solidLayout.boundingBox.x + solidLayout.boundingBox.width / 2; + const cy = solidLayout.boundingBox.y + solidLayout.boundingBox.height / 2; + this.viewportEl.current!.smoothZoomToSVGCoords(cx, cy, 1); + }; - return {links}; - } + unfocus = () => { + this.viewportEl.current!.autocenter(true); + }; render() { - const layout = getDagrePipelineLayout(this.props.pipeline); + const { layout, pipeline, onClickBackground } = this.props; return ( - - + + {({ scale }: any) => ( evt.preventDefault()} + onClick={onClickBackground} + onDoubleClick={this.unfocus} > - {this.renderConnections(layout)} - {this.renderSolids(layout)} + - - + )} + ); } } -const GraphWrapper = styled.div` - width: 100%; - height: 100%; - position: relative; - overflow: hidden; - user-select: none; - background-color: ${Colors.LIGHT_GRAY5}; -`; - -const PanAndZoomStyled = styled(PanAndZoom)` - width: 100%; - height: 100%; -`; - const SVGContainer = styled.svg` border-radius: 0; `; const StyledLink = styled(Link)` - stroke-width: 2; + stroke-width: 6; stroke: ${Colors.BLACK} - strokeOpacity: 0.6; fill: none; `; diff --git a/python_modules/dagit/dagit/webapp/src/graph/SolidNode.tsx b/python_modules/dagit/dagit/webapp/src/graph/SolidNode.tsx index 8bdf355558a7c..16f72eb2ac845 100644 --- a/python_modules/dagit/dagit/webapp/src/graph/SolidNode.tsx +++ b/python_modules/dagit/dagit/webapp/src/graph/SolidNode.tsx @@ -1,16 +1,24 @@ import * as React from "react"; import gql from "graphql-tag"; -import styled from "styled-components"; -import { Card, H5, Code, Colors } from "@blueprintjs/core"; +import styled, { StyledComponentClass } from "styled-components"; +import { Colors } from "@blueprintjs/core"; import PipelineColorScale from "./PipelineColorScale"; -import { SolidNodeFragment } from "./types/SolidNodeFragment"; -import { IFullSolidLayout } from "./getFullSolidLayout"; +import { + SolidNodeFragment, + SolidNodeFragment_inputs, + SolidNodeFragment_outputs +} from "./types/SolidNodeFragment"; +import { IFullSolidLayout, ILayout } from "./getFullSolidLayout"; +import { TypeName } from "../TypeWithTooltip"; interface ISolidNodeProps { layout: IFullSolidLayout; solid: SolidNodeFragment; - selected?: boolean; - onClick?: (solid: any) => void; + minified: boolean; + selected: boolean; + dim: boolean; + onClick?: (solid: string) => void; + onDoubleClick?: (solid: string) => void; } export default class SolidNode extends React.Component { @@ -52,122 +60,155 @@ export default class SolidNode extends React.Component { handleClick = (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); if (this.props.onClick) { this.props.onClick(this.props.solid.name); } }; - renderInputs() { - return this.props.solid.inputs.map((input, i) => { - const { - layout: { x, y, width, height } - } = this.props.layout.inputs[input.definition.name]; + handleDoubleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (this.props.onDoubleClick) { + this.props.onDoubleClick(this.props.solid.name); + } + }; - return ( - - -
    - {input.definition.name} ({input.definition.type.name} - ) -
    -
    -
    - ); - }); - } + renderIO( + ComponentClass: StyledComponentClass, + items: Array, + layout: { [inputName: string]: { layout: ILayout } } + ) { + const root = this.props.layout.solid; - renderOutputs() { - return this.props.solid.outputs.map((output, i) => { - const { - layout: { x, y, width, height } - } = this.props.layout.outputs[output.definition.name]; + return Object.keys(layout).map((key, i) => { + const { x, y, width, height } = layout[key].layout; + const input = items.find(o => o.definition.name === key); return ( - - -
    - {output.definition.name} ( - {output.definition.type.name}) -
    -
    -
    + + {width == 0 && + !this.props.minified && ( + {input!.definition.name}: + )} + {width == 0 && + !this.props.minified && ( + {input!.definition.type.name} + )} + ); }); } renderSelectedBox() { - if (this.props.selected) { - const width = this.props.layout.solid.width + 200 * 2 + 20; - const height = this.props.layout.solid.height + 20; - return ( - - ); - } else { + if (!this.props.selected) { return null; } + const { x, y, width, height } = this.props.layout.boundingBox; + return ( + + ); + } + + renderSolid() { + return ( + + + {this.props.solid.name} + + + ); } public render() { + const { solid, layout } = this.props; + return ( - + <> {this.renderSelectedBox()} - -
    - {this.props.solid.name} -
    -
    + {this.renderSolid()} + {this.renderIO(InputContainer, solid.inputs, layout.inputs)} + {this.renderIO(OutputContainer, solid.outputs, layout.outputs)}
    - {this.renderInputs()} - {this.renderOutputs()} -
    + ); } } + +const SolidContainer = styled.div` + padding: 12px; + border: 1px solid #979797; + background-color: ${PipelineColorScale("solid")}; + height: 100%; + margin-top: 0; + display: flex; + align-items: center; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); +`; + +const SolidName = styled.div<{ fontSize: number }>` + font-family: "Source Code Pro", monospace; + font-weight: 500; + font-size: ${props => props.fontSize}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const IOContainer = styled.div` + padding: 7px; + position: absolute; + display: flex; + align-items: center; +`; + +const InputContainer = styled(IOContainer)` + border: 1px solid #979797; + background-color: ${PipelineColorScale("input")}; +`; + +const OutputContainer = styled(IOContainer)` + border: 1px solid #979797; + background-color: ${PipelineColorScale("output")}; +`; + +const InputOutputName = styled.div` + font-family: "Source Code Pro", monospace; + font-size: 15px; + color: white; + overflow: hidden; + padding-left: 7px; + text-overflow: ellipsis; + padding-right: 12px; + white-space: nowrap; + max-width: 300px; +`; + +const Port = styled.div<{ filled: boolean }>` + display: inline-block; + width: 14px; + height: 14px; + border-radius: 7px; + border: 2px solid rgba(255, 255, 255, 0.7); + background: ${props => + props.filled ? "rgba(0, 0, 0, 0.3)" : "rgba(255, 255, 255, 0.3)"}; +`; diff --git a/python_modules/dagit/dagit/webapp/src/graph/getFullSolidLayout.ts b/python_modules/dagit/dagit/webapp/src/graph/getFullSolidLayout.ts index 8c092072a7eae..6d08595b01abe 100644 --- a/python_modules/dagit/dagit/webapp/src/graph/getFullSolidLayout.ts +++ b/python_modules/dagit/dagit/webapp/src/graph/getFullSolidLayout.ts @@ -1,15 +1,28 @@ import * as dagre from "dagre"; +export type ILayoutConnectionMember = { + point: IPoint; + solidName: string; + edgeName: string; +}; + +export type ILayoutConnection = { + from: ILayoutConnectionMember; + to: ILayoutConnectionMember; +}; + export type IFullPipelineLayout = { solids: { [solidName: string]: IFullSolidLayout; }; + connections: Array; width: number; height: number; }; export interface IFullSolidLayout { solid: ILayout; + boundingBox: ILayout; inputs: { [inputName: string]: { layout: ILayout; @@ -38,7 +51,6 @@ interface ILayoutSolid { definition: { name: string; }; - solid: { name: string; }; @@ -63,23 +75,23 @@ export interface IPoint { y: number; } -const SOLID_WIDTH = 250; +const SOLID_WIDTH = 350; const SOLID_BASE_HEIGHT = 60; -const SOLID_GAP = 100; -const INPUT_WIDTH = 200; -const INPUT_HEIGHT = 80; -const INPUT_GAP = 20; -const OUTPUT_WIDTH = 200; -const OUTPUT_HEIGHT = 80; -const INPUT_OUTPUT_INSET = 10; +const IO_HEIGHT = 36; +const IO_INSET = 6; +const IO_MINI_WIDTH = 35; +const IO_THRESHOLD_FOR_MINI = 4; +const PORT_INSET_X = 15; +const PORT_INSET_Y = IO_HEIGHT / 2; export function getDagrePipelineLayout( pipeline: ILayoutPipeline ): IFullPipelineLayout { const g = new dagre.graphlib.Graph(); + + // Define a new top-down, left to right graph layout g.setGraph({ - rankdir: "LR", - align: "UL", + rankdir: "TB", marginx: 100, marginy: 100 }); @@ -87,16 +99,36 @@ export function getDagrePipelineLayout( return {}; }); + const connections: Array = []; + pipeline.solids.forEach(solid => { - const layout = layoutSolid({ solid, x: 0, y: 0 }); + // Lay out each solid individually to get it's width and height based on it's + // inputs and outputs, and then attach it to the graph. Dagre will give us it's + // x,y position. + const layout = layoutSolid(solid, { x: 0, y: 0 }); g.setNode(solid.name, { - height: layout.solid.height, - width: layout.solid.width + INPUT_WIDTH * 2 - INPUT_OUTPUT_INSET * 2 + width: layout.boundingBox.width, + height: layout.boundingBox.height }); + // Give Dagre the dependency edges and build a flat set of them so we + // can reference them in a single pass later solid.inputs.forEach(input => { if (input.dependsOn) { g.setEdge(input.dependsOn.solid.name, solid.name); + + connections.push({ + from: { + point: { x: 0, y: 0 }, + solidName: input.dependsOn.solid.name, + edgeName: input.dependsOn.definition.name + }, + to: { + point: { x: 0, y: 0 }, + solidName: solid.name, + edgeName: input.definition.name + } + }); } }); }); @@ -106,97 +138,131 @@ export function getDagrePipelineLayout( const solids: { [solidName: string]: IFullSolidLayout; } = {}; + + // Due to a bug in Dagre when run without an "align" value, we need to calculate + // the total width of the graph coordinate space ourselves. + let maxWidth = 0; + + // Read the Dagre layout and map "nodes" back to our solids, but with + // X,Y coordinates this time. g.nodes().forEach(function(solidName) { const node = g.node(solidName); const solid = pipeline.solids.find(({ name }) => name === solidName); if (solid) { - solids[solidName] = layoutSolid({ - solid: solid, - x: node.x, - y: node.y + solids[solidName] = layoutSolid(solid, { + x: node.x - node.width / 2, // Dagre's x/y is the center, we want top left + y: node.y - node.height / 2 }); + maxWidth = Math.max(maxWidth, node.x + node.width); + } + }); + + // Read the Dagre layout and map "edges" back to our data model. We don't + // currently use the "closest points on the node" Dagre suggests (but we could). + g.edges().forEach(function(e) { + const conn = connections.find( + c => c.from.solidName === e.v && c.to.solidName === e.w + ); + const points = g.edge(e).points; + if (conn) { + conn.from.point = points[0]; + conn.to.point = points[points.length - 1]; } }); - // g.edges().forEach(function(e) { - // console.log( - // "Edge " + e.v + " -> " + e.w + ": " + JSON.stringify(g.edge(e)) - // ); - // }); return { solids, - width: g.graph().width as number, + connections, + width: maxWidth, height: g.graph().height as number }; } -function layoutSolid({ - solid, - x: solidX, - y: solidY -}: { - solid: ILayoutSolid; - x: number; - y: number; -}): IFullSolidLayout { - const solidLayout: ILayout = { - x: solidX, - y: solidY, - width: SOLID_WIDTH, - height: - SOLID_BASE_HEIGHT + - (INPUT_HEIGHT + INPUT_GAP) * - Math.max(solid.inputs.length, solid.outputs.length) - }; - const inputs: { - [inputName: string]: { - layout: ILayout; - port: IPoint; - }; +function layoutSolid(solid: ILayoutSolid, root: IPoint): IFullSolidLayout { + // Starting at the root (top left) X,Y, return the layout information for a solid with + // input blocks, then the main block, then output blocks (arranged vertically) + let accY = root.y; + + const inputsLayouts: { + [inputName: string]: { layout: ILayout; port: IPoint }; } = {}; - solid.inputs.forEach((input, i) => { - const inputX = solidX + INPUT_OUTPUT_INSET - INPUT_WIDTH; - const inputY = solidY + INPUT_GAP + (INPUT_HEIGHT + INPUT_GAP) * i; - inputs[input.definition.name] = { - layout: { - x: inputX, - y: inputY, - width: INPUT_WIDTH, - height: INPUT_HEIGHT - }, + + const buildIOSmallLayout = (idx: number, count: number) => { + const centeringOffsetX = (SOLID_WIDTH - IO_MINI_WIDTH * count) / 2; + const x = root.x + IO_MINI_WIDTH * idx + centeringOffsetX; + return { port: { - x: inputX, - y: inputY + INPUT_HEIGHT / 2 + x: x + PORT_INSET_X, + y: accY + PORT_INSET_Y + }, + layout: { + x: x, + y: accY, + width: IO_MINI_WIDTH, + height: IO_HEIGHT + } + }; + }; + + const buildIOLayout = () => { + const layout: { layout: ILayout; port: IPoint } = { + port: { x: root.x + PORT_INSET_X, y: accY + PORT_INSET_Y }, + layout: { + x: root.x, + y: accY, + width: 0, + height: IO_HEIGHT } }; + accY += IO_HEIGHT; + return layout; + }; + + solid.inputs.forEach((input, idx) => { + inputsLayouts[input.definition.name] = + solid.inputs.length > IO_THRESHOLD_FOR_MINI + ? buildIOSmallLayout(idx, solid.inputs.length) + : buildIOLayout(); }); + if (solid.inputs.length > IO_THRESHOLD_FOR_MINI) { + accY += IO_HEIGHT; + } - const outputs: { + const solidLayout: ILayout = { + x: root.x, + y: Math.max(root.y, accY - IO_INSET), + width: SOLID_WIDTH, + height: SOLID_BASE_HEIGHT + IO_INSET * 2 + }; + + accY += SOLID_BASE_HEIGHT; + + const outputLayouts: { [outputName: string]: { layout: ILayout; port: IPoint; }; } = {}; - solid.outputs.forEach((output, i) => { - const outputX = solidX + SOLID_WIDTH - INPUT_OUTPUT_INSET; - const outputY = solidY + INPUT_GAP + (OUTPUT_HEIGHT + INPUT_GAP) * i; - outputs[output.definition.name] = { - layout: { - x: outputX, - y: outputY, - width: OUTPUT_WIDTH, - height: OUTPUT_HEIGHT - }, - port: { - x: outputX, - y: outputY + OUTPUT_HEIGHT / 2 - } - }; + + solid.outputs.forEach((output, idx) => { + outputLayouts[output.definition.name] = + solid.outputs.length > IO_THRESHOLD_FOR_MINI + ? buildIOSmallLayout(idx, solid.outputs.length) + : buildIOLayout(); }); + if (solid.outputs.length > IO_THRESHOLD_FOR_MINI) { + accY += IO_HEIGHT; + } return { + boundingBox: { + x: root.x, + y: root.y, + width: SOLID_WIDTH, + height: accY - root.y + }, solid: solidLayout, - inputs, - outputs + inputs: inputsLayouts, + outputs: outputLayouts }; } diff --git a/python_modules/dagit/dagit/webapp/src/index.tsx b/python_modules/dagit/dagit/webapp/src/index.tsx index 7a45381a127f7..619e3996cc25f 100644 --- a/python_modules/dagit/dagit/webapp/src/index.tsx +++ b/python_modules/dagit/dagit/webapp/src/index.tsx @@ -7,6 +7,7 @@ import App from "./App"; import AppCache from "./AppCache"; import "@blueprintjs/core/lib/css/blueprint.css"; import "@blueprintjs/icons/lib/css/blueprint-icons.css"; +import "@blueprintjs/select/lib/css/blueprint-select.css"; const client = new ApolloClient({ cache: AppCache, @@ -43,4 +44,9 @@ injectGlobal` padding: 0; font-family: sans-serif; } + + /* Prevent Blueprint's Select dropdowns from having duplicate icons */ + .bp3-popover-content { + svg[data-icon] { display: none; } + } `; diff --git a/python_modules/dagit/dagit/webapp/src/types/SolidListItemFragment.ts b/python_modules/dagit/dagit/webapp/src/types/SolidListItemFragment.ts deleted file mode 100644 index 9b9eaea0c10de..0000000000000 --- a/python_modules/dagit/dagit/webapp/src/types/SolidListItemFragment.ts +++ /dev/null @@ -1,58 +0,0 @@ - - -/* tslint:disable */ -// This file was automatically generated and should not be edited. - -// ==================================================== -// GraphQL fragment: SolidListItemFragment -// ==================================================== - -export interface SolidListItemFragment_definition { - description: string | null; -} - -export interface SolidListItemFragment_outputs_definition_type { - name: string; - description: string | null; -} - -export interface SolidListItemFragment_outputs_definition { - name: string; - type: SolidListItemFragment_outputs_definition_type; -} - -export interface SolidListItemFragment_outputs { - definition: SolidListItemFragment_outputs_definition; -} - -export interface SolidListItemFragment_inputs_definition_type { - name: string; - description: string | null; -} - -export interface SolidListItemFragment_inputs_definition { - name: string; - type: SolidListItemFragment_inputs_definition_type; -} - -export interface SolidListItemFragment_inputs { - definition: SolidListItemFragment_inputs_definition; -} - -export interface SolidListItemFragment { - name: string; - definition: SolidListItemFragment_definition; - outputs: SolidListItemFragment_outputs[]; - inputs: SolidListItemFragment_inputs[]; -} - -/* tslint:disable */ -// This file was automatically generated and should not be edited. - -//============================================================== -// START Enums and Input Objects -//============================================================== - -//============================================================== -// END Enums and Input Objects -//============================================================== \ No newline at end of file diff --git a/python_modules/dagit/dagit/webapp/types/amator.d.ts b/python_modules/dagit/dagit/webapp/types/amator.d.ts new file mode 100644 index 0000000000000..f3dfba66f6f6c --- /dev/null +++ b/python_modules/dagit/dagit/webapp/types/amator.d.ts @@ -0,0 +1 @@ +declare module "amator"; diff --git a/python_modules/dagit/dagit/webapp/yarn.lock b/python_modules/dagit/dagit/webapp/yarn.lock index 524f931bc68ab..667fb08c970de 100644 --- a/python_modules/dagit/dagit/webapp/yarn.lock +++ b/python_modules/dagit/dagit/webapp/yarn.lock @@ -49,6 +49,21 @@ resize-observer-polyfill "^1.5.0" tslib "^1.9.0" +"@blueprintjs/core@^3.6.0": + version "3.6.1" + resolved "https://registry.yarnpkg.com/@blueprintjs/core/-/core-3.6.1.tgz#d168423b1c38964e50c2cc8aef4cba8076bfe01a" + dependencies: + "@blueprintjs/icons" "^3.0.0" + "@types/dom4" "^2.0.0" + classnames "^2.2" + dom4 "^2.0.1" + normalize.css "^8.0.0" + popper.js "^1.14.1" + react-popper "^1.0.0" + react-transition-group "^2.2.1" + resize-observer-polyfill "^1.5.0" + tslib "^1.9.0" + "@blueprintjs/icons@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@blueprintjs/icons/-/icons-3.0.0.tgz#2eed041dd52c9feed2758ca526f4505911813a31" @@ -56,6 +71,14 @@ classnames "^2.2" tslib "^1.9.0" +"@blueprintjs/select@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@blueprintjs/select/-/select-3.2.0.tgz#743fdafd6b4d3140473f45cba452bd937338b419" + dependencies: + "@blueprintjs/core" "^3.6.0" + classnames "^2.2" + tslib "^1.9.0" + "@heroku-cli/color@^1.1.3": version "1.1.10" resolved "https://registry.yarnpkg.com/@heroku-cli/color/-/color-1.1.10.tgz#a9326b25da187fa64f28404f32c0708a37d7fbab" @@ -6639,13 +6662,6 @@ pako@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" -panzoom@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/panzoom/-/panzoom-6.1.3.tgz#d7ad4c6780f9c394d14b2f2ce75c29abc105c05e" - dependencies: - amator "^1.1.0" - wheel "0.0.5" - parallel-transform@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" @@ -9408,10 +9424,6 @@ whatwg-url@^6.4.0, whatwg-url@^6.4.1: tr46 "^1.0.1" webidl-conversions "^4.0.2" -wheel@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/wheel/-/wheel-0.0.5.tgz#8b4d0930e72c9b8dfbb907833ee333de97b014fa" - whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"