diff --git a/src/web/src/__tests__/api/workspaceApi.test.tsx b/src/web/src/__tests__/api/workspaceApi.test.tsx
index 0d7058ca..443a089c 100644
--- a/src/web/src/__tests__/api/workspaceApi.test.tsx
+++ b/src/web/src/__tests__/api/workspaceApi.test.tsx
@@ -77,13 +77,18 @@ describe("Workspace API", () => {
describe("deleteWorkspace", () => {
it("should delete workspace by name", async () => {
- await expect(workspaceApi.deleteWorkspace("test-workspace-1")).resolves.toBeUndefined();
+ const operation = workspaceApi.deleteWorkspace;
+ expect(operation.loadingMessage).toBe("Deleting workspace...");
+ await expect(operation.fn("test-workspace-1")).resolves.toBeUndefined();
});
});
describe("renameWorkspace", () => {
it("should rename workspace and return new name", async () => {
- const result = await workspaceApi.renameWorkspace("/AAZ/Editor/Workspaces/test-workspace-1", "renamed-workspace");
+ const operation = workspaceApi.renameWorkspace;
+ expect(operation.loadingMessage).toBe("Renaming workspace...");
+
+ const result = await operation.fn("/AAZ/Editor/Workspaces/test-workspace-1", "renamed-workspace");
expect(result).toEqual({
name: "renamed-workspace",
diff --git a/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx b/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx
index 5a9134be..a824f73e 100644
--- a/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx
+++ b/src/web/src/__tests__/components/WSEditorClientConfig.test.tsx
@@ -12,7 +12,14 @@ vi.mock("../../services", () => ({
},
specsApi: {
getPlanes: vi.fn(),
- getSwaggerModules: vi.fn(),
+ getModulesForPlane: {
+ loadingMessage: "Loading modules for plane...",
+ fn: vi.fn(),
+ },
+ getResourcesForWorkspace: {
+ loadingMessage: "Loading resources...",
+ fn: vi.fn(),
+ },
getResourceProviders: vi.fn(),
getProviderResources: vi.fn(),
},
@@ -54,7 +61,7 @@ describe("WSEditorClientConfigDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
(specsApi.getPlanes as any).mockResolvedValue(mockPlanes);
- (specsApi.getSwaggerModules 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/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx
index dbca2c27..965e519d 100644
--- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx
+++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx
@@ -127,10 +127,30 @@ 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).updateCommand.mockResolvedValue(mockCommand);
- vi.mocked(commandApi).updateCommandExamples.mockResolvedValue(mockCommand);
- vi.mocked(commandApi).updateCommandOutputs.mockResolvedValue(mockCommand);
+ vi.mocked(commandApi).deleteResource = {
+ loadingMessage: "Deleting commands...",
+ fn: vi.fn().mockResolvedValue(undefined),
+ };
+ 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 = {
+ loadingMessage: "Updating command outputs...",
+ fn: vi.fn().mockResolvedValue(mockCommand),
+ };
});
describe("Core Rendering", () => {
@@ -470,7 +490,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();
@@ -500,7 +520,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();
diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx
index 0565e713..80355ef7 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,28 @@ 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: {
+ loadingMessage: "Updating command group...",
+ fn: vi.fn(),
+ },
+ renameCommandGroup: {
+ loadingMessage: "Renaming command group...",
+ fn: vi.fn(),
+ },
+ },
+ errorHandlerApi: {
+ getErrorMessage: vi.fn(),
+ },
+}));
const mockCommandApi = commandApi as any;
+
describe("WSEditorCommandGroupContent", () => {
const mockWorkspaceUrl = "https://test-workspace.com/workspace/ws1";
@@ -37,6 +55,21 @@ 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", () => {
@@ -160,8 +193,7 @@ describe("WSEditorCommandGroupContent", () => {
});
});
- it.skip("saves changes and updates command group", async () => {
- // @NOTE: will change approach once mocking setup changes
+ it("saves changes and updates command group", async () => {
const user = userEvent.setup();
render(
@@ -183,7 +215,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" }),
@@ -270,7 +302,7 @@ describe("WSEditorCommandGroupContent", () => {
await user.click(confirmDeleteButton);
await waitFor(() => {
- expect(mockCommandApi.deleteCommandGroup).toHaveBeenCalled();
+ expect(mockCommandApi.deleteCommandGroup.fn).toHaveBeenCalled();
expect(mockOnUpdateCommandGroup).toHaveBeenCalledWith(null);
});
});
@@ -282,7 +314,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();
});
});
@@ -313,7 +345,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/__tests__/components/WSEditorSwaggerPicker.test.tsx b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx
index e2cd9f85..1cc249fc 100644
--- a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx
+++ b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx
@@ -84,7 +84,10 @@ describe("WSEditorSwaggerPicker", () => {
vi.mocked(workspaceApi).addSwaggerResources.mockResolvedValue(undefined);
vi.mocked(workspaceApi).addTypespecResources.mockResolvedValue(undefined);
- vi.mocked(specsApi).getSwaggerModules.mockResolvedValue(mockModules);
+ vi.mocked(specsApi).getResourcesForWorkspace = {
+ loadingMessage: "Loading resources...",
+ fn: vi.fn().mockResolvedValue(mockModules),
+ };
vi.mocked(specsApi).getResourceProvidersWithType.mockResolvedValue(mockResourceProviders);
vi.mocked(specsApi).getProviderResources.mockResolvedValue(mockResources);
vi.mocked(specsApi).filterResourcesByPlane.mockResolvedValue({ resources: mockResources });
@@ -126,7 +129,7 @@ describe("WSEditorSwaggerPicker", () => {
render();
await waitFor(() => {
- expect(vi.mocked(specsApi).getSwaggerModules).toHaveBeenCalledWith("ResourceManagement");
+ expect(vi.mocked(specsApi).getResourcesForWorkspace.fn).toHaveBeenCalledWith("ResourceManagement");
});
});
@@ -686,79 +689,6 @@ describe("WSEditorSwaggerPicker", () => {
expect(onCloseMock).toHaveBeenCalledWith(false);
});
});
-
- describe("Error Handling", () => {
- it.skip("displays error when swagger modules fail to load", async () => {
- // @NOTE: will address once loading issues have been addressed
- vi.mocked(specsApi).getSwaggerModules.mockRejectedValue(new Error("Failed to load modules"));
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText(/ResponseError/)).toBeInTheDocument();
- });
- });
-
- it.skip("displays error when resource providers fail to load", async () => {
- // @NOTE: will address once loading issues have been addressed
- vi.mocked(specsApi).getResourceProvidersWithType.mockRejectedValue(new Error("Failed to load providers"));
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText(/ResponseError/)).toBeInTheDocument();
- });
- });
-
- it.skip("displays error when resources fail to load", async () => {
- // @NOTE: will address once loading issues have been addressed
- vi.mocked(specsApi).getProviderResources.mockRejectedValue(new Error("Failed to load resources"));
-
- render();
-
- await waitFor(() => {
- expect(screen.getByText(/ResponseError/)).toBeInTheDocument();
- });
- });
-
- it.skip("displays error when submission fails", async () => {
- // @NOTE: will address once loading issues have been addressed
- vi.mocked(workspaceApi).addSwaggerResources.mockRejectedValue(new Error("Submission failed"));
-
- render();
-
- await waitFor(() => {
- const resourceCheckbox = screen.getAllByRole("checkbox")[1];
- fireEvent.click(resourceCheckbox);
- });
-
- const submitButton = screen.getByRole("button", { name: /submit/i });
- fireEvent.click(submitButton);
-
- await waitFor(() => {
- expect(screen.getByText(/ResponseError/)).toBeInTheDocument();
- });
- });
-
- it.skip("allows dismissing error messages", async () => {
- // @NOTE: will address once loading issues have been addressed
- vi.mocked(specsApi).getSwaggerModules.mockRejectedValue(new Error("Failed to load modules"));
-
- render();
-
- await waitFor(() => {
- const errorAlert = screen.getByText(/ResponseError/);
- expect(errorAlert).toBeInTheDocument();
- });
-
- const closeErrorButton = screen.getByLabelText(/close/i);
- fireEvent.click(closeErrorButton);
-
- await waitFor(() => {
- expect(screen.queryByText(/ResponseError/)).not.toBeInTheDocument();
- });
- });
- });
});
describe("SwaggerItemSelector", () => {
diff --git a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx
index 5efbcdba..6c926826 100644
--- a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx
+++ b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx
@@ -281,20 +281,29 @@ describe("Workspace Management", () => {
});
it("should delete workspace successfully", async () => {
- (workspaceApi.deleteWorkspace as any).mockResolvedValue(undefined);
+ const mockOperation = {
+ loadingMessage: "Deleting workspace...",
+ fn: vi.fn().mockResolvedValue(undefined),
+ };
+ (workspaceApi.deleteWorkspace as any) = mockOperation;
- await workspaceApi.deleteWorkspace("test-workspace-1");
+ await workspaceApi.deleteWorkspace.fn("test-workspace-1");
- expect(workspaceApi.deleteWorkspace).toHaveBeenCalledWith("test-workspace-1");
+ expect(workspaceApi.deleteWorkspace.fn).toHaveBeenCalledWith("test-workspace-1");
+ expect(workspaceApi.deleteWorkspace.loadingMessage).toBe("Deleting workspace...");
});
it("should rename workspace successfully", async () => {
const expectedResult = { name: "renamed-workspace" };
- (workspaceApi.renameWorkspace as any).mockResolvedValue(expectedResult);
+ const mockFn = vi.fn().mockResolvedValue(expectedResult);
+ (workspaceApi.renameWorkspace as any) = {
+ loadingMessage: "Renaming workspace...",
+ fn: mockFn,
+ };
- const result = await workspaceApi.renameWorkspace("/workspace/test-workspace-1", "renamed-workspace");
+ const result = await workspaceApi.renameWorkspace.fn("/workspace/test-workspace-1", "renamed-workspace");
- expect(workspaceApi.renameWorkspace).toHaveBeenCalledWith("/workspace/test-workspace-1", "renamed-workspace");
+ expect(mockFn).toHaveBeenCalledWith("/workspace/test-workspace-1", "renamed-workspace");
expect(result).toEqual(expectedResult);
});
});
diff --git a/src/web/src/__tests__/integration/WSEditorClientConfig.integration.test.tsx b/src/web/src/__tests__/integration/WSEditorClientConfig.integration.test.tsx
index 010c3201..f0b7143e 100644
--- a/src/web/src/__tests__/integration/WSEditorClientConfig.integration.test.tsx
+++ b/src/web/src/__tests__/integration/WSEditorClientConfig.integration.test.tsx
@@ -94,7 +94,7 @@ describe("WSEditorClientConfigDialog - Integration", () => {
});
describe("Complete User Workflows", () => {
- it("should handle user inputs for relevant fields", async () => {
+ it("should handle user inputs for relevant fields", { timeout: 10000 }, async () => {
const user = userEvent.setup();
render();
diff --git a/src/web/src/components/AsyncOperationBanner.tsx b/src/web/src/components/AsyncOperationBanner.tsx
new file mode 100644
index 00000000..781dfae9
--- /dev/null
+++ b/src/web/src/components/AsyncOperationBanner.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+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;
+ textColor?: string;
+ spinnerColor?: "primary" | "secondary" | "error" | "info" | "success" | "warning" | "inherit";
+ spinnerSize?: number;
+}
+
+const StyledLoadingBanner = styled(Box)<{ backgroundColor?: string; textColor?: string }>(
+ ({ theme, backgroundColor, textColor }) => ({
+ padding: theme.spacing(1.5),
+ marginBottom: theme.spacing(2),
+ backgroundColor: backgroundColor || theme.palette.grey[200],
+ color: textColor || theme.palette.text.primary,
+ borderRadius: theme.spacing(1),
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(1),
+ }),
+);
+
+/**
+ * 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.
+ *
+ * Returns null if !loading
+ *
+ * @example
+ * ```tsx
+ * const modulesLoader = useAsyncOperation(specsApi.getModulesForPlane);
+ *
+ * return (
+ *
+ * );
+ * ```
+ */
+export const AsyncOperationBanner: React.FC = ({
+ operation,
+ backgroundColor = "white",
+ textColor,
+ spinnerColor = "secondary",
+}) => {
+ return (
+
+ );
+};
+
+export default AsyncOperationBanner;
diff --git a/src/web/src/components/index.ts b/src/web/src/components/index.ts
new file mode 100644
index 00000000..2195e76a
--- /dev/null
+++ b/src/web/src/components/index.ts
@@ -0,0 +1,4 @@
+export { AppNavBar } from "./AppNavBar";
+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/services/cliApi.ts b/src/web/src/services/cliApi.ts
index 18cfd6cb..cf505f30 100644
--- a/src/web/src/services/cliApi.ts
+++ b/src/web/src/services/cliApi.ts
@@ -39,11 +39,17 @@ export const cliApi = {
return res.data;
},
- updateCliModule: async (repoName: string, moduleName: string, data: any): Promise => {
- await axios.put(`/CLI/Az/${repoName}/Modules/${moduleName}`, data);
- },
-
- patchCliModule: async (repoName: string, moduleName: string, data: any): Promise => {
- await axios.patch(`/CLI/Az/${repoName}/Modules/${moduleName}`, data);
+ updateCliModule: {
+ loadingMessage: "Generating CLI commands...",
+ fn: async (repoName: string, moduleName: string, data: any): Promise => {
+ await axios.put(`/CLI/Az/${repoName}/Modules/${moduleName}`, data);
+ },
+ },
+
+ patchCliModule: {
+ loadingMessage: "Generating CLI commands...",
+ fn: async (repoName: string, moduleName: string, data: any): Promise => {
+ await axios.patch(`/CLI/Az/${repoName}/Modules/${moduleName}`, data);
+ },
},
} as const;
diff --git a/src/web/src/services/commandApi.ts b/src/web/src/services/commandApi.ts
index e458dbb6..466dddef 100644
--- a/src/web/src/services/commandApi.ts
+++ b/src/web/src/services/commandApi.ts
@@ -11,48 +11,72 @@ 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 => {
- 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 => {
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 => {
- 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 => {
@@ -67,21 +91,27 @@ 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 (
- 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 => {
@@ -90,15 +120,27 @@ export const commandApi = {
return res.data;
},
- createSubresource: async (
- subresourceUrl: string,
- data: {
- commandGroupName: string;
- refArgsOptions: { [argVar: string]: string[] };
- arg: string;
+ findSimilarArgumentsOperation: {
+ 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: {
+ loadingMessage: "Creating subcommands...",
+ fn: async (
+ subresourceUrl: string,
+ data: {
+ commandGroupName: string;
+ refArgsOptions: { [argVar: string]: string[] };
+ arg: string;
+ },
+ ): Promise => {
+ const response = await axios.post(subresourceUrl, data);
+ return response.data;
},
- ): Promise => {
- const response = await axios.post(subresourceUrl, data);
- return response.data;
},
} as const;
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..a572f650
--- /dev/null
+++ b/src/web/src/services/hooks/useAsyncOperation.ts
@@ -0,0 +1,87 @@
+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);
+ * ```
+ */
+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..3bc0bc6a 100644
--- a/src/web/src/services/specsApi.ts
+++ b/src/web/src/services/specsApi.ts
@@ -26,9 +26,12 @@ 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);
+ 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);
+ },
},
getResourceProviders: async (moduleUrl: string): Promise => {
@@ -41,11 +44,6 @@ export const specsApi = {
return res.data;
},
- getSwaggerModules: async (plane: string): Promise => {
- const res = await axios.get(`/Swagger/Specs/${plane}`);
- return res.data.map((v: any) => v.url);
- },
-
getResourceProvidersWithType: async (moduleUrl: string, type?: string): Promise => {
let url = `${moduleUrl}/ResourceProviders`;
if (type) {
diff --git a/src/web/src/services/workspaceApi.ts b/src/web/src/services/workspaceApi.ts
index cf5eff56..4dbc4216 100644
--- a/src/web/src/services/workspaceApi.ts
+++ b/src/web/src/services/workspaceApi.ts
@@ -56,14 +56,20 @@ export const workspaceApi = {
return res.data;
},
- deleteWorkspace: async (workspaceName: string): Promise => {
- const nodeUrl = `/AAZ/Editor/Workspaces/${workspaceName}`;
- await axios.delete(nodeUrl);
- },
-
- renameWorkspace: async (workspaceUrl: string, newName: string): Promise<{ name: string }> => {
- const res = await axios.post(`${workspaceUrl}/Rename`, { name: newName });
- return res.data;
+ deleteWorkspace: {
+ loadingMessage: "Deleting workspace...",
+ fn: async (workspaceName: string): Promise => {
+ const nodeUrl = `/AAZ/Editor/Workspaces/${workspaceName}`;
+ await axios.delete(nodeUrl);
+ },
+ },
+
+ renameWorkspace: {
+ loadingMessage: "Renaming workspace...",
+ fn: async (workspaceUrl: string, newName: string): Promise<{ name: string }> => {
+ const res = await axios.post(`${workspaceUrl}/Rename`, { name: newName });
+ return res.data;
+ },
},
getWorkspaceClientConfig: async (workspaceUrl: string): Promise => {
diff --git a/src/web/src/views/cli/components/GenerateDialog.tsx b/src/web/src/views/cli/components/GenerateDialog.tsx
index 4f95560a..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 { useState } from "react";
-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";
@@ -17,8 +18,8 @@ interface GenerateDialogProps {
}
const GenerateDialog = (props: GenerateDialogProps) => {
- const [updating, setUpdating] = useState(false);
- const [invalidText, setInvalidText] = useState(undefined);
+ const updateAllOperation = useAsyncOperation(cliApi.updateCliModule);
+ const updateModifiedOperation = useAsyncOperation(cliApi.patchCliModule);
const handleClose = () => {
props.onClose(false);
@@ -34,15 +35,11 @@ const GenerateDialog = (props: GenerateDialogProps) => {
profiles: profiles,
};
- setUpdating(true);
try {
- await cliApi.updateCliModule(props.repoName, props.moduleName, data);
- setUpdating(false);
+ await updateAllOperation.execute(props.repoName, props.moduleName, data);
props.onClose(true);
- } catch (err: any) {
- console.error(err);
- setInvalidText(errorHandlerApi.getErrorMessage(err));
- setUpdating(false);
+ } catch (error) {
+ console.error("Generate all failed:", error);
}
};
@@ -56,42 +53,40 @@ const GenerateDialog = (props: GenerateDialogProps) => {
profiles: profiles,
};
- setUpdating(true);
try {
- await cliApi.patchCliModule(props.repoName, props.moduleName, data);
- setUpdating(false);
+ await updateModifiedOperation.execute(props.repoName, props.moduleName, data);
props.onClose(true);
- } catch (err: any) {
- console.error(err);
- setInvalidText(errorHandlerApi.getErrorMessage(err));
- setUpdating(false);
+ } catch (error) {
+ console.error("Generate modified failed:", error);
}
};
+ const isLoading = updateAllOperation.loading || updateModifiedOperation.loading;
+ const error = updateAllOperation.error || updateModifiedOperation.error;
+
return (
);
diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx
index 4ad5085d..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,
@@ -21,6 +20,8 @@ import {
Tab,
} from "@mui/material";
import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services";
+import { useAsyncOperation } from "../../../../services/hooks";
+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";
@@ -68,6 +69,8 @@ const WSEditorClientConfigDialog: React.FC = ({
const [invalidText, setInvalidText] = useState(undefined);
const [isAdd, setIsAdd] = useState(true);
+ const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace);
+
const [endpointType, setEndpointType] = useState<"template" | "http-operation">("template");
const [templateAzureCloud, setTemplateAzureCloud] = useState("");
@@ -125,16 +128,13 @@ const WSEditorClientConfigDialog: React.FC = ({
await onModuleSelectionUpdate(null);
} else {
try {
- setUpdating(true);
- const options = await specsApi.getSwaggerModules(plane!.name);
- setUpdating(false);
- setModuleOptions(options);
+ const options = await resourcesLoader.execute(plane!.name);
+ setModuleOptions(options || []);
setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane!.name}/`);
await onModuleSelectionUpdate(null);
} catch (err: any) {
console.error(err);
const message = errorHandlerApi.getErrorMessage(err);
- setUpdating(false);
setInvalidText(`ResponseError: ${message}`);
}
}
@@ -728,6 +728,7 @@ const WSEditorClientConfigDialog: React.FC = ({
pb: 2,
}}
>
+
= ({
One more scope
+
- {updating && (
-
-
-
- )}
{!updating && (
{!isAdd && }
diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx
index fe05557a..d77783b2 100644
--- a/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx
+++ b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx
@@ -1,16 +1,8 @@
import React, { useState, Fragment } from "react";
-import {
- Box,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- LinearProgress,
- Button,
- TextField,
- Alert,
-} from "@mui/material";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Alert } from "@mui/material";
import { workspaceApi, errorHandlerApi } from "../../../../services";
+import { useAsyncOperation } from "../../../../services/hooks";
+import { AsyncOperationBanner } from "../../../../components";
interface WSEditorDeleteDialogProps {
workspaceName: string;
@@ -19,24 +11,22 @@ interface WSEditorDeleteDialogProps {
}
const WSEditorDeleteDialog: React.FC = ({ workspaceName, open, onClose }) => {
- const [updating, setUpdating] = useState(false);
const [invalidText, setInvalidText] = useState(undefined);
const [confirmName, setConfirmName] = useState(undefined);
+ const deleteWorkspaceOperation = useAsyncOperation(workspaceApi.deleteWorkspace);
+
const handleClose = () => {
onClose(false);
};
const handleDelete = async () => {
- setUpdating(true);
try {
- await workspaceApi.deleteWorkspace(workspaceName);
- setUpdating(false);
+ await deleteWorkspaceOperation.execute(workspaceName);
onClose(true);
} catch (err: any) {
console.error(err);
setInvalidText(errorHandlerApi.getErrorMessage(err));
- setUpdating(false);
}
};
@@ -66,12 +56,8 @@ const WSEditorDeleteDialog: React.FC = ({ workspaceNa
/>
- {updating && (
-
-
-
- )}
- {!updating && (
+
+ {!deleteWorkspaceOperation.loading && (