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 ( - Generate CLI commands to {props.moduleName} + {!isLoading && `Generate CLI commands for ${props.moduleName} module?`} - {invalidText && ( + + + {error && ( {" "} - {invalidText}{" "} + {errorHandlerApi.getErrorMessage(error)}{" "} )} - {updating && ( - - - - )} - {!updating && ( - <> - - - - - )} + + + ); 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 && ( } diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx index fab095eb..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,13 +247,9 @@ const WSEditorSwaggerReloadDialog: React.FC = )} + - {updating && ( - - - - )} {!updating && ( diff --git a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx index e701f569..42b2c06f 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.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 WSRenameDialogProps { workspaceUrl: string; @@ -22,7 +14,8 @@ interface WSRenameDialogProps { const WSRenameDialog: React.FC = ({ workspaceUrl, workspaceName, open, onClose }) => { const [newWSName, setNewWSName] = useState(workspaceName); const [invalidText, setInvalidText] = useState(undefined); - const [updating, setUpdating] = useState(false); + + const renameWorkspaceOperation = useAsyncOperation(workspaceApi.renameWorkspace); const handleModify = async () => { const nName = newWSName.trim(); @@ -32,18 +25,14 @@ const WSRenameDialog: React.FC = ({ workspaceUrl, workspace } setInvalidText(undefined); - setUpdating(true); if (workspaceName === nName) { - setUpdating(false); onClose(null); } else { try { - const res = await workspaceApi.renameWorkspace(workspaceUrl, nName); - setUpdating(false); - onClose(res.name); + const res = await renameWorkspaceOperation.execute(workspaceUrl, nName); + onClose(res?.name || nName); } catch (err: any) { - setUpdating(false); setInvalidText(errorHandlerApi.getErrorMessage(err)); } } @@ -79,12 +68,8 @@ const WSRenameDialog: React.FC = ({ workspaceUrl, workspace /> - {updating && ( - - - - )} - {!updating && ( + + {!renameWorkspaceOperation.loading && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx index f9f647b0..2fa64c21 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.findSimilarArgumentsOperation); + 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 && ( <> diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx index 3b6fea94..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,12 +97,8 @@ const UnwrapClsDialog: React.FC = (props) => { )} {props.arg.type} + - {updating && ( - - - - )} {!updating && ( <> diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx index fb05d1ab..565ef547 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx @@ -1,17 +1,8 @@ -import { - Alert, - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormLabel, - LinearProgress, - TextField, -} from "@mui/material"; +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormLabel, TextField } from "@mui/material"; import React, { useState, useEffect } from "react"; import { commandApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import { AsyncOperationBanner } from "../../../../components"; import type { Command } from "../../interfaces"; export interface AddSubcommandDialogProps { @@ -33,11 +24,12 @@ const AddSubcommandDialog: React.FC = ({ open, onClose, }) => { - const [updating, setUpdating] = useState(false); const [invalidText, setInvalidText] = useState(undefined); const [commandGroupName, setCommandGroupName] = useState(""); const [refArgsOptions, setRefArgsOptions] = useState<{ var: string; options: string }[]>([]); + const createSubresourceOperation = useAsyncOperation(commandApi.createSubresource); + useEffect(() => { setCommandGroupName(defaultGroupNames.join(" ")); setRefArgsOptions(subArgOptions); @@ -103,10 +95,8 @@ const AddSubcommandDialog: React.FC = ({ return; } - setUpdating(true); - try { - await commandApi.createSubresource(urls[0], { + await createSubresourceOperation.execute(urls[0], { ...data, arg: argVar, }); @@ -115,7 +105,6 @@ const AddSubcommandDialog: React.FC = ({ console.error(err); const message = errorHandlerApi.getErrorMessage(err); setInvalidText(`ResponseError: ${message}`); - setUpdating(false); } }; @@ -180,14 +169,10 @@ const AddSubcommandDialog: React.FC = ({ {refArgsOptions.map(buildRefArgText)} )} + - {updating && ( - - - - )} - {!updating && ( + {!createSubresourceOperation.loading && ( <> 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 && ( - <> - - - - )} + + ); 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 && ( 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 && } + ); diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx index 69168afb..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", }} > - {loading && ( - - - - )} {!loading && ( = ({ 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 && ( diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx index 1021c14d..12fdd1fa 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 && ( diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index c83406d8..da905bd2 100644 --- a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -27,6 +27,8 @@ import { } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; +import { useAsyncOperation } from "../../../../services/hooks"; +import AsyncOperationBanner from "../../../../components/AsyncOperationBanner"; import EditorPageLayout from "../../../../components/EditorPageLayout"; import { styled } from "@mui/material/styles"; import { getTypespecRPResources, getTypespecRPResourcesOperations } from "../../../../typespec"; @@ -87,6 +89,8 @@ const UpdateOptions = ["Default", "Generic(Get&Put) First", "Patch First", "No u const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwaggerPickerProps) => { const { filterText, updateFilter, filterResources } = useResourceFilter(); + const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace); + const [loading, setLoading] = useState(false); const [invalidText, setInvalidText] = useState(undefined); const [_defaultModule, setDefaultModule] = useState(null); @@ -116,8 +120,8 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge await loadWorkspaceResources(); try { - const allModules = await specsApi.getSwaggerModules(plane); - setModuleOptions(allModules); + const allModules = await resourcesLoader.execute(plane); + setModuleOptions(allModules || []); setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane}/`); const swaggerDefault = await workspaceApi.getSwaggerDefault(workspaceName); @@ -126,7 +130,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge } const moduleValueUrl = `/Swagger/Specs/${plane}/` + swaggerDefault.modNames.join("/"); - if (allModules.findIndex((v) => v === moduleValueUrl) == -1) { + if (!allModules || allModules.findIndex((v) => v === moduleValueUrl) == -1) { return; } @@ -302,7 +306,6 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge [plane, existingResources], ); - // Effect to load resources when selectedResourceProvider changes useEffect(() => { if (selectedResourceProvider) { loadResources(selectedResourceProvider); @@ -608,6 +611,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge > Swagger Filters + = ({ openDialo const [invalidText, setInvalidText] = useState(undefined); const [workspaceName, setWorkspaceName] = useState(name); + const resourcesLoader = useAsyncOperation(specsApi.getResourcesForWorkspace); + const [planes, setPlanes] = useState([]); const [planeOptions, setPlaneOptions] = useState([]); const [selectedPlane, setSelectedPlane] = useState(null); @@ -103,21 +107,18 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo await onModuleSelectionUpdate(null); } else { try { - setLoading(true); - const options = await specsApi.getModulesForPlane(plane.name); + const options = await resourcesLoader.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)); } } @@ -247,7 +248,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo }, [onClose]); return ( - + Create a new workspace {invalidText && ( @@ -256,6 +257,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo {invalidText}{" "} )} + API Specs = ({ openDialo