From 18d929afff5dbf2a4c8f325fac98194122866e42 Mon Sep 17 00:00:00 2001 From: Mohammed Faisal Hussain Date: Thu, 26 Oct 2023 17:15:47 +0530 Subject: [PATCH 1/3] feat: show navigation and zoom utilities in resources explorer #1070 --- .../components/explorer/DependencyGraph.tsx | 118 ++++++++++++++++-- dashboard/components/icons/DragIcon.tsx | 21 ++++ dashboard/components/icons/MinusIcon.tsx | 22 ++++ dashboard/components/icons/PlusIcon.tsx | 2 + dashboard/components/icons/SlashIcon.tsx | 16 +++ 5 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 dashboard/components/icons/DragIcon.tsx create mode 100644 dashboard/components/icons/MinusIcon.tsx create mode 100644 dashboard/components/icons/SlashIcon.tsx diff --git a/dashboard/components/explorer/DependencyGraph.tsx b/dashboard/components/explorer/DependencyGraph.tsx index 6b115e14e..f1df6719f 100644 --- a/dashboard/components/explorer/DependencyGraph.tsx +++ b/dashboard/components/explorer/DependencyGraph.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, memo, useEffect } from 'react'; +import React, { useState, memo, useEffect, useRef } from 'react'; import CytoscapeComponent from 'react-cytoscapejs'; import Cytoscape, { EdgeSingular, EventObject } from 'cytoscape'; import popper from 'cytoscape-popper'; @@ -16,6 +16,10 @@ import EmptyState from '@components/empty-state/EmptyState'; import Tooltip from '@components/tooltip/Tooltip'; import WarningIcon from '@components/icons/WarningIcon'; +import PlusIcon from '@components/icons/PlusIcon'; +import MinusIcon from '@components/icons/MinusIcon'; +import SlashIcon from '@components/icons/SlashIcon'; +import DragIcon from '@components/icons/DragIcon'; import { ReactFlowData } from './hooks/useDependencyGraph'; import { edgeAnimationConfig, @@ -26,6 +30,7 @@ import { minZoom, nodeHTMLLabelConfig, nodeStyeConfig, + // popperStyleConfig, zoomLevelBreakpoint } from './config'; @@ -37,9 +42,17 @@ nodeHtmlLabel(Cytoscape.use(COSEBilkent)); Cytoscape.use(popper); const DependencyGraph = ({ data }: DependencyGraphProps) => { const [initDone, setInitDone] = useState(false); - const dataIsEmpty: boolean = data.nodes.length === 0; + const [zoomLevel, setZoomLevel] = useState(minZoom); + const [zoomVal, setZoomVal] = useState(minZoom); // debounced zoom state + + const percentageZoomChange = ((maxZoom - minZoom) / 100) * 5; // increase or decrease by 5% + + const [isNodeDraggingEnabled, setNodeDraggingEnabled] = useState(true); + + const cyRef = useRef(null); + // Type technically is Cytoscape.EdgeCollection but that throws an unexpected error const loopAnimation = (eles: any) => { const ani = eles.animation(edgeAnimationConfig[0], edgeAnimationConfig[1]); @@ -109,7 +122,10 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { // Hide labels when being zoomed out cy.on('zoom', event => { - if (cy.zoom() <= zoomLevelBreakpoint) { + const newZoomLevel = event.cy.zoom(); + // setZoomLevel(newZoomLevel); + + if (newZoomLevel <= zoomLevelBreakpoint) { interface ExtendedEdgeSingular extends EdgeSingular { popperRefObj?: any; } @@ -123,10 +139,13 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { }); } + // update state with new zoom level + setZoomLevel(newZoomLevel); + const opacity = cy.zoom() <= zoomLevelBreakpoint ? 0 : 1; Array.from( - document.querySelectorAll('.dependency-graph-node-label'), + document.querySelectorAll('.dependency-graph-nodeLabel'), e => { // @ts-ignore e.style.opacity = opacity; @@ -139,6 +158,38 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { } }; + useEffect(() => { + const handler = setTimeout(() => { + setZoomVal(zoomLevel); + }, 100); // 100ms debounce + return () => { + clearTimeout(handler); + }; + }, [zoomLevel]); + + const toggleNodeDragging = () => { + if (cyRef.current) { + if (isNodeDraggingEnabled) { + // to disable node dragging in Cytoscape + cyRef.current.nodes().ungrabify(); + } else { + // to enable node dragging in Cytoscape + cyRef.current.nodes().grabify(); + } + setNodeDraggingEnabled(!isNodeDraggingEnabled); + } + }; + + const handleZoomChange = (zoomLevelNo: number) => { + let newZoomLevel = zoomLevelNo; + if (newZoomLevel < minZoom) newZoomLevel = minZoom; + if (newZoomLevel > maxZoom) newZoomLevel = maxZoom; + if (cyRef.current) { + cyRef.current.zoom(newZoomLevel); + setZoomLevel(newZoomLevel); + } + }; + return (
{/* { style: leafStyleConfig } ]} - cy={(cy: Cytoscape.Core) => cyActionHandlers(cy)} + cy={(cy: Cytoscape.Core) => { + cyActionHandlers(cy); + cyRef.current = cy; + }} /> )} -
- {data?.nodes?.length} Resources - {!dataIsEmpty && ( -
- - - Only AWS resources are currently supported on the explorer. +
+
+
+ {data?.nodes?.length} Resources + {!dataIsEmpty && ( +
+ + + Only AWS resources are currently supported on the explorer. + +
+ )} +
+
+ + + {isNodeDraggingEnabled + ? 'Disable node dragging' + : 'Enable node dragging'} + +
+ +
+ {Math.round(((zoomVal - minZoom) / (maxZoom - minZoom)) * 100)}% +
+ +
- )} +
); diff --git a/dashboard/components/icons/DragIcon.tsx b/dashboard/components/icons/DragIcon.tsx new file mode 100644 index 000000000..0148acd4e --- /dev/null +++ b/dashboard/components/icons/DragIcon.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from 'react'; + +const DragIcon = (props: SVGProps) => ( + + + +); + +export default DragIcon; diff --git a/dashboard/components/icons/MinusIcon.tsx b/dashboard/components/icons/MinusIcon.tsx new file mode 100644 index 000000000..681b833f6 --- /dev/null +++ b/dashboard/components/icons/MinusIcon.tsx @@ -0,0 +1,22 @@ +import { SVGProps } from 'react'; + +const MinusIcon = (props: SVGProps) => ( + + + +); + +export default MinusIcon; diff --git a/dashboard/components/icons/PlusIcon.tsx b/dashboard/components/icons/PlusIcon.tsx index e0e5eed6f..47d072cf6 100644 --- a/dashboard/components/icons/PlusIcon.tsx +++ b/dashboard/components/icons/PlusIcon.tsx @@ -4,6 +4,8 @@ const PlusIcon = (props: SVGProps) => ( diff --git a/dashboard/components/icons/SlashIcon.tsx b/dashboard/components/icons/SlashIcon.tsx new file mode 100644 index 000000000..62837f147 --- /dev/null +++ b/dashboard/components/icons/SlashIcon.tsx @@ -0,0 +1,16 @@ +import { SVGProps } from 'react'; + +const SlashIcon = (props: SVGProps) => ( + + + +); + +export default SlashIcon; From 5d5415e8a63457871000267e55766e3bcd732f26 Mon Sep 17 00:00:00 2001 From: Mohammed Faisal Hussain Date: Sat, 28 Oct 2023 00:24:03 +0530 Subject: [PATCH 2/3] feat: added number input component to storybook and enhanced zoom input for dependency graph #1070 --- .../components/explorer/DependencyGraph.tsx | 66 +++++---- dashboard/components/icons/DragIcon.tsx | 2 +- dashboard/components/icons/SlashIcon.tsx | 16 --- .../number-input/NumberInput.stories.tsx | 95 ++++++++++++ .../components/number-input/NumberInput.tsx | 135 ++++++++++++++++++ dashboard/styles/globals.css | 7 + 6 files changed, 274 insertions(+), 47 deletions(-) delete mode 100644 dashboard/components/icons/SlashIcon.tsx create mode 100644 dashboard/components/number-input/NumberInput.stories.tsx create mode 100644 dashboard/components/number-input/NumberInput.tsx diff --git a/dashboard/components/explorer/DependencyGraph.tsx b/dashboard/components/explorer/DependencyGraph.tsx index f1df6719f..472f25e77 100644 --- a/dashboard/components/explorer/DependencyGraph.tsx +++ b/dashboard/components/explorer/DependencyGraph.tsx @@ -16,10 +16,8 @@ import EmptyState from '@components/empty-state/EmptyState'; import Tooltip from '@components/tooltip/Tooltip'; import WarningIcon from '@components/icons/WarningIcon'; -import PlusIcon from '@components/icons/PlusIcon'; -import MinusIcon from '@components/icons/MinusIcon'; -import SlashIcon from '@components/icons/SlashIcon'; import DragIcon from '@components/icons/DragIcon'; +import NumberInput from '@components/number-input/NumberInput'; import { ReactFlowData } from './hooks/useDependencyGraph'; import { edgeAnimationConfig, @@ -45,9 +43,7 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { const dataIsEmpty: boolean = data.nodes.length === 0; const [zoomLevel, setZoomLevel] = useState(minZoom); - const [zoomVal, setZoomVal] = useState(minZoom); // debounced zoom state - - const percentageZoomChange = ((maxZoom - minZoom) / 100) * 5; // increase or decrease by 5% + const [zoomVal, setZoomVal] = useState(0); // debounced zoom state to display percentage const [isNodeDraggingEnabled, setNodeDraggingEnabled] = useState(true); @@ -159,8 +155,11 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { }; useEffect(() => { + const zoomPercentage = Math.round( + ((zoomLevel - minZoom) / (maxZoom - minZoom)) * 100 + ); const handler = setTimeout(() => { - setZoomVal(zoomLevel); + setZoomVal(zoomPercentage); }, 100); // 100ms debounce return () => { clearTimeout(handler); @@ -180,8 +179,8 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { } }; - const handleZoomChange = (zoomLevelNo: number) => { - let newZoomLevel = zoomLevelNo; + const handleZoomChange = (zoomPercentage: number) => { + let newZoomLevel = minZoom + zoomPercentage * ((maxZoom - minZoom) / 100); if (newZoomLevel < minZoom) newZoomLevel = minZoom; if (newZoomLevel > maxZoom) newZoomLevel = maxZoom; if (cyRef.current) { @@ -190,6 +189,16 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { } }; + let translateXClass; + + if (zoomVal < 10) { + translateXClass = 'translate-x-1'; + } else if (zoomVal >= 10 && zoomVal < 100) { + translateXClass = 'translate-x-2'; + } else { + translateXClass = 'translate-x-3'; + } + return (
{/* {
)}
-
+
{isNodeDraggingEnabled @@ -288,22 +296,20 @@ const DependencyGraph = ({ data }: DependencyGraphProps) => { : 'Enable node dragging'} -
- -
- {Math.round(((zoomVal - minZoom) / (maxZoom - minZoom)) * 100)}% -
- + % +
diff --git a/dashboard/components/icons/DragIcon.tsx b/dashboard/components/icons/DragIcon.tsx index 0148acd4e..6bd240499 100644 --- a/dashboard/components/icons/DragIcon.tsx +++ b/dashboard/components/icons/DragIcon.tsx @@ -12,7 +12,7 @@ const DragIcon = (props: SVGProps) => ( diff --git a/dashboard/components/icons/SlashIcon.tsx b/dashboard/components/icons/SlashIcon.tsx deleted file mode 100644 index 62837f147..000000000 --- a/dashboard/components/icons/SlashIcon.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { SVGProps } from 'react'; - -const SlashIcon = (props: SVGProps) => ( - - - -); - -export default SlashIcon; diff --git a/dashboard/components/number-input/NumberInput.stories.tsx b/dashboard/components/number-input/NumberInput.stories.tsx new file mode 100644 index 000000000..db071c464 --- /dev/null +++ b/dashboard/components/number-input/NumberInput.stories.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import NumberInput, { InputProps } from './NumberInput'; + +// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction + +const InputWrapper = ({ + name, + label, + value, + action, + handleValueChange, + ...otherProps +}: InputProps) => { + const [currValue, setCurrValue] = useState(0); + const handleChange = (newValue: number) => { + setCurrValue(newValue); + }; + return ( +
+ handleChange(Number(newData.title))} + handleValueChange={handleChange} + {...otherProps} + /> +
+ ); +}; + +const meta: Meta = { + title: 'Komiser/NumberInput', + component: InputWrapper, + tags: ['autodocs'], + argTypes: { + name: { + control: 'text', + description: 'the name for your form (if exist)', + defaultValue: 'input title' + }, + label: { + control: 'text', + description: 'the label for your input (if exist)', + defaultValue: '' + }, + disabled: { + control: 'boolean', + description: 'disables the input', + defaultValue: false + }, + required: { + control: 'boolean', + description: 'Conditionally set the input field as required', + defaultValue: false + }, + max: { + control: 'number', + description: 'the maximum value' + }, + min: { + control: 'number', + description: 'the minimum value' + }, + step: { + control: 'number', + description: 'change in value', + defaultValue: false + }, + maxLength: { + control: 'number', + description: 'max length of the input' + } + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args +export const Small: Story = { + args: { + name: 'title', + label: '' + } +}; + +export const Large: Story = { + render: InputWrapper, + args: { + name: 'title', + label: 'Limit' + } +}; diff --git a/dashboard/components/number-input/NumberInput.tsx b/dashboard/components/number-input/NumberInput.tsx new file mode 100644 index 000000000..4100b7b8b --- /dev/null +++ b/dashboard/components/number-input/NumberInput.tsx @@ -0,0 +1,135 @@ +import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from 'react'; +import MinusIcon from '@components/icons/MinusIcon'; +import PlusIcon from '@components/icons/PlusIcon'; +import { required } from '../../utils/regex'; + +export type InputEvent = ChangeEvent; + +export type InputProps = { + disabled?: boolean; + id?: number; + name: string; + label?: string; + required?: boolean; + regex?: RegExp; + error?: string; + value: number; + autofocus?: boolean; + min?: number; + max?: number; + maxLength?: number; + positiveNumberOnly?: boolean; + action: (newData: any, id?: number) => void; + handleValueChange: (value: number) => void; + step?: number; +}; + +function NumberInput({ + id, + name, + label, + regex = required, + error = 'Please provide a value', + autofocus, + positiveNumberOnly, + action, + handleValueChange, + value, + step = 1, + maxLength, + ...otherProps +}: InputProps) { + const [isValid, setIsValid] = useState(undefined); + const inputRef = useRef(null); + + useEffect(() => { + if (autofocus) { + inputRef.current?.focus(); + } + }, []); + + function handleBlur(e: InputEvent): void { + const trimmedValue = e.target.value.trim(); + if (!regex || !trimmedValue) return; + + const testResult = regex.test(trimmedValue); + setIsValid(testResult); + } + + function handleFocus(): void { + setIsValid(undefined); + } + + function handleKeyDown(e: KeyboardEvent) { + if (positiveNumberOnly) { + const invalidChars = ['-', '+', 'e']; + if (invalidChars.includes(e.key)) { + e.preventDefault(); + } + } + } + + const adjustBtn = `absolute ${ + label ? 'w-14' : 'w-11' + } h-full p-3 border-gray-200 inline-flex justify-center items-center focus:outline-none`; + + const iconStyle = `text-neutral-900 ${label ? 'w-8 h-8' : 'w-6 h-6'}`; + + return ( +
+
+ + handleBlur(e)} + onChange={e => { + // e.target.value = e.target.value.slice(0, maxLength) + // if(Number(e.target.value) === 0) e.target.value = "0" + if (typeof id === 'number') { + action({ [name]: e.target.value }, id); + } else { + action({ [name]: e.target.value }); + } + }} + onKeyDown={e => handleKeyDown(e)} + ref={inputRef} + autoComplete="off" + data-lpignore="true" + data-form-type="other" + value={value} + step={step} + {...otherProps} + /> + + {label && ( + + {label} + + )} +
+ {isValid === false && ( +

{error}

+ )} +
+ ); +} + +export default NumberInput; diff --git a/dashboard/styles/globals.css b/dashboard/styles/globals.css index 796bc0a55..1e62db64b 100644 --- a/dashboard/styles/globals.css +++ b/dashboard/styles/globals.css @@ -23,6 +23,13 @@ .scrollbar::-webkit-scrollbar-thumb:hover { background: #95a3a3; } + + /* Remove the default browser styles */ + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } } @variants responsive { From 43f30d996cd5c645de091fbb1adbe91123c5de8a Mon Sep 17 00:00:00 2001 From: Mohammed Faisal Hussain Date: Tue, 31 Oct 2023 01:29:15 +0530 Subject: [PATCH 3/3] feat: added all the icons to storybook --- dashboard/components/icons/Icons.stories.tsx | 38 ++++++++++++++++++++ dashboard/components/icons/index.tsx | 31 ++++++++++++++++ dashboard/styles/globals.css | 10 ++---- 3 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 dashboard/components/icons/Icons.stories.tsx create mode 100644 dashboard/components/icons/index.tsx diff --git a/dashboard/components/icons/Icons.stories.tsx b/dashboard/components/icons/Icons.stories.tsx new file mode 100644 index 000000000..dc9fa689a --- /dev/null +++ b/dashboard/components/icons/Icons.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as icons from '@components/icons'; +import { SVGProps } from 'react'; +import Tooltip from '@components/tooltip/Tooltip'; + +// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction + +const IconsWrapper = (props: SVGProps) => ( +
+ {Object.entries(icons).map(([name, Icon]) => ( +
+
+ +

{name}

+
+ {`import { ${name} } from "@components/icons"`} +
+ ))} +
+); + +const meta: Meta = { + title: 'Komiser/Icons', + component: IconsWrapper, + tags: ['autodocs'], + argTypes: {} +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args +export const Primary: Story = { + args: { + width: '24', + height: '24' + } +}; diff --git a/dashboard/components/icons/index.tsx b/dashboard/components/icons/index.tsx new file mode 100644 index 000000000..c309a0205 --- /dev/null +++ b/dashboard/components/icons/index.tsx @@ -0,0 +1,31 @@ +export { default as AlertIcon } from './AlertIcon'; +export { default as ArrowDownIcon } from './ArrowDownIcon'; +export { default as ArrowLeftIcon } from './ArrowLeftIcon'; +export { default as BookmarkIcon } from './BookmarkIcon'; +export { default as CheckIcon } from './CheckIcon'; +export { default as ChevronDownIcon } from './ChevronDownIcon'; +export { default as ChevronRightIcon } from './ChevronRightIcon'; +export { default as ClearFilterIcon } from './ClearFilterIcon'; +export { default as CloseIcon } from './CloseIcon'; +export { default as DeleteIcon } from './DeleteIcon'; +export { default as DocumentTextIcon } from './DocumentTextIcon'; +export { default as DownloadIcon } from './DownloadIcon'; +export { default as DragIcon } from './DragIcon'; +export { default as DuplicateIcon } from './DuplicateIcon'; +export { default as EditIcon } from './EditIcon'; +export { default as ErrorIcon } from './ErrorIcon'; +export { default as FilterIcon } from './FilterIcon'; +export { default as Folder2Icon } from './Folder2Icon'; +export { default as KeyIcon } from './KeyIcon'; +export { default as LinkIcon } from './LinkIcon'; +export { default as LoadingSpinner } from './LoadingSpinner'; +export { default as MinusIcon } from './MinusIcon'; +export { default as More2Icon } from './More2Icon'; +export { default as PlusIcon } from './PlusIcon'; +export { default as RecordCircleIcon } from './RecordCircleIcon'; +export { default as RefreshIcon } from './RefreshIcon'; +export { default as SearchIcon } from './SearchIcon'; +export { default as ShieldSecurityIcon } from './ShieldSecurityIcon'; +export { default as StarIcon } from './StarIcon'; +export { default as VariableIcon } from './VariableIcon'; +export { default as WarningIcon } from './WarningIcon'; diff --git a/dashboard/styles/globals.css b/dashboard/styles/globals.css index 6846868b6..1e62db64b 100644 --- a/dashboard/styles/globals.css +++ b/dashboard/styles/globals.css @@ -55,14 +55,8 @@ } .popper-div { - text-shadow: - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, - 0 0 5px #f4f9f9, + text-shadow: 0 0 5px #f4f9f9, 0 0 5px #f4f9f9, 0 0 5px #f4f9f9, + 0 0 5px #f4f9f9, 0 0 5px #f4f9f9, 0 0 5px #f4f9f9, 0 0 5px #f4f9f9, 0 0 5px #f4f9f9; position: relative; color: #000;