From 81494a16a51fce7490ea847c086c7b7f30bc7f5f Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 14:28:12 -0600 Subject: [PATCH 1/7] feat: Integrate search functionality in DebugConsole and ListFiltersHeader; enhance FilterSearchText for controlled mode Co-authored-by: Copilot --- src/features/DebugConsole/DebugConsole.tsx | 12 +++++++- src/shared/FilterSearchText.tsx | 34 +++++++++++++++++----- src/shared/ListFiltersHeader.tsx | 6 +++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index 24e7f67..526cfea 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -10,6 +10,9 @@ import { useSetLoadConfigMutation, useSetRestartMutation } from "../../store/apiSlice"; +import { selectSearchText } from '../../store/debugConsole/debugConsoleSelectors'; +import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { RootState } from '../../store/store'; import ConsoleWindow from "./ConsoleWindow"; import { DebugFilters } from "./DebugFilters"; @@ -21,8 +24,10 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { //* HOOKS ***********************************************************/ const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); + const dispatch = useAppDispatch(); const messages = useSelector((state: RootState) => state.websocket.messages); const failedUrl = useSelector((state: RootState) => state.websocket.failedUrl); + const searchText = useAppSelector(selectSearchText); const certUrl = failedUrl ? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') : null; @@ -119,7 +124,12 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { {', accept the certificate, then try "Start Debug Session" again.'} )} - } /> + dispatch(debugConsoleActions.setSearchText(val))} + filters={} + /> diff --git a/src/shared/FilterSearchText.tsx b/src/shared/FilterSearchText.tsx index 1cd6597..d97248a 100644 --- a/src/shared/FilterSearchText.tsx +++ b/src/shared/FilterSearchText.tsx @@ -5,13 +5,15 @@ import { useSearchParams } from 'react-router-dom'; export const FilterSearchText = ({ disabled, placeholder, + value: controlledValue, + onChangeValue, }: FilterSearchTextProps) => { /* HOOKS ***********************************************************/ /** Debounce timer for search box */ let searchTimerHandle: NodeJS.Timeout; const PARAM = 'searchText'; const [searchParams, setSearchParams] = useSearchParams(); - const [searchText, setSearchText] = useState(''); + const [searchText, setSearchText] = useState(controlledValue ?? ''); /* FUNCTIONS *******************************************************/ /** Handles search text change, after 1s debounce */ @@ -21,18 +23,30 @@ export const FilterSearchText = ({ if (searchTimerHandle) clearTimeout(searchTimerHandle); searchTimerHandle = setTimeout(() => { const val: string = change.target.value.trim(); - const tokens = val.split(' '); - searchParams.delete(PARAM); - if (val.length) tokens.forEach((t) => searchParams.append(PARAM, t)); - setSearchParams(searchParams); + + if (onChangeValue) { + // Controlled (Redux) mode — call the provided callback + onChangeValue(val); + } else { + // URL params mode (default) + const tokens = val.split(' '); + searchParams.delete(PARAM); + if (val.length) tokens.forEach((t) => searchParams.append(PARAM, t)); + setSearchParams(searchParams); + } }, 1000); } /* EFFECTS *********************************************************/ - /** Watch params for relevant changes and update dropdowns **/ + /** In URL-params mode, sync local state from params. In controlled mode, sync from prop. **/ useEffect(() => { - setSearchText(searchParams.getAll(PARAM).join(' ')); - }, [searchParams]); + if (onChangeValue) { + setSearchText(controlledValue ?? ''); + } else { + setSearchText(searchParams.getAll(PARAM).join(' ')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlledValue, searchParams]); /* RENDER **********************************************************/ return ( @@ -52,4 +66,8 @@ export const FilterSearchText = ({ interface FilterSearchTextProps { disabled?: boolean; placeholder?: string; + /** Controlled value (Redux mode). When provided, `onChangeValue` is also required. */ + value?: string; + /** Called with the debounced text when in controlled mode. */ + onChangeValue?: (val: string) => void; } diff --git a/src/shared/ListFiltersHeader.tsx b/src/shared/ListFiltersHeader.tsx index c6c5d7c..91702a5 100644 --- a/src/shared/ListFiltersHeader.tsx +++ b/src/shared/ListFiltersHeader.tsx @@ -7,6 +7,8 @@ const ListFiltersHeader = ({ groupBy, listTypeButtons, showSearch, + searchValue, + onSearchChange, rightContent, }: ListFiltersHeaderProps) => { return ( @@ -14,7 +16,7 @@ const ListFiltersHeader = ({
{showSearch && (
- +
)}
{filters}
@@ -37,6 +39,8 @@ export default ListFiltersHeader; interface ListFiltersHeaderProps { showSearch?: boolean; + searchValue?: string; + onSearchChange?: (val: string) => void; filters: ReactNode; groupBy?: ReactNode; listTypeButtons?: ReactNode; From f13e6d6befe5600cb82aa34aba2a13098717c746 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 14:49:29 -0600 Subject: [PATCH 2/7] feat: Add refresh button to ConfigFile component and improve loading state handling; adjust ListFiltersHeader styling Co-authored-by: Copilot --- src/features/ConfigFile.tsx | 16 ++++++++++++++-- src/shared/ListFiltersHeader.tsx | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/features/ConfigFile.tsx b/src/features/ConfigFile.tsx index 07c8ae9..3015e05 100644 --- a/src/features/ConfigFile.tsx +++ b/src/features/ConfigFile.tsx @@ -1,6 +1,7 @@ import { Editor, OnMount, useMonaco } from "@monaco-editor/react"; import { skipToken } from "@reduxjs/toolkit/query"; import { useEffect, useRef } from "react"; +import { Button } from "react-bootstrap"; import useAppParams from "../shared/hooks/useAppParams"; import { useGetConfigQuery } from "../store/apiSlice"; @@ -8,13 +9,24 @@ type IConfigViewer = Parameters[0]; const ConfigFile = () => { const { appId } = useAppParams(); - const { data: config } = useGetConfigQuery(appId ? { appId } : skipToken); + const { data: config, refetch, isFetching } = useGetConfigQuery(appId ? { appId } : skipToken); if (!config) { return
Config Data Loading or Not Available
; } - return ; + return ( +
+
+ +
+
+ +
+
+ ); }; export default ConfigFile; diff --git a/src/shared/ListFiltersHeader.tsx b/src/shared/ListFiltersHeader.tsx index 91702a5..7390405 100644 --- a/src/shared/ListFiltersHeader.tsx +++ b/src/shared/ListFiltersHeader.tsx @@ -12,7 +12,7 @@ const ListFiltersHeader = ({ rightContent, }: ListFiltersHeaderProps) => { return ( -
+
{showSearch && (
From afd3fc565cc77c27ad60de0a43f5905084cc7cb7 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 15:00:01 -0600 Subject: [PATCH 3/7] feat: Add version display to LoginForm component for better user awareness Co-authored-by: Copilot --- src/features/LoginForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 79fdd96..34a0655 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -70,7 +70,8 @@ const LoginForm = () => { } return ( -
+
+ Version: {APP_VERSION}

PepperDash Essentials Developer Tools

Sign In

From 3aae200175c2491af93e84b726d9d7e1a2c2be46 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 15:03:04 -0600 Subject: [PATCH 4/7] refactor: Improve debounce handling in FilterSearchText component and clean up code formatting Co-authored-by: Copilot --- src/shared/FilterSearchText.tsx | 54 +++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/shared/FilterSearchText.tsx b/src/shared/FilterSearchText.tsx index d97248a..42e8fb1 100644 --- a/src/shared/FilterSearchText.tsx +++ b/src/shared/FilterSearchText.tsx @@ -1,6 +1,6 @@ -import { ChangeEvent, useEffect, useState } from 'react'; -import { FormControl } from 'react-bootstrap'; -import { useSearchParams } from 'react-router-dom'; +import { ChangeEvent, useEffect, useRef, useState } from "react"; +import { FormControl } from "react-bootstrap"; +import { useSearchParams } from "react-router-dom"; export const FilterSearchText = ({ disabled, @@ -9,19 +9,19 @@ export const FilterSearchText = ({ onChangeValue, }: FilterSearchTextProps) => { /* HOOKS ***********************************************************/ - /** Debounce timer for search box */ - let searchTimerHandle: NodeJS.Timeout; - const PARAM = 'searchText'; + const timerRef = useRef | null>(null); + const PARAM = "searchText"; const [searchParams, setSearchParams] = useSearchParams(); - const [searchText, setSearchText] = useState(controlledValue ?? ''); + const [searchText, setSearchText] = useState(controlledValue ?? ""); /* FUNCTIONS *******************************************************/ /** Handles search text change, after 1s debounce */ function searchTextChange(change: ChangeEvent) { setSearchText(change.target.value); - if (searchTimerHandle) clearTimeout(searchTimerHandle); - searchTimerHandle = setTimeout(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; const val: string = change.target.value.trim(); if (onChangeValue) { @@ -29,7 +29,7 @@ export const FilterSearchText = ({ onChangeValue(val); } else { // URL params mode (default) - const tokens = val.split(' '); + const tokens = val.split(" "); searchParams.delete(PARAM); if (val.length) tokens.forEach((t) => searchParams.append(PARAM, t)); setSearchParams(searchParams); @@ -38,14 +38,21 @@ export const FilterSearchText = ({ } /* EFFECTS *********************************************************/ + /** Clear any pending debounce timer on unmount */ + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + /** In URL-params mode, sync local state from params. In controlled mode, sync from prop. **/ useEffect(() => { if (onChangeValue) { - setSearchText(controlledValue ?? ''); + setSearchText(controlledValue ?? ""); } else { - setSearchText(searchParams.getAll(PARAM).join(' ')); + setSearchText(searchParams.getAll(PARAM).join(" ")); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlledValue, searchParams]); /* RENDER **********************************************************/ @@ -63,11 +70,20 @@ export const FilterSearchText = ({ ); }; -interface FilterSearchTextProps { +type FilterSearchTextBaseProps = { disabled?: boolean; placeholder?: string; - /** Controlled value (Redux mode). When provided, `onChangeValue` is also required. */ - value?: string; - /** Called with the debounced text when in controlled mode. */ - onChangeValue?: (val: string) => void; -} +}; +type FilterSearchTextControlledProps = FilterSearchTextBaseProps & { + /** Controlled value (Redux mode). */ + value: string; + onChangeValue: (val: string) => void; +}; +type FilterSearchTextUncontrolledProps = FilterSearchTextBaseProps & { + value?: undefined; + onChangeValue?: undefined; +}; + +type FilterSearchTextProps = + | FilterSearchTextControlledProps + | FilterSearchTextUncontrolledProps; From c5bd41ccd7af47c8941d8114ff204b5eb1a226eb Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 15:06:39 -0600 Subject: [PATCH 5/7] fix: Ensure FilterSearchText handles undefined onSearchChange gracefully Co-authored-by: Copilot --- src/shared/ListFiltersHeader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/ListFiltersHeader.tsx b/src/shared/ListFiltersHeader.tsx index 7390405..86ca4af 100644 --- a/src/shared/ListFiltersHeader.tsx +++ b/src/shared/ListFiltersHeader.tsx @@ -16,7 +16,9 @@ const ListFiltersHeader = ({
{showSearch && (
- + {onSearchChange !== undefined + ? + : }
)}
{filters}
From 5726f833908d9a46d24c30a734dcd2ef01bb46b9 Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Tue, 5 May 2026 18:58:51 -0600 Subject: [PATCH 6/7] style: Standardize import statements and improve code formatting across multiple components Co-authored-by: Copilot --- src/features/ConfigFile.tsx | 12 ++--- src/features/DebugConsole/DebugConsole.tsx | 7 ++- src/features/LoginForm.tsx | 63 ++++++++++++++-------- src/shared/FilterSearchText.tsx | 6 +-- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/features/ConfigFile.tsx b/src/features/ConfigFile.tsx index 3015e05..e2980bb 100644 --- a/src/features/ConfigFile.tsx +++ b/src/features/ConfigFile.tsx @@ -1,9 +1,9 @@ -import { Editor, OnMount, useMonaco } from "@monaco-editor/react"; -import { skipToken } from "@reduxjs/toolkit/query"; -import { useEffect, useRef } from "react"; -import { Button } from "react-bootstrap"; -import useAppParams from "../shared/hooks/useAppParams"; -import { useGetConfigQuery } from "../store/apiSlice"; +import { Editor, OnMount, useMonaco } from '@monaco-editor/react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useEffect, useRef } from 'react'; +import { Button } from 'react-bootstrap'; +import useAppParams from '../shared/hooks/useAppParams'; +import { useGetConfigQuery } from '../store/apiSlice'; type IConfigViewer = Parameters[0]; diff --git a/src/features/DebugConsole/DebugConsole.tsx b/src/features/DebugConsole/DebugConsole.tsx index 526cfea..9324274 100644 --- a/src/features/DebugConsole/DebugConsole.tsx +++ b/src/features/DebugConsole/DebugConsole.tsx @@ -1,7 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; -import { useSelector } from 'react-redux'; import ListFiltersHeader from "../../shared/ListFiltersHeader"; import useAppParams from '../../shared/hooks/useAppParams'; import { @@ -13,7 +12,7 @@ import { import { selectSearchText } from '../../store/debugConsole/debugConsoleSelectors'; import { debugConsoleActions } from '../../store/debugConsole/debugConsoleSlice'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { RootState } from '../../store/store'; +import type { RootState } from '../../store/store'; import ConsoleWindow from "./ConsoleWindow"; import { DebugFilters } from "./DebugFilters"; import MinimumLogLevelDropdown from './MinimumLogLevelDropdown'; @@ -25,8 +24,8 @@ const DebugConsole = ({isConnected, join, stop, clear}: DebugConsoleProps) => { const [showModal, setShowModal] = useState(false); const { appId } = useAppParams(); const dispatch = useAppDispatch(); - const messages = useSelector((state: RootState) => state.websocket.messages); - const failedUrl = useSelector((state: RootState) => state.websocket.failedUrl); + const messages = useAppSelector((state: RootState) => state.websocket.messages); + const failedUrl = useAppSelector((state: RootState) => state.websocket.failedUrl); const searchText = useAppSelector(selectSearchText); const certUrl = failedUrl ? new URL(failedUrl).origin.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:') diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index 34a0655..bc9d0b3 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -1,15 +1,26 @@ -import { FormEvent, useState } from 'react'; -import { Alert, Button, Form, Spinner } from 'react-bootstrap'; -import { Navigate, useLocation, useNavigate } from 'react-router-dom'; -import useAppParams from '../shared/hooks/useAppParams'; -import { useSetLoginCredentialsMutation } from '../store/apiSlice'; -import { selectAvailableApps, selectIsAuthenticated } from '../store/auth/authSelectors'; -import { authActions } from '../store/auth/authSlice'; -import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { FormEvent, useState } from "react"; +import { Alert, Button, Form, Spinner } from "react-bootstrap"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import useAppParams from "../shared/hooks/useAppParams"; +import { useSetLoginCredentialsMutation } from "../store/apiSlice"; +import { + selectAvailableApps, + selectIsAuthenticated, +} from "../store/auth/authSelectors"; +import { authActions } from "../store/auth/authSlice"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; const ALL_APP_IDS = [ - 'app01', 'app02', 'app03', 'app04', 'app05', - 'app06', 'app07', 'app08', 'app09', 'app10', + "app01", + "app02", + "app03", + "app04", + "app05", + "app06", + "app07", + "app08", + "app09", + "app10", ]; const LoginForm = () => { @@ -20,8 +31,8 @@ const LoginForm = () => { const navigate = useNavigate(); const location = useLocation(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -44,22 +55,26 @@ const LoginForm = () => { try { // First authenticate using the current (or first) appId to confirm credentials are valid - await setLoginCredentials({ appId: probeAppId, username, password }).unwrap(); + await setLoginCredentials({ + appId: probeAppId, + username, + password, + }).unwrap(); } catch { setIsLoading(false); - setError('Invalid credentials. Please try again.'); + setError("Invalid credentials. Please try again."); return; } // Credentials are valid — now probe all slots in parallel to discover which are running const results = await Promise.allSettled( ALL_APP_IDS.map((id) => - setLoginCredentials({ appId: id, username, password }).unwrap() - ) + setLoginCredentials({ appId: id, username, password }).unwrap(), + ), ); const availableApps = ALL_APP_IDS.filter( - (_, i) => results[i].status === 'fulfilled' + (_, i) => results[i].status === "fulfilled", ); setIsLoading(false); @@ -70,10 +85,14 @@ const LoginForm = () => { } return ( -
- Version: {APP_VERSION} -

PepperDash Essentials Developer Tools

-
+
+ + Version: {APP_VERSION} + +

+ PepperDash Essentials Developer Tools +

+

Sign In

{error && {error}}
@@ -106,7 +125,7 @@ const LoginForm = () => { Signing in… ) : ( - 'Sign In' + "Sign In" )}
diff --git a/src/shared/FilterSearchText.tsx b/src/shared/FilterSearchText.tsx index 42e8fb1..101015e 100644 --- a/src/shared/FilterSearchText.tsx +++ b/src/shared/FilterSearchText.tsx @@ -1,6 +1,6 @@ -import { ChangeEvent, useEffect, useRef, useState } from "react"; -import { FormControl } from "react-bootstrap"; -import { useSearchParams } from "react-router-dom"; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; +import { FormControl } from 'react-bootstrap'; +import { useSearchParams } from 'react-router-dom'; export const FilterSearchText = ({ disabled, From c801ea5eb43d37507b9bf057ca00ab16964a463a Mon Sep 17 00:00:00 2001 From: Neil Dorin Date: Thu, 7 May 2026 09:35:35 -0600 Subject: [PATCH 7/7] fix: Improve error handling for invalid credentials in LoginForm component Co-authored-by: Copilot --- src/features/LoginForm.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/features/LoginForm.tsx b/src/features/LoginForm.tsx index bc9d0b3..959cea3 100644 --- a/src/features/LoginForm.tsx +++ b/src/features/LoginForm.tsx @@ -53,20 +53,7 @@ const LoginForm = () => { setError(null); setIsLoading(true); - try { - // First authenticate using the current (or first) appId to confirm credentials are valid - await setLoginCredentials({ - appId: probeAppId, - username, - password, - }).unwrap(); - } catch { - setIsLoading(false); - setError("Invalid credentials. Please try again."); - return; - } - - // Credentials are valid — now probe all slots in parallel to discover which are running + // Probe all slots in parallel — credentials are valid if at least one succeeds const results = await Promise.allSettled( ALL_APP_IDS.map((id) => setLoginCredentials({ appId: id, username, password }).unwrap(), @@ -77,6 +64,12 @@ const LoginForm = () => { (_, i) => results[i].status === "fulfilled", ); + if (availableApps.length === 0) { + setIsLoading(false); + setError("Invalid credentials. Please try again."); + return; + } + setIsLoading(false); dispatch(authActions.loginSuccess(availableApps));