From bb92716a04ddd5e2f7107b8067cb6d2627f44de8 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:08:11 -0500 Subject: [PATCH 1/6] feat: redo ui to be per pipeline Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> --- ui/package-lock.json | 128 +++++ ui/package.json | 5 + ui/src/App.tsx | 38 +- ui/src/app/layout.tsx | 19 + ui/src/app/pipeline.tsx | 40 ++ ui/src/app/root.tsx | 12 + ui/src/app/system.tsx | 67 --- ui/src/components/header.tsx | 26 + ui/src/components/pipeline.tsx | 70 ++- ui/src/components/sidebar.tsx | 128 +++++ ui/src/components/ui/command.tsx | 143 ++++++ ui/src/components/ui/popover.tsx | 31 ++ ui/src/components/ui/separator.tsx | 26 + ui/src/components/ui/sheet.tsx | 121 +++++ ui/src/components/ui/sidebar.tsx | 736 +++++++++++++++++++++++++++++ ui/src/components/ui/skeleton.tsx | 7 + ui/src/components/ui/tooltip.tsx | 30 ++ ui/src/hooks/useMobile.ts | 9 + ui/src/index.css | 18 + ui/src/index.tsx | 11 +- ui/src/store/hooks.ts | 5 + ui/src/store/index.ts | 18 + ui/src/store/pipelinesSlice.ts | 61 +++ ui/src/store/systemSlice.ts | 49 ++ 24 files changed, 1685 insertions(+), 113 deletions(-) create mode 100644 ui/src/app/layout.tsx create mode 100644 ui/src/app/pipeline.tsx create mode 100644 ui/src/app/root.tsx delete mode 100644 ui/src/app/system.tsx create mode 100644 ui/src/components/header.tsx create mode 100644 ui/src/components/sidebar.tsx create mode 100644 ui/src/components/ui/command.tsx create mode 100644 ui/src/components/ui/popover.tsx create mode 100644 ui/src/components/ui/separator.tsx create mode 100644 ui/src/components/ui/sheet.tsx create mode 100644 ui/src/components/ui/sidebar.tsx create mode 100644 ui/src/components/ui/skeleton.tsx create mode 100644 ui/src/components/ui/tooltip.tsx create mode 100644 ui/src/hooks/useMobile.ts create mode 100644 ui/src/store/hooks.ts create mode 100644 ui/src/store/index.ts create mode 100644 ui/src/store/pipelinesSlice.ts create mode 100644 ui/src/store/systemSlice.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 6d65889..5bc0fa4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,17 +12,22 @@ "@dagrejs/dagre": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.4", "@reduxjs/toolkit": "^2.3.0", + "@uidotdev/usehooks": "^2.4.1", "@xyflow/react": "^12.3.4", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" }, @@ -2379,6 +2384,42 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -2492,6 +2533,28 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", + "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -2730,6 +2793,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@swc/core": { "version": "1.7.42", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.42.tgz", @@ -3242,6 +3313,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@uidotdev/usehooks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@xyflow/react": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.3.4.tgz", @@ -3819,6 +3902,21 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", + "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "use-sync-external-store": "^1.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7316,6 +7414,36 @@ } } }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", diff --git a/ui/package.json b/ui/package.json index 1682ca9..472526c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,17 +34,22 @@ "@dagrejs/dagre": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.4", "@reduxjs/toolkit": "^2.3.0", + "@uidotdev/usehooks": "^2.4.1", "@xyflow/react": "^12.3.4", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7" } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c9f89b6..fcf66b2 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,44 @@ -import System from './app/system'; +import Layout from './app/layout'; +import Root from './app/root'; import { ThemeProvider } from './components/theme-provider'; +import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from './store/hooks'; +import { fetchPipelines } from './store/pipelinesSlice'; +import { fetchSystem } from './store/systemSlice'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import Pipeline from './app/pipeline'; +import { RootState } from './store'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { + path: '/', + element: + }, + { + path: 'pipelines/:pipelineId', + element: + } + ] + // errorElement: , + } +]); export function App() { + const dispatch = useAppDispatch(); + const pipelinesState = useAppSelector((state: RootState) => state.pipelines); + + useEffect(() => { + dispatch(fetchSystem()); + dispatch(fetchPipelines()); + }, [dispatch]); + return ( - + ); } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx new file mode 100644 index 0000000..9d0abde --- /dev/null +++ b/ui/src/app/layout.tsx @@ -0,0 +1,19 @@ +import { SidebarProvider } from '@/components/ui/sidebar'; +import { Sidebar } from '@/components/sidebar'; +import { ThemeToggle } from '@/components/theme-toggle'; +import { Outlet } from 'react-router-dom'; +import { Header } from '@/components/header'; + +export default function Layout() { + return ( + + +
+
+
+ +
+
+
+ ); +} diff --git a/ui/src/app/pipeline.tsx b/ui/src/app/pipeline.tsx new file mode 100644 index 0000000..5bdb4f2 --- /dev/null +++ b/ui/src/app/pipeline.tsx @@ -0,0 +1,40 @@ +import '@xyflow/react/dist/style.css'; +import { Pipeline as PipelineComponent } from '@/components/pipeline'; +import { ReactFlowProvider } from '@xyflow/react'; +import { useParams } from 'react-router-dom'; +import { useAppSelector } from '@/store/hooks'; +import { selectPipelineByName, setSelectedPipeline } from '@/store/pipelinesSlice'; +import { RootState } from '@/store'; +import { useEffect } from 'react'; +import { useAppDispatch } from '@/store/hooks'; + +export default function Pipeline() { + const { pipelineId } = useParams(); + const dispatch = useAppDispatch(); + + const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines); + const pipeline = useAppSelector((state: RootState) => selectPipelineByName(state, pipelineId)); + + useEffect(() => { + if (pipeline) { + dispatch(setSelectedPipeline(pipeline)); + } + return () => { + dispatch(setSelectedPipeline(null)); + }; + }, [pipeline, dispatch]); + + if (loading || !pipelines) { + return
Loading pipeline...
; + } + + if (!pipeline) { + return
Pipeline not found: {pipelineId}
; + } + + return ( + + + + ); +} diff --git a/ui/src/app/root.tsx b/ui/src/app/root.tsx new file mode 100644 index 0000000..f47b2ac --- /dev/null +++ b/ui/src/app/root.tsx @@ -0,0 +1,12 @@ +export default function Root() { + return ( +
+
+

Welcome to Glu

+

+ Please select a pipeline from the sidebar to get started +

+
+
+ ); +} diff --git a/ui/src/app/system.tsx b/ui/src/app/system.tsx deleted file mode 100644 index 57eb597..0000000 --- a/ui/src/app/system.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from 'react'; -import { WorkflowIcon } from 'lucide-react'; -import '@xyflow/react/dist/style.css'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { getSystem, listPipelines } from '@/services/api'; -import { Badge } from '@/components/ui/badge'; -import { Pipeline } from '@/components/pipeline'; -import { System as SystemType } from '@/types/system'; -import { Pipeline as PipelineType } from '@/types/pipeline'; -import { ReactFlowProvider } from '@xyflow/react'; - -export default function System() { - const [system, setSystem] = useState(); - const [pipelines, setPipelines] = useState(); - - useEffect(() => { - const fetchData = async () => { - setSystem(await getSystem()); - setPipelines(await listPipelines()); - }; - fetchData(); - }, []); - - return ( -
-
-
-
- - {system && ( -
-

{system.name}

- {system.labels && ( - <> - {Object.keys(system.labels).map((key: string) => { - return ( - - {key}: {(system.labels ?? {})[key]} - - ); - })} - - )} -
- )} -
- -
-
- -
-
- {pipelines && - pipelines.map((pipeline: PipelineType) => ( - - - - ))} -
-
-
- ); -} diff --git a/ui/src/components/header.tsx b/ui/src/components/header.tsx new file mode 100644 index 0000000..09642e8 --- /dev/null +++ b/ui/src/components/header.tsx @@ -0,0 +1,26 @@ +import { useAppSelector } from '@/store/hooks'; +import { RootState } from '@/store'; +import { ThemeToggle } from './theme-toggle'; +import { ChevronRight } from 'lucide-react'; + +export function Header({ className }: HeaderProps) { + const { data: system, loading } = useAppSelector((state: RootState) => state.system); + const selectedPipeline = useAppSelector((state: RootState) => state.pipelines.selectedPipeline); + + return ( +
+
+
+ {loading ? 'Loading...' : system?.name} + {selectedPipeline && ( + <> + + {selectedPipeline.name} + + )} +
+ +
+
+ ); +} diff --git a/ui/src/components/pipeline.tsx b/ui/src/components/pipeline.tsx index 0f5fd38..31eaf5d 100644 --- a/ui/src/components/pipeline.tsx +++ b/ui/src/components/pipeline.tsx @@ -51,49 +51,33 @@ export function Pipeline(props: { pipeline: PipelineType }) { }, [nodes, edges, fitView]); return ( - - - - {pipeline.name} - - - -
- - - - -
-
-
+
+ + + + +
); } diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx new file mode 100644 index 0000000..9da433f --- /dev/null +++ b/ui/src/components/sidebar.tsx @@ -0,0 +1,128 @@ +import { useAppSelector } from '@/store/hooks'; +import { RootState } from '@/store/index'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; +import { + Sidebar as SidebarComponent, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem +} from '@/components/ui/sidebar'; +import { Check, ChevronsUpDown, WorkflowIcon, BookOpen, Github } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +export function Sidebar() { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines); + + return ( + + + + +
+ +

Glu

+
+
+ + + + + + + + + + + No pipeline found. + + {loading ? ( + Loading pipelines... + ) : ( + pipelines?.map((pipeline) => ( + { + setValue(currentValue === value ? '' : currentValue); + setOpen(false); + navigate(`/pipelines/${currentValue}`); + }} + > + + {pipeline.name} + + )) + )} + + + + + + + +
+ + + + + + + + + + + + + +
+
+ ); +} diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx new file mode 100644 index 0000000..2ee33a0 --- /dev/null +++ b/ui/src/components/ui/command.tsx @@ -0,0 +1,143 @@ +'use client'; + +import * as React from 'react'; +import { type DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator +}; diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx new file mode 100644 index 0000000..f224d5e --- /dev/null +++ b/ui/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx new file mode 100644 index 0000000..89b9167 --- /dev/null +++ b/ui/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '@/lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/ui/src/components/ui/sheet.tsx b/ui/src/components/ui/sheet.tsx new file mode 100644 index 0000000..9b07382 --- /dev/null +++ b/ui/src/components/ui/sheet.tsx @@ -0,0 +1,121 @@ +'use client'; + +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm' + } + }, + defaultVariants: { + side: 'right' + } + } +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription +}; diff --git a/ui/src/components/ui/sidebar.tsx b/ui/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..b274bb2 --- /dev/null +++ b/ui/src/components/ui/sidebar.tsx @@ -0,0 +1,736 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { VariantProps, cva } from 'class-variance-authority'; +import { PanelLeft } from 'lucide-react'; + +import { useIsMobile } from '@/hooks/useMobile'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Sheet, SheetContent } from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContext = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.'); + } + + return context; +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); + + return ( + + +
+ {children} +
+
+
+ ); + } +); +SidebarProvider.displayName = 'SidebarProvider'; + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; + } +>( + ( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); + } +); +Sidebar.displayName = 'Sidebar'; + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +}); +SidebarTrigger.displayName = 'SidebarTrigger'; + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar(); + + return ( + +
+ +
+
+
+

Details

+
+
+ Pipeline: + {node.data.pipeline} +
+
+ Depends on: + {node.data.depends_on || 'None'} +
+
+ Digest: + {node.data.digest} +
+
+
+
+ +
+
+

Labels

+
+ {node.data.labels && + Object.entries(node.data.labels).map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+
+
+
+ ); +} diff --git a/ui/src/components/node.tsx b/ui/src/components/node.tsx index 824259e..f6cf834 100644 --- a/ui/src/components/node.tsx +++ b/ui/src/components/node.tsx @@ -35,7 +35,7 @@ const PhaseNode = ({ data }: NodeProps) => { }; return ( -
+
diff --git a/ui/src/components/pipeline.tsx b/ui/src/components/pipeline.tsx index e6cb0c4..f13da0c 100644 --- a/ui/src/components/pipeline.tsx +++ b/ui/src/components/pipeline.tsx @@ -16,7 +16,7 @@ import { PhaseNode as PhaseNodeComponent } from '@/components/node'; import { Pipeline as PipelineType } from '@/types/pipeline'; import { FlowPipeline, PipelineEdge, PhaseNode, PipelineNode } from '@/types/flow'; import Dagre from '@dagrejs/dagre'; -import { NodeDetails } from '@/components/node-details'; +import { NodePanel } from '@/components/node-panel'; const nodeTypes = { phase: PhaseNodeComponent @@ -25,8 +25,8 @@ const nodeTypes = { export function Pipeline(props: { pipeline: PipelineType }) { const { theme } = useTheme(); const { fitView, getNodes, getEdges } = useReactFlow(); - const [isOpen, setIsOpen] = useState(true); const [selectedNode, setSelectedNode] = useState(null); + const [isPanelExpanded, setIsPanelExpanded] = useState(true); const { pipeline } = props; const { nodes: initNodes, edges: initEdges } = getElements(pipeline); @@ -56,34 +56,41 @@ export function Pipeline(props: { pipeline: PipelineType }) { }; return ( -
- - - - - setSelectedNode(null)} /> +
+
+ + + + +
+ setIsPanelExpanded(!isPanelExpanded)} + />
); } diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx index 6c7aa3b..54d2ac2 100644 --- a/ui/src/components/sidebar.tsx +++ b/ui/src/components/sidebar.tsx @@ -19,13 +19,15 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, - SidebarMenuItem + SidebarMenuItem, + useSidebar } from '@/components/ui/sidebar'; -import { Check, ChevronsUpDown, WorkflowIcon, BookOpen, Github } from 'lucide-react'; +import { Check, ChevronsUpDown, BookOpen, Github } from 'lucide-react'; import { Link, useNavigate } from 'react-router-dom'; export function Sidebar() { const navigate = useNavigate(); + const { toggleSidebar } = useSidebar(); const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines); From ebe567f64b44ed19d888806bbfb6f3f172fc58c9 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Wed, 20 Nov 2024 20:26:13 -0500 Subject: [PATCH 6/6] chore: types Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- ui/src/components/node-panel.tsx | 30 ++++++++++++++++-------------- ui/src/components/sidebar.tsx | 12 ++++++------ ui/src/index.css | 1 + ui/src/store/pipelinesSlice.ts | 2 +- 5 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c09ea67..449d189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: Go Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: test: @@ -15,7 +15,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: "1.23" check-latest: true - name: Install dependencies diff --git a/ui/src/components/node-panel.tsx b/ui/src/components/node-panel.tsx index 7d07711..f824de1 100644 --- a/ui/src/components/node-panel.tsx +++ b/ui/src/components/node-panel.tsx @@ -55,27 +55,29 @@ export function NodePanel({ node, isExpanded, onToggle }: NodePanelProps) {
Digest: - {node.data.digest} + {node.data.digest}
-
-

Labels

-
- {node.data.labels && - Object.entries(node.data.labels).map(([key, value]) => ( - - {key}: {value} - - ))} + {node.data.labels && Object.keys(node.data.labels).length > 0 && ( +
+

Labels

+
+ {node.data.labels && + Object.entries(node.data.labels).map(([key, value]) => ( + + {key}: {value} + + ))} +
-
+ )}
diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx index 54d2ac2..3f12387 100644 --- a/ui/src/components/sidebar.tsx +++ b/ui/src/components/sidebar.tsx @@ -19,15 +19,14 @@ import { SidebarGroupContent, SidebarGroupLabel, SidebarMenu, - SidebarMenuItem, - useSidebar + SidebarMenuItem } from '@/components/ui/sidebar'; import { Check, ChevronsUpDown, BookOpen, Github } from 'lucide-react'; import { Link, useNavigate } from 'react-router-dom'; +import { Pipeline } from '@/types/pipeline'; export function Sidebar() { const navigate = useNavigate(); - const { toggleSidebar } = useSidebar(); const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const { data: pipelines, loading } = useAppSelector((state: RootState) => state.pipelines); @@ -59,7 +58,7 @@ export function Sidebar() { {loading ? 'Loading pipelines...' : value - ? pipelines?.find((pipeline) => pipeline.name === value)?.name + ? pipelines?.find((pipeline: Pipeline) => pipeline.name === value)?.name : 'Select pipeline...'} @@ -72,15 +71,16 @@ export function Sidebar() { {loading ? ( Loading pipelines... ) : ( - pipelines?.map((pipeline) => ( + pipelines?.map((pipeline: Pipeline) => ( { + onSelect={(currentValue: string) => { setValue(currentValue === value ? '' : currentValue); setOpen(false); navigate(`/pipelines/${currentValue}`); }} + className="truncate" > { if (!name) return undefined; - return state.pipelines.data?.find((pipeline) => pipeline.name === name); + return state.pipelines.data?.find((pipeline: Pipeline) => pipeline.name === name); }; export const selectSelectedPipeline = (state: RootState): Pipeline | null =>