From a3413e5ab961ed115cf7bad9153481ef0f8693c4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 30 Oct 2025 14:32:34 +1100 Subject: [PATCH 01/31] feature: (WIP) loading states for planes --- src/web/src/services/hooks/index.ts | 7 ++ .../src/services/hooks/useAsyncOperation.ts | 95 +++++++++++++++++++ src/web/src/services/specsApi.ts | 10 +- .../WorkspaceCreateDialog.tsx | 27 ++++-- 4 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/web/src/services/hooks/index.ts create mode 100644 src/web/src/services/hooks/useAsyncOperation.ts diff --git a/src/web/src/services/hooks/index.ts b/src/web/src/services/hooks/index.ts new file mode 100644 index 00000000..406c611d --- /dev/null +++ b/src/web/src/services/hooks/index.ts @@ -0,0 +1,7 @@ +export { useAsyncOperation } from "./useAsyncOperation"; +export type { + AsyncOperationState, + AsyncOperationActions, + AsyncServiceMethod, + UseAsyncOperationResult, +} from "./useAsyncOperation"; diff --git a/src/web/src/services/hooks/useAsyncOperation.ts b/src/web/src/services/hooks/useAsyncOperation.ts new file mode 100644 index 00000000..9cfcf410 --- /dev/null +++ b/src/web/src/services/hooks/useAsyncOperation.ts @@ -0,0 +1,95 @@ +import { useState, useCallback } from "react"; + +export interface AsyncOperationState { + data: T | null; + loading: boolean; + error: Error | null; + loadingMessage: string; +} + +export interface AsyncOperationActions { + execute: (...args: any[]) => Promise; + reset: () => void; +} + +export interface AsyncServiceMethod { + loadingMessage: string; + fn: (...args: any[]) => Promise; +} + +export interface UseAsyncOperationResult extends AsyncOperationState, AsyncOperationActions {} + +/** + * Generic hook for wrapping async service operations with loading states and messages. + * + * @param serviceMethod - Object containing the async function and loading message + * @returns Object with data, loading, error, loadingMessage, execute, and reset + * + * @example + * ```typescript + * const resourceProviders = useAsyncOperation({ + * loadingMessage: "Loading resource providers...", + * fn: specsApi.getResourceProviders + * }); + * + * // Usage + * await resourceProviders.execute(moduleUrl); + * + * // In JSX + * {resourceProviders.loading && ( + * + * + * {resourceProviders.loadingMessage} + * + * )} + * ``` + */ +export const useAsyncOperation = (serviceMethod?: AsyncServiceMethod): UseAsyncOperationResult => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadingMessage, setLoadingMessage] = useState(""); + + const execute = useCallback( + async (...args: any[]): Promise => { + if (!serviceMethod) { + console.warn("useAsyncOperation: No service method provided"); + return; + } + + setLoading(true); + setError(null); + setLoadingMessage(serviceMethod.loadingMessage); + + try { + const result = await serviceMethod.fn(...args); + setData(result); + return result; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + throw error; + } finally { + setLoading(false); + setLoadingMessage(""); + } + }, + [serviceMethod], + ); + + const reset = useCallback(() => { + setData(null); + setError(null); + setLoading(false); + setLoadingMessage(""); + }, []); + + return { + data, + loading, + error, + loadingMessage, + execute, + reset, + }; +}; diff --git a/src/web/src/services/specsApi.ts b/src/web/src/services/specsApi.ts index 9d577552..8693b0f8 100644 --- a/src/web/src/services/specsApi.ts +++ b/src/web/src/services/specsApi.ts @@ -26,9 +26,13 @@ export const specsApi = { return res.data.map((v: any) => v.name); }, - getModulesForPlane: async (planeName: string): Promise => { - const res = await axios.get(`/Swagger/Specs/${planeName}`); - return res.data.map((v: any) => v.url); + getModulesForPlane: { + // @TODO: revisit msg: + loadingMessage: "Loading modules for plane... (this may take up to 40+ seconds)", + fn: async (planeName: string): Promise => { + const res = await axios.get(`/Swagger/Specs/${planeName}`); + return res.data.map((v: any) => v.url); + }, }, getResourceProviders: async (moduleUrl: string): Promise => { diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx index b72525e5..4cb596c5 100644 --- a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx @@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react"; import SwaggerItemSelector from "../../common/SwaggerItemSelector"; import styled from "@emotion/styled"; import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; import type { Plane } from "../../interfaces"; interface WorkspaceCreateDialogProps { @@ -26,6 +27,8 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo const [invalidText, setInvalidText] = useState(undefined); const [workspaceName, setWorkspaceName] = useState(name); + const modulesLoader = useAsyncOperation(specsApi.getModulesForPlane); + const [planes, setPlanes] = useState([]); const [planeOptions, setPlaneOptions] = useState([]); const [selectedPlane, setSelectedPlane] = useState(null); @@ -103,21 +106,18 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo await onModuleSelectionUpdate(null); } else { try { - setLoading(true); - const options = await specsApi.getModulesForPlane(plane.name); + const options = await modulesLoader.execute(plane.name); setPlanes((prevPlanes) => { const updatedPlanes = [...prevPlanes]; const index = updatedPlanes.findIndex((v: Plane) => v.name === plane.name); - updatedPlanes[index].moduleOptions = options; + updatedPlanes[index].moduleOptions = options || []; return updatedPlanes; }); - setLoading(false); - setModuleOptions(options); + setModuleOptions(options || []); setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane.name}/`); await onModuleSelectionUpdate(null); } catch (err: any) { console.error(err); - setLoading(false); setInvalidText(errorHandlerApi.getErrorMessage(err)); } } @@ -256,6 +256,12 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo {invalidText}{" "} )} + {/* @TODO: revisit msg and component */} + {modulesLoader.loading && ( + + {modulesLoader.loadingMessage} + + )} API Specs = ({ openDialo From b58ffbfb93eed9a0844355616c2fac41f3de6a8e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 11:56:23 +1100 Subject: [PATCH 12/31] refactor: change banner spinner to LinearProgress --- src/web/src/components/AsyncOperationBanner.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/web/src/components/AsyncOperationBanner.tsx b/src/web/src/components/AsyncOperationBanner.tsx index 56cb468e..9df1e6cb 100644 --- a/src/web/src/components/AsyncOperationBanner.tsx +++ b/src/web/src/components/AsyncOperationBanner.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, CircularProgress, Typography } from "@mui/material"; +import { Box, LinearProgress, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; import type { UseAsyncOperationResult } from "../services/hooks"; @@ -19,8 +19,8 @@ const LoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string color: textColor || theme.palette.text.primary, borderRadius: theme.spacing(1), display: "flex", - alignItems: "center", - gap: theme.spacing(2), + flexDirection: "column", + gap: theme.spacing(1), }), ); @@ -41,10 +41,8 @@ const LoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string */ export const AsyncOperationBanner: React.FC = ({ operation, - backgroundColor = "grey.200", + backgroundColor = "white", textColor, - spinnerColor = "primary", - spinnerSize = 20, }) => { if (!operation.loading) { return null; @@ -52,8 +50,8 @@ export const AsyncOperationBanner: React.FC = ({ return ( - - {operation.loadingMessage} + {operation.loadingMessage} + ); }; From da5a2439ecdcb17cf840b46a1efed1f0eaac0dec Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 12:01:04 +1100 Subject: [PATCH 13/31] refactor: remove loadingMessage string from cli api calls as they are redundant --- src/web/src/services/cliApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/src/services/cliApi.ts b/src/web/src/services/cliApi.ts index 965371ac..7e75204e 100644 --- a/src/web/src/services/cliApi.ts +++ b/src/web/src/services/cliApi.ts @@ -40,14 +40,14 @@ export const cliApi = { }, updateCliModule: { - loadingMessage: "Generating all CLI commands...", + loadingMessage: "", fn: async (repoName: string, moduleName: string, data: any): Promise => { await axios.put(`/CLI/Az/${repoName}/Modules/${moduleName}`, data); }, }, patchCliModule: { - loadingMessage: "Generating modified CLI commands...", + loadingMessage: "", fn: async (repoName: string, moduleName: string, data: any): Promise => { await axios.patch(`/CLI/Az/${repoName}/Modules/${moduleName}`, data); }, From 978b23e7dc0bd869c535884eeb30130743c5321f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 13:10:08 +1100 Subject: [PATCH 14/31] refactor: GenerateDialog to use use async banner --- src/web/src/services/cliApi.ts | 4 +-- .../views/cli/components/GenerateDialog.tsx | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/web/src/services/cliApi.ts b/src/web/src/services/cliApi.ts index 7e75204e..cf505f30 100644 --- a/src/web/src/services/cliApi.ts +++ b/src/web/src/services/cliApi.ts @@ -40,14 +40,14 @@ export const cliApi = { }, updateCliModule: { - loadingMessage: "", + loadingMessage: "Generating CLI commands...", fn: async (repoName: string, moduleName: string, data: any): Promise => { await axios.put(`/CLI/Az/${repoName}/Modules/${moduleName}`, data); }, }, patchCliModule: { - loadingMessage: "", + loadingMessage: "Generating CLI commands...", fn: async (repoName: string, moduleName: string, data: any): Promise => { await axios.patch(`/CLI/Az/${repoName}/Modules/${moduleName}`, data); }, diff --git a/src/web/src/views/cli/components/GenerateDialog.tsx b/src/web/src/views/cli/components/GenerateDialog.tsx index fed188d3..7c669933 100644 --- a/src/web/src/views/cli/components/GenerateDialog.tsx +++ b/src/web/src/views/cli/components/GenerateDialog.tsx @@ -1,6 +1,7 @@ -import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, LinearProgress } from "@mui/material"; +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; import { cliApi, errorHandlerApi } from "../../../services"; import { useAsyncOperation } from "../../../services/hooks"; +import { AsyncOperationBanner } from "../../../components"; import { exportModViewProfile, type ProfileCommandTree } from "../utils/commandTreeInitialization"; import { type CLIModViewProfiles } from "../interfaces"; @@ -65,8 +66,10 @@ const GenerateDialog = (props: GenerateDialogProps) => { return ( - Generate CLI commands to {props.moduleName} + {!isLoading && `Generate CLI commands for ${props.moduleName} module?`} + + {error && ( {" "} @@ -75,18 +78,15 @@ const GenerateDialog = (props: GenerateDialogProps) => { )} - {isLoading && ( - - - - )} - {!isLoading && ( - <> - - - - - )} + + + ); From e8c4278ed8c7c33fddbb007fdbdc34f59642aa63 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 13:46:18 +1100 Subject: [PATCH 15/31] refactor: commandApi.deleteOperation to new pattern --- src/web/src/services/commandApi.ts | 7 ++- .../CommandDeleteDialog.tsx | 44 +++++++------------ 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index e458dbb6..71931782 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -11,8 +11,11 @@ export const commandApi = { return res.data; }, - deleteResource: async (resourceUrl: string): Promise => { - await axios.delete(resourceUrl); + deleteResource: { + loadingMessage: "Deleting commands...", + fn: async (resourceUrl: string): Promise => { + await axios.delete(resourceUrl); + }, }, updateCommand: async (leafUrl: string, data: any): Promise => { diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx index 795909db..7eedc02e 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx @@ -1,15 +1,8 @@ -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, - Typography, -} from "@mui/material"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; import React, { useState, useEffect } from "react"; import { commandApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import { COMMAND_PREFIX } from "../../../../constants"; import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; import type { Command, ResponseCommand } from "../../interfaces"; @@ -22,7 +15,7 @@ export interface CommandDeleteDialogProps { } const CommandDeleteDialog: React.FC = (props) => { - const [updating, setUpdating] = useState(false); + const deleteOperation = useAsyncOperation(commandApi.deleteResource); const [relatedCommands, setRelatedCommands] = useState([]); const getUrls = () => { @@ -73,39 +66,32 @@ const CommandDeleteDialog: React.FC = (props) => { }; const handleDelete = async () => { - setUpdating(true); const urls = getUrls(); try { - await Promise.all(urls.map((url) => commandApi.deleteResource(url))); - setUpdating(false); + await Promise.all(urls.map((url) => deleteOperation.execute(url))); props.onClose(true); - } catch (err) { - setUpdating(false); - console.error(err); + } catch (error) { + console.error("Delete failed:", error); } }; return ( - Delete Commands + {!deleteOperation.loading && Delete Commands} + {relatedCommands.map((command, idx) => ( {`${COMMAND_PREFIX}${command}`} ))} - {updating && ( - - - - )} - {!updating && ( - <> - - - - )} + + ); From d827f1a7ca15ef0633f955b164fe5bbd779be118 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 13:51:52 +1100 Subject: [PATCH 16/31] refactor: fix tests for commandApi.deleteOperation --- .../components/WSEditorCommandContent.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx index dbca2c27..bee518d6 100644 --- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx @@ -127,7 +127,10 @@ describe("WSEditorCommandContent", () => { vi.clearAllMocks(); vi.mocked(commandApi).getCommand.mockResolvedValue(mockCommand); vi.mocked(commandApi).getCommandsForResource.mockResolvedValue([mockCommand]); - vi.mocked(commandApi).deleteResource.mockResolvedValue(undefined); + vi.mocked(commandApi).deleteResource = { + loadingMessage: "Deleting commands...", + fn: vi.fn().mockResolvedValue(undefined), + }; vi.mocked(commandApi).updateCommand.mockResolvedValue(mockCommand); vi.mocked(commandApi).updateCommandExamples.mockResolvedValue(mockCommand); vi.mocked(commandApi).updateCommandOutputs.mockResolvedValue(mockCommand); @@ -500,7 +503,10 @@ describe("WSEditorCommandContent", () => { }); it("handles delete dialog confirmation", async () => { - vi.mocked(commandApi).deleteResource.mockResolvedValue(undefined); + vi.mocked(commandApi).deleteResource = { + loadingMessage: "Deleting commands...", + fn: vi.fn().mockResolvedValue(undefined), + }; render(); From d21c9855804822e693671fc8944ebff4107ca24c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 15:14:04 +1100 Subject: [PATCH 17/31] refactor: update ExampleDialog loading handlers --- .../WSEditorCommandContent.test.tsx | 20 +++++++-- src/web/src/services/commandApi.ts | 42 +++++++++++------- .../WSEditorCommandContent/CommandDialog.tsx | 43 +++++++++---------- .../WSEditorCommandContent/ExampleDialog.tsx | 41 ++++++++---------- 4 files changed, 81 insertions(+), 65 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx index bee518d6..a6f016df 100644 --- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx @@ -131,8 +131,22 @@ describe("WSEditorCommandContent", () => { loadingMessage: "Deleting commands...", fn: vi.fn().mockResolvedValue(undefined), }; - vi.mocked(commandApi).updateCommand.mockResolvedValue(mockCommand); - vi.mocked(commandApi).updateCommandExamples.mockResolvedValue(mockCommand); + vi.mocked(commandApi).updateCommand = { + loadingMessage: "Updating command...", + fn: vi.fn().mockResolvedValue(mockCommand), + }; + vi.mocked(commandApi).renameCommand = { + loadingMessage: "Renaming command...", + fn: vi.fn().mockResolvedValue(mockCommand), + }; + vi.mocked(commandApi).updateCommandExamples = { + loadingMessage: "Updating command examples...", + fn: vi.fn().mockResolvedValue(mockCommand), + }; + vi.mocked(commandApi).generateSwaggerExamples = { + loadingMessage: "Generating examples from OpenAPI...", + fn: vi.fn().mockResolvedValue([]), + }; vi.mocked(commandApi).updateCommandOutputs.mockResolvedValue(mockCommand); }); @@ -473,7 +487,7 @@ describe("WSEditorCommandContent", () => { it("handles example dialog close with changes", async () => { const updatedCommand = { ...mockCommand, version: "2.0" }; - vi.mocked(commandApi).updateCommandExamples.mockResolvedValue(updatedCommand); + (vi.mocked(commandApi).updateCommandExamples.fn as any).mockResolvedValue(updatedCommand); render(); diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index 71931782..fea6f9f1 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -18,27 +18,39 @@ export const commandApi = { }, }, - updateCommand: async (leafUrl: string, data: any): Promise => { - const res = await axios.patch(leafUrl, data); - return res.data; + updateCommand: { + loadingMessage: "Updating command...", + fn: async (leafUrl: string, data: any): Promise => { + const res = await axios.patch(leafUrl, data); + return res.data; + }, }, - renameCommand: async (leafUrl: string, newName: string): Promise => { - const res = await axios.post(`${leafUrl}/Rename`, { name: newName }); - return res.data; + renameCommand: { + loadingMessage: "Renaming command...", + fn: async (leafUrl: string, newName: string): Promise => { + const res = await axios.post(`${leafUrl}/Rename`, { name: newName }); + return res.data; + }, }, - updateCommandExamples: async (leafUrl: string, examples: any[]): Promise => { - const res = await axios.patch(leafUrl, { examples }); - return res.data; + updateCommandExamples: { + loadingMessage: "Updating command examples...", + fn: async (leafUrl: string, examples: any[]): Promise => { + const res = await axios.patch(leafUrl, { examples }); + return res.data; + }, }, - generateSwaggerExamples: async (leafUrl: string): Promise => { - const res = await axios.post(`${leafUrl}/GenerateExamples`, { source: "swagger" }); - return res.data.map((v: any) => ({ - name: v.name, - commands: v.commands, - })); + generateSwaggerExamples: { + loadingMessage: "Generating examples from OpenAPI...", + fn: async (leafUrl: string): Promise => { + const res = await axios.post(`${leafUrl}/GenerateExamples`, { source: "swagger" }); + return res.data.map((v: any) => ({ + name: v.name, + commands: v.commands, + })); + }, }, addSubcommands: async (resourceUrl: string, data: any): Promise => { diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx index 837038d1..0b8f53ca 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx @@ -1,6 +1,5 @@ import { Alert, - Box, Button, Dialog, DialogActions, @@ -8,13 +7,14 @@ import { DialogTitle, FormControlLabel, InputLabel, - LinearProgress, Radio, RadioGroup, TextField, } from "@mui/material"; import React, { useState, useCallback } from "react"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; import type { Command } from "../../interfaces"; @@ -26,13 +26,15 @@ export interface CommandDialogProps { } const CommandDialog: React.FC = ({ workspaceUrl, open, command, onClose }) => { + const updateOperation = useAsyncOperation(commandApi.updateCommand); + const renameOperation = useAsyncOperation(commandApi.renameCommand); + const [name, setName] = useState(command.names.join(" ")); const [shortHelp, setShortHelp] = useState(command.help?.short ?? ""); const [longHelp, setLongHelp] = useState(command.help?.lines?.join("\n") ?? ""); const [stage, setStage] = useState(command.stage); const [confirmation, setConfirmation] = useState(command.confirmation ?? ""); const [invalidText, setInvalidText] = useState(undefined); - const [updating, setUpdating] = useState(false); const handleModify = useCallback(async () => { let trimmedName = name.trim(); @@ -67,8 +69,6 @@ const CommandDialog: React.FC = ({ workspaceUrl, open, comma lines = trimmedLongHelp.split("\n").filter((l) => l.length > 0); } - setUpdating(true); - const leafUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/` + command.names.slice(0, -1).join("/") + @@ -76,7 +76,7 @@ const CommandDialog: React.FC = ({ workspaceUrl, open, comma command.names[command.names.length - 1]; try { - const commandData = await commandApi.updateCommand(leafUrl, { + const commandData = await updateOperation.execute(leafUrl, { help: { short: trimmedShortHelp, lines: lines, @@ -88,18 +88,15 @@ const CommandDialog: React.FC = ({ workspaceUrl, open, comma const commandName = names.join(" "); if (commandName === command.names.join(" ")) { const cmd = DecodeResponseCommand(commandData); - setUpdating(false); onClose(cmd); } else { - const renamedData = await commandApi.renameCommand(leafUrl, commandName); + const renamedData = await renameOperation.execute(leafUrl, commandName); const cmd = DecodeResponseCommand(renamedData); - setUpdating(false); onClose(cmd); } } catch (err: any) { console.error(err); setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); } }, [name, shortHelp, longHelp, confirmation, stage, workspaceUrl, command, onClose]); @@ -108,14 +105,19 @@ const CommandDialog: React.FC = ({ workspaceUrl, open, comma onClose(); }, [onClose]); + const isLoading = updateOperation.loading || renameOperation.loading; + const error = updateOperation.error || renameOperation.error; + return ( Command - {invalidText && ( + + + {(invalidText || error) && ( {" "} - {invalidText}{" "} + {invalidText || errorHandlerApi.getErrorMessage(error!)}{" "} )} @@ -191,17 +193,12 @@ const CommandDialog: React.FC = ({ workspaceUrl, open, comma /> - {updating && ( - - - - )} - {!updating && ( - - - - - )} + + ); diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx index 479bea6f..b4b88bb0 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx @@ -11,7 +11,6 @@ import { Input, InputAdornment, InputLabel, - LinearProgress, TextField, Typography, TypographyProps, @@ -22,6 +21,8 @@ import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; import CloseIcon from "@mui/icons-material/Close"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import { COMMAND_PREFIX } from "../../../../constants"; import { ExampleItemSelector } from "./ExampleItemSelector"; import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; @@ -43,11 +44,13 @@ const ExampleCommandTypography = styled(Typography)(({ theme }) })); const ExampleDialog: React.FC = ({ workspaceUrl, open, command, idx, onClose }) => { + const updateExamplesOperation = useAsyncOperation(commandApi.updateCommandExamples); + const generateExamplesOperation = useAsyncOperation(commandApi.generateSwaggerExamples); + const [name, setName] = useState(""); const [exampleCommands, setExampleCommands] = useState([""]); const [isAdd, setIsAdd] = useState(true); const [invalidText, setInvalidText] = useState(undefined); - const [updating, setUpdating] = useState(false); const [source, setSource] = useState(undefined); const [exampleOptions, setExampleOptions] = useState([]); @@ -58,7 +61,6 @@ const ExampleDialog: React.FC = ({ workspaceUrl, open, comma setExampleCommands([""]); setIsAdd(true); setInvalidText(undefined); - setUpdating(false); setSource(undefined); setExampleOptions([]); } else { @@ -67,7 +69,6 @@ const ExampleDialog: React.FC = ({ workspaceUrl, open, comma setExampleCommands(example.commands); setIsAdd(false); setInvalidText(undefined); - setUpdating(false); setSource(undefined); setExampleOptions([]); } @@ -81,21 +82,17 @@ const ExampleDialog: React.FC = ({ workspaceUrl, open, comma "/Leaves/" + command.names[command.names.length - 1]; - setUpdating(true); - try { - const responseData = await commandApi.updateCommandExamples(leafUrl, examples); + const responseData = await updateExamplesOperation.execute(leafUrl, examples); const cmd = DecodeResponseCommand(responseData); - setUpdating(false); onClose(cmd); } catch (err: any) { console.error(err); const message = errorHandlerApi.getErrorMessage(err); setInvalidText(`ResponseError: ${message}`); - setUpdating(false); } }, - [workspaceUrl, command.names, onClose], + [workspaceUrl, command.names, onClose, updateExamplesOperation], ); const handleDelete = useCallback(() => { @@ -205,19 +202,18 @@ const ExampleDialog: React.FC = ({ workspaceUrl, open, comma command.names[command.names.length - 1]; setSource("swagger"); - setUpdating(true); - const examples = await commandApi.generateSwaggerExamples(leafUrl); - setExampleOptions(examples); - setUpdating(false); - if (examples.length > 0) { - onExampleSelectorUpdate(examples[0].name); + const examples = await generateExamplesOperation.execute(leafUrl); + if (examples) { + setExampleOptions(examples); + if (examples.length > 0) { + onExampleSelectorUpdate(examples[0].name); + } } } catch (err: any) { console.error(err.response); - setUpdating(false); setInvalidText(errorHandlerApi.getErrorMessage(err)); } - }, [workspaceUrl, command.names]); + }, [workspaceUrl, command.names, generateExamplesOperation]); const onExampleSelectorUpdate = useCallback( (exampleDisplayName: string | null) => { @@ -359,12 +355,9 @@ const ExampleDialog: React.FC = ({ workspaceUrl, open, comma {(!isAdd || source != undefined) && ( - {updating && ( - - - - )} - {!updating && ( + + + {!updateExamplesOperation.loading && !generateExamplesOperation.loading && ( {!isAdd && ( From 43922c784288a0ac33621c0cedc3a0611652267f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 15:22:00 +1100 Subject: [PATCH 18/31] refactor: update Outputdialog with new loading pattern --- .../WSEditorCommandContent.test.tsx | 5 +++- src/web/src/services/commandApi.ts | 9 ++++--- .../WSEditorCommandContent/OutputDialog.tsx | 27 +++++++------------ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx index a6f016df..965e519d 100644 --- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx @@ -147,7 +147,10 @@ describe("WSEditorCommandContent", () => { loadingMessage: "Generating examples from OpenAPI...", fn: vi.fn().mockResolvedValue([]), }; - vi.mocked(commandApi).updateCommandOutputs.mockResolvedValue(mockCommand); + vi.mocked(commandApi).updateCommandOutputs = { + loadingMessage: "Updating command outputs...", + fn: vi.fn().mockResolvedValue(mockCommand), + }; }); describe("Core Rendering", () => { diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index fea6f9f1..415e4cd4 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -57,9 +57,12 @@ export const commandApi = { await axios.post(resourceUrl, data); }, - updateCommandOutputs: async (leafUrl: string, outputs: any[]): Promise => { - const res = await axios.patch(leafUrl, { outputs }); - return res.data; + updateCommandOutputs: { + loadingMessage: "Updating command outputs...", + fn: async (leafUrl: string, outputs: any[]): Promise => { + const res = await axios.patch(leafUrl, { outputs }); + return res.data; + }, }, updateCommandArgument: async (argumentUrl: string, data: any): Promise => { diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx index b5750163..645a0db0 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx @@ -9,7 +9,6 @@ import { DialogTitle, FormControlLabel, FormLabel, - LinearProgress, Switch, Typography, TypographyProps, @@ -17,6 +16,8 @@ import { } from "@mui/material"; import { styled } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; interface ObjectOutput { @@ -81,7 +82,7 @@ interface OutputDialogProps { } const OutputDialog: React.FC = (props) => { - const [updating, setUpdating] = useState(false); + const updateOutputsOperation = useAsyncOperation(commandApi.updateCommandOutputs); const [invalidText, setInvalidText] = useState(undefined); const outputs = props.command.outputs ?? []; const output = outputs[props.idx!]; @@ -95,7 +96,6 @@ const OutputDialog: React.FC = (props) => { const handleUpdateOutput = async () => { setInvalidText(undefined); - setUpdating(true); if (isObjectOutput(output) || isArrayOutput(output)) { let commandNames = props.command.names; @@ -104,22 +104,17 @@ const OutputDialog: React.FC = (props) => { commandNames.slice(0, -1).join("/") + "/Leaves/" + commandNames[commandNames.length - 1]; - console.log("Original clientFlatten: "); - console.log(output.clientFlatten); + output.clientFlatten = !output.clientFlatten; - console.log("New clientFlatten: "); - console.log(output.clientFlatten); try { - const responseData = await commandApi.updateCommandOutputs(leafUrl, outputs); + const responseData = await updateOutputsOperation.execute(leafUrl, outputs); const cmd = DecodeResponseCommand(responseData); - setUpdating(false); props.onClose(cmd); } catch (err: any) { console.error(err); const message = errorHandlerApi.getErrorMessage(err); setInvalidText(`ResponseError: ${message}`); - setUpdating(false); } } else { console.error(`Invalid output type for flatten switch: ${output.type}`); @@ -177,13 +172,11 @@ const OutputDialog: React.FC = (props) => { )} - {updating && ( - - - - )} - {!updating && } - + + {!updateOutputsOperation.loading && } + ); From 1c91ae522c453bad75f9e2789efe6b34a394096d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 15:41:22 +1100 Subject: [PATCH 19/31] refactor: update ArgumentDialog to be using new pattern --- src/web/src/services/commandApi.ts | 25 +++++++---- .../ArgumentDialog.tsx | 41 ++++++++----------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index 415e4cd4..a0afd7d0 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -65,12 +65,18 @@ export const commandApi = { }, }, - updateCommandArgument: async (argumentUrl: string, data: any): Promise => { - await axios.patch(argumentUrl, data); + updateCommandArgument: { + loadingMessage: "Updating command argument...", + fn: async (argumentUrl: string, data: any): Promise => { + await axios.patch(argumentUrl, data); + }, }, - updateArgumentById: async (argId: string, data: any): Promise => { - await axios.patch(argId, data); + updateArgumentById: { + loadingMessage: "Updating argument...", + fn: async (argId: string, data: any): Promise => { + await axios.patch(argId, data); + }, }, flattenArgument: async (flattenUrl: string, data?: any): Promise => { @@ -102,10 +108,13 @@ export const commandApi = { return res.data; }, - findSimilarArguments: async (commandUrl: string, argVar: string): Promise => { - const similarUrl = `${commandUrl}/Arguments/${argVar}/FindSimilar`; - const res = await axios.post(similarUrl); - return res.data; + findSimilarArguments: { + loadingMessage: "Finding similar arguments...", + fn: async (commandUrl: string, argVar: string): Promise => { + const similarUrl = `${commandUrl}/Arguments/${argVar}/FindSimilar`; + const res = await axios.post(similarUrl); + return res.data; + }, }, createSubresource: async ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx index f9f647b0..415d1366 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx @@ -9,7 +9,6 @@ import { DialogTitle, FormControlLabel, InputLabel, - LinearProgress, Radio, RadioGroup, Switch, @@ -17,6 +16,8 @@ import { } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import React, { useEffect, useState } from "react"; import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; import { convertArgDefaultText } from "../../utils/convertArgDefaultText"; @@ -131,7 +132,13 @@ interface ArgumentDialogProps { } const ArgumentDialog: React.FC = (props) => { - const [updating, setUpdating] = useState(false); + const updateArgumentOperation = useAsyncOperation(commandApi.updateCommandArgument); + const findSimilarOperation = useAsyncOperation(commandApi.findSimilarArguments); + const updateArgumentByIdOperation = useAsyncOperation(commandApi.updateArgumentById); + + const isLoading = + updateArgumentOperation.loading || findSimilarOperation.loading || updateArgumentByIdOperation.loading; + const [stage, setStage] = useState(""); const [invalidText, setInvalidText] = useState(undefined); const [options, setOptions] = useState(""); @@ -292,18 +299,14 @@ const ArgumentDialog: React.FC = (props) => { return; } - setUpdating(true); - const argumentUrl = `${props.commandUrl}/Arguments/${props.arg.var}`; try { - await commandApi.updateCommandArgument(argumentUrl, data); - setUpdating(false); + await updateArgumentOperation.execute(argumentUrl, data); await props.onClose(true); } catch (err: any) { console.error(err); setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); } }; @@ -312,11 +315,8 @@ const ArgumentDialog: React.FC = (props) => { return; } - setUpdating(true); - try { - const res = await commandApi.findSimilarArguments(props.commandUrl, props.arg.var); - setUpdating(false); + const res = await findSimilarOperation.execute(props.commandUrl, props.arg.var); const { tree, expandedIds } = BuildArgSimilarTree(res); setArgSimilarTree(tree); setArgSimilarTreeExpandedIds(expandedIds); @@ -324,7 +324,6 @@ const ArgumentDialog: React.FC = (props) => { } catch (err: any) { console.error(err); setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); } }; @@ -347,14 +346,13 @@ const ArgumentDialog: React.FC = (props) => { return; } - setUpdating(true); let invalidText = ""; const updatedIds: string[] = [...argSimilarTreeArgIdsUpdated]; for (const idx in argSimilarTree!.selectedArgIds) { const argId = argSimilarTree!.selectedArgIds[idx]; if (updatedIds.indexOf(argId) === -1) { try { - await commandApi.updateArgumentById(argId, data); + await updateArgumentByIdOperation.execute(argId, data); updatedIds.push(argId); setArgSimilarTreeArgIdsUpdated([...updatedIds]); } catch (err: any) { @@ -366,9 +364,7 @@ const ArgumentDialog: React.FC = (props) => { if (invalidText.length > 0) { setInvalidText(invalidText); - setUpdating(false); } else { - setUpdating(false); await props.onClose(true); } }; @@ -410,7 +406,6 @@ const ArgumentDialog: React.FC = (props) => { setShortHelp(props.arg.help?.short ?? ""); setLongHelp(props.arg.help?.lines?.join("\n") ?? ""); setConfigurationKey(props.arg.configurationKey ?? ""); - setUpdating(false); setArgSimilarTree(undefined); setArgSimilarTreeExpandedIds([]); @@ -719,12 +714,10 @@ const ArgumentDialog: React.FC = (props) => { )} - {updating && ( - - - - )} - {!updating && !argSimilarTree && ( + + + + {!isLoading && !argSimilarTree && ( <> {!props.arg.var.startsWith("@") && ( @@ -733,7 +726,7 @@ const ArgumentDialog: React.FC = (props) => { {!isClientArg && } )} - {!updating && argSimilarTree && ( + {!isLoading && argSimilarTree && ( <> From 6f9721e0610169bb8e2a9790fb33870e66342086 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 17:56:11 +1100 Subject: [PATCH 22/31] refactor: update deleteCommandgroup to use new loading pattern --- .../WSEditorCommandGroupContent.test.tsx | 25 ++++++++++++---- src/web/src/services/commandApi.ts | 7 +++-- .../CommandGroupDeleteDialog.tsx | 30 +++++-------------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx index 0565e713..931830a2 100644 --- a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import WSEditorCommandGroupContent from "../../views/workspace/components/WSEditorCommandGroupContent/WSEditorCommandGroupContent"; -import * as commandApi from "../../services/commandApi"; +import { commandApi } from "../../services"; interface CommandGroup { id: string; @@ -15,10 +15,22 @@ interface CommandGroup { canDelete: boolean; } -vi.mock("../../services/commandApi"); -vi.mock("../../services/errorHandlerApi"); +vi.mock("../../services", () => ({ + commandApi: { + deleteCommandGroup: { + loadingMessage: "Deleting command group...", + fn: vi.fn(), + }, + updateCommandGroup: vi.fn(), + renameCommandGroup: vi.fn(), + }, + errorHandlerApi: { + getErrorMessage: vi.fn(), + }, +})); const mockCommandApi = commandApi as any; + describe("WSEditorCommandGroupContent", () => { const mockWorkspaceUrl = "https://test-workspace.com/workspace/ws1"; @@ -37,6 +49,7 @@ describe("WSEditorCommandGroupContent", () => { beforeEach(() => { vi.clearAllMocks(); + mockCommandApi.deleteCommandGroup.fn.mockResolvedValue(undefined); }); describe("Core Rendering", () => { @@ -270,7 +283,7 @@ describe("WSEditorCommandGroupContent", () => { await user.click(confirmDeleteButton); await waitFor(() => { - expect(mockCommandApi.deleteCommandGroup).toHaveBeenCalled(); + expect(mockCommandApi.deleteCommandGroup.fn).toHaveBeenCalled(); expect(mockOnUpdateCommandGroup).toHaveBeenCalledWith(null); }); }); @@ -313,7 +326,7 @@ describe("WSEditorCommandGroupContent", () => { const user = userEvent.setup(); const mockError = new Error("Delete failed"); - mockCommandApi.deleteCommandGroup.mockRejectedValue(mockError); + mockCommandApi.deleteCommandGroup.fn.mockRejectedValue(mockError); render( { await user.click(confirmDeleteButton); await waitFor(() => { - expect(mockCommandApi.deleteCommandGroup).toHaveBeenCalled(); + expect(mockCommandApi.deleteCommandGroup.fn).toHaveBeenCalled(); }); }); diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index 3c39f34d..2d80694a 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -91,8 +91,11 @@ export const commandApi = { await axios.post(flattenUrl); }, - deleteCommandGroup: async (nodeUrl: string): Promise => { - await axios.delete(nodeUrl); + deleteCommandGroup: { + loadingMessage: "Deleting command group...", + fn: async (nodeUrl: string): Promise => { + await axios.delete(nodeUrl); + }, }, updateCommandGroup: async ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx index 5999a67d..77d29b06 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx @@ -1,14 +1,7 @@ -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, - Typography, -} from "@mui/material"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; import { commandApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import * as React from "react"; import { COMMAND_PREFIX } from "../../../../constants"; import type { CommandGroup } from "../../interfaces"; @@ -26,7 +19,7 @@ const CommandGroupDeleteDialog: React.FC = ({ commandGroup, onClose, }) => { - const [updating, setUpdating] = React.useState(false); + const deleteCommandGroupOperation = useAsyncOperation(commandApi.deleteCommandGroup); const handleClose = React.useCallback(() => { onClose(false); @@ -34,31 +27,24 @@ const CommandGroupDeleteDialog: React.FC = ({ const handleDelete = React.useCallback(async () => { const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/${commandGroup.names.join("/")}`; - setUpdating(true); try { - await commandApi.deleteCommandGroup(nodeUrl); - setUpdating(false); + await deleteCommandGroupOperation.execute(nodeUrl); onClose(true); } catch (err: any) { - setUpdating(false); console.error(err); } - }, [workspaceUrl, commandGroup.names, onClose]); + }, [workspaceUrl, commandGroup.names, onClose, deleteCommandGroupOperation]); return ( Delete Command Group + {`${COMMAND_PREFIX}${commandGroup.names.join(" ")}`} - {updating && ( - - - - )} - {!updating && ( + {!deleteCommandGroupOperation.loading && ( From 52bf0f0716cd0b7ff5364c2cef8383aabc18ccfc Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 18:18:53 +1100 Subject: [PATCH 23/31] refactor: change loading message for loading resrouces --- .../__tests__/components/WSEditorClientConfig.test.tsx | 6 +++++- .../__tests__/components/WSEditorSwaggerPicker.test.tsx | 6 +++--- src/web/src/services/specsApi.ts | 4 ++-- .../components/WSEditor/WSEditorClientConfig.tsx | 6 +++--- .../WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx | 6 +++--- .../WorkspaceInstruction/WorkspaceCreateDialog.tsx | 8 ++++---- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx b/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx index d273c081..a824f73e 100644 --- a/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx +++ b/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx @@ -16,6 +16,10 @@ vi.mock("../../services", () => ({ loadingMessage: "Loading modules for plane...", fn: vi.fn(), }, + getResourcesForWorkspace: { + loadingMessage: "Loading resources...", + fn: vi.fn(), + }, getResourceProviders: vi.fn(), getProviderResources: vi.fn(), }, @@ -57,7 +61,7 @@ describe("WSEditorClientConfigDialog", () => { beforeEach(() => { vi.clearAllMocks(); (specsApi.getPlanes as any).mockResolvedValue(mockPlanes); - (specsApi.getModulesForPlane.fn as any).mockResolvedValue(["storage", "compute"]); + (specsApi.getResourcesForWorkspace.fn as any).mockResolvedValue(["storage", "compute"]); (specsApi.getResourceProviders as any).mockResolvedValue(mockResourceProviders); (specsApi.getProviderResources as any).mockResolvedValue(mockProviderResources); (errorHandlerApi.getErrorMessage as any).mockReturnValue("Mock error message"); diff --git a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx index 21ee075d..1cc249fc 100644 --- a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx +++ b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx @@ -84,8 +84,8 @@ describe("WSEditorSwaggerPicker", () => { vi.mocked(workspaceApi).addSwaggerResources.mockResolvedValue(undefined); vi.mocked(workspaceApi).addTypespecResources.mockResolvedValue(undefined); - vi.mocked(specsApi).getModulesForPlane = { - loadingMessage: "Loading modules for plane...", + vi.mocked(specsApi).getResourcesForWorkspace = { + loadingMessage: "Loading resources...", fn: vi.fn().mockResolvedValue(mockModules), }; vi.mocked(specsApi).getResourceProvidersWithType.mockResolvedValue(mockResourceProviders); @@ -129,7 +129,7 @@ describe("WSEditorSwaggerPicker", () => { render(); await waitFor(() => { - expect(vi.mocked(specsApi).getModulesForPlane.fn).toHaveBeenCalledWith("ResourceManagement"); + expect(vi.mocked(specsApi).getResourcesForWorkspace.fn).toHaveBeenCalledWith("ResourceManagement"); }); }); diff --git a/src/web/src/services/specsApi.ts b/src/web/src/services/specsApi.ts index 6f8fa05a..3bc0bc6a 100644 --- a/src/web/src/services/specsApi.ts +++ b/src/web/src/services/specsApi.ts @@ -26,8 +26,8 @@ export const specsApi = { return res.data.map((v: any) => v.name); }, - getModulesForPlane: { - loadingMessage: "Loading modules for plane...", + getResourcesForWorkspace: { + loadingMessage: "Loading resources...", fn: async (planeName: string): Promise => { const res = await axios.get(`/Swagger/Specs/${planeName}`); return res.data.map((v: any) => v.url); diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx index 0104fe7c..615d08de 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx @@ -70,7 +70,7 @@ const WSEditorClientConfigDialog: React.FC = ({ const [invalidText, setInvalidText] = useState(undefined); const [isAdd, setIsAdd] = useState(true); - const modulesLoader = useAsyncOperation(specsApi.getModulesForPlane); + const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace); const [endpointType, setEndpointType] = useState<"template" | "http-operation">("template"); @@ -129,7 +129,7 @@ const WSEditorClientConfigDialog: React.FC = ({ await onModuleSelectionUpdate(null); } else { try { - const options = await modulesLoader.execute(plane!.name); + const options = await resourcesLoader.execute(plane!.name); setModuleOptions(options || []); setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane!.name}/`); await onModuleSelectionUpdate(null); @@ -729,7 +729,7 @@ const WSEditorClientConfigDialog: React.FC = ({ pb: 2, }} > - + { const { filterText, updateFilter, filterResources } = useResourceFilter(); - const modulesLoader = useAsyncOperation(specsApi.getModulesForPlane); + const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace); const [loading, setLoading] = useState(false); const [invalidText, setInvalidText] = useState(undefined); @@ -120,7 +120,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge await loadWorkspaceResources(); try { - const allModules = await modulesLoader.execute(plane); + const allModules = await resourcesLoader.execute(plane); setModuleOptions(allModules || []); setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane}/`); @@ -611,7 +611,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge > Swagger Filters - + = ({ openDialo const [invalidText, setInvalidText] = useState(undefined); const [workspaceName, setWorkspaceName] = useState(name); - const modulesLoader = useAsyncOperation(specsApi.getModulesForPlane); + const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace); const [planes, setPlanes] = useState([]); const [planeOptions, setPlaneOptions] = useState([]); @@ -107,7 +107,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo await onModuleSelectionUpdate(null); } else { try { - const options = await modulesLoader.execute(plane.name); + const options = await resourcesLoader.execute(plane.name); setPlanes((prevPlanes) => { const updatedPlanes = [...prevPlanes]; const index = updatedPlanes.findIndex((v: Plane) => v.name === plane.name); @@ -257,7 +257,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo {invalidText}{" "} )} - + API Specs = ({ openDialo From 54563ffc0fdb3d115f3bc02eb5836133a9611e2e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 3 Nov 2025 18:55:49 +1100 Subject: [PATCH 25/31] refactor: COmmandGroupDialog to new loading pattenr --- .../WSEditorCommandGroupContent.test.tsx | 34 ++++++++++++---- src/web/src/services/commandApi.ts | 21 +++++----- .../CommandGroupDialog.tsx | 39 ++++++++++--------- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx index 931830a2..3ffb1113 100644 --- a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx @@ -21,8 +21,14 @@ vi.mock("../../services", () => ({ loadingMessage: "Deleting command group...", fn: vi.fn(), }, - updateCommandGroup: vi.fn(), - renameCommandGroup: vi.fn(), + updateCommandGroup: { + loadingMessage: "Updating command group...", + fn: vi.fn(), + }, + renameCommandGroup: { + loadingMessage: "Renaming command group...", + fn: vi.fn(), + }, }, errorHandlerApi: { getErrorMessage: vi.fn(), @@ -50,6 +56,20 @@ describe("WSEditorCommandGroupContent", () => { beforeEach(() => { vi.clearAllMocks(); mockCommandApi.deleteCommandGroup.fn.mockResolvedValue(undefined); + mockCommandApi.updateCommandGroup.fn.mockResolvedValue({ + id: "updated-group-id", + names: ["updated-group"], + stage: "Stable", + help: { short: "Updated help text" }, + canDelete: true, + }); + mockCommandApi.renameCommandGroup.fn.mockResolvedValue({ + id: "renamed-group-id", + names: ["renamed-group"], + stage: "Stable", + help: { short: "Test help" }, + canDelete: true, + }); }); describe("Core Rendering", () => { @@ -173,7 +193,7 @@ describe("WSEditorCommandGroupContent", () => { }); }); - it.skip("saves changes and updates command group", async () => { + it("saves changes and updates command group", async () => { // @NOTE: will change approach once mocking setup changes const user = userEvent.setup(); @@ -196,7 +216,7 @@ describe("WSEditorCommandGroupContent", () => { await user.click(saveButton); await waitFor(() => - expect(mockCommandApi.updateCommandGroup).toHaveBeenCalledWith( + expect(mockCommandApi.updateCommandGroup.fn).toHaveBeenCalledWith( expect.stringContaining(mockWorkspaceUrl), expect.objectContaining({ help: expect.objectContaining({ short: "Updated help text" }), @@ -295,7 +315,7 @@ describe("WSEditorCommandGroupContent", () => { const user = userEvent.setup(); const mockError = new Error("Update failed"); - mockCommandApi.updateCommandGroup.mockRejectedValue(mockError); + mockCommandApi.updateCommandGroup.fn.mockRejectedValue(mockError); render( { await user.click(editButton); await waitFor(() => { - expect(screen.getByText("Edit Command Group")).toBeInTheDocument(); + expect(screen.getByText("Command Group")).toBeInTheDocument(); }); const saveButton = screen.getByRole("button", { name: /save/i }); await user.click(saveButton); await waitFor(() => { - expect(mockCommandApi.updateCommandGroup).toHaveBeenCalled(); + expect(mockCommandApi.updateCommandGroup.fn).toHaveBeenCalled(); }); }); diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts index 140ef63c..466dddef 100644 --- a/src/web/src/services/commandApi.ts +++ b/src/web/src/services/commandApi.ts @@ -98,17 +98,20 @@ export const commandApi = { }, }, - updateCommandGroup: async ( - nodeUrl: string, - data: { help: { short: string; lines: string[] }; stage: string }, - ): Promise => { - const res = await axios.patch(nodeUrl, data); - return res.data; + updateCommandGroup: { + loadingMessage: "Updating command group...", + fn: async (nodeUrl: string, data: { help: { short: string; lines: string[] }; stage: string }): Promise => { + const res = await axios.patch(nodeUrl, data); + return res.data; + }, }, - renameCommandGroup: async (nodeUrl: string, name: string): Promise => { - const res = await axios.post(`${nodeUrl}/Rename`, { name }); - return res.data; + renameCommandGroup: { + loadingMessage: "Renaming command group...", + fn: async (nodeUrl: string, name: string): Promise => { + const res = await axios.post(`${nodeUrl}/Rename`, { name }); + return res.data; + }, }, findSimilarArguments: async (commandUrl: string, argVar: string): Promise => { diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx index 1021c14d..986a7816 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx @@ -1,6 +1,5 @@ import { Alert, - Box, Button, Dialog, DialogActions, @@ -8,12 +7,13 @@ import { DialogTitle, FormControlLabel, InputLabel, - LinearProgress, Radio, RadioGroup, TextField, } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import * as React from "react"; import { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; import type { CommandGroup } from "../../interfaces"; @@ -31,7 +31,9 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o const [shortHelp, setShortHelp] = React.useState(commandGroup.help?.short ?? ""); const [longHelp, setLongHelp] = React.useState(commandGroup.help?.lines?.join("\n") ?? ""); const [invalidText, setInvalidText] = React.useState(undefined); - const [updating, setUpdating] = React.useState(false); + + const updateCommandGroupOperation = useAsyncOperation(commandApi.updateCommandGroup); + const renameCommandGroupOperation = useAsyncOperation(commandApi.renameCommandGroup); React.useEffect(() => { setName(commandGroup.names.join(" ")); @@ -39,7 +41,6 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o setShortHelp(commandGroup.help?.short ?? ""); setLongHelp(commandGroup.help?.lines?.join("\n") ?? ""); setInvalidText(undefined); - setUpdating(false); }, [commandGroup]); const handleModify = React.useCallback(async () => { @@ -74,12 +75,10 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o lines = trimmedLongHelp.split("\n").filter((l: string) => l.length > 0); } - setUpdating(true); - const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/${commandGroup.names.join("/")}`; try { - const res = await commandApi.updateCommandGroup(nodeUrl, { + const res = await updateCommandGroupOperation.execute(nodeUrl, { help: { short: trimmedShortHelp, lines: lines, @@ -90,20 +89,27 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o const finalName = names.join(" "); if (finalName === commandGroup.names.join(" ")) { const cmdGroup = DecodeResponseCommandGroup(res); - setUpdating(false); onClose(cmdGroup); } else { - const renameRes = await commandApi.renameCommandGroup(nodeUrl, finalName); + const renameRes = await renameCommandGroupOperation.execute(nodeUrl, finalName); const cmdGroup = DecodeResponseCommandGroup(renameRes); - setUpdating(false); onClose(cmdGroup); } } catch (err: any) { console.error(err); - setUpdating(false); setInvalidText(errorHandlerApi.getErrorMessage(err)); } - }, [name, shortHelp, longHelp, stage, workspaceUrl, commandGroup.names, onClose]); + }, [ + name, + shortHelp, + longHelp, + stage, + workspaceUrl, + commandGroup.names, + onClose, + updateCommandGroupOperation, + renameCommandGroupOperation, + ]); const handleClose = React.useCallback(() => { setInvalidText(undefined); @@ -178,14 +184,11 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o }} margin="normal" /> + {} + {} - {updating && ( - - - - )} - {!updating && ( + {!updateCommandGroupOperation.loading && !renameCommandGroupOperation.loading && ( From c29bdbc2fe7d1505e47b25aeec004b248bfa337b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 5 Nov 2025 10:26:57 +1100 Subject: [PATCH 26/31] refactor: create uniform loading banner visual component --- .../src/components/AsyncOperationBanner.tsx | 60 ++++++++++++++++--- src/web/src/components/index.ts | 2 +- .../WSEditor/WSEditorClientConfig.tsx | 1 + .../WSEditor/WSEditorExportDialog.tsx | 1 + .../WSEditor/WSEditorSwaggerReloadDialog.tsx | 1 + .../FlattenDialog.tsx | 1 + .../UnwrapClsDialog.tsx | 1 + .../WSEditorCommandContent.tsx | 1 + 8 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/web/src/components/AsyncOperationBanner.tsx b/src/web/src/components/AsyncOperationBanner.tsx index 9df1e6cb..781dfae9 100644 --- a/src/web/src/components/AsyncOperationBanner.tsx +++ b/src/web/src/components/AsyncOperationBanner.tsx @@ -3,6 +3,14 @@ import { Box, LinearProgress, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; import type { UseAsyncOperationResult } from "../services/hooks"; +interface LoadingBannerProps { + loading: boolean; + message?: string; + backgroundColor?: string; + textColor?: string; + spinnerColor?: "primary" | "secondary" | "error" | "info" | "success" | "warning" | "inherit"; +} + interface AsyncOperationBannerProps { operation: UseAsyncOperationResult; backgroundColor?: string; @@ -11,7 +19,7 @@ interface AsyncOperationBannerProps { spinnerSize?: number; } -const LoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string }>( +const StyledLoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string }>( ({ theme, backgroundColor, textColor }) => ({ padding: theme.spacing(1.5), marginBottom: theme.spacing(2), @@ -24,6 +32,40 @@ const LoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string }), ); +/** + * A generic loading banner component that displays a loading state with message and progress bar. + * Can be used anywhere you need to show a loading state, not just with async operations. + * + * Returns null if !loading + * + * @example + * ```tsx + * const [loading, setLoading] = useState(false); + * + * return ( + * + * ); + * ``` + */ +export const LoadingBanner: React.FC = ({ + loading, + message, + backgroundColor = "white", + textColor, + spinnerColor = "secondary", +}) => { + if (!loading) { + return null; + } + + return ( + + {message && {message}} + + + ); +}; + /** * A reusable banner component that displays loading state for async operations. * Designed to be used with the UseAsyncOperationResult interface. @@ -43,16 +85,16 @@ export const AsyncOperationBanner: React.FC = ({ operation, backgroundColor = "white", textColor, + spinnerColor = "secondary", }) => { - if (!operation.loading) { - return null; - } - return ( - - {operation.loadingMessage} - - + ); }; diff --git a/src/web/src/components/index.ts b/src/web/src/components/index.ts index 566e20ef..2195e76a 100644 --- a/src/web/src/components/index.ts +++ b/src/web/src/components/index.ts @@ -1,4 +1,4 @@ export { AppNavBar } from "./AppNavBar"; -export { default as AsyncOperationBanner } from "./AsyncOperationBanner"; +export { default as AsyncOperationBanner, LoadingBanner } from "./AsyncOperationBanner"; export { default as EditorPageLayout } from "./EditorPageLayout"; export { default as PageLayout } from "./PageLayout"; diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx index 615d08de..caaac0a1 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx @@ -805,6 +805,7 @@ const WSEditorClientConfigDialog: React.FC = ({ + {/* @TODO: update usage: */} {updating && ( diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx index 3b0942e1..ea289c2c 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx @@ -90,6 +90,7 @@ const WSEditorExportDialog: React.FC = ({ )} + {/* @TODO: update usage: */} {updating && ( diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx index fab095eb..e0cb7fd6 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -249,6 +249,7 @@ const WSEditorSwaggerReloadDialog: React.FC = + {/* @TODO: update usage: */} {updating && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx index f2dca82c..5e22bcbe 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx @@ -301,6 +301,7 @@ const FlattenDialog: React.FC = (props) => { )} + {/* @TODO: update usage: */} {updating && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx index 3b6fea94..145c0a7d 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx @@ -99,6 +99,7 @@ const UnwrapClsDialog: React.FC = (props) => { {props.arg.type} + {/* @TODO: update usage: */} {updating && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx index 69168afb..e16486be 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -399,6 +399,7 @@ const WSEditorCommandContent: React.FC = ({ justifyContent: "flex-start", }} > + {/* @TODO: update usage: */} {loading && ( From d9a3b2e19fb100174a808ba7e0ef9116c6e05892 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 5 Nov 2025 12:32:40 +1100 Subject: [PATCH 27/31] refactor: update remaining elements to use uniform version of LoadingBar --- .../WSEditor/WSEditorClientConfig.tsx | 10 ++-------- .../WSEditor/WSEditorExportDialog.tsx | 10 +++------- .../WSEditor/WSEditorSwaggerReloadDialog.tsx | 9 ++------- .../FlattenDialog.tsx | 20 +++---------------- .../UnwrapClsDialog.tsx | 10 ++-------- .../WSEditorCommandContent.tsx | 9 ++------- 6 files changed, 14 insertions(+), 54 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx index caaac0a1..d5d20d4b 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx @@ -7,7 +7,6 @@ import { DialogTitle, DialogContent, DialogActions, - LinearProgress, Button, Paper, TextField, @@ -22,7 +21,7 @@ import { } from "@mui/material"; import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; import { useAsyncOperation } from "../../../../services/hooks"; -import AsyncOperationBanner from "../../../../components/AsyncOperationBanner"; +import AsyncOperationBanner, { LoadingBanner } from "../../../../components/AsyncOperationBanner"; import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; import SwaggerItemSelector from "../../common/SwaggerItemSelector"; @@ -803,14 +802,9 @@ const WSEditorClientConfigDialog: React.FC = ({ One more scope + - {/* @TODO: update usage: */} - {updating && ( - - - - )} {!updating && ( {!isAdd && } diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx index ea289c2c..557e7d6d 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, Fragment } from "react"; -import { Box, Dialog, DialogTitle, DialogContent, DialogActions, LinearProgress, Button, Alert } from "@mui/material"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Alert } from "@mui/material"; import { workspaceApi, errorHandlerApi } from "../../../../services"; +import { LoadingBanner } from "../../../../components"; interface WSEditorExportDialogProps { workspaceUrl: string; @@ -89,13 +90,8 @@ const WSEditorExportDialog: React.FC = ({ )} + - {/* @TODO: update usage: */} - {updating && ( - - - - )} {!updating && ( {clientConfigOOD && } diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx index e0cb7fd6..f7e90ed4 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -5,7 +5,6 @@ import { DialogTitle, DialogContent, DialogActions, - LinearProgress, Button, List, ListSubheader, @@ -20,6 +19,7 @@ import { import type { Resource } from "../../interfaces"; import { getTypespecRPResourcesOperations } from "../../../../typespec"; import { workspaceApi, errorHandlerApi } from "../../../../services"; +import { LoadingBanner } from "../../../../components"; interface WSEditorSwaggerReloadDialogProps { workspaceName: string; @@ -247,14 +247,9 @@ const WSEditorSwaggerReloadDialog: React.FC = )} + - {/* @TODO: update usage: */} - {updating && ( - - - - )} {!updating && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx index 5e22bcbe..b699ae0a 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx @@ -1,17 +1,8 @@ -import { - Alert, - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, - TextField, -} from "@mui/material"; +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; import React, { useEffect, useState } from "react"; import { commandApi, errorHandlerApi } from "../../../../services"; import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; +import { LoadingBanner } from "../../../../components"; interface FlattenDialogProps { commandUrl: string; @@ -300,13 +291,8 @@ const FlattenDialog: React.FC = (props) => { )} + - {/* @TODO: update usage: */} - {updating && ( - - - - )} {!updating && !argSimilarTree && ( <> diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx index 145c0a7d..1e7bf4ca 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx @@ -1,12 +1,10 @@ import { Alert, - Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, - LinearProgress, styled, Typography, TypographyProps, @@ -14,6 +12,7 @@ import { import { commandApi, errorHandlerApi } from "../../../../services"; import React, { useState } from "react"; +import { LoadingBanner } from "../../../../components"; const ArgTypeTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -98,13 +97,8 @@ const UnwrapClsDialog: React.FC = (props) => { )} {props.arg.type} + - {/* @TODO: update usage: */} - {updating && ( - - - - )} {!updating && ( <> diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx index e16486be..aa78be74 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -6,7 +6,6 @@ import { CardActions, CardContent, Accordion, - LinearProgress, Typography, TypographyProps, AccordionDetails, @@ -39,6 +38,7 @@ import CommandDialog from "./CommandDialog"; import OutputCard from "./OutputCard"; import OutputDialog from "./OutputDialog"; import type { Command, Example } from "../../interfaces"; +import { LoadingBanner } from "../../../../components"; interface WSEditorCommandContentProps { workspaceUrl: string; @@ -391,6 +391,7 @@ const WSEditorCommandContent: React.FC = ({ )} + = ({ justifyContent: "flex-start", }} > - {/* @TODO: update usage: */} - {loading && ( - - - - )} {!loading && ( Date: Wed, 5 Nov 2025 12:33:59 +1100 Subject: [PATCH 28/31] refactor: remove comment --- .../__tests__/components/WSEditorCommandGroupContent.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx index 3ffb1113..80355ef7 100644 --- a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx @@ -194,7 +194,6 @@ describe("WSEditorCommandGroupContent", () => { }); it("saves changes and updates command group", async () => { - // @NOTE: will change approach once mocking setup changes const user = userEvent.setup(); render( From 257a2d5f94e805ad94f6fb748ee7baa2ced58b0c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 5 Nov 2025 12:39:54 +1100 Subject: [PATCH 29/31] refactor: remove jsx example in useAsyncOperation --- src/web/src/services/hooks/useAsyncOperation.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/web/src/services/hooks/useAsyncOperation.ts b/src/web/src/services/hooks/useAsyncOperation.ts index 9cfcf410..a572f650 100644 --- a/src/web/src/services/hooks/useAsyncOperation.ts +++ b/src/web/src/services/hooks/useAsyncOperation.ts @@ -34,14 +34,6 @@ export interface UseAsyncOperationResult extends AsyncOperationState, Asyn * * // Usage * await resourceProviders.execute(moduleUrl); - * - * // In JSX - * {resourceProviders.loading && ( - * - * - * {resourceProviders.loadingMessage} - * - * )} * ``` */ export const useAsyncOperation = (serviceMethod?: AsyncServiceMethod): UseAsyncOperationResult => { From 93d6005815fe305f8d6640c8bc6f6ee5f89b2bdd Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 5 Nov 2025 12:57:59 +1100 Subject: [PATCH 30/31] Refactor: remove unnecc {} --- .../WSEditorCommandGroupContent/CommandGroupDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx index 986a7816..12fdd1fa 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx @@ -184,8 +184,8 @@ const CommandGroupDialog: React.FC = ({ workspaceUrl, o }} margin="normal" /> - {} - {} + + {!updateCommandGroupOperation.loading && !renameCommandGroupOperation.loading && ( From 02a1c3bac5ed31593353b47659f5bba5fc756c26 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 5 Nov 2025 13:06:08 +1100 Subject: [PATCH 31/31] feature: remove lose of workspace creation on click outside --- .../components/WorkspaceInstruction/WorkspaceCreateDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx index 69ba8685..556eead7 100644 --- a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx @@ -248,7 +248,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo }, [onClose]); return ( - + Create a new workspace {invalidText && (