diff --git a/script_runner/frontend/src/App.css b/script_runner/frontend/src/App.css index 95ca77d..c343c00 100644 --- a/script_runner/frontend/src/App.css +++ b/script_runner/frontend/src/App.css @@ -138,65 +138,18 @@ a.group-text { height: 100%; padding: 10px; display: flex; - justify-content: center; -} - -.home-search { - width: 60%; - min-width: 500px; - max-width: 1200px; + flex-direction: column; + align-items: center; + padding-top: 40px; } -.home-search-text { +.home-search-prompt { width: 100%; font-family: var(--font-heavy); font-size: 16px; text-align: center; font-weight: 900; - margin: 20px 0; -} - -.home-search-input input { - width: 100%; - border: 1px solid var(--border); - border-radius: 3px; - padding: 10px 20px; - font-size: 14px; -} - -.home-search-input input:not(:focus) { - background-color: var(--bg-light); -} - -.home-search-results ul { - margin: 0; - padding: 0; - list-style-type: none; -} - -.home-search-results li { - border: 1px solid var(--border); - border-top: 0; -} - -.home-no-result { - font-size: 14px; - line-height: 20px; - padding: 10px; -} - -.home-search-results li a { - display: block; - width: 100%; - padding: 10px; -} - -li.home-search-result:hover { - background-color: var(--border); -} - -.function-group { - color: var(--text-hover); + margin-bottom: 15px; } .functions { @@ -263,31 +216,12 @@ li.home-search-result:hover { height: 100%; } -.function-left, -.function-right { +.function-left { flex: 1; height: calc(100vh - 70px); overflow-y: scroll; } -.function-left { - display: flex; - flex-direction: column; -} - -.function-right { - background-color: var(--bg-light); -} - -.function-right-button { - padding: 10px; -} - -.function-right-description { - padding: 10px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; -} - .function-result { margin: 10px; flex-grow: 1; diff --git a/script_runner/frontend/src/Home.tsx b/script_runner/frontend/src/Home.tsx index a0e4f13..bc72d90 100644 --- a/script_runner/frontend/src/Home.tsx +++ b/script_runner/frontend/src/Home.tsx @@ -1,95 +1,24 @@ -import React, {useState, useRef} from 'react'; -import {ConfigFunction, ConfigGroup, Route} from './types.tsx' +import React from "react"; +import { ConfigGroup, Route } from "./types"; +import SearchBar from "./components/SearchBar/SearchBar"; type Props = { - route: Route, - navigate: (to: Route) => void, - groups: ConfigGroup[], -} + route: Route; + navigate: (to: Route) => void; + groups: ConfigGroup[]; +}; function Home(props: Props) { - const [searchResults, setSearchResults] = useState<{function: ConfigFunction, group: string}[] | null>(null); - const [showResults, setShowResults] = useState(false); - const dropdownRef = useRef(null); - - - function search(query: string) { - if (query === "") { - setSearchResults(null); - setShowResults(false); - return; - } - - const results = []; - - const substrings = query.split(" "); - - for (const group of props.groups) { - for (const f of group.functions) { - let found = true; - for (const substr of substrings) { - if (!f.name.includes(substr) && !group.group.includes(substr)) { - found = false; - break; - } - } - // All substrings found - if (found) { - results.push({function: f, group: group.group}); - } - - } - } - - setSearchResults(results.slice(0, 10)); - setShowResults(true); - } - - function handleBlur(e: React.FocusEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(e.relatedTarget)) { - setShowResults(false); - } - } - - function handleFocus() { - setShowResults(true); - } - return (
-
-
What do you want to do today?
-
- search(e.target.value)} - onFocus={handleFocus} - onBlur={handleBlur} - /> -
-
- {searchResults !== null && showResults && ( - - )} -
- - -
+
What do you want to do today?
+
- ) + ); } -export default Home +export default Home; diff --git a/script_runner/frontend/src/Nav.tsx b/script_runner/frontend/src/Nav.tsx index 604132d..adc77a1 100644 --- a/script_runner/frontend/src/Nav.tsx +++ b/script_runner/frontend/src/Nav.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import "./App.css"; import { ConfigGroup, Route } from "./types.tsx"; import { FolderPlusIcon, FolderMinusIcon } from "@heroicons/react/24/outline"; -import Tag from "./Tag"; +import Tag from "./components/Tag/Tag"; interface Props { route: Route; diff --git a/script_runner/frontend/src/Script.tsx b/script_runner/frontend/src/Script.tsx index 7e981dd..366831f 100644 --- a/script_runner/frontend/src/Script.tsx +++ b/script_runner/frontend/src/Script.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import ScriptResult from "./ScriptResult.tsx"; import { RunResult, ConfigFunction } from "./types.tsx"; -import Tag from "./Tag"; +import Tag from "./components/Tag/Tag"; +import Button from "./components/Button/Button"; +import Input from "./components/Input/Input"; +import CodeViewer from "./components/CodeViewer/CodeViewer"; interface Props { regions: string[]; @@ -22,18 +24,15 @@ function Script(props: Props) { parameters.map((a) => a.default) ); const [result, setResult] = useState(null); - // we keep another piece of state because the result value might itself be null const [hasResult, setHasResult] = useState(false); const [error, setError] = useState(null); const [isRunning, setIsRunning] = useState(false); - const [codeCollapsed, setCodeCollapsed] = useState(false); // If the selected function changes, reset all state useEffect(() => { setParams(parameters.map((a) => a.default)); setResult(null); setHasResult(false); - setCodeCollapsed(false); setError(null); }, [parameters, props.group, props.function, props.execute]); @@ -82,51 +81,26 @@ function Script(props: Props) { {functionName}
-
+ { + e.preventDefault(); + executeFunction(); + }} + > {parameters.length > 0 && (
To execute this function, provide the following parameters:
- {parameters.map((arg, idx) => { - return ( -
-
- -
-
- {arg.enumValues && ( - - )} - {!arg.enumValues && ( - - handleInputChange(idx, e.target.value) - } - required - disabled={inputDisabled} - /> - )} -
-
- ); - })} + {parameters.map((arg, idx) => ( + handleInputChange(idx, value)} + /> + ))}
)}
@@ -136,7 +110,9 @@ function Script(props: Props) { Select a region to run this function )}
- +
{error && ( @@ -154,35 +130,7 @@ function Script(props: Props) { /> )} - {} - {codeCollapsed ? ( -
- -
- ) : ( -
-
- ✨ This is the {functionName} function definition - ✨ -
- - {source} - -
- -
-
- )} + ); } diff --git a/script_runner/frontend/src/ScriptResult.tsx b/script_runner/frontend/src/ScriptResult.tsx index 9a13274..26599b4 100644 --- a/script_runner/frontend/src/ScriptResult.tsx +++ b/script_runner/frontend/src/ScriptResult.tsx @@ -1,38 +1,39 @@ -import { useEffect, useState } from 'react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { AgGridReact } from 'ag-grid-react'; -import jq from 'jq-web'; -import { AgCharts } from 'ag-charts-react'; -import { RunResult } from './types'; - -import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; +import React, { useEffect, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { AgGridReact } from "ag-grid-react"; +import jq from "jq-web"; +import { AgCharts } from "ag-charts-react"; +import { RunResult } from "./types"; +import Button from "./components/Button/Button"; + +import { AllCommunityModule, ModuleRegistry } from "ag-grid-community"; ModuleRegistry.registerModules([AllCommunityModule]); type RowData = { [key: string]: unknown; -} +}; type ChartData = { - data: unknown[], - series: { xKey: string, yKey: string, yName: string }[] -} + data: unknown[]; + series: { xKey: string; yKey: string; yName: string }[]; +}; type Props = { - group: string, - function: string, + group: string; + function: string; data: RunResult | null; - regions: string[] -} - + regions: string[]; +}; // Either return merged data or null function mergeRegionKeys(data: unknown, regions: string[]) { try { - if (typeof data === 'object' && data !== null) { - const shouldMerge = Object.keys(data).every((r: string) => regions.includes(r)); + if (typeof data === "object" && data !== null) { + const shouldMerge = Object.keys(data).every((r: string) => + regions.includes(r) + ); if (shouldMerge) { - const firstRegionData = Object.values(data)[0]; if (!Array.isArray(firstRegionData)) { return null; @@ -41,38 +42,48 @@ function mergeRegionKeys(data: unknown, regions: string[]) { return null; } - if (!firstRegionData.every(el => typeof el === 'object' && el !== null)) { + if ( + !firstRegionData.every((el) => typeof el === "object" && el !== null) + ) { return null; } - const firstRowKeys = Object.keys(firstRegionData[0]) + const firstRowKeys = Object.keys(firstRegionData[0]); // All keys match for (let i = 1; i < firstRegionData.length; i++) { - const rowKeys = Object.keys(firstRegionData[i]) - if (rowKeys.length !== firstRowKeys.length || !rowKeys.every(k => firstRowKeys.includes(k))) { - return null - } - } - - const processed = Object.entries(data).map(([region, regionData]) => { - if (!Array.isArray(regionData)) { + const rowKeys = Object.keys(firstRegionData[i]); + if ( + rowKeys.length !== firstRowKeys.length || + !rowKeys.every((k) => firstRowKeys.includes(k)) + ) { return null; } + } - return regionData.map((row) => { - const rowData = Object.keys(row).reduce((acc: RowData, key) => { - const value = row[key]; - acc[key] = typeof value === 'object' && value !== null ? JSON.stringify(value) : value; - return acc; - }, {}); - - return { - region, - ...rowData, + const processed = Object.entries(data) + .map(([region, regionData]) => { + if (!Array.isArray(regionData)) { + return null; } - }); - }).flat(1); + + return regionData.map((row) => { + const rowData = Object.keys(row).reduce((acc: RowData, key) => { + const value = row[key]; + acc[key] = + typeof value === "object" && value !== null + ? JSON.stringify(value) + : value; + return acc; + }, {}); + + return { + region, + ...rowData, + }; + }); + }) + .flat(1); return processed; } @@ -82,16 +93,20 @@ function mergeRegionKeys(data: unknown, regions: string[]) { } return null; - } - // returns table formatted data if data is table like // otherwise return null -function getGridData(data: { [region: string]: unknown[] } | unknown, regions: string[]) { +function getGridData( + data: { [region: string]: unknown[] } | unknown, + regions: string[] +) { const mergedData = mergeRegionKeys(data, regions) || data; - if (Array.isArray(mergedData) && mergedData.every(row => typeof row === 'object' && row !== null)) { + if ( + Array.isArray(mergedData) && + mergedData.every((row) => typeof row === "object" && row !== null) + ) { if (mergedData.length === 0) { return null; } @@ -115,24 +130,34 @@ function getChartData(data: unknown, regions: string[]) { if (merged && Array.isArray(merged)) { if (DATE_KEY in merged[0]) { - const numericFields = Object.entries(merged[0]).filter(([, value]) => typeof value === 'number').map(([key,]) => key); - - const series = regions.map(region => { - return numericFields.map(f => [region, f]) - }).flat(1); - - const mergedByDate: object = merged.reduce((acc: { [date: string]: { [key: string]: unknown } }, curr: { date: string, [key: string]: unknown }) => { - const date = curr[DATE_KEY]; - if (!acc[date]) { - acc[date] = {}; - } + const numericFields = Object.entries(merged[0]) + .filter(([, value]) => typeof value === "number") + .map(([key]) => key); + + const series = regions + .map((region) => { + return numericFields.map((f) => [region, f]); + }) + .flat(1); + + const mergedByDate: object = merged.reduce( + ( + acc: { [date: string]: { [key: string]: unknown } }, + curr: { date: string; [key: string]: unknown } + ) => { + const date = curr[DATE_KEY]; + if (!acc[date]) { + acc[date] = {}; + } - numericFields.forEach(field => { - acc[date][`${curr["region"]}-${field}`] = curr[field]; - }); + numericFields.forEach((field) => { + acc[date][`${curr["region"]}-${field}`] = curr[field]; + }); - return acc; - }, {}); + return acc; + }, + {} + ); const arr = Object.entries(mergedByDate).map(([date, obj]) => { return { date, ...obj }; @@ -145,8 +170,8 @@ function getChartData(data: unknown, regions: string[]) { xKey: "date", yKey: `${region}-${field}`, yName: `${field} (${region})`, - })) - } + })), + }; } } } @@ -155,27 +180,25 @@ function getChartData(data: unknown, regions: string[]) { } return null; - } - function ScriptResult(props: Props) { - const [displayType, setDisplayType] = useState('json'); - + const [displayType, setDisplayType] = useState("json"); const [filteredData, setFilteredData] = useState(props.data); - const [displayOptions, setDisplayOptions] = useState<{ [k: string]: boolean }>( - { 'json': true, 'grid': false, 'chart': false, 'download': true } - ); + const [displayOptions, setDisplayOptions] = useState<{ + [k: string]: boolean; + }>({ json: true, grid: false, chart: false, download: true }); const [rowData, setRowData] = useState(null); const [colDefs, setColumnDefs] = useState<{ field: string }[] | null>(null); const [chartOptions, setChartOptions] = useState(null); - function download() { - const blob = new Blob([JSON.stringify(filteredData)], { type: 'application/json' }); - const timestamp = new Date().toISOString().replace(/[:.]/g, ''); - const fileName = `${props.group}_${props.function}_${timestamp}.json` - const link = document.createElement('a'); + const blob = new Blob([JSON.stringify(filteredData)], { + type: "application/json", + }); + const timestamp = new Date().toISOString().replace(/[:.]/g, ""); + const fileName = `${props.group}_${props.function}_${timestamp}.json`; + const link = document.createElement("a"); link.download = fileName; link.href = URL.createObjectURL(blob); link.click(); @@ -189,24 +212,35 @@ function ScriptResult(props: Props) { function applyJqFilter(raw: RunResult | null, filter: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - jq.then((jq: any) => jq.json(raw, filter)).catch(() => { - // If any error occurs, display the raw data - return raw - } - ).then(setFilteredData).catch(() => { }) + jq.then((jq: any) => jq.json(raw, filter)) + .catch(() => { + // If any error occurs, display the raw data + return raw; + }) + .then(setFilteredData) + .catch(() => {}); } useEffect(() => { - const gridData = getGridData(filteredData, props.regions); - const chartData = getChartData(filteredData, props.regions); + const gridData = getGridData(props.data, props.regions); + const chartData = getChartData(props.data, props.regions); + + const options = { + json: true, + grid: !!gridData, + chart: !!chartData, + download: true, + }; + setDisplayOptions(options); + setFilteredData(props.data); if (gridData) { - setColumnDefs(gridData.columns.map(f => ({ "field": f }))) + setDisplayType("grid"); setRowData(gridData.data); + setColumnDefs(gridData.columns.map((field) => ({ field }))); } else { - setColumnDefs(null) - setRowData(null) + setDisplayType("json"); } if (chartData) { @@ -214,65 +248,71 @@ function ScriptResult(props: Props) { } else { setChartOptions(null); } - - setDisplayOptions(prev => { - prev["grid"] = gridData !== null; - prev["chart"] = chartData !== null; - return prev; - }); - - if (displayOptions[displayType] === false) { - setDisplayType('json'); - } - - - }, [filteredData, props.regions, displayOptions, displayType]); + }, [props.data, props.regions]); return ( -
+
- {Object.entries(displayOptions).filter(([, active]) => active).map(([opt,]) => ( - - ))} + {Object.entries(displayOptions) + .filter(([key, value]) => value && key !== "download") + .map(([key]) => ( +
setDisplayType(key)} + > + {key} +
+ ))} +
+ + +
- applyJqFilter(props.data, e.target.value)} /> + applyJqFilter(props.data, e.target.value)} + />
- { - displayType === 'json' &&
- - {JSON.stringify(filteredData)} - -
- } - { - displayType === "grid" &&
- -
- } - { - displayType === "chart" && chartOptions &&
- -
- } - - { - displayType === "download" &&
-
-
-
+
+ {displayType === "json" && ( +
+ + {JSON.stringify(filteredData)} +
-
- } + )} + {displayType === "grid" && rowData && colDefs && ( +
+ +
+ )} + {displayType === "chart" && chartOptions && ( +
+ +
+ )} +
- ) - + ); } -export default ScriptResult +export default ScriptResult; diff --git a/script_runner/frontend/src/components/Button/Button.css b/script_runner/frontend/src/components/Button/Button.css new file mode 100644 index 0000000..37c9b21 --- /dev/null +++ b/script_runner/frontend/src/components/Button/Button.css @@ -0,0 +1,66 @@ +/* Base styles for ALL buttons (layout, alignment, transitions, etc.) */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 4px; + /* font-size and padding are moved to size classes */ + font-weight: 500; + text-align: center; + cursor: pointer; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + color 0.15s ease-in-out; + white-space: nowrap; +} + +/* --- Size Modifiers --- */ +.button-md { + /* Default size */ + padding: 8px 16px; + font-size: 14px; +} + +.button-sm { + padding: 4px 8px; + font-size: 12px; +} + +/* --- Variant Modifiers (Colors, Backgrounds, Borders) --- */ + +/* Primary Button Styles */ +.button-primary { + background-color: #0d6efd; + color: white; + border-color: #0d6efd; +} +.button-primary:hover:not(:disabled) { + background-color: #0b5ed7; + border-color: #0a58ca; +} +.button-primary:disabled { + /* Use general disabled style opacity */ +} + +/* Secondary Button Styles */ +.button-secondary { + background-color: #f8f9fa; + color: #212529; + border-color: #ccc; +} +.button-secondary:hover:not(:disabled) { + background-color: #e2e6ea; + border-color: #adb5bd; +} +.button-secondary:disabled { + background-color: #e9ecef; + color: #6c757d; + border-color: #ced4da; + /* Use general disabled style opacity */ +} + +/* General Disabled State Styles (applied to all variants/sizes) */ +.button:disabled { + cursor: not-allowed; + opacity: 0.65; +} diff --git a/script_runner/frontend/src/components/Button/Button.tsx b/script_runner/frontend/src/components/Button/Button.tsx new file mode 100644 index 0000000..97b60d3 --- /dev/null +++ b/script_runner/frontend/src/components/Button/Button.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import "./Button.css"; + +// Define button variants +type ButtonVariant = "primary" | "secondary"; +// Define button sizes +type ButtonSize = "sm" | "md"; // Add more sizes if needed (e.g., "lg") + +// Define the props for the Button component, extending standard button attributes +interface ButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; + variant?: ButtonVariant; // Add optional variant prop, default to primary + size?: ButtonSize; // Add optional size prop +} + +// The Button component +function Button({ + children, + className, + variant = "primary", + size = "md", // Default size to medium + ...props +}: ButtonProps) { + // Combine base class, variant class, size class, and any additional classes + const combinedClassName = `button button-${variant} button-${size} ${ + className || "" + }`.trim(); + + return ( + + ); +} + +export default Button; diff --git a/script_runner/frontend/src/components/CodeViewer/CodeViewer.css b/script_runner/frontend/src/components/CodeViewer/CodeViewer.css new file mode 100644 index 0000000..54912e6 --- /dev/null +++ b/script_runner/frontend/src/components/CodeViewer/CodeViewer.css @@ -0,0 +1,48 @@ +/* Styles for the expanded code viewer container */ +.code-viewer-expanded { + display: flex; + flex: 1; /* Make it take available horizontal space */ + flex-direction: column; + height: 100%; /* Occupy available height */ + border-left: 1px solid var(--border, #ccc); /* Add a separator line */ + background-color: var( + --bg-light, + #f8f9fa + ); /* Slightly different background */ +} + +/* Header section within the expanded view */ +.code-viewer-header { + display: flex; + justify-content: space-between; /* Push title group and button apart */ + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border, #ccc); + font-size: 0.9em; + font-weight: 500; + flex-shrink: 0; /* Prevent header from shrinking */ +} + +/* Style for the group containing tag and title */ +.code-viewer-title-group { + display: flex; + align-items: center; + gap: 6px; /* Add space between tag and title */ +} + +/* Content area for the syntax highlighter */ +.code-viewer-content { + flex-grow: 1; /* Allow content to fill remaining space */ + overflow: hidden; /* Prevent parent overflow, rely on SyntaxHighlighter scroll */ +} + +/* Styles for the collapsed state placeholder */ +.code-viewer-collapsed { + display: flex; + align-items: flex-start; /* Align button to the top */ + justify-content: flex-start; /* Align button to the left */ + height: 100%; + border-left: 1px solid var(--border, #ccc); + background-color: var(--bg-light, #f8f9fa); + padding: 10px; +} diff --git a/script_runner/frontend/src/components/CodeViewer/CodeViewer.tsx b/script_runner/frontend/src/components/CodeViewer/CodeViewer.tsx new file mode 100644 index 0000000..8a76c06 --- /dev/null +++ b/script_runner/frontend/src/components/CodeViewer/CodeViewer.tsx @@ -0,0 +1,68 @@ +import React, { useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import Button from "../Button/Button"; // Adjust path as needed +import Tag from "../Tag/Tag"; // Import Tag component +import "./CodeViewer.css"; + +interface CodeViewerProps { + functionName: string; + source: string; + type: "read" | "write"; // Add type prop +} + +function CodeViewer({ functionName, source, type }: CodeViewerProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + + if (isCollapsed) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + ✨ {functionName} source ✨ +
+ +
+
+ + {source} + +
+
+ ); +} + +export default CodeViewer; diff --git a/script_runner/frontend/src/components/Input/Input.css b/script_runner/frontend/src/components/Input/Input.css new file mode 100644 index 0000000..836ffc5 --- /dev/null +++ b/script_runner/frontend/src/components/Input/Input.css @@ -0,0 +1,45 @@ +/* Styles for the input group */ +.input-group { + margin-bottom: 15px; + display: flex; + flex-direction: column; +} + +.input-label { + margin-bottom: 5px; + font-weight: 500; +} + +/* Common styles for input and select controls */ +.input-control input[type="text"], +.input-control select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border, #ccc); + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} + +.input-control input[type="text"]:focus, +.input-control select:focus { + outline: none; + border-color: var(--highlight-border, #0d6efd); + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25); +} + +.input-control input[type="text"]:disabled, +.input-control select:disabled { + background-color: #e9ecef; + cursor: not-allowed; + opacity: 0.7; +} + +/* Specific classes if needed, otherwise handled by selectors above */ +.input-select { + /* Add specific select styles here if different from text input */ +} + +.input-text { + /* Add specific text input styles here if different */ +} diff --git a/script_runner/frontend/src/components/Input/Input.tsx b/script_runner/frontend/src/components/Input/Input.tsx new file mode 100644 index 0000000..b950712 --- /dev/null +++ b/script_runner/frontend/src/components/Input/Input.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { ConfigParam } from "../../types"; // Adjust path if needed +import "./Input.css"; + +interface InputProps { + parameter: ConfigParam; + value: string | null; + isDisabled: boolean; + onChange: (value: string) => void; +} + +function Input({ parameter, value, isDisabled, onChange }: InputProps) { + const { name, enumValues } = parameter; + const inputId = `param-${name}`; // Create unique ID for label association + + return ( +
+ {" "} + {/* Renamed class */} +
+ {" "} + {/* Renamed class */} + +
+
+ {" "} + {/* Renamed class */} + {enumValues ? ( + + ) : ( + onChange(e.target.value)} + className="input-text" // Renamed class + /> + )} +
+
+ ); +} + +export default Input; diff --git a/script_runner/frontend/src/components/SearchBar/SearchBar.css b/script_runner/frontend/src/components/SearchBar/SearchBar.css new file mode 100644 index 0000000..7a6c7fa --- /dev/null +++ b/script_runner/frontend/src/components/SearchBar/SearchBar.css @@ -0,0 +1,77 @@ +/* Container for the search bar */ +.search-bar-container { + width: 100%; /* Take full width of its parent */ + max-width: 600px; /* Max width for better readability */ + position: relative; /* Needed for absolute positioning of results */ + margin: 0 auto; /* Center it if parent allows */ +} + +/* Input field styles */ +.search-bar-input input { + width: 100%; + border: 1px solid var(--border, #ccc); + border-radius: 4px; + padding: 10px 15px; + font-size: 14px; + box-sizing: border-box; /* Include padding and border in width */ +} + +.search-bar-input input:focus { + outline: none; + border-color: var(--highlight-border, #0d6efd); + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25); /* Subtle focus ring */ +} + +/* Results dropdown styles */ +.search-bar-results { + position: absolute; + top: 100%; /* Position below the input */ + left: 0; + right: 0; + background-color: var(--bg-primary, white); + border: 1px solid var(--border, #ccc); + border-top: none; /* Avoid double border with input */ + border-radius: 0 0 4px 4px; + z-index: 10; /* Ensure it appears above other content */ + max-height: 300px; /* Limit height and allow scrolling */ + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.search-bar-results ul { + list-style: none; + margin: 0; + padding: 0; +} + +.search-bar-results li { + padding: 0; + margin: 0; +} + +.search-bar-results li a { + display: block; + padding: 8px 15px; + cursor: pointer; + text-decoration: none; + color: var(--text-primary, #212529); +} + +.search-bar-results li a:hover { + background-color: var(--bg-hover, #f8f9fa); +} + +.result-group { + color: var(--text-secondary, #6c757d); + font-size: 0.9em; +} + +.result-function { + /* Add specific styles if needed */ +} + +.search-bar-no-result { + padding: 8px 15px; + color: var(--text-secondary, #6c757d); + font-style: italic; +} diff --git a/script_runner/frontend/src/components/SearchBar/SearchBar.tsx b/script_runner/frontend/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..266f737 --- /dev/null +++ b/script_runner/frontend/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,130 @@ +import React, { useState, useRef, useEffect } from "react"; +import { ConfigGroup, ConfigFunction, Route } from "../../types"; // Adjust path +import "./SearchBar.css"; + +interface SearchResult { + function: ConfigFunction; + group: string; +} + +interface SearchBarProps { + groups: ConfigGroup[]; + route: Route; // Needed for navigation context + navigate: (to: Route) => void; +} + +function SearchBar({ groups, route, navigate }: SearchBarProps) { + const [searchResults, setSearchResults] = useState( + null + ); + const [showResults, setShowResults] = useState(false); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + // Add event listener to close dropdown when clicking outside + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setShowResults(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [dropdownRef, inputRef]); + + function performSearch(currentQuery: string) { + if (currentQuery === "") { + setSearchResults(null); + setShowResults(false); + return; + } + + const results: SearchResult[] = []; + const substrings = currentQuery + .toLowerCase() + .split(" ") + .filter((s) => s); + + for (const group of groups) { + for (const f of group.functions) { + const searchableText = `${group.group.toLowerCase()} ${f.name.toLowerCase()}`; + let found = true; + for (const substr of substrings) { + if (!searchableText.includes(substr)) { + found = false; + break; + } + } + if (found) { + results.push({ function: f, group: group.group }); + } + } + } + + setSearchResults(results.slice(0, 10)); // Limit results + setShowResults(true); + } + + function handleResultClick(result: SearchResult) { + navigate({ ...route, group: result.group, function: result.function.name }); + setShowResults(false); // Close dropdown after selection + if (inputRef.current) { + inputRef.current.value = ""; // Clear visual input value + } + } + + function handleFocus() { + if (searchResults && searchResults.length > 0) { + setShowResults(true); + } + } + + return ( +
+
+ performSearch(e.target.value)} + onFocus={handleFocus} + // onBlur handling is done via click outside listener + /> +
+ {showResults && searchResults !== null && ( +
+ +
+ )} +
+ ); +} + +export default SearchBar; diff --git a/script_runner/frontend/src/Tag.css b/script_runner/frontend/src/components/Tag/Tag.css similarity index 100% rename from script_runner/frontend/src/Tag.css rename to script_runner/frontend/src/components/Tag/Tag.css diff --git a/script_runner/frontend/src/Tag.tsx b/script_runner/frontend/src/components/Tag/Tag.tsx similarity index 100% rename from script_runner/frontend/src/Tag.tsx rename to script_runner/frontend/src/components/Tag/Tag.tsx