diff --git a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx new file mode 100644 index 00000000..5dbf5aa5 --- /dev/null +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -0,0 +1,419 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import WSECArgumentSimilarPicker, { + BuildArgSimilarTree, + type ArgSimilarTree, +} from "../../views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker"; +import { render } from "../test-utils"; + +describe("WSECArgumentSimilarPicker", () => { + const mockOnTreeUpdated = vi.fn(); + const mockOnToggle = vi.fn(); + + const mockArgSimilarTree: ArgSimilarTree = { + root: { + id: "az", + name: "az", + total: 4, + selectedCount: 0, + groups: [ + { + id: "az/storage", + name: "storage", + total: 2, + selectedCount: 0, + commands: [ + { + id: "az/storage/account", + name: "account create", + total: 2, + selectedCount: 0, + args: [ + { + id: "az/storage/account/Arguments/account_name", + var: "account_name", + display: "--account-name", + indexes: ["account-name"], + isSelected: false, + }, + { + id: "az/storage/account/Arguments/resource_group", + var: "resource_group", + display: "--resource-group -g", + indexes: ["resource-group", "g"], + isSelected: false, + }, + ], + }, + ], + }, + { + id: "az/vm", + name: "vm", + total: 2, + selectedCount: 0, + commands: [ + { + id: "az/vm/create", + name: "create", + total: 2, + selectedCount: 0, + args: [ + { + id: "az/vm/create/Arguments/vm_name", + var: "vm_name", + display: "--vm-name", + indexes: ["vm-name"], + isSelected: false, + }, + { + id: "az/vm/create/Arguments/vm_size", + var: "vm_size", + display: "--vm-size", + indexes: ["vm-size"], + isSelected: false, + }, + ], + }, + ], + }, + ], + }, + selectedArgIds: [], + }; + + const defaultProps = { + tree: mockArgSimilarTree, + expandedIds: ["az", "az/storage", "az/vm", "az/storage/account", "az/vm/create"], + updatedIds: [], + onTreeUpdated: mockOnTreeUpdated, + onToggle: mockOnToggle, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Component Rendering", () => { + it("renders the tree structure correctly", () => { + render(); + + // Check that the root group is rendered + expect(screen.getByText("az")).toBeInTheDocument(); + + // Check that command groups are rendered + expect(screen.getByText("storage")).toBeInTheDocument(); + expect(screen.getByText("vm")).toBeInTheDocument(); + + // Check that commands are rendered + expect(screen.getByText("account create")).toBeInTheDocument(); + expect(screen.getByText("create")).toBeInTheDocument(); + + // Check that arguments are rendered + expect(screen.getByText("--account-name")).toBeInTheDocument(); + expect(screen.getByText("--resource-group -g")).toBeInTheDocument(); + expect(screen.getByText("--vm-name")).toBeInTheDocument(); + expect(screen.getByText("--vm-size")).toBeInTheDocument(); + }); + + it("renders checkboxes for all tree items", () => { + render(); + + // Should have checkboxes for: root (1) + groups (2) + commands (2) + args (4) = 9 total + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(9); + }); + + it("renders expand/collapse icons", () => { + render(); + + // TreeView should render with expand/collapse functionality + const treeView = screen.getByRole("tree"); + expect(treeView).toBeInTheDocument(); + }); + }); + + describe("Selection Functionality", () => { + it("calls onTreeUpdated when an argument is selected", () => { + render(); + + const accountNameCheckbox = screen.getByLabelText("--account-name"); + fireEvent.click(accountNameCheckbox); + + expect(mockOnTreeUpdated).toHaveBeenCalledTimes(1); + const calledTree = mockOnTreeUpdated.mock.calls[0][0]; + expect(calledTree.selectedArgIds).toContain("az/storage/account/Arguments/account_name"); + }); + + it("calls onTreeUpdated when an argument is deselected", () => { + const treeWithSelection = { + ...mockArgSimilarTree, + selectedArgIds: ["az/storage/account/Arguments/account_name"], + root: { + ...mockArgSimilarTree.root, + selectedCount: 1, + groups: mockArgSimilarTree.root.groups?.map((group) => + group.id === "az/storage" + ? { + ...group, + selectedCount: 1, + commands: group.commands?.map((command) => + command.id === "az/storage/account" + ? { + ...command, + selectedCount: 1, + args: command.args.map((arg) => + arg.id === "az/storage/account/Arguments/account_name" ? { ...arg, isSelected: true } : arg, + ), + } + : command, + ), + } + : group, + ), + }, + }; + + const propsWithSelection = { + ...defaultProps, + tree: treeWithSelection, + }; + + render(); + + const accountNameCheckbox = screen.getByLabelText("--account-name"); + fireEvent.click(accountNameCheckbox); + + expect(mockOnTreeUpdated).toHaveBeenCalledTimes(1); + const calledTree = mockOnTreeUpdated.mock.calls[0][0]; + expect(calledTree.selectedArgIds).not.toContain("az/storage/account/Arguments/account_name"); + }); + + it("selects all command arguments when command is selected", () => { + render(); + + const commandCheckbox = screen.getByLabelText("account create"); + fireEvent.click(commandCheckbox); + + expect(mockOnTreeUpdated).toHaveBeenCalledTimes(1); + const calledTree = mockOnTreeUpdated.mock.calls[0][0]; + expect(calledTree.selectedArgIds).toContain("az/storage/account/Arguments/account_name"); + expect(calledTree.selectedArgIds).toContain("az/storage/account/Arguments/resource_group"); + }); + + it("shows indeterminate state when some but not all children are selected", () => { + const treeWithPartialSelection = { + ...mockArgSimilarTree, + selectedArgIds: ["az/storage/account/Arguments/account_name"], + root: { + ...mockArgSimilarTree.root, + selectedCount: 1, + groups: mockArgSimilarTree.root.groups?.map((group) => + group.id === "az/storage" + ? { + ...group, + selectedCount: 1, + commands: group.commands?.map((command) => + command.id === "az/storage/account" + ? { + ...command, + selectedCount: 1, + args: command.args.map((arg) => + arg.id === "az/storage/account/Arguments/account_name" ? { ...arg, isSelected: true } : arg, + ), + } + : command, + ), + } + : group, + ), + }, + }; + + const propsWithPartialSelection = { + ...defaultProps, + tree: treeWithPartialSelection, + }; + + render(); + + const commandCheckbox = screen.getByLabelText("account create"); + + expect(commandCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); + + it("disables checkboxes for updated arguments", () => { + const propsWithUpdatedIds = { + ...defaultProps, + updatedIds: ["az/storage/account/Arguments/account_name"], + }; + + render(); + + const accountNameCheckbox = screen.getByLabelText("--account-name"); + expect(accountNameCheckbox).toBeDisabled(); + }); + }); + + describe("Tree Expansion", () => { + it("renders TreeView with expand/collapse functionality", () => { + render(); + + const treeView = screen.getByRole("tree"); + expect(treeView).toBeInTheDocument(); + + // Check that expanded IDs are passed correctly + expect(defaultProps.expandedIds).toContain("az"); + expect(defaultProps.expandedIds).toContain("az/storage"); + }); + }); + + describe("BuildArgSimilarTree Utility", () => { + it("transforms API response into correct tree structure", () => { + const mockApiResponse = { + aaz: { + id: "aaz", + commandGroups: { + storage: { + id: "storage", + commands: { + "account create": { + id: "account-create", + args: { + name: ["name"], + resource_group: ["resource-group", "g"], + }, + }, + }, + }, + }, + }, + }; + + const result = BuildArgSimilarTree(mockApiResponse); + + expect(result.tree.root.name).toBe("az storage"); + expect(result.tree.selectedArgIds).toEqual([]); + expect(result.expandedIds).toContain("storage"); + expect(result.tree.root.commands).toBeDefined(); + expect(result.tree.root.commands![0].name).toBe("account create"); + }); + + it("handles single character options correctly", () => { + const mockApiResponse = { + aaz: { + id: "aaz", + commands: { + test: { + id: "test", + args: { + short_option: ["g"], + long_option: ["resource-group"], + both_options: ["resource-group", "g"], + }, + }, + }, + }, + }; + + const result = BuildArgSimilarTree(mockApiResponse); + const command = result.tree.root.commands![0]; + + expect(command.args[0].display).toBe("--g"); + expect(command.args[1].display).toBe("--resource-group"); + expect(command.args[2].display).toBe("[both_options] --resource-group --g"); + }); + + it("handles nested special characters in options", () => { + const mockApiResponse = { + aaz: { + id: "aaz", + commands: { + test: { + id: "test", + args: { + nested_option: [".property", "[index]", "{key}"], + }, + }, + }, + }, + }; + + const result = BuildArgSimilarTree(mockApiResponse); + const command = result.tree.root.commands![0]; + + // The logic checks idx[1], so for ".property" -> "p", "[index]" -> "i", "{key}" -> "k" + // Since these are not ".", "[", or "{", they get "--" prefix + expect(command.args[0].display).toBe("[nested_option] --.property --[index] --{key}"); + }); + }); + + describe("Edge Cases", () => { + it("handles empty tree gracefully", () => { + const emptyTree: ArgSimilarTree = { + root: { + id: "empty", + name: "empty", + total: 0, + selectedCount: 0, + }, + selectedArgIds: [], + }; + + const emptyProps = { + ...defaultProps, + tree: emptyTree, + expandedIds: ["empty"], + }; + + render(); + + expect(screen.getByText("empty")).toBeInTheDocument(); + expect(screen.getAllByRole("checkbox")).toHaveLength(1); + }); + + it("handles tree with only groups (no commands)", () => { + const groupOnlyTree: ArgSimilarTree = { + root: { + id: "root", + name: "root", + total: 0, + selectedCount: 0, + groups: [ + { + id: "group1", + name: "group1", + total: 0, + selectedCount: 0, + }, + ], + }, + selectedArgIds: [], + }; + + const groupOnlyProps = { + ...defaultProps, + tree: groupOnlyTree, + expandedIds: ["root", "group1"], + }; + + render(); + + expect(screen.getByText("root")).toBeInTheDocument(); + expect(screen.getByText("group1")).toBeInTheDocument(); + }); + + it("prevents event propagation on checkbox clicks", () => { + render(); + + const accountNameCheckbox = screen.getByLabelText("--account-name"); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + + fireEvent(accountNameCheckbox, event); + + // The component should call stopPropagation and preventDefault + // This ensures clicking checkbox doesn't trigger tree node expansion + expect(mockOnTreeUpdated).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index e6fe97d6..541488b7 100644 --- a/src/web/src/__tests__/components/WSEditor.test.tsx +++ b/src/web/src/__tests__/components/WSEditor.test.tsx @@ -1,10 +1,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { WSEditor } from "../../views/workspace/WSEditor"; +import { WSEditor } from "../../views/workspace/components/WSEditor"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; -vi.mock("../../views/workspace/WSEditorToolBar", () => ({ +vi.mock("../../views/workspace/components/WSEditor/WSEditorToolBar", () => ({ default: ({ workspaceName, onHomePage, onGenerate, onDelete, onModify }: any) => (
{workspaceName} @@ -24,7 +24,7 @@ vi.mock("../../views/workspace/WSEditorToolBar", () => ({ ), })); -vi.mock("../../views/workspace/WSEditorCommandTree", () => ({ +vi.mock("../../views/workspace/components/WSEditor/WSEditorCommandTree", () => ({ default: ({ onSelected, onToggle, onAdd, onReload, selected, expanded, onEditClientConfig }: any) => (
- - - Document - - - Send a Feedback - - - - -
- ); - } -} - -export { AppAppBar }; diff --git a/src/web/src/components/AppNavBar.tsx b/src/web/src/components/AppNavBar.tsx new file mode 100644 index 00000000..699692ee --- /dev/null +++ b/src/web/src/components/AppNavBar.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import { AppBar, Toolbar } from "@mui/material"; +import theme from "../theme"; +import Button from "@mui/material/Button"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; + +type AppNavBarProps = { + pageName: string | null; +}; + +const AppNavBar: React.FC = ({ pageName }) => { + const [anchorEl, setAnchorEl] = useState(null); + + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; + + return ( +
+ + + + + Home + + + + Workspace + + + + CLI + + + + + + + Documentation + + + Send Feedback + + + + +
+ ); +}; + +export { AppNavBar }; diff --git a/src/web/src/components/EditorPageLayout.tsx b/src/web/src/components/EditorPageLayout.tsx index a52b22c3..c1db759d 100644 --- a/src/web/src/components/EditorPageLayout.tsx +++ b/src/web/src/components/EditorPageLayout.tsx @@ -1,5 +1,5 @@ +import React from "react"; import { styled, Box } from "@mui/material"; -import * as React from "react"; const PageContainer = styled(Box)(({ theme }) => ({ color: theme.palette.common.white, @@ -25,13 +25,13 @@ const Background = styled(Box)({ zIndex: -2, }); -export default function EditorPageLayout(props: React.HTMLAttributes) { - const { children } = props; - +const EditorPageLayout: React.FC> = ({ children }) => { return ( - + <> {children} - + ); -} +}; + +export default EditorPageLayout; diff --git a/src/web/src/components/PageLayout.tsx b/src/web/src/components/PageLayout.tsx index 2e551ea7..8d233720 100644 --- a/src/web/src/components/PageLayout.tsx +++ b/src/web/src/components/PageLayout.tsx @@ -1,5 +1,5 @@ +import React from "react"; import { Container, styled, Box } from "@mui/material"; -import * as React from "react"; const PageContainer = styled(Container)(({ theme }) => ({ color: theme.palette.common.white, @@ -25,13 +25,13 @@ const Background = styled(Box)({ zIndex: -2, }); -export default function PageLayout(props: React.HTMLAttributes) { - const { children } = props; - +const PageLayout: React.FC> = ({ children }) => { return ( - + <> {children} - + ); -} +}; + +export default PageLayout; diff --git a/src/web/src/constants/index.ts b/src/web/src/constants/index.ts new file mode 100644 index 00000000..3aa23a61 --- /dev/null +++ b/src/web/src/constants/index.ts @@ -0,0 +1,4 @@ +/** + * The command prefix for Azure CLI commands + */ +export const COMMAND_PREFIX = "az "; diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index 8804c0cd..9cd4fb99 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -1,44 +1,43 @@ import React from "react"; -import ReactDOM from "react-dom"; +import { createRoot } from "react-dom/client"; import { HashRouter, Routes, Route } from "react-router-dom"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; import "./index.css"; import App from "./App"; import HomePage from "./views/home/HomePage"; -import WorkspacePage from "./views/workspace/WorkspacePage"; -import WorkspaceInstruction from "./views/workspace/WorkspaceInstruction"; -import { WSEditor } from "./views/workspace/WSEditor"; -import CommandsPage from "./views/commands/CommandsPage"; -import CLIPage from "./views/cli/CLIPage"; -import CLIInstruction from "./views/cli/CLIInstruction"; -import { CLIModuleGenerator } from "./views/cli/CLIModuleGenerator"; -// import reportWebVitals from './reportWebVitals'; +import WorkspacePage from "./views/workspace/components/WorkspacePage"; +import WorkspaceInstruction from "./views/workspace/components/WorkspaceInstruction"; +import { WSEditor } from "./views/workspace/components/WSEditor"; +import CLIPage from "./views/cli/components/CLIPage"; +import CLIInstruction from "./views/cli/components/CLIInstruction"; +import { CLIModuleGenerator } from "./views/cli/components/CLIModuleGenerator"; +import theme from "./theme"; -ReactDOM.render( +const container = document.getElementById("root"); +const root = createRoot(container!); +root.render( - - - }> - } /> - } /> - }> - } /> - } /> - } /> + + + + + }> + } /> + } /> + }> + } /> + } /> + } /> + + }> + } /> + }> + } /> + - }> - }> - } /> - }> - } /> - - - - + + + , - document.getElementById("root"), ); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -// reportWebVitals(); diff --git a/src/web/src/logo.svg b/src/web/src/logo.svg deleted file mode 100644 index 9dfc1c05..00000000 --- a/src/web/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/web/src/react-app-env.d.ts b/src/web/src/react-app-env.d.ts deleted file mode 100644 index 6431bc5f..00000000 --- a/src/web/src/react-app-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/src/web/src/reportWebVitals.js b/src/web/src/reportWebVitals.js deleted file mode 100644 index 9ecd33f9..00000000 --- a/src/web/src/reportWebVitals.js +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = (onPerfEntry) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/src/web/src/setupTests.js b/src/web/src/setupTests.js deleted file mode 100644 index 1dd407a6..00000000 --- a/src/web/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import "@testing-library/jest-dom"; diff --git a/src/web/src/views/cli/CLIInstruction.tsx b/src/web/src/views/cli/CLIInstruction.tsx deleted file mode 100644 index 37954e73..00000000 --- a/src/web/src/views/cli/CLIInstruction.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from "react"; -import { Typography, Box } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import CLIModuleSelector from "./CLIModuleSelector"; -import { AppAppBar } from "../../components/AppAppBar"; -import PageLayout from "../../components/PageLayout"; - -const MiddlePadding = styled(Box)(() => ({ - height: "6vh", -})); - -const SpacePadding = styled(Box)(() => ({ - width: "3vh", -})); - -class CLIInstruction extends React.Component { - render() { - return ( - - - - - - - - Please select a CLI Module - - - - - - - Or - - - - - - - - - - ); - } -} - -export default CLIInstruction; diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx deleted file mode 100644 index 39f2f1b0..00000000 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ /dev/null @@ -1,975 +0,0 @@ -import * as React from "react"; -import TreeView from "@mui/lab/TreeView"; -import TreeItem from "@mui/lab/TreeItem"; - -import ArrowRightIcon from "@mui/icons-material/ArrowRight"; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; -import FolderIcon from "@mui/icons-material/Folder"; -import EditIcon from "@mui/icons-material/Edit"; -import { - Box, - Checkbox, - FormControl, - Typography, - Select, - MenuItem, - styled, - TypographyProps, - InputLabel, - IconButton, -} from "@mui/material"; -import { - CLIModViewCommand, - CLIModViewCommandGroup, - CLIModViewCommandGroups, - CLIModViewCommands, - CLIModViewProfile, -} from "./CLIModuleCommon"; -import { - CLISpecsCommand, - CLISpecsCommandGroup, - CLISpecsSimpleCommand, - CLISpecsSimpleCommandGroup, - CLISpecsSimpleCommandTree, -} from "./CLIModuleGenerator"; - -const CommandGroupTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 17, - fontWeight: 600, -})); - -const CommandTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 20, - fontWeight: 400, -})); - -const SelectionTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.grey[700], - fontFamily: "'Work Sans', sans-serif", - fontSize: 15, - fontWeight: 400, -})); - -const RegisteredTypography = styled(SelectionTypography)(() => ({})); - -const UnregisteredTypography = styled(SelectionTypography)(() => ({ - color: "#d9c136", -})); - -interface CommandItemProps { - command: ProfileCTCommand; - onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void; - onLoadCommand(names: string[]): Promise; -} - -const CommandItem: React.FC = React.memo(({ command, onUpdateCommand, onLoadCommand }) => { - const leafName = command.names[command.names.length - 1]; - - const selectCommand = React.useCallback( - (selected: boolean) => { - onUpdateCommand(leafName, (oldCommand) => { - if (oldCommand.versions === undefined && selected === true) { - onLoadCommand(oldCommand.names); - } - return { - ...oldCommand, - loading: selected && oldCommand.versions === undefined, - selected: selected, - selectedVersion: selected - ? oldCommand.selectedVersion - ? oldCommand.selectedVersion - : oldCommand.versions - ? oldCommand.versions[0].name - : undefined - : oldCommand.selectedVersion, - modified: true, - }; - }); - }, - [onUpdateCommand, onLoadCommand], - ); - - const selectVersion = React.useCallback( - (version: string) => { - onUpdateCommand(leafName, (oldCommand) => { - return { - ...oldCommand, - selectedVersion: version, - modified: true, - }; - }); - }, - [onUpdateCommand], - ); - - const selectRegistered = React.useCallback( - (registered: boolean) => { - onUpdateCommand(leafName, (oldCommand) => { - return { - ...oldCommand, - registered: registered, - modified: true, - }; - }); - }, - [onUpdateCommand], - ); - - return ( - - { - selectCommand(!command.selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - - {leafName} - - {!command.modified && command.selectedVersion !== undefined && ( - { - selectCommand(true); - }} - > - - - )} - {command.modified && } - - - {command.versions !== undefined && command.selectedVersion !== undefined && ( - - - Version - - - - Command table - - - - )} - {command.loading === true && command.selected && ( - - - Loading... - - - )} - - } - onClick={(event) => { - event.stopPropagation(); - event.preventDefault(); - }} - /> - ); -}); - -interface CommandGroupItemProps { - commandGroup: ProfileCTCommandGroup; - onUpdateCommandGroup: ( - name: string, - updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup, - ) => void; - onLoadCommands: (names: string[][]) => Promise; -} - -const CommandGroupItem: React.FC = React.memo( - ({ commandGroup, onUpdateCommandGroup, onLoadCommands }) => { - const nodeName = commandGroup.names[commandGroup.names.length - 1]; - const selected = commandGroup.selected ?? false; - - const onUpdateCommand = React.useCallback( - (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { - onUpdateCommandGroup(nodeName, (oldCommandGroup) => { - const commands = { - ...oldCommandGroup.commands, - [name]: updater(oldCommandGroup.commands![name]), - }; - const selected = calculateSelected(commands, oldCommandGroup.commandGroups ?? {}); - return { - ...oldCommandGroup, - commands: commands, - selected: selected, - }; - }); - }, - [onUpdateCommandGroup], - ); - - const onUpdateSubCommandGroup = React.useCallback( - (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { - onUpdateCommandGroup(nodeName, (oldCommandGroup) => { - const commandGroups = { - ...oldCommandGroup.commandGroups, - [name]: updater(oldCommandGroup.commandGroups![name]), - }; - const commands = oldCommandGroup.commands; - const selected = calculateSelected(commands ?? {}, commandGroups); - return { - ...oldCommandGroup, - commandGroups: commandGroups, - selected: selected, - }; - }); - }, - [onUpdateCommandGroup], - ); - - const onLoadCommand = React.useCallback( - async (names: string[]) => { - await onLoadCommands([names]); - }, - [onLoadCommands], - ); - - const updateCommandSelected = (command: ProfileCTCommand, selected: boolean): ProfileCTCommand => { - if (selected === command.selected) { - return command; - } - return { - ...command, - selected: selected, - selectedVersion: selected - ? command.selectedVersion - ? command.selectedVersion - : command.versions - ? command.versions[0].name - : undefined - : command.selectedVersion, - modified: true, - }; - }; - - const updateGroupSelected = (group: ProfileCTCommandGroup, selected: boolean): ProfileCTCommandGroup => { - if (selected === group.selected) { - return group; - } - const commands = group.commands - ? Object.fromEntries( - Object.entries(group.commands).map(([key, value]) => [key, updateCommandSelected(value, selected)]), - ) - : undefined; - const commandGroups = group.commandGroups - ? Object.fromEntries( - Object.entries(group.commandGroups).map(([key, value]) => [key, updateGroupSelected(value, selected)]), - ) - : undefined; - return { - ...group, - commands: commands, - commandGroups: commandGroups, - selected: selected, - }; - }; - - const selectCommandGroup = React.useCallback( - (selected: boolean) => { - onUpdateCommandGroup(nodeName, (oldCommandGroup) => { - const selectedGroup = updateGroupSelected(oldCommandGroup, selected); - const [loadingNamesList, newGroup] = prepareLoadCommandsOfCommandGroup(selectedGroup); - if (loadingNamesList.length > 0) { - onLoadCommands(loadingNamesList); - } - return newGroup; - }); - }, - [onUpdateCommandGroup, onLoadCommands], - ); - - return ( - - { - selectCommandGroup(!selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - - {nodeName} - - } - > - {commandGroup.commands !== undefined && - Object.values(commandGroup.commands).map((command) => ( - - ))} - {commandGroup.commandGroups !== undefined && - Object.values(commandGroup.commandGroups).map((group) => ( - - ))} - - ); - }, -); - -interface CLIModGeneratorProfileCommandTreeProps { - profile?: string; - profileCommandTree: ProfileCommandTree; - onChange: (updater: ((oldProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void; - onLoadCommands: (namesList: string[][]) => Promise; -} - -const CLIModGeneratorProfileCommandTree: React.FC = ({ - profileCommandTree, - onChange, - onLoadCommands, -}) => { - const [defaultExpanded, _] = React.useState(GetDefaultExpanded(profileCommandTree)); - - const onUpdateCommandGroup = React.useCallback( - (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { - onChange((profileCommandTree) => { - return { - ...profileCommandTree, - commandGroups: { - ...profileCommandTree.commandGroups, - [name]: updater(profileCommandTree.commandGroups[name]), - }, - }; - }); - }, - [onChange], - ); - - const handleBatchedLoadedCommands = React.useCallback( - (commands: CLISpecsCommand[]) => { - onChange((profileCommandTree) => { - const newTree = commands.reduce((tree, command) => { - return ( - genericUpdateCommand(tree, command.names, (unloadedCommand) => { - return decodeProfileCTCommand( - command, - unloadedCommand.selected, - unloadedCommand.modified, - unloadedCommand.registered, - unloadedCommand.selectedVersion, - ); - }) ?? tree - ); - }, profileCommandTree); - return newTree; - }); - }, - [onChange], - ); - - const onLoadAndDecodeCommands = React.useCallback( - async (names: string[][]) => { - const commands = await onLoadCommands(names); - handleBatchedLoadedCommands(commands); - }, - [onLoadCommands], - ); - - React.useEffect(() => { - const [loadingNamesList, newTree] = PrepareLoadCommands(profileCommandTree); - if (loadingNamesList.length > 0) { - onChange(newTree); - onLoadCommands(loadingNamesList).then((commands) => { - handleBatchedLoadedCommands(commands); - }); - } - }, [profileCommandTree]); - - return ( - - } - defaultExpandIcon={} - > - {Object.values(profileCommandTree.commandGroups).map((commandGroup) => ( - - ))} - - - ); -}; - -interface ProfileCommandTree { - name: string; - commandGroups: ProfileCTCommandGroups; -} - -interface ProfileCTCommandGroups { - [name: string]: ProfileCTCommandGroup; -} - -interface ProfileCTCommands { - [name: string]: ProfileCTCommand; -} - -interface ProfileCTCommandGroup { - id: string; - names: string[]; - // We use simple command tree now. - // `help` is not used. - // help: string; - - commandGroups?: ProfileCTCommandGroups; - commands?: ProfileCTCommands; - waitCommand?: CLIModViewCommand; - - loading: boolean; - selected?: boolean; -} - -interface ProfileCTCommand { - id: string; - names: string[]; - // help: string; - - versions?: ProfileCTCommandVersion[]; - - selectedVersion?: string; - registered?: boolean; - modified: boolean; - - loading: boolean; - selected: boolean; -} - -interface ProfileCTCommandVersion { - name: string; - stage: string; -} - -function decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { - return { - name: response.name, - stage: response.stage, - }; -} - -function decodeProfileCTCommand( - response: CLISpecsCommand, - selected: boolean = false, - modified: boolean = false, - registered: boolean | undefined = undefined, - selectedVersion: string | undefined = undefined, -): ProfileCTCommand { - const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); - const command = { - id: response.names.join("/"), - names: [...response.names], - // help: response.help.short, - versions: versions, - modified: modified, - loading: false, - selected: selected, - registered: registered, - }; - if (selected) { - let version: string | undefined; - if (selectedVersion !== undefined) { - version = selectedVersion; - } else { - version = versions ? versions[0].name : undefined; - } - - return { - ...command, - selectedVersion: version, - }; - } else { - return command; - } -} - -function decodeProfileCTCommandGroup(response: CLISpecsCommandGroup, selected: boolean = false): ProfileCTCommandGroup { - const commands = - response.commands !== undefined - ? Object.fromEntries( - Object.entries(response.commands).map(([name, command]) => [ - name, - decodeProfileCTCommand(command, selected, selected, undefined), - ]), - ) - : undefined; - const commandGroups = - response.commandGroups !== undefined - ? Object.fromEntries( - Object.entries(response.commandGroups).map(([name, group]) => [ - name, - decodeProfileCTCommandGroup(group, selected), - ]), - ) - : undefined; - return { - id: response.names.join("/"), - names: [...response.names], - // help: response.help?.short ?? '', - commandGroups: commandGroups, - commands: commands, - loading: false, - selected: selected, - }; -} - -function BuildProfileCommandTree(profileName: string, response: CLISpecsCommandGroup): ProfileCommandTree { - const commandGroups = - response.commandGroups !== undefined - ? Object.fromEntries( - Object.entries(response.commandGroups).map(([name, group]) => [name, decodeProfileCTCommandGroup(group)]), - ) - : {}; - return { - name: profileName, - commandGroups: commandGroups, - }; -} - -function getDefaultExpandedOfCommandGroup(commandGroup: ProfileCTCommandGroup): string[] { - const expandedIds = commandGroup.commandGroups - ? Object.values(commandGroup.commandGroups).flatMap((value) => - value.selected !== false ? [value.id, ...getDefaultExpandedOfCommandGroup(value)] : [], - ) - : []; - return expandedIds; -} - -function GetDefaultExpanded(tree: ProfileCommandTree): string[] { - return Object.values(tree.commandGroups).flatMap((value) => { - const ids = getDefaultExpandedOfCommandGroup(value); - if (value.selected !== false) { - ids.push(value.id); - } - return ids; - }); -} - -function prepareLoadCommandsOfCommandGroup(commandGroup: ProfileCTCommandGroup): [string[][], ProfileCTCommandGroup] { - const namesList: string[][] = []; - const commands = commandGroup.commands - ? Object.fromEntries( - Object.entries(commandGroup.commands).map(([key, value]) => { - if (value.selected === true && value.versions === undefined && value.loading === false) { - namesList.push(value.names); - return [ - key, - { - ...value, - loading: true, - }, - ]; - } - return [key, value]; - }), - ) - : undefined; - const commandGroups = commandGroup.commandGroups - ? Object.fromEntries( - Object.entries(commandGroup.commandGroups).map(([key, value]) => { - const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); - namesList.push(...namesListSub); - return [key, updatedGroup]; - }), - ) - : undefined; - if (namesList.length > 0) { - return [ - namesList, - { - ...commandGroup, - commands: commands, - commandGroups: commandGroups, - }, - ]; - } else { - return [[], commandGroup]; - } -} - -function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileCommandTree] { - const namesList: string[][] = []; - const commandGroups = Object.fromEntries( - Object.entries(tree.commandGroups).map(([key, value]) => { - const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); - namesList.push(...namesListSub); - return [key, updatedGroup]; - }), - ); - if (namesList.length > 0) { - return [ - namesList, - { - ...tree, - commandGroups: commandGroups, - }, - ]; - } else { - return [[], tree]; - } -} - -function genericUpdateCommand( - tree: ProfileCommandTree, - names: string[], - updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined, -): ProfileCommandTree | undefined { - const nodes: ProfileCTCommandGroup[] = []; - for (const name of names.slice(0, -1)) { - const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; - if (node.commandGroups === undefined) { - throw new Error("Invalid names: " + names.join(" ")); - } - nodes.push(node.commandGroups[name]); - } - let currentCommandGroup = nodes[nodes.length - 1]; - const updatedCommand = updater(currentCommandGroup.commands![names[names.length - 1]]); - if (updatedCommand === undefined) { - return undefined; - } - const commands = { - ...currentCommandGroup.commands, - [names[names.length - 1]]: updatedCommand, - }; - const groupSelected = calculateSelected(commands, currentCommandGroup.commandGroups!); - currentCommandGroup = { - ...currentCommandGroup, - commands: commands, - selected: groupSelected, - }; - for (const node of nodes.reverse().slice(1)) { - const commandGroups = { - ...node.commandGroups, - [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, - }; - const selected = calculateSelected(node.commands ?? {}, commandGroups); - currentCommandGroup = { - ...node, - commandGroups: commandGroups, - selected: selected, - }; - } - return { - ...tree, - commandGroups: { - ...tree.commandGroups, - [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, - }, - }; -} - -function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCTCommandGroups): boolean | undefined { - const commandsAllSelected = Object.values(commands).reduce((pre, value) => { - return pre && value.selected; - }, true); - const commandsAllUnselected = Object.values(commands).reduce((pre, value) => { - return pre && !value.selected; - }, true); - const commandGroupsAllSelected = Object.values(commandGroups).reduce((pre, value) => { - return pre && value.selected === true; - }, true); - const commandGroupsAllUnselected = Object.values(commandGroups).reduce((pre, value) => { - return pre && value.selected === false; - }, true); - if (commandsAllUnselected && commandGroupsAllUnselected) { - return false; - } else if (commandsAllSelected && commandGroupsAllSelected) { - return true; - } else { - return undefined; - } -} - -function initializeCommandByModView( - view: CLIModViewCommand | undefined, - simpleCommand: CLISpecsSimpleCommand, -): ProfileCTCommand { - return { - id: simpleCommand.names.join("/"), - names: simpleCommand.names, - modified: false, - loading: false, - selected: view !== undefined && view.version !== undefined, - selectedVersion: view !== undefined ? view.version : undefined, - registered: view !== undefined ? view.registered : true, - }; -} - -function initializeCommandGroupByModView( - view: CLIModViewCommandGroup | undefined, - simpleCommandGroup: CLISpecsSimpleCommandGroup, -): ProfileCTCommandGroup { - const commands = - simpleCommandGroup.commands !== undefined - ? Object.fromEntries( - Object.entries(simpleCommandGroup.commands).map(([key, value]) => [ - key, - initializeCommandByModView(view?.commands?.[key], value), - ]), - ) - : undefined; - const commandGroups = - simpleCommandGroup.commandGroups !== undefined - ? Object.fromEntries( - Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [ - key, - initializeCommandGroupByModView(view?.commandGroups?.[key], value), - ]), - ) - : undefined; - const leftCommands = Object.entries(view?.commands ?? {}) - .filter(([key, _]) => commands?.[key] === undefined) - .map(([_, value]) => value.names) - .map((names) => "`az " + names.join(" ") + "`"); - const leftCommandGroups = Object.entries(view?.commandGroups ?? {}) - .filter(([key, _]) => commandGroups?.[key] === undefined) - .map(([_, value]) => value.names) - .map((names) => "`az " + names.join(" ") + "`"); - const errors = []; - if (leftCommands.length > 0) { - errors.push(`Miss commands in aaz: ${leftCommands.join(", ")}`); - } - if (leftCommandGroups.length > 0) { - errors.push(`Miss command groups in aaz: ${leftCommandGroups.join(", ")}`); - } - if (errors.length > 0) { - throw new Error( - "\n" + - errors.join("\n") + - "\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.", - ); - } - const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); - return { - id: simpleCommandGroup.names.join("/"), - names: simpleCommandGroup.names, - commands: commands, - commandGroups: commandGroups, - waitCommand: view?.waitCommand, - loading: false, - selected: selected, - }; -} - -function InitializeCommandTreeByModView( - profileName: string, - view: CLIModViewProfile | null, - simpleTree: CLISpecsSimpleCommandTree, -): ProfileCommandTree { - const commandGroups = Object.fromEntries( - Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [ - key, - initializeCommandGroupByModView(view?.commandGroups?.[key], value), - ]), - ); - const leftCommandGroups = Object.entries(view?.commandGroups ?? {}) - .filter(([key, _]) => commandGroups?.[key] === undefined) - .map(([_, value]) => value.names) - .map((names) => "`az " + names.join(" ") + "`"); - if (leftCommandGroups.length > 0) { - throw new Error( - `\nMiss command groups in aaz: ${leftCommandGroups.join(", ")}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`, - ); - } - return { - name: profileName, - commandGroups: commandGroups, - }; -} - -function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | undefined { - if (command.selectedVersion === undefined) { - return undefined; - } - - return { - names: command.names, - registered: command.registered!, - version: command.selectedVersion!, - modified: command.modified, - }; -} - -function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined { - if (commandGroup.selected === false) { - return undefined; - } - - let commands: CLIModViewCommands | undefined = undefined; - if (commandGroup.commands !== undefined) { - commands = {}; - - Object.values(commandGroup.commands!).forEach((value) => { - const view = ExportModViewCommand(value); - if (view !== undefined) { - commands![value.names[value.names.length - 1]] = view; - } - }); - } - - let commandGroups: CLIModViewCommandGroups | undefined = undefined; - if (commandGroup.commandGroups !== undefined) { - commandGroups = {}; - - Object.values(commandGroup.commandGroups!).forEach((value) => { - const view = ExportModViewCommandGroup(value); - if (view !== undefined) { - commandGroups![value.names[value.names.length - 1]] = view; - } - }); - } - return { - names: commandGroup.names, - commandGroups: commandGroups, - commands: commands, - waitCommand: commandGroup.waitCommand, - }; -} - -function ExportModViewProfile(tree: ProfileCommandTree): CLIModViewProfile { - const commandGroups: CLIModViewCommandGroups = {}; - - Object.values(tree.commandGroups).forEach((value) => { - const view = ExportModViewCommandGroup(value); - if (view !== undefined) { - commandGroups[value.names[value.names.length - 1]] = view; - } - }); - - return { - name: tree.name, - commandGroups: commandGroups, - }; -} - -export default CLIModGeneratorProfileCommandTree; - -export type { ProfileCommandTree }; - -export { InitializeCommandTreeByModView, BuildProfileCommandTree, ExportModViewProfile }; diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx deleted file mode 100644 index a1cd079a..00000000 --- a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; - -interface CLIModGeneratorProfileTabsProps { - value: string; - profiles: string[]; - onChange: (newValue: string) => void; -} - -class CLIModGeneratorProfileTabs extends React.Component { - render() { - const { value, profiles, onChange } = this.props; - return ( - { - onChange(newValue); - }} - aria-label="Vertical tabs example" - sx={{ borderRight: 1, borderColor: "divider" }} - > - {profiles.map((profile, _idx) => { - return ( - - ); - })} - - ); - } -} - -export default CLIModGeneratorProfileTabs; diff --git a/src/web/src/views/cli/CLIModGeneratorToolBar.tsx b/src/web/src/views/cli/CLIModGeneratorToolBar.tsx deleted file mode 100644 index 809bdb27..00000000 --- a/src/web/src/views/cli/CLIModGeneratorToolBar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { AppBar, Button, IconButton, Toolbar, Typography, Tooltip, Box } from "@mui/material"; -import HomeIcon from "@mui/icons-material/Home"; - -interface CLIModGeneratorToolBarProps { - moduleName: string; - onHomePage: () => void; - onGenerate: () => void; -} - -class CLIModGeneratorToolBar extends React.Component { - render() { - const { moduleName, onHomePage, onGenerate } = this.props; - return ( - - theme.zIndex.drawer + 1 }}> - - - - - GENERATION - - - - - {moduleName} - - - - - - - - - - - - ); - } -} - -export default CLIModGeneratorToolBar; diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx deleted file mode 100644 index 24d10a6f..00000000 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ /dev/null @@ -1,400 +0,0 @@ -import * as React from "react"; -import { - Backdrop, - Box, - Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Drawer, - LinearProgress, - Toolbar, - Alert, -} from "@mui/material"; -import { useParams } from "react-router"; -import { cliApi, errorHandlerApi } from "../../services"; -import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; -import CLIModGeneratorProfileCommandTree, { - ExportModViewProfile, - InitializeCommandTreeByModView, - ProfileCommandTree, -} from "./CLIModGeneratorProfileCommandTree"; -import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; -import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; - -interface CLISpecsSimpleCommand { - names: string[]; -} - -interface CLISpecsSimpleCommands { - [name: string]: CLISpecsSimpleCommand; -} - -interface CLISpecsSimpleCommandGroups { - [name: string]: CLISpecsSimpleCommandGroup; -} - -interface CLISpecsSimpleCommandGroup { - names: string[]; - commands: CLISpecsSimpleCommands; - commandGroups: CLISpecsSimpleCommandGroups; -} - -interface CLISpecsSimpleCommandTree { - root: CLISpecsSimpleCommandGroup; -} - -interface CLISpecsHelp { - short: string; - lines?: string[]; -} - -interface CLISpecsResource { - plane: string; - id: string; - version: string; - subresource?: string; -} - -interface CLISpecsCommandExample { - name: string; - commands: string[]; -} - -interface CLISpecsCommandVersion { - name: string; - stage?: string; - resources: CLISpecsResource[]; - examples?: CLISpecsCommandExample[]; -} - -interface CLISpecsCommand { - names: string[]; - help: CLISpecsHelp; - versions: CLISpecsCommandVersion[]; -} - -interface CLISpecsCommandGroup { - names: string[]; - help?: CLISpecsHelp; - commands?: CLISpecsCommands; - commandGroups?: CLISpecsCommandGroups; -} - -interface CLISpecsCommandGroups { - [name: string]: CLISpecsCommandGroup; -} - -interface CLISpecsCommands { - [name: string]: CLISpecsCommand; -} - -async function retrieveCommand(names: string[]): Promise { - return await cliApi.getSpecsCommand(names); -} - -async function retrieveCommands(namesList: string[][]): Promise { - return await cliApi.retrieveCommands(namesList); -} - -const useSpecsCommandTree: () => (namesList: string[][]) => Promise = () => { - const commandCache = React.useRef(new Map>()); - - const fetchCommands = React.useCallback( - async (namesList: string[][]) => { - const promiseResults = []; - const uncachedNamesList = []; - for (const names of namesList) { - const cachedPromise = commandCache.current.get(names.join("/")); - if (!cachedPromise) { - uncachedNamesList.push(names); - } else { - promiseResults.push(cachedPromise); - } - } - if (uncachedNamesList.length === 0) { - return await Promise.all(promiseResults); - } else if (uncachedNamesList.length === 1) { - const commandPromise = retrieveCommand(uncachedNamesList[0]); - commandCache.current.set(uncachedNamesList[0].join("/"), commandPromise); - return await Promise.all(promiseResults.concat(commandPromise)); - } else { - const uncachedCommandsPromise = retrieveCommands(uncachedNamesList); - uncachedNamesList.forEach((names, idx) => { - commandCache.current.set( - names.join("/"), - uncachedCommandsPromise.then((commands) => commands[idx]), - ); - }); - return (await Promise.all(promiseResults)).concat(await uncachedCommandsPromise); - } - }, - [commandCache], - ); - return fetchCommands; -}; - -interface ProfileCommandTrees { - [name: string]: ProfileCommandTree; -} - -interface CLIModuleGeneratorProps { - params: { - repoName: string; - moduleName: string; - }; -} - -const CLIModuleGenerator: React.FC = ({ params }) => { - const [loading, setLoading] = React.useState(false); - const [invalidText, setInvalidText] = React.useState(undefined); - const [profiles, setProfiles] = React.useState([]); - const [commandTrees, setCommandTrees] = React.useState({}); - const [selectedProfile, setSelectedProfile] = React.useState(undefined); - const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); - - const fetchCommands = useSpecsCommandTree(); - - React.useEffect(() => { - loadModule(); - }, []); - - const loadModule = async () => { - try { - setLoading(true); - setInvalidText(undefined); - const profiles = await cliApi.getCliProfiles(); - const modView: CLIModView = await cliApi.getCliModule(params.repoName, params.moduleName); - const simpleTree: CLISpecsSimpleCommandTree = await cliApi.getSimpleCommandTree(); - - Object.keys(modView!.profiles).forEach((profile) => { - const idx = profiles.findIndex((v) => v === profile); - if (idx === -1) { - throw new Error(`Invalid profile ${profile}`); - } - }); - - const commandTrees = Object.fromEntries( - profiles.map((profile) => { - return [profile, InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; - }), - ); - - const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; - setProfiles(profiles); - setCommandTrees(commandTrees); - setSelectedProfile(selectedProfile); - setLoading(false); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - } - }; - - const selectedCommandTree = selectedProfile ? commandTrees[selectedProfile] : undefined; - - const handleBackToHomepage = () => { - window.open("/?#/cli", "_blank"); - }; - - const handleGenerate = () => { - setShowGenerateDialog(true); - }; - - const handleGenerationClose = () => { - setShowGenerateDialog(false); - }; - - const onProfileChange = React.useCallback((selectedProfile: string) => { - setSelectedProfile(selectedProfile); - }, []); - - const onSelectedProfileTreeUpdate = React.useCallback( - (updater: ((oldTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { - setCommandTrees((commandTrees) => { - const selectedCommandTree = commandTrees[selectedProfile!]; - const newTree = typeof updater === "function" ? updater(selectedCommandTree!) : updater; - return { ...commandTrees, [selectedProfile!]: newTree }; - }); - }, - [selectedProfile], - ); - - return ( - - - - - - {selectedProfile !== undefined && ( - - )} - - - - {selectedCommandTree !== undefined && ( - - )} - - - {showGenerateDialog && ( - - )} - theme.zIndex.drawer + 1 }} open={loading}> - {invalidText !== undefined ? ( - { - setInvalidText(undefined); - setLoading(false); - }} - > - {invalidText} - - ) : ( - - )} - - - ); -}; - -function GenerateDialog(props: { - repoName: string; - moduleName: string; - profileCommandTrees: ProfileCommandTrees; - open: boolean; - onClose: (generated: boolean) => void; -}) { - const [updating, setUpdating] = React.useState(false); - const [invalidText, setInvalidText] = React.useState(undefined); - - const handleClose = () => { - props.onClose(false); - }; - - const handleGenerateAll = async () => { - const profiles: CLIModViewProfiles = {}; - Object.values(props.profileCommandTrees).forEach((tree) => { - profiles[tree.name] = ExportModViewProfile(tree); - }); - const data = { - name: props.moduleName, - profiles: profiles, - }; - - setUpdating(true); - try { - await cliApi.updateCliModule(props.repoName, props.moduleName, data); - setUpdating(false); - props.onClose(true); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - const handleGenerateModified = async () => { - const profiles: CLIModViewProfiles = {}; - Object.values(props.profileCommandTrees).forEach((tree) => { - profiles[tree.name] = ExportModViewProfile(tree); - }); - const data = { - name: props.moduleName, - profiles: profiles, - }; - - setUpdating(true); - try { - await cliApi.patchCliModule(props.repoName, props.moduleName, data); - setUpdating(false); - props.onClose(true); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - return ( - - Generate CLI commands to {props.moduleName} - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - {updating && ( - - - - )} - {!updating && ( - - - - - - )} - - - ); -} - -const CLIModuleGeneratorWrapper = (props: any) => { - const params = useParams(); - return ; -}; - -export type { - CLISpecsCommandGroup, - CLISpecsCommand, - CLISpecsSimpleCommandTree, - CLISpecsSimpleCommandGroup, - CLISpecsSimpleCommand, -}; -export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; diff --git a/src/web/src/views/cli/CLIPage.tsx b/src/web/src/views/cli/CLIPage.tsx deleted file mode 100644 index 419b62f1..00000000 --- a/src/web/src/views/cli/CLIPage.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from "react"; -import withRoot from "../../withRoot"; -import { Outlet } from "react-router"; - -class CLIPage extends React.Component { - render() { - return ( - - - - ); - } -} - -export default withRoot(CLIPage); diff --git a/src/web/src/views/cli/components/CLIInstruction.tsx b/src/web/src/views/cli/components/CLIInstruction.tsx new file mode 100644 index 00000000..bac59756 --- /dev/null +++ b/src/web/src/views/cli/components/CLIInstruction.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Typography, Box } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import CLIModuleSelector from "./CLIModuleSelector"; +import { AppNavBar } from "../../../components/AppNavBar"; +import PageLayout from "../../../components/PageLayout"; + +const MiddlePadding = styled(Box)(() => ({ + height: "6vh", +})); + +const SpacePadding = styled(Box)(() => ({ + width: "3vh", +})); + +const CLIInstruction: React.FC = () => { + return ( + <> + + + + + + + Please select a CLI Module + + + + + + + Or + + + + + + + + + + ); +}; + +export default CLIInstruction; diff --git a/src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx new file mode 100644 index 00000000..dcbbc7d6 --- /dev/null +++ b/src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx @@ -0,0 +1,197 @@ +import React, { useState, useCallback, useEffect } from "react"; +import TreeView from "@mui/lab/TreeView"; + +import ArrowRightIcon from "@mui/icons-material/ArrowRight"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import { CLISpecsCommand } from "./CLIModuleGenerator"; +import CommandGroupItem from "./CommandGroupItem"; +import { + calculateSelected, + prepareLoadCommandsOfCommandGroup, + type ProfileCTCommandGroup, + type ProfileCTCommand, +} from "../utils/commandTreeUtils"; +import { ProfileCommandTree, decodeProfileCTCommand } from "../utils/commandTreeInitialization"; + +interface CLIModGeneratorProfileCommandTreeProps { + profile?: string; + profileCommandTree: ProfileCommandTree; + onChange: (updater: ((oldProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void; + onLoadCommands: (namesList: string[][]) => Promise; +} + +const CLIModGeneratorProfileCommandTree: React.FC = ({ + profileCommandTree, + onChange, + onLoadCommands, +}) => { + const [defaultExpanded, _] = useState(getDefaultExpanded(profileCommandTree)); + + const onUpdateCommandGroup = useCallback( + (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { + onChange((profileCommandTree) => { + return { + ...profileCommandTree, + commandGroups: { + ...profileCommandTree.commandGroups, + [name]: updater(profileCommandTree.commandGroups[name]), + }, + }; + }); + }, + [onChange], + ); + + const handleBatchedLoadedCommands = useCallback( + (commands: CLISpecsCommand[]) => { + onChange((profileCommandTree) => { + const newTree = commands.reduce((tree, command) => { + return ( + genericUpdateCommand(tree, command.names, (unloadedCommand) => { + return decodeProfileCTCommand( + command, + unloadedCommand.selected, + unloadedCommand.modified, + unloadedCommand.registered, + unloadedCommand.selectedVersion, + ); + }) ?? tree + ); + }, profileCommandTree); + return newTree; + }); + }, + [onChange], + ); + + const onLoadAndDecodeCommands = useCallback( + async (names: string[][]) => { + const commands = await onLoadCommands(names); + handleBatchedLoadedCommands(commands); + }, + [onLoadCommands], + ); + + useEffect(() => { + const [loadingNamesList, newTree] = prepareLoadCommands(profileCommandTree); + if (loadingNamesList.length > 0) { + onChange(newTree); + onLoadCommands(loadingNamesList).then((commands) => { + handleBatchedLoadedCommands(commands); + }); + } + }, [profileCommandTree]); + + return ( + + } + defaultExpandIcon={} + data-testid="cli-command-tree" + > + {Object.values(profileCommandTree.commandGroups).map((commandGroup) => ( + + ))} + + + ); +}; + +const getDefaultExpandedOfCommandGroup = (commandGroup: ProfileCTCommandGroup): string[] => { + const expandedIds = commandGroup.commandGroups + ? Object.values(commandGroup.commandGroups).flatMap((value) => + value.selected !== false ? [value.id, ...getDefaultExpandedOfCommandGroup(value)] : [], + ) + : []; + return expandedIds; +}; + +const getDefaultExpanded = (tree: ProfileCommandTree): string[] => { + return Object.values(tree.commandGroups).flatMap((value) => { + const ids = getDefaultExpandedOfCommandGroup(value); + if (value.selected !== false) { + ids.push(value.id); + } + return ids; + }); +}; + +const prepareLoadCommands = (tree: ProfileCommandTree): [string[][], ProfileCommandTree] => { + const namesList: string[][] = []; + const commandGroups = Object.fromEntries( + Object.entries(tree.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + }), + ); + if (namesList.length > 0) { + return [ + namesList, + { + ...tree, + commandGroups: commandGroups, + }, + ]; + } else { + return [[], tree]; + } +}; + +const genericUpdateCommand = ( + tree: ProfileCommandTree, + names: string[], + updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined, +): ProfileCommandTree | undefined => { + const nodes: ProfileCTCommandGroup[] = []; + for (const name of names.slice(0, -1)) { + const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; + if (node.commandGroups === undefined) { + throw new Error("Invalid names: " + names.join(" ")); + } + nodes.push(node.commandGroups[name]); + } + let currentCommandGroup = nodes[nodes.length - 1]; + const updatedCommand = updater(currentCommandGroup.commands![names[names.length - 1]]); + if (updatedCommand === undefined) { + return undefined; + } + const commands = { + ...currentCommandGroup.commands, + [names[names.length - 1]]: updatedCommand, + }; + const groupSelected = calculateSelected(commands, currentCommandGroup.commandGroups!); + currentCommandGroup = { + ...currentCommandGroup, + commands: commands, + selected: groupSelected, + }; + for (const node of nodes.reverse().slice(1)) { + const commandGroups = { + ...node.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + }; + const selected = calculateSelected(node.commands ?? {}, commandGroups); + currentCommandGroup = { + ...node, + commandGroups: commandGroups, + selected: selected, + }; + } + return { + ...tree, + commandGroups: { + ...tree.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + }, + }; +}; + +export default CLIModGeneratorProfileCommandTree; diff --git a/src/web/src/views/cli/components/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/components/CLIModGeneratorProfileTabs.tsx new file mode 100644 index 00000000..49365ed6 --- /dev/null +++ b/src/web/src/views/cli/components/CLIModGeneratorProfileTabs.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; + +interface CLIModGeneratorProfileTabsProps { + value: string; + profiles: string[]; + onChange: (newValue: string) => void; +} + +const CLIModGeneratorProfileTabs: React.FC = ({ value, profiles, onChange }) => { + return ( + { + onChange(newValue); + }} + aria-label="Vertical tabs example" + sx={{ borderRight: 1, borderColor: "divider" }} + > + {profiles.map((profile) => ( + + ))} + + ); +}; + +export default CLIModGeneratorProfileTabs; diff --git a/src/web/src/views/cli/components/CLIModGeneratorToolBar.tsx b/src/web/src/views/cli/components/CLIModGeneratorToolBar.tsx new file mode 100644 index 00000000..d1d6b26d --- /dev/null +++ b/src/web/src/views/cli/components/CLIModGeneratorToolBar.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { AppBar, Button, IconButton, Toolbar, Typography, Tooltip, Box } from "@mui/material"; +import HomeIcon from "@mui/icons-material/Home"; + +interface CLIModGeneratorToolBarProps { + moduleName: string; + onHomePage: () => void; + onGenerate: () => void; +} + +const CLIModGeneratorToolBar: React.FC = ({ moduleName, onHomePage, onGenerate }) => { + return ( + <> + theme.zIndex.drawer + 1 }}> + + + + + GENERATION + + + + + {moduleName} + + + + + + + + + + + + ); +}; + +export default CLIModGeneratorToolBar; diff --git a/src/web/src/views/cli/components/CLIModuleGenerator.tsx b/src/web/src/views/cli/components/CLIModuleGenerator.tsx new file mode 100644 index 00000000..e05d3a84 --- /dev/null +++ b/src/web/src/views/cli/components/CLIModuleGenerator.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect, useCallback, Fragment, FC } from "react"; +import { Backdrop, Box, CircularProgress, Drawer, Toolbar, Alert } from "@mui/material"; +import { useParams } from "react-router"; +import { cliApi, errorHandlerApi } from "../../../services"; +import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; +import CLIModGeneratorProfileCommandTree from "./CLIModGeneratorProfileCommandTree"; +import { initializeCommandTreeByModView, ProfileCommandTree } from "../utils/commandTreeInitialization"; +import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; +import { CLIModView } from "../interfaces"; +import GenerateDialog, { type ProfileCommandTrees } from "./GenerateDialog"; +import { useSpecsCommandTree } from "../hooks"; + +interface CLISpecsSimpleCommand { + names: string[]; +} + +interface CLISpecsSimpleCommands { + [name: string]: CLISpecsSimpleCommand; +} + +interface CLISpecsSimpleCommandGroups { + [name: string]: CLISpecsSimpleCommandGroup; +} + +interface CLISpecsSimpleCommandGroup { + names: string[]; + commands: CLISpecsSimpleCommands; + commandGroups: CLISpecsSimpleCommandGroups; +} + +interface CLISpecsSimpleCommandTree { + root: CLISpecsSimpleCommandGroup; +} + +interface CLIModuleGeneratorProps { + params: { + repoName: string; + moduleName: string; + }; +} + +const CLIModuleGenerator: FC = ({ params }) => { + const [loading, setLoading] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [profiles, setProfiles] = useState([]); + const [commandTrees, setCommandTrees] = useState({}); + const [selectedProfile, setSelectedProfile] = useState(undefined); + const [showGenerateDialog, setShowGenerateDialog] = useState(false); + + const fetchCommands = useSpecsCommandTree(); + + useEffect(() => { + loadModule(); + }, []); + + const loadModule = async () => { + try { + setLoading(true); + setInvalidText(undefined); + const profiles = await cliApi.getCliProfiles(); + const modView: CLIModView = await cliApi.getCliModule(params.repoName, params.moduleName); + const simpleTree: CLISpecsSimpleCommandTree = await cliApi.getSimpleCommandTree(); + + Object.keys(modView!.profiles).forEach((profile) => { + const idx = profiles.findIndex((v) => v === profile); + if (idx === -1) { + throw new Error(`Invalid profile ${profile}`); + } + }); + + const commandTrees = Object.fromEntries( + profiles.map((profile) => { + return [profile, initializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; + }), + ); + + const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; + setProfiles(profiles); + setCommandTrees(commandTrees); + setSelectedProfile(selectedProfile); + setLoading(false); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + }; + + const selectedCommandTree = selectedProfile ? commandTrees[selectedProfile] : undefined; + + const handleBackToHomepage = () => { + window.open("/?#/cli", "_blank"); + }; + + const handleGenerate = () => { + setShowGenerateDialog(true); + }; + + const handleGenerationClose = () => { + setShowGenerateDialog(false); + }; + + const onProfileChange = useCallback((selectedProfile: string) => { + setSelectedProfile(selectedProfile); + }, []); + + const onSelectedProfileTreeUpdate = useCallback( + (updater: ((oldTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { + setCommandTrees((commandTrees) => { + const selectedCommandTree = commandTrees[selectedProfile!]; + const newTree = typeof updater === "function" ? updater(selectedCommandTree!) : updater; + return { ...commandTrees, [selectedProfile!]: newTree }; + }); + }, + [selectedProfile], + ); + + return ( + + + + + + {selectedProfile !== undefined && ( + + )} + + + + {selectedCommandTree !== undefined && ( + + )} + + + {showGenerateDialog && ( + + )} + theme.zIndex.drawer + 1 }} open={loading}> + {invalidText !== undefined ? ( + { + setInvalidText(undefined); + setLoading(false); + }} + > + {invalidText} + + ) : ( + + )} + + + ); +}; + +const CLIModuleGeneratorWrapper = (props: any) => { + const params = useParams(); + return ; +}; + +export type { CLISpecsCommand } from "../hooks"; +export type { CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; +export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; diff --git a/src/web/src/views/cli/CLIModuleSelector.tsx b/src/web/src/views/cli/components/CLIModuleSelector.tsx similarity index 99% rename from src/web/src/views/cli/CLIModuleSelector.tsx rename to src/web/src/views/cli/components/CLIModuleSelector.tsx index aa52544b..5ba6d9b5 100644 --- a/src/web/src/views/cli/CLIModuleSelector.tsx +++ b/src/web/src/views/cli/components/CLIModuleSelector.tsx @@ -9,7 +9,7 @@ import { TextField, Button, } from "@mui/material"; -import { cliApi, errorHandlerApi } from "../../services"; +import { cliApi, errorHandlerApi } from "../../../services"; import * as React from "react"; interface CLIModule { diff --git a/src/web/src/views/cli/components/CLIPage.tsx b/src/web/src/views/cli/components/CLIPage.tsx new file mode 100644 index 00000000..c7cbba4a --- /dev/null +++ b/src/web/src/views/cli/components/CLIPage.tsx @@ -0,0 +1,12 @@ +import { FC } from "react"; +import { Outlet } from "react-router"; + +const CLIPage: FC = () => { + return ( + <> + + + ); +}; + +export default CLIPage; diff --git a/src/web/src/views/cli/components/CommandGroupItem.tsx b/src/web/src/views/cli/components/CommandGroupItem.tsx new file mode 100644 index 00000000..c92aaa9e --- /dev/null +++ b/src/web/src/views/cli/components/CommandGroupItem.tsx @@ -0,0 +1,190 @@ +import React, { memo, useCallback } from "react"; +import TreeItem from "@mui/lab/TreeItem"; +import FolderIcon from "@mui/icons-material/Folder"; +import { Box, Checkbox, Typography, styled, TypographyProps } from "@mui/material"; +import CommandItem from "./CommandItem"; +import { + calculateSelected, + prepareLoadCommandsOfCommandGroup, + type ProfileCTCommandGroup, + type ProfileCTCommand, +} from "../utils/commandTreeUtils"; + +const CommandGroupTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 17, + fontWeight: 600, +})); + +interface CommandGroupItemProps { + commandGroup: ProfileCTCommandGroup; + onUpdateCommandGroup: ( + name: string, + updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup, + ) => void; + onLoadCommands: (names: string[][]) => Promise; +} + +const CommandGroupItem: React.FC = memo( + ({ commandGroup, onUpdateCommandGroup, onLoadCommands }) => { + const nodeName = commandGroup.names[commandGroup.names.length - 1]; + const selected = commandGroup.selected ?? false; + + const onUpdateCommand = useCallback( + (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commands = { + ...oldCommandGroup.commands, + [name]: updater(oldCommandGroup.commands![name]), + }; + const selected = calculateSelected(commands, oldCommandGroup.commandGroups ?? {}); + return { + ...oldCommandGroup, + commands: commands, + selected: selected, + }; + }); + }, + [onUpdateCommandGroup, nodeName], + ); + + const onUpdateSubCommandGroup = useCallback( + (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commandGroups = { + ...oldCommandGroup.commandGroups, + [name]: updater(oldCommandGroup.commandGroups![name]), + }; + const commands = oldCommandGroup.commands; + const selected = calculateSelected(commands ?? {}, commandGroups); + return { + ...oldCommandGroup, + commandGroups: commandGroups, + selected: selected, + }; + }); + }, + [onUpdateCommandGroup, nodeName], + ); + + const onLoadCommand = useCallback( + async (names: string[]) => { + await onLoadCommands([names]); + }, + [onLoadCommands], + ); + + const updateCommandSelected = (command: ProfileCTCommand, selected: boolean): ProfileCTCommand => { + if (selected === command.selected) { + return command; + } + return { + ...command, + selected: selected, + selectedVersion: selected + ? command.selectedVersion + ? command.selectedVersion + : command.versions + ? command.versions[0].name + : undefined + : command.selectedVersion, + modified: true, + }; + }; + + const updateGroupSelected = (group: ProfileCTCommandGroup, selected: boolean): ProfileCTCommandGroup => { + if (selected === group.selected) { + return group; + } + const commands = group.commands + ? Object.fromEntries( + Object.entries(group.commands).map(([key, value]) => [key, updateCommandSelected(value, selected)]), + ) + : undefined; + const commandGroups = group.commandGroups + ? Object.fromEntries( + Object.entries(group.commandGroups).map(([key, value]) => [key, updateGroupSelected(value, selected)]), + ) + : undefined; + return { + ...group, + commands: commands, + commandGroups: commandGroups, + selected: selected, + }; + }; + + const selectCommandGroup = useCallback( + (selected: boolean) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const selectedGroup = updateGroupSelected(oldCommandGroup, selected); + const [loadingNamesList, newGroup] = prepareLoadCommandsOfCommandGroup(selectedGroup); + if (loadingNamesList.length > 0) { + onLoadCommands(loadingNamesList); + } + return newGroup; + }); + }, + [onUpdateCommandGroup, onLoadCommands, nodeName], + ); + + return ( + + { + selectCommandGroup(!selected); + event.stopPropagation(); + event.preventDefault(); + }} + /> + + {nodeName} + + } + > + {commandGroup.commands !== undefined && + Object.values(commandGroup.commands).map((command) => ( + + ))} + {commandGroup.commandGroups !== undefined && + Object.values(commandGroup.commandGroups).map((group) => ( + + ))} + + ); + }, +); + +CommandGroupItem.displayName = "CommandGroupItem"; + +export default CommandGroupItem; + +export type { CommandGroupItemProps }; diff --git a/src/web/src/views/cli/components/CommandItem.tsx b/src/web/src/views/cli/components/CommandItem.tsx new file mode 100644 index 00000000..615230ef --- /dev/null +++ b/src/web/src/views/cli/components/CommandItem.tsx @@ -0,0 +1,246 @@ +import React, { memo, useCallback } from "react"; +import TreeItem from "@mui/lab/TreeItem"; +import EditIcon from "@mui/icons-material/Edit"; +import { + Box, + Checkbox, + FormControl, + Typography, + Select, + MenuItem, + styled, + TypographyProps, + InputLabel, + IconButton, +} from "@mui/material"; +import { type ProfileCTCommand } from "../utils/commandTreeUtils"; + +const CommandTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 20, + fontWeight: 400, +})); + +const SelectionTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.grey[700], + fontFamily: "'Work Sans', sans-serif", + fontSize: 15, + fontWeight: 400, +})); + +const RegisteredTypography = styled(SelectionTypography)(() => ({})); + +const UnregisteredTypography = styled(SelectionTypography)(() => ({ + color: "#d9c136", +})); + +interface CommandItemProps { + command: ProfileCTCommand; + onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void; + onLoadCommand(names: string[]): Promise; +} + +const CommandItem: React.FC = memo(({ command, onUpdateCommand, onLoadCommand }) => { + const leafName = command.names[command.names.length - 1]; + + const selectCommand = useCallback( + (selected: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + if (oldCommand.versions === undefined && selected === true) { + onLoadCommand(oldCommand.names); + } + return { + ...oldCommand, + loading: selected && oldCommand.versions === undefined, + selected: selected, + selectedVersion: selected + ? oldCommand.selectedVersion + ? oldCommand.selectedVersion + : oldCommand.versions + ? oldCommand.versions[0].name + : undefined + : oldCommand.selectedVersion, + modified: true, + }; + }); + }, + [onUpdateCommand, onLoadCommand, leafName], + ); + + const selectVersion = useCallback( + (version: string) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + selectedVersion: version, + modified: true, + }; + }); + }, + [onUpdateCommand, leafName], + ); + + const selectRegistered = useCallback( + (registered: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + registered: registered, + modified: true, + }; + }); + }, + [onUpdateCommand, leafName], + ); + + return ( + + { + selectCommand(!command.selected); + event.stopPropagation(); + event.preventDefault(); + }} + /> + + {leafName} + + {!command.modified && command.selectedVersion !== undefined && ( + { + selectCommand(true); + }} + > + + + )} + {command.modified && } + + + {command.versions !== undefined && command.selectedVersion !== undefined && ( + + + Version + + + + Command table + + + + )} + {command.loading === true && command.selected && ( + + + Loading... + + + )} + + } + onClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + /> + ); +}); + +CommandItem.displayName = "CommandItem"; + +export default CommandItem; + +export type { CommandItemProps }; diff --git a/src/web/src/views/cli/components/GenerateDialog.tsx b/src/web/src/views/cli/components/GenerateDialog.tsx new file mode 100644 index 00000000..4f95560a --- /dev/null +++ b/src/web/src/views/cli/components/GenerateDialog.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, LinearProgress } from "@mui/material"; +import { cliApi, errorHandlerApi } from "../../../services"; +import { exportModViewProfile, type ProfileCommandTree } from "../utils/commandTreeInitialization"; +import { type CLIModViewProfiles } from "../interfaces"; + +interface ProfileCommandTrees { + [name: string]: ProfileCommandTree; +} + +interface GenerateDialogProps { + repoName: string; + moduleName: string; + profileCommandTrees: ProfileCommandTrees; + open: boolean; + onClose: (generated: boolean) => void; +} + +const GenerateDialog = (props: GenerateDialogProps) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + + const handleClose = () => { + props.onClose(false); + }; + + const handleGenerateAll = async () => { + const profiles: CLIModViewProfiles = {}; + Object.values(props.profileCommandTrees).forEach((tree) => { + profiles[tree.name] = exportModViewProfile(tree); + }); + const data = { + name: props.moduleName, + profiles: profiles, + }; + + setUpdating(true); + try { + await cliApi.updateCliModule(props.repoName, props.moduleName, data); + setUpdating(false); + props.onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleGenerateModified = async () => { + const profiles: CLIModViewProfiles = {}; + Object.values(props.profileCommandTrees).forEach((tree) => { + profiles[tree.name] = exportModViewProfile(tree); + }); + const data = { + name: props.moduleName, + profiles: profiles, + }; + + setUpdating(true); + try { + await cliApi.patchCliModule(props.repoName, props.moduleName, data); + setUpdating(false); + props.onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + return ( + + Generate CLI commands to {props.moduleName} + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + {updating && ( + + + + )} + {!updating && ( + <> + + + + + )} + + + ); +}; + +export default GenerateDialog; +export type { ProfileCommandTrees }; diff --git a/src/web/src/views/cli/hooks/index.ts b/src/web/src/views/cli/hooks/index.ts new file mode 100644 index 00000000..450dd0a6 --- /dev/null +++ b/src/web/src/views/cli/hooks/index.ts @@ -0,0 +1,8 @@ +export { useSpecsCommandTree } from "./useSpecsCommandTree"; +export type { + CLISpecsCommand, + CLISpecsHelp, + CLISpecsResource, + CLISpecsCommandExample, + CLISpecsCommandVersion, +} from "./useSpecsCommandTree"; diff --git a/src/web/src/views/cli/hooks/useSpecsCommandTree.ts b/src/web/src/views/cli/hooks/useSpecsCommandTree.ts new file mode 100644 index 00000000..959acf59 --- /dev/null +++ b/src/web/src/views/cli/hooks/useSpecsCommandTree.ts @@ -0,0 +1,77 @@ +import { useCallback, useRef } from "react"; +import { cliApi } from "../../../services"; + +export interface CLISpecsHelp { + short: string; + lines?: string[]; +} + +export interface CLISpecsResource { + plane: string; + id: string; + version: string; + subresource?: string; +} + +export interface CLISpecsCommandExample { + name: string; + commands: string[]; +} + +export interface CLISpecsCommandVersion { + name: string; + stage?: string; + resources: CLISpecsResource[]; + examples?: CLISpecsCommandExample[]; +} + +export interface CLISpecsCommand { + names: string[]; + help: CLISpecsHelp; + versions: CLISpecsCommandVersion[]; +} + +const retrieveCommand = async (names: string[]): Promise => { + return await cliApi.getSpecsCommand(names); +}; + +const retrieveCommands = async (namesList: string[][]): Promise => { + return await cliApi.retrieveCommands(namesList); +}; + +export const useSpecsCommandTree = (): ((namesList: string[][]) => Promise) => { + const commandCache = useRef(new Map>()); + + const fetchCommands = useCallback( + async (namesList: string[][]) => { + const promiseResults = []; + const uncachedNamesList = []; + for (const names of namesList) { + const cachedPromise = commandCache.current.get(names.join("/")); + if (!cachedPromise) { + uncachedNamesList.push(names); + } else { + promiseResults.push(cachedPromise); + } + } + if (uncachedNamesList.length === 0) { + return await Promise.all(promiseResults); + } else if (uncachedNamesList.length === 1) { + const commandPromise = retrieveCommand(uncachedNamesList[0]); + commandCache.current.set(uncachedNamesList[0].join("/"), commandPromise); + return await Promise.all(promiseResults.concat(commandPromise)); + } else { + const uncachedCommandsPromise = retrieveCommands(uncachedNamesList); + uncachedNamesList.forEach((names, idx) => { + commandCache.current.set( + names.join("/"), + uncachedCommandsPromise.then((commands) => commands[idx]), + ); + }); + return (await Promise.all(promiseResults)).concat(await uncachedCommandsPromise); + } + }, + [commandCache], + ); + return fetchCommands; +}; diff --git a/src/web/src/views/cli/CLIModuleCommon.tsx b/src/web/src/views/cli/interfaces/cliModule.ts similarity index 100% rename from src/web/src/views/cli/CLIModuleCommon.tsx rename to src/web/src/views/cli/interfaces/cliModule.ts diff --git a/src/web/src/views/cli/interfaces/index.ts b/src/web/src/views/cli/interfaces/index.ts new file mode 100644 index 00000000..6574c0d7 --- /dev/null +++ b/src/web/src/views/cli/interfaces/index.ts @@ -0,0 +1,9 @@ +export type { + CLIModView, + CLIModViewProfile, + CLIModViewProfiles, + CLIModViewCommandGroup, + CLIModViewCommandGroups, + CLIModViewCommand, + CLIModViewCommands, +} from "./cliModule"; diff --git a/src/web/src/views/cli/utils/commandTreeInitialization.ts b/src/web/src/views/cli/utils/commandTreeInitialization.ts new file mode 100644 index 00000000..f481d795 --- /dev/null +++ b/src/web/src/views/cli/utils/commandTreeInitialization.ts @@ -0,0 +1,228 @@ +import { + CLIModViewCommand, + CLIModViewCommandGroup, + CLIModViewCommandGroups, + CLIModViewCommands, + CLIModViewProfile, +} from "../interfaces"; +import { + CLISpecsCommand, + CLISpecsSimpleCommand, + CLISpecsSimpleCommandGroup, + CLISpecsSimpleCommandTree, +} from "../components/CLIModuleGenerator"; +import { + calculateSelected, + type ProfileCTCommandGroup, + type ProfileCTCommand, + type ProfileCTCommandGroups, + type ProfileCTCommandVersion, +} from "./commandTreeUtils"; + +export interface ProfileCommandTree { + name: string; + commandGroups: ProfileCTCommandGroups; +} + +export const decodeProfileCTCommandVersion = (response: any): ProfileCTCommandVersion => { + return { + name: response.name, + stage: response.stage, + }; +}; + +export const decodeProfileCTCommand = ( + response: CLISpecsCommand, + selected: boolean = false, + modified: boolean = false, + registered: boolean | undefined = undefined, + selectedVersion: string | undefined = undefined, +): ProfileCTCommand => { + const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); + const command = { + id: response.names.join("/"), + names: [...response.names], + versions: versions, + modified: modified, + loading: false, + selected: selected, + registered: registered, + }; + if (selected) { + let version: string | undefined; + if (selectedVersion !== undefined) { + version = selectedVersion; + } else { + version = versions ? versions[0].name : undefined; + } + + return { + ...command, + selectedVersion: version, + }; + } else { + return command; + } +}; + +const initializeCommandByModView = ( + view: CLIModViewCommand | undefined, + simpleCommand: CLISpecsSimpleCommand, +): ProfileCTCommand => { + return { + id: simpleCommand.names.join("/"), + names: simpleCommand.names, + modified: false, + loading: false, + selected: view !== undefined && view.version !== undefined, + selectedVersion: view !== undefined ? view.version : undefined, + registered: view !== undefined ? view.registered : true, + }; +}; + +const initializeCommandGroupByModView = ( + view: CLIModViewCommandGroup | undefined, + simpleCommandGroup: CLISpecsSimpleCommandGroup, +): ProfileCTCommandGroup => { + const commands = + simpleCommandGroup.commands !== undefined + ? Object.fromEntries( + Object.entries(simpleCommandGroup.commands).map(([key, value]) => [ + key, + initializeCommandByModView(view?.commands?.[key], value), + ]), + ) + : undefined; + const commandGroups = + simpleCommandGroup.commandGroups !== undefined + ? Object.fromEntries( + Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [ + key, + initializeCommandGroupByModView(view?.commandGroups?.[key], value), + ]), + ) + : undefined; + const leftCommands = Object.entries(view?.commands ?? {}) + .filter(([key, _]) => commands?.[key] === undefined) + .map(([_, value]) => value.names) + .map((names) => "`az " + names.join(" ") + "`"); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}) + .filter(([key, _]) => commandGroups?.[key] === undefined) + .map(([_, value]) => value.names) + .map((names) => "`az " + names.join(" ") + "`"); + const errors = []; + if (leftCommands.length > 0) { + errors.push(`Miss commands in aaz: ${leftCommands.join(", ")}`); + } + if (leftCommandGroups.length > 0) { + errors.push(`Miss command groups in aaz: ${leftCommandGroups.join(", ")}`); + } + if (errors.length > 0) { + throw new Error( + "\n" + + errors.join("\n") + + "\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.", + ); + } + const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); + return { + id: simpleCommandGroup.names.join("/"), + names: simpleCommandGroup.names, + commands: commands, + commandGroups: commandGroups, + waitCommand: view?.waitCommand, + loading: false, + selected: selected, + }; +}; + +export const initializeCommandTreeByModView = ( + profileName: string, + view: CLIModViewProfile | null, + simpleTree: CLISpecsSimpleCommandTree, +): ProfileCommandTree => { + const commandGroups = Object.fromEntries( + Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [ + key, + initializeCommandGroupByModView(view?.commandGroups?.[key], value), + ]), + ); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}) + .filter(([key, _]) => commandGroups?.[key] === undefined) + .map(([_, value]) => value.names) + .map((names) => "`az " + names.join(" ") + "`"); + if (leftCommandGroups.length > 0) { + throw new Error( + `\nMiss command groups in aaz: ${leftCommandGroups.join(", ")}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`, + ); + } + return { + name: profileName, + commandGroups: commandGroups, + }; +}; + +const exportModViewCommand = (command: ProfileCTCommand): CLIModViewCommand | undefined => { + if (command.selectedVersion === undefined) { + return undefined; + } + + return { + names: command.names, + registered: command.registered!, + version: command.selectedVersion!, + modified: command.modified, + }; +}; + +const exportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined => { + if (commandGroup.selected === false) { + return undefined; + } + + let commands: CLIModViewCommands | undefined = undefined; + if (commandGroup.commands !== undefined) { + commands = {}; + + Object.values(commandGroup.commands!).forEach((value) => { + const view = exportModViewCommand(value); + if (view !== undefined) { + commands![value.names[value.names.length - 1]] = view; + } + }); + } + + let commandGroups: CLIModViewCommandGroups | undefined = undefined; + if (commandGroup.commandGroups !== undefined) { + commandGroups = {}; + + Object.values(commandGroup.commandGroups!).forEach((value) => { + const view = exportModViewCommandGroup(value); + if (view !== undefined) { + commandGroups![value.names[value.names.length - 1]] = view; + } + }); + } + return { + names: commandGroup.names, + commandGroups: commandGroups, + commands: commands, + waitCommand: commandGroup.waitCommand, + }; +}; + +export const exportModViewProfile = (tree: ProfileCommandTree): CLIModViewProfile => { + const commandGroups: CLIModViewCommandGroups = {}; + + Object.values(tree.commandGroups).forEach((value) => { + const view = exportModViewCommandGroup(value); + if (view !== undefined) { + commandGroups[value.names[value.names.length - 1]] = view; + } + }); + + return { + name: tree.name, + commandGroups: commandGroups, + }; +}; diff --git a/src/web/src/views/cli/utils/commandTreeUtils.ts b/src/web/src/views/cli/utils/commandTreeUtils.ts new file mode 100644 index 00000000..7fc7e678 --- /dev/null +++ b/src/web/src/views/cli/utils/commandTreeUtils.ts @@ -0,0 +1,107 @@ +interface ProfileCTCommandGroups { + [name: string]: ProfileCTCommandGroup; +} + +interface ProfileCTCommands { + [name: string]: ProfileCTCommand; +} + +interface ProfileCTCommandGroup { + id: string; + names: string[]; + commandGroups?: ProfileCTCommandGroups; + commands?: ProfileCTCommands; + waitCommand?: any; + loading: boolean; + selected?: boolean; +} + +interface ProfileCTCommand { + id: string; + names: string[]; + versions?: ProfileCTCommandVersion[]; + selectedVersion?: string; + registered?: boolean; + modified: boolean; + loading: boolean; + selected: boolean; +} + +interface ProfileCTCommandVersion { + name: string; + stage: string; +} + +function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCTCommandGroups): boolean | undefined { + const commandsAllSelected = Object.values(commands).reduce((pre, value) => { + return pre && value.selected; + }, true); + const commandsAllUnselected = Object.values(commands).reduce((pre, value) => { + return pre && !value.selected; + }, true); + const commandGroupsAllSelected = Object.values(commandGroups).reduce((pre, value) => { + return pre && value.selected === true; + }, true); + const commandGroupsAllUnselected = Object.values(commandGroups).reduce((pre, value) => { + return pre && value.selected === false; + }, true); + if (commandsAllUnselected && commandGroupsAllUnselected) { + return false; + } else if (commandsAllSelected && commandGroupsAllSelected) { + return true; + } else { + return undefined; + } +} + +function prepareLoadCommandsOfCommandGroup(commandGroup: ProfileCTCommandGroup): [string[][], ProfileCTCommandGroup] { + const namesList: string[][] = []; + const commands = commandGroup.commands + ? Object.fromEntries( + Object.entries(commandGroup.commands).map(([key, value]) => { + if (value.selected === true && value.versions === undefined && value.loading === false) { + namesList.push(value.names); + return [ + key, + { + ...value, + loading: true, + }, + ]; + } + return [key, value]; + }), + ) + : undefined; + const commandGroups = commandGroup.commandGroups + ? Object.fromEntries( + Object.entries(commandGroup.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + }), + ) + : undefined; + if (namesList.length > 0) { + return [ + namesList, + { + ...commandGroup, + commands: commands, + commandGroups: commandGroups, + }, + ]; + } else { + return [[], commandGroup]; + } +} + +export { calculateSelected, prepareLoadCommandsOfCommandGroup }; + +export type { + ProfileCTCommandGroup, + ProfileCTCommand, + ProfileCTCommandGroups, + ProfileCTCommands, + ProfileCTCommandVersion, +}; diff --git a/src/web/src/views/commands/CommandsPage.tsx b/src/web/src/views/commands/CommandsPage.tsx deleted file mode 100644 index b01987b8..00000000 --- a/src/web/src/views/commands/CommandsPage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; -import withRoot from "../../withRoot"; -import { AppAppBar } from "../../components/AppAppBar"; - -class CommandsPage extends React.Component { - render() { - return ( - - - {/* - Commands Page - */} - - ); - } -} - -export default withRoot(CommandsPage); diff --git a/src/web/src/views/home/HomePage.tsx b/src/web/src/views/home/HomePage.tsx index 18ea1ffa..1b291230 100644 --- a/src/web/src/views/home/HomePage.tsx +++ b/src/web/src/views/home/HomePage.tsx @@ -2,8 +2,7 @@ import * as React from "react"; import { Typography, Box, Link, Stepper, Step, StepButton, StepContent, TypographyProps } from "@mui/material"; import { styled } from "@mui/material/styles"; -import withRoot from "../../withRoot"; -import { AppAppBar } from "../../components/AppAppBar"; +import { AppNavBar } from "../../components/AppNavBar"; import PageLayout from "../../components/PageLayout"; const MiddlePadding = styled(Box)(() => ({ @@ -33,7 +32,7 @@ function HomePage() { return ( - + - {"Introduce"} + {"Introduction"} @@ -94,11 +93,11 @@ function HomePage() { } - {"Go to "} + {"Go to the "} - Introduction + introduction - {" for more details."} + {" page in our docs for more details."} @@ -108,10 +107,10 @@ function HomePage() { - {"The definition of API in swagger/TypeSpec is required before using AAZDev tool."} + {"The definition of API specs in Swagger/TypeSpec is required before using the AAZDev tool."} - {"Please make sure the API specs has been defined in "} + {"Please make sure the API specs have been defined in the "} azure-rest-api-specs - {" repo or "} + {" repo or the "} {"Model editors can help you build command models."} - {"To build command models from swagger/TypeSpec,"} + {"To build command models from Swagger/TypeSpec,"} - {"please use "} + {"please use the "} Workspace @@ -160,7 +159,7 @@ function HomePage() { {"To convert command models to CLI code,"} - {"please use "} + {"please use the "} CLI @@ -179,4 +178,4 @@ function HomePage() { ); } -export default withRoot(HomePage); +export default HomePage; diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx deleted file mode 100644 index ac4bb02f..00000000 --- a/src/web/src/views/workspace/WSEditor.tsx +++ /dev/null @@ -1,1160 +0,0 @@ -import * as React from "react"; -import { - Box, - Dialog, - Slide, - Drawer, - Toolbar, - DialogTitle, - DialogContent, - DialogActions, - LinearProgress, - Button, - List, - ListSubheader, - Paper, - ListItemButton, - ListItemIcon, - Checkbox, - ListItemText, - ListItem, - TextField, - Alert, -} from "@mui/material"; -import { useParams } from "react-router"; -import { TransitionProps } from "@mui/material/transitions"; -import WSEditorSwaggerPicker from "./WSEditorSwaggerPicker"; -import WSEditorToolBar from "./WSEditorToolBar"; -import WSEditorCommandTree, { CommandTreeLeaf, CommandTreeNode } from "./WSEditorCommandTree"; -import WSEditorCommandGroupContent, { - CommandGroup, - DecodeResponseCommandGroup, - ResponseCommandGroup, - ResponseCommandGroups, -} from "./WSEditorCommandGroupContent"; -import WSEditorCommandContent, { - Command, - Resource, - DecodeResponseCommand, - ResponseCommand, -} from "./WSEditorCommandContent"; -import WSEditorClientConfigDialog from "./WSEditorClientConfig"; -import { getTypespecRPResourcesOperations } from "../../typespec"; -import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; - -interface CommandGroupMap { - [id: string]: CommandGroup; -} - -interface CommandMap { - [id: string]: Command; -} - -interface WSEditorProps { - params: { - workspaceName: string; - }; -} - -interface WSEditorState { - name: string; - workspaceUrl: string; - plane: string; - source: string; - clientConfigurable: boolean; - - selected: Command | CommandGroup | null; - reloadTimestamp: number | null; - expanded: Set; - - commandMap: CommandMap; - commandGroupMap: CommandGroupMap; - commandTree: CommandTreeNode[]; - - showSwaggerResourcePicker: boolean; - showSwaggerReloadDialog: boolean; - showClientConfigDialog: boolean; - showExportDialog: boolean; - showDeleteDialog: boolean; - showModifyDialog: boolean; -} - -const swaggerResourcePickerTransition = React.forwardRef(function swaggerResourcePickerTransition( - props: TransitionProps & { children: React.ReactElement }, - ref: React.Ref, -) { - return ; -}); - -const drawerWidth = 300; - -class WSEditor extends React.Component { - constructor(props: WSEditorProps) { - super(props); - this.state = { - name: this.props.params.workspaceName, - workspaceUrl: `/AAZ/Editor/Workspaces/${this.props.params.workspaceName}`, - plane: "", - source: "", - clientConfigurable: false, - selected: null, - reloadTimestamp: null, - expanded: new Set(), - commandMap: {}, - commandGroupMap: {}, - commandTree: [], - showSwaggerResourcePicker: false, - showSwaggerReloadDialog: false, - showClientConfigDialog: false, - showExportDialog: false, - showDeleteDialog: false, - showModifyDialog: false, - }; - } - - componentDidMount() { - this.loadWorkspace(); - } - - loadWorkspace = async (preSelectedId?: string | null) => { - const { workspaceUrl } = this.state; - if (preSelectedId === undefined) { - preSelectedId = this.state.selected?.id; - } - - try { - const planeNames = await specsApi.getPlaneNames(); - const workspaceData = await workspaceApi.getWorkspace(workspaceUrl); - const reloadTimestamp = Date.now(); - const commandMap: CommandMap = {}; - const commandGroupMap: CommandGroupMap = {}; - - const buildCommand = (command_1: ResponseCommand): CommandTreeLeaf => { - const cmd: Command = DecodeResponseCommand(command_1); - commandMap[cmd.id] = cmd; - return { - id: cmd.id, - names: [...cmd.names], - }; - }; - - const buildCommandGroup = (commandGroup_1: ResponseCommandGroup): CommandTreeNode => { - const group: CommandGroup = DecodeResponseCommandGroup(commandGroup_1); - - commandGroupMap[group.id] = group; - - const node: CommandTreeNode = { - id: group.id, - names: [...group.names], - canDelete: group.canDelete, - }; - - if (typeof commandGroup_1.commands === "object" && commandGroup_1.commands !== null) { - node["leaves"] = []; - - for (const name in commandGroup_1.commands) { - const subLeave = buildCommand(commandGroup_1.commands[name]); - node["leaves"].push(subLeave); - } - node["leaves"].sort((a, b) => a.id.localeCompare(b.id)); - if (node["leaves"].length > 0) { - node.canDelete = false; - } - } - - if (typeof commandGroup_1.commandGroups === "object" && commandGroup_1.commandGroups !== null) { - node["nodes"] = []; - for (const name_1 in commandGroup_1.commandGroups) { - const subNode = buildCommandGroup(commandGroup_1.commandGroups[name_1]); - node["nodes"].push(subNode); - if (!subNode.canDelete) { - node.canDelete = false; - } - } - node["nodes"].sort((a_1, b_1) => a_1.id.localeCompare(b_1.id)); - } - - if ((node["leaves"]?.length ?? 0) > 1) { - node.canDelete = false; - } - group.canDelete = node.canDelete; - return node; - }; - - const commandTree: CommandTreeNode[] = []; - - if (workspaceData.commandTree.commandGroups) { - const cmdGroups: ResponseCommandGroups = workspaceData.commandTree.commandGroups; - for (const key in cmdGroups) { - commandTree.push(buildCommandGroup(cmdGroups[key])); - } - commandTree.sort((a_2, b_2) => a_2.id.localeCompare(b_2.id)); - } - - let selected: Command | CommandGroup | null = null; - - if (preSelectedId != null) { - if (preSelectedId.startsWith("command:")) { - let id: string = preSelectedId; - if (id in commandMap) { - selected = commandMap[id]; - } else { - id = "group:" + id.slice(8); - let parts = id.split("/"); - while (parts.length > 1 && !(id in commandGroupMap)) { - parts = parts.slice(0, -1); - id = parts.join("/"); - } - if (id in commandGroupMap) { - selected = commandGroupMap[id]; - } - } - } else if (preSelectedId.startsWith("group:")) { - let id_1: string = preSelectedId; - let parts_1 = id_1.split("/"); - while (parts_1.length > 1 && !(id_1 in commandGroupMap)) { - parts_1 = parts_1.slice(0, -1); - id_1 = parts_1.join("/"); - } - if (id_1 in commandGroupMap) { - selected = commandGroupMap[id_1]; - } - } - } - - if (selected === null && commandTree.length > 0) { - selected = commandGroupMap[commandTree[0].id]; - } - - // when the plane name not included in the built-in planes, it is a client configurable plane - const clientConfigurable = !planeNames.includes(workspaceData.plane); - this.setState((preState) => { - const newExpanded = new Set(); - - // clean up removed group Id - preState.expanded.forEach((value) => { - if (value in commandGroupMap) { - newExpanded.add(value); - } - }); - - // expand new groupId by default - for (const groupId in commandGroupMap) { - if (!(groupId in preState.commandGroupMap)) { - newExpanded.add(groupId); - } - } - - return { - ...preState, - plane: workspaceData.plane, - source: workspaceData.source, - clientConfigurable: clientConfigurable, - commandTree: commandTree, - selected: selected, - reloadTimestamp: reloadTimestamp, - commandMap: commandMap, - commandGroupMap: commandGroupMap, - expanded: newExpanded, - }; - }); - - if (selected) { - let expandedId = selected.id; - if (expandedId.startsWith("command:")) { - expandedId = expandedId.replace("command:", "group:").split("/").slice(0, -1).join("/"); - } - const expandedIdParts = expandedId.split("/"); - this.setState((preState) => { - const newExpanded = new Set(preState.expanded); - expandedIdParts.forEach((_value, idx) => { - newExpanded.add(expandedIdParts.slice(0, idx + 1).join("/")); - }); - return { - ...preState, - expanded: newExpanded, - }; - }); - } - - if (clientConfigurable) { - const clientConfig = await this.getWorkspaceClientConfig(workspaceUrl); - if (clientConfig == null) { - this.showClientConfigDialog(); - return; - } - } - - if (commandTree.length === 0) { - this.showSwaggerResourcePicker(); - } - } catch (err) { - return console.error(err); - } - }; - - getWorkspaceClientConfig = async (workspaceUrl: string) => { - return await workspaceApi.getWorkspaceClientConfig(workspaceUrl); - }; - - showClientConfigDialog = () => { - this.setState({ showClientConfigDialog: true }); - }; - - showSwaggerResourcePicker = () => { - this.setState({ showSwaggerResourcePicker: true }); - }; - - showSwaggerReloadDialog = () => { - this.setState({ showSwaggerReloadDialog: true }); - }; - - handleSwaggerReloadDialogClose = async (reloaded: boolean) => { - if (reloaded) { - await this.loadWorkspace(); - } - this.setState({ - showSwaggerReloadDialog: false, - }); - }; - - handleSwaggerResourcePickerClose = (updated: boolean) => { - if (updated) { - this.loadWorkspace(); - } - this.setState({ - showSwaggerResourcePicker: false, - }); - }; - - handleBackToHomepage = (blank: boolean) => { - if (blank) { - window.open("/?#/workspace", "_blank"); - } else { - window.location.href = "/?#/workspace"; - } - }; - - handleGenerate = () => { - this.setState({ - showExportDialog: true, - }); - }; - - handleGenerationClose = (_exported: boolean, showClientConfigDialog: boolean) => { - this.setState({ - showExportDialog: false, - }); - if (showClientConfigDialog) { - this.setState({ - showClientConfigDialog: true, - }); - } - }; - - handleDelete = () => { - this.setState({ - showDeleteDialog: true, - }); - }; - - handleDeleteClose = (deleted: boolean) => { - this.setState({ - showDeleteDialog: false, - }); - if (deleted) { - this.handleBackToHomepage(false); - } - }; - - handleModify = () => { - this.setState({ - showModifyDialog: true, - }); - }; - - handleModifyClose = (newWSName: string | null) => { - this.setState({ - showModifyDialog: false, - }); - if (!newWSName) { - return; - } - setTimeout(() => { - const target_url = `/?#/workspace/` + newWSName; - window.location.href = target_url; - window.location.reload(); - }); - }; - - handleCommandTreeSelect = (nodeId: string) => { - if (nodeId.startsWith("command:")) { - this.setState((preState) => { - const selected = preState.commandMap[nodeId]; - return { - ...preState, - selected: selected, - }; - }); - } else if (nodeId.startsWith("group:")) { - this.setState((preState) => { - const selected = preState.commandGroupMap[nodeId]; - return { - ...preState, - selected: selected, - }; - }); - } - }; - - handleCommandGroupUpdate = (commandGroup: CommandGroup | null) => { - this.loadWorkspace(commandGroup?.id); - }; - - handleCommandUpdate = (command: Command | null) => { - this.loadWorkspace(command?.id); - }; - - handleCommandTreeToggle = (nodeIds: string[]) => { - const newExpanded = new Set(nodeIds); - this.setState({ - expanded: newExpanded, - }); - }; - - handleClientConfigDialogClose = (updated: boolean) => { - this.setState({ - showClientConfigDialog: false, - }); - if (updated) { - this.loadWorkspace(); - } - }; - - render() { - const { - showSwaggerResourcePicker, - showSwaggerReloadDialog, - showExportDialog, - showDeleteDialog, - showModifyDialog, - plane, - source, - name, - commandTree, - selected, - reloadTimestamp, - workspaceUrl, - expanded, - showClientConfigDialog, - clientConfigurable, - } = this.state; - const expandedIds: string[] = []; - expanded.forEach((expandId) => { - expandedIds.push(expandId); - }); - return ( - - { - this.handleBackToHomepage(true); - }} - onGenerate={this.handleGenerate} - onDelete={this.handleDelete} - onModify={this.handleModify} - > - - - - - {selected != null && ( - - )} - - - - - {selected != null && selected.id.startsWith("group:") && ( - - )} - {selected != null && selected.id.startsWith("command:") && ( - - )} - - - - - - - {showModifyDialog && ( - - )} - {showDeleteDialog && ( - - )} - {showExportDialog && ( - - )} - {showSwaggerReloadDialog && ( - - )} - {showClientConfigDialog && ( - - )} - - ); - } -} - -interface WSEditorExportDialogProps { - workspaceUrl: string; - open: boolean; - clientConfigurable: boolean; - onClose: (exported: boolean, showClientConfigDialog: boolean) => void; -} - -interface WSEditorExportDialogState { - updating: boolean; - invalidText: string | undefined; - clientConfigOOD: boolean; -} -class WSEditorExportDialog extends React.Component { - constructor(props: WSEditorExportDialogProps) { - super(props); - this.state = { - updating: false, - invalidText: undefined, - clientConfigOOD: false, - }; - } - - componentDidMount(): void { - if (this.props.clientConfigurable) { - this.verifyClientConfig(); - } - } - - handleClose = () => { - this.props.onClose(false, false); - }; - - verifyClientConfig = async () => { - this.setState({ updating: true }); - try { - await workspaceApi.verifyClientConfig(this.props.workspaceUrl); - this.setState({ clientConfigOOD: false, updating: false }); - } catch (err: any) { - // catch 409 error - if (errorHandlerApi.isHttpError(err, 409)) { - this.setState({ - invalidText: `The client config in this workspace is out of date. Please refresh it first.`, - clientConfigOOD: true, - updating: false, - }); - return; - } else { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - } - }; - - inheritClientConfig = async () => { - this.setState({ updating: true }); - try { - await workspaceApi.inheritClientConfig(this.props.workspaceUrl); - this.setState({ clientConfigOOD: false, updating: false }); - this.props.onClose(false, true); - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - }; - - handleExport = async () => { - this.setState({ updating: true }); - - try { - await workspaceApi.generateWorkspace(this.props.workspaceUrl); - this.setState({ updating: false }); - this.props.onClose(false, false); - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - }; - - render(): React.ReactNode { - const { updating, invalidText, clientConfigOOD } = this.state; - return ( - - Export workspace command models to AAZ Repo - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - {updating && ( - - - - )} - {!updating && ( - - {clientConfigOOD && } - {!clientConfigOOD && } - {!clientConfigOOD && } - - )} - - - ); - } -} - -function WSEditorDeleteDialog(props: { workspaceName: string; open: boolean; onClose: (deleted: boolean) => void }) { - const [updating, setUpdating] = React.useState(false); - const [invalidText, setInvalidText] = React.useState(undefined); - const [confirmName, setConfirmName] = React.useState(undefined); - - const handleClose = () => { - props.onClose(false); - }; - - const handleDelete = () => { - setUpdating(true); - workspaceApi - .deleteWorkspace(props.workspaceName) - .then(() => { - setUpdating(false); - props.onClose(true); - }) - .catch((err: any) => { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - }); - }; - - return ( - - Delete '{props.workspaceName}' workspace? - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - { - setConfirmName(event.target.value); - }} - margin="normal" - required - /> - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); -} - -interface WSEditorSwaggerReloadDialogProps { - workspaceName: string; - workspaceUrl: string; - open: boolean; - source: string; - onClose: (exported: boolean) => void; -} - -interface WSEditorSwaggerReloadDialogState { - updating: boolean; - invalidText?: string; - resourceOptions: Resource[]; - selectedResources: Set; -} - -class WSEditorSwaggerReloadDialog extends React.Component< - WSEditorSwaggerReloadDialogProps, - WSEditorSwaggerReloadDialogState -> { - constructor(props: WSEditorSwaggerReloadDialogProps) { - super(props); - this.state = { - updating: false, - invalidText: undefined, - resourceOptions: [], - selectedResources: new Set(), - }; - } - - componentDidMount() { - this.loadResourceOptions(); - } - - loadResourceOptions = async () => { - this.setState({ - invalidText: undefined, - updating: true, - }); - try { - const resources: Resource[] = await workspaceApi.getWorkspaceResources(this.props.workspaceUrl); - this.setState({ - updating: false, - resourceOptions: resources, - selectedResources: new Set(resources.map((resource) => resource.id)), - }); - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - }; - - handleClose = () => { - this.props.onClose(false); - }; - - handleReload = async () => { - const { selectedResources, resourceOptions } = this.state; - const data = { - resources: resourceOptions - .filter((option) => selectedResources.has(option.id)) - .map((option) => { - return { - id: option.id, - version: option.version, - }; - }), - }; - - if (data.resources.length === 0) { - this.props.onClose(false); - return; - } - - this.setState({ - invalidText: undefined, - updating: true, - }); - - try { - if (this.props.source.toLowerCase() === "typespec") { - const swaggerDefault = await workspaceApi.getWorkspaceSwaggerDefault(this.props.workspaceName); - const { modNames, rpName, source } = swaggerDefault; - if (!modNames || modNames.length === 0 || !rpName || !source || source.toLowerCase() !== "typespec") { - this.setState({ - invalidText: "Invalid workspace info", - updating: false, - }); - return; - } - const resourceProviderUrl = - "/Swagger/Specs/" + - swaggerDefault.plane + - "/" + - swaggerDefault.modNames.join("/") + - `/ResourceProviders/${swaggerDefault.rpName}/TypeSpec`; - const requestBody = { - version: resourceOptions[0].version, - resources: data.resources, - resourceProviderUrl: resourceProviderUrl, - }; - console.log("request emitter data: ", requestBody); - const emitterOptionRes = await getTypespecRPResourcesOperations(requestBody); - console.log("emitterResourceOptionRes: ", emitterOptionRes); - if (emitterOptionRes.length === 0) { - this.setState({ - invalidText: "Invalid resource operation emitter info", - updating: false, - }); - return; - } - data.resources = emitterOptionRes; - await workspaceApi.reloadTypespecResources(this.props.workspaceUrl, data); - } else { - await workspaceApi.reloadSwaggerResources(this.props.workspaceUrl, data); - } - - this.setState({ - updating: false, - }); - this.props.onClose(true); - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - }; - - onSelectedAllClick = () => { - this.setState((preState) => { - return { - ...preState, - selectedResources: - preState.selectedResources.size > 0 ? new Set() : new Set(preState.resourceOptions.map((op) => op.id)), - }; - }); - }; - - onResourceItemClick = (resourceId: string) => { - return () => { - this.setState((preState) => { - const selectedResources = new Set(preState.selectedResources); - if (selectedResources.has(resourceId)) { - selectedResources.delete(resourceId); - } else { - selectedResources.add(resourceId); - } - return { - ...preState, - selectedResources: selectedResources, - }; - }); - }; - }; - - render() { - const { invalidText, selectedResources, updating, resourceOptions } = this.state; - - return ( - - - Reload {this.props.source.toLowerCase() === "typespec" ? "TypeSpec" : "Swagger"} Resources - - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - {/* Resource Url */} - - - - - 0 && selectedResources.size === resourceOptions.length} - indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} - tabIndex={-1} - disableRipple - inputProps={{ "aria-labelledby": "SelectAll" }} - /> - - - - - - - } - > - {resourceOptions.length > 0 && ( - - {resourceOptions.map((option) => { - const labelId = `resource-${option.id}`; - const selected = selectedResources.has(option.id); - return ( - - - - - - - - - ); - })} - - )} - - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); - } -} - -interface WSRenameDialogProps { - workspaceUrl: string; - workspaceName: string; - open: boolean; - onClose: (newWSName: string | null) => void; -} - -interface WSRenameDialogState { - newWSName: string; - invalidText?: string; - updating: boolean; -} - -class WSRenameDialog extends React.Component { - constructor(props: WSRenameDialogProps) { - super(props); - this.state = { - newWSName: this.props.workspaceName, - updating: false, - }; - } - - handleModify = () => { - const { newWSName } = this.state; - const { workspaceUrl, workspaceName } = this.props; - - const nName = newWSName.trim(); - if (nName.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); - return; - } - - this.setState({ - invalidText: undefined, - }); - this.setState({ - updating: true, - }); - - if (workspaceName === nName) { - this.setState({ - updating: false, - }); - this.props.onClose(null); - } else { - workspaceApi - .renameWorkspace(workspaceUrl, nName) - .then((res: any) => { - this.setState({ - updating: false, - }); - this.props.onClose(res.name); - }) - .catch((err: any) => { - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - }); - } - }; - - handleClose = () => { - this.setState({ - invalidText: undefined, - }); - this.props.onClose(null); - }; - - render() { - const { invalidText, updating } = this.state; - return ( - - Rename Workspace - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - { - this.setState({ - newWSName: event.target.value, - }); - }} - margin="normal" - required - /> - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); - } -} - -const WSEditorWrapper = (props: any) => { - const params = useParams(); - - return ; -}; - -export { WSEditorWrapper as WSEditor }; diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx deleted file mode 100644 index b88c364e..00000000 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ /dev/null @@ -1,2627 +0,0 @@ -import { - Alert, - Box, - Button, - ButtonBase, - CardContent, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - InputLabel, - LinearProgress, - Radio, - RadioGroup, - styled, - Switch, - TextField, - Typography, - TypographyProps, -} from "@mui/material"; -import { ChevronRight } from "@mui/icons-material"; -import AddIcon from "@mui/icons-material/Add"; -import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; -import CallSplitSharpIcon from "@mui/icons-material/CallSplitSharp"; -import EditIcon from "@mui/icons-material/Edit"; -import ImportExportIcon from "@mui/icons-material/ImportExport"; - -import { commandApi, errorHandlerApi } from "../../services"; -import pluralize from "pluralize"; -import React, { useEffect, useState } from "react"; -import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; -import { - CardTitleTypography, - ExperimentalTypography, - LongHelpTypography, - PreviewTypography, - ShortHelpPlaceHolderTypography, - ShortHelpTypography, - SmallExperimentalTypography, - SmallPreviewTypography, - StableTypography, - SubtitleTypography, -} from "./WSEditorTheme"; - -function WSEditorCommandArgumentsContent(props: { - commandUrl: string; - args: CMDArg[]; - clsArgDefineMap: ClsArgDefinitionMap; - onReloadArgs: () => Promise; - onAddSubCommand: (argVar: string, subArgOptions: { var: string; options: string }[], argStackNames: string[]) => void; -}) { - const [displayArgumentDialog, setDisplayArgumentDialog] = useState(false); - const [editArg, setEditArg] = useState(undefined); - const [, setEditArgIdxStack] = useState(undefined); - const [displayFlattenDialog, setDisplayFlattenDialog] = useState(false); - const [displayUnwrapClsDialog, setDisplayUnwrapClsDialog] = useState(false); - - const handleArgumentDialogClose = async (updated: boolean) => { - if (updated) { - props.onReloadArgs(); - } - setDisplayArgumentDialog(false); - setEditArg(undefined); - setEditArgIdxStack(undefined); - }; - - const handleEditArgument = (arg: CMDArg, argIdxStack: ArgIdx[]) => { - setEditArg(arg); - setEditArgIdxStack(argIdxStack); - setDisplayArgumentDialog(true); - }; - - const handleFlattenDialogClose = async (flattened: boolean) => { - if (flattened) { - props.onReloadArgs(); - } - setDisplayFlattenDialog(false); - setEditArg(undefined); - setEditArgIdxStack(undefined); - }; - - const handleArgumentFlatten = (arg: CMDArg, argIdxStack: ArgIdx[]) => { - setEditArg(arg); - setEditArgIdxStack(argIdxStack); - setDisplayFlattenDialog(true); - }; - - const handleUnwrapClsDialogClose = async (unwrapped: boolean) => { - if (unwrapped) { - props.onReloadArgs(); - } - setDisplayUnwrapClsDialog(false); - setEditArg(undefined); - setEditArgIdxStack(undefined); - }; - - const handleUnwrapClsArgument = (arg: CMDArg, argIdxStack: ArgIdx[]) => { - setEditArg(arg); - setEditArgIdxStack(argIdxStack); - setDisplayUnwrapClsDialog(true); - }; - - const handleAddSubcommand = (arg: CMDArg, argIdxStack: ArgIdx[]) => { - const argVar = arg.var; - const argStackNames = argIdxStack.map((argIdx) => { - let name = argIdx.displayKey; - while (name.startsWith("-")) { - name = name.slice(1); - } - if (name.endsWith("[]") || name.endsWith("{}")) { - name = name.slice(0, name.length - 2); - name = pluralize.singular(name); - } - return name; - }); - let a: CMDArgBase | undefined = arg; - if (a.type.startsWith("@")) { - const clsName = (a as CMDClsArg).clsName; - a = props.clsArgDefineMap[clsName]; - } - if (a.type.startsWith("dict<")) { - a = (a as CMDDictArgBase).item; - } else if (a.type.startsWith("array<")) { - a = (a as CMDArrayArgBase).item; - } - let subArgOptions: { var: string; options: string }[] = []; - if (a !== undefined) { - let subArgs; - if (a.type.startsWith("@")) { - const clsName = (a as CMDClsArg).clsName; - subArgs = (props.clsArgDefineMap[clsName] as CMDObjectArgBase).args; - } else { - subArgs = (a as CMDObjectArg).args; - } - subArgOptions = subArgs.map((value) => { - return { - var: value.var, - options: value.options.join(" "), - }; - }); - } - - props.onAddSubCommand(argVar, subArgOptions, argStackNames); - }; - - return ( - - - - [ ARGUMENT ] - - - - - {displayArgumentDialog && ( - - )} - {displayFlattenDialog && ( - - )} - {displayUnwrapClsDialog && ( - - )} - - ); -} - -interface ArgIdx { - var: string; - displayKey: string; -} - -function ArgumentNavigation(props: { - commandUrl: string; - args: CMDArg[]; - clsArgDefineMap: ClsArgDefinitionMap; - onEdit: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; - onFlatten: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; - onUnwrap: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; - onAddSubcommand: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; -}) { - const [argIdxStack, setArgIdxStack] = useState([]); - - const getArgProps = ( - selectedArgBase: CMDArgBase, - ): { title: string; props: CMDArg[]; flattenArgVar: string | undefined } | undefined => { - if (selectedArgBase.type.startsWith("@")) { - const clsArgDefine = props.clsArgDefineMap[(selectedArgBase as CMDClsArgBase).clsName]; - const clsArgProps = getArgProps(clsArgDefine); - if (clsArgProps !== undefined && clsArgDefine.type === "object") { - clsArgProps!.flattenArgVar = (selectedArgBase as CMDClsArg).var; - } - return clsArgProps; - } - if (selectedArgBase.type === "object") { - return { - title: "Props", - props: (selectedArgBase as CMDObjectArgBase).args, - flattenArgVar: (selectedArgBase as CMDObjectArg).var, - }; - } else if (selectedArgBase.type.startsWith("dict<")) { - const item = (selectedArgBase as CMDDictArgBase).item; - const itemProps = item ? getArgProps(item) : undefined; - if (!itemProps) { - return undefined; - } - return { - title: "Dict Element Props", - props: itemProps.props, - flattenArgVar: undefined, - }; - } else if (selectedArgBase.type.startsWith("array<")) { - const itemProps = getArgProps((selectedArgBase as CMDArrayArgBase).item); - if (!itemProps) { - return undefined; - } - return { - title: "Array Element Props", - props: itemProps.props, - flattenArgVar: undefined, - }; - } else { - return undefined; - } - }; - - const getSelectedArg = (stack: ArgIdx[]): CMDArg | undefined => { - if (stack.length === 0) { - return undefined; - } else { - let args: CMDArg[] = [...props.args]; - let selectedArg: CMDArg | undefined = undefined; - for (const i in stack) { - const argVar = stack[i].var; - selectedArg = args.find((arg) => arg.var === argVar); - if (!selectedArg) { - break; - } - args = getArgProps(selectedArg)?.props ?? []; - } - return selectedArg; - } - }; - - useEffect(() => { - setArgIdxStack([]); - }, [props.commandUrl]); - - useEffect(() => { - // update argument idx stack - const stack = [...argIdxStack]; - while (stack.length > 0 && !getSelectedArg(stack)) { - stack.pop(); - } - setArgIdxStack(stack); - }, [props.args, props.clsArgDefineMap]); - - const handleSelectSubArg = (subArgVar: string) => { - let subArg; - if (argIdxStack.length > 0) { - const arg = getSelectedArg(argIdxStack); - if (!arg) { - return; - } - subArg = getArgProps(arg)?.props.find((a) => a.var === subArgVar); - } else { - subArg = props.args.find((a) => a.var === subArgVar); - } - - if (!subArg) { - return; - } - const argIdx: ArgIdx = { - var: subArg.var, - displayKey: subArg.options[0], - }; - if (argIdxStack.length === 0) { - if (argIdx.displayKey.length === 1) { - argIdx.displayKey = `-${argIdx.displayKey}`; - } else { - argIdx.displayKey = `--${argIdx.displayKey}`; - } - } - - let argType = subArg.type; - if (argType.startsWith("@")) { - argType = props.clsArgDefineMap[(subArg as CMDClsArg).clsName].type; - } - if (argType.startsWith("dict<")) { - argIdx.displayKey += "{}"; - } else if (argType.startsWith("array<")) { - argIdx.displayKey += "[]"; - } - - setArgIdxStack([...argIdxStack, argIdx]); - }; - - const handleChangeArgIdStack = (end: number) => { - setArgIdxStack(argIdxStack.slice(0, end)); - }; - - const buildArgumentReviewer = () => { - const selectedArg = getSelectedArg(argIdxStack); - if (!selectedArg) { - return <>; - } - - const stage = selectedArg.stage; - - return ( - - - - {stage === "Stable" && {stage}} - {stage === "Preview" && {stage}} - {stage === "Experimental" && {stage}} - - { - props.onEdit(selectedArg, argIdxStack); - }} - onUnwrap={() => { - props.onUnwrap(selectedArg, argIdxStack); - }} - /> - - ); - }; - - const buildArgumentPropsReviewer = () => { - if (argIdxStack.length === 0) { - if (props.args.length === 0) { - return <>; - } - return ( - - ); - } else { - const selectedArg = getSelectedArg(argIdxStack); - if (!selectedArg) { - return <>; - } - const argProps = getArgProps(selectedArg); - if (!argProps) { - return <>; - } - const canFlatten = argProps.flattenArgVar !== undefined; - return ( - { - props.onFlatten(selectedArg!, argIdxStack); - } - : undefined - } - onAddSubcommand={() => { - props.onAddSubcommand(selectedArg!, argIdxStack); - }} - onSelectSubArg={handleSelectSubArg} - /> - ); - } - }; - - return ( - - {argIdxStack.length > 0 && {buildArgumentReviewer()}} - {buildArgumentPropsReviewer()} - - ); -} - -const NavBarItemTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const NavBarItemHightLightedTypography = styled(NavBarItemTypography)(() => ({ - color: "#5d64cf", -})); - -function ArgNavBar(props: { argIdxStack: ArgIdx[]; onChangeArgIdStack: (end: number) => void }) { - return ( - - - { - props.onChangeArgIdStack(0); - }} - > - - - {props.argIdxStack.slice(0, -1).map((argIdx, index) => ( - { - props.onChangeArgIdStack(index + 1); - }} - > - - {index > 0 ? `.${argIdx.displayKey}` : argIdx.displayKey} - - - ))} - { - props.onChangeArgIdStack(props.argIdxStack.length); - }} - > - - {props.argIdxStack.length > 1 - ? `.${props.argIdxStack[props.argIdxStack.length - 1].displayKey}` - : props.argIdxStack[props.argIdxStack.length - 1].displayKey} - - - - - ); -} - -const spliceArgOptionsString = (arg: CMDArg, depth: number) => { - let optionsString = arg.options - .map((option) => { - if (depth === 0) { - if (option.length === 1) { - return "-" + option; - } else { - return "--" + option; - } - } else { - return "." + option; - } - }) - .join(" "); - - if ((arg as CMDArrayArg).singularOptions) { - const singularOptionString = (arg as CMDArrayArg) - .singularOptions!.map((option) => { - if (depth === 0) { - if (option.length === 1) { - return "-" + option; - } else { - return "--" + option; - } - } else { - return "." + option; - } - }) - .join(" "); - optionsString += ` (${singularOptionString})`; - } else if ((arg as CMDClsArg).singularOptions) { - const singularOptionString = (arg as CMDArrayArg) - .singularOptions!.map((option) => { - if (depth === 0) { - if (option.length === 1) { - return "-" + option; - } else { - return "--" + option; - } - } else { - return "." + option; - } - }) - .join(" "); - optionsString += ` (${singularOptionString})`; - } - - return optionsString; -}; - -const ArgNameTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 26, - fontWeight: 700, -})); - -const ArgTypeTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 700, -})); - -const ArgRequiredTypography = styled(Typography)(() => ({ - color: "#fad105", - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 200, -})); - -const ArgEditTypography = styled(Typography)(() => ({ - color: "#5d64cf", - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const ArgChoicesTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 14, - fontWeight: 700, -})); - -function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => void; onUnwrap: () => void }) { - const [choices, setChoices] = useState([]); - - const buildArgOptionsString = () => { - const argOptionsString = spliceArgOptionsString(props.arg, props.depth - 1); - return {argOptionsString}; - }; - - useEffect(() => { - const newChoices: string[] = []; - if ((props.arg as CMDStringArg).enum) { - const items = (props.arg as CMDStringArg).enum!.items; - for (const idx in items) { - const enumItem = items[idx]; - newChoices.push(enumItem.name); - } - } else if ((props.arg as CMDNumberArg).enum) { - const items = (props.arg as CMDNumberArg).enum!.items; - for (const idx in items) { - const enumItem = items[idx]; - newChoices.push(enumItem.name); - } - } - setChoices(newChoices); - }, [props.arg]); - - const getUnwrapKeywords = () => { - if (props.arg.type.startsWith("@")) { - return "Unwrap"; - } else if (props.arg.type.startsWith("array")) { - if ((props.arg as CMDArrayArg).item?.type.startsWith("@")) { - return "Unwrap Element"; - } - } else if (props.arg.type.startsWith("dict")) { - if ((props.arg as CMDDictArg).item?.type.startsWith("@")) { - return "Unwrap Element"; - } - } - return null; - }; - - const getDefaultValueToString = () => { - if ( - props.arg.type === "object" || - props.arg.type.startsWith("dict<") || - props.arg.type.startsWith("array<") || - props.arg.type.startsWith("@") - ) { - if (props.arg.default !== undefined && props.arg.default !== null) { - return JSON.stringify(props.arg.default.value); - } - } else { - if (props.arg.default !== undefined && props.arg.default !== null) { - return props.arg.default.value.toString(); - } - } - return ""; - }; - - return ( - - - - {buildArgOptionsString()} - - - - - - {`/${props.arg.type}/`} - - {getUnwrapKeywords() !== null && ( - - )} - - {props.arg.required && [Required]} - - {(props.arg.default !== undefined || choices.length > 0 || props.arg.configurationKey !== undefined) && ( - - {choices.length > 0 && ( - {`Choices: ` + choices.join(", ")} - )} - {props.arg.default !== undefined && ( - {`Default: ${getDefaultValueToString()}`} - )} - {props.arg.configurationKey !== undefined && ( - - {`ConfigurationKey: ${props.arg.configurationKey}`} - - )} - - )} - {props.arg.help?.short && ( - {props.arg.help?.short} - )} - {!props.arg.help?.short && ( - - Please add argument short summary! - - )} - {props.arg.help?.lines && ( - - {props.arg.help.lines.map((line, idx) => ( - {line} - ))} - - )} - - - ); -} - -function ArgumentDialog(props: { - commandUrl: string; - arg: CMDArg; - clsArgDefineMap: ClsArgDefinitionMap; - open: boolean; - onClose: (updated: boolean) => Promise; -}) { - const [updating, setUpdating] = useState(false); - const [stage, setStage] = useState(""); - const [invalidText, setInvalidText] = useState(undefined); - const [options, setOptions] = useState(""); - const [singularOptions, setSingularOptions] = useState(undefined); - const [group, setGroup] = useState(""); - const [hide, setHide] = useState(false); - const [supportEnumExtension, setSupportEnumExtension] = useState(false); - const [shortHelp, setShortHelp] = useState(""); - const [longHelp, setLongHelp] = useState(""); - const [argSimilarTree, setArgSimilarTree] = useState(undefined); - const [argSimilarTreeExpandedIds, setArgSimilarTreeExpandedIds] = useState([]); - const [argSimilarTreeArgIdsUpdated, setArgSimilarTreeArgIdsUpdated] = useState([]); - const [hasDefault, setHasDefault] = useState(false); - const [defaultValue, setDefaultValue] = useState(undefined); - const [defaultValueInJson, setDefaultValueInJson] = useState(false); - const [hasPrompt, setHasPrompt] = useState(false); - const [promptMsg, setPromptMsg] = useState(undefined); - const [promptConfirm, setPromptConfirm] = useState(undefined); - const [configurationKey, setConfigurationKey] = useState(""); - const [isClientArg, setIsClientArg] = useState(false); - - const handleClose = () => { - setInvalidText(undefined); - props.onClose(false); - }; - - const verifyModification = () => { - setInvalidText(undefined); - const name = options.trim(); - const sName = singularOptions?.trim() ?? undefined; - const sHelp = shortHelp.trim(); - const lHelp = longHelp.trim(); - const gName = group.trim(); - const cfgKey = configurationKey.trim(); - - const names = name.split(" ").filter((n) => n.length > 0); - const sNames = sName?.split(" ").filter((n) => n.length > 0) ?? undefined; - - if (names.length < 1) { - setInvalidText(`Argument 'Option names' is required.`); - return undefined; - } - - for (const idx in names) { - const piece = names[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - setInvalidText(`Invalid 'Option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `); - return undefined; - } - } - - if (sNames) { - for (const idx in sNames) { - const piece = sNames[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - setInvalidText( - `Invalid 'Singular option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `, - ); - return undefined; - } - } - } - - if ( - sHelp.length < 1 && - names.find((n) => { - return n === "subscription" || n === "resource-group"; - }) === undefined - ) { - setInvalidText(`Field 'Short Summary' is required.`); - return undefined; - } - - let lines: string[] | null = null; - if (lHelp.length > 1) { - lines = lHelp.split("\n").filter((l) => l.length > 0); - } - - let argCfgKey: string | null = null; - if (cfgKey.length > 0) { - argCfgKey = cfgKey; - } - - let argDefault = undefined; - if (hasDefault === false) { - if (props.arg.default !== undefined) { - argDefault = null; - } - } else if (hasDefault === true) { - if (defaultValue === undefined) { - setInvalidText(`Field 'Default Value' is undefined.`); - return undefined; - } else { - try { - let argType = props.arg.type; - if (argType.startsWith("@")) { - argType = props.clsArgDefineMap[(props.arg as CMDClsArg).clsName].type; - } - argDefault = { - value: convertArgDefaultText(defaultValue!, argType), - }; - } catch (err: any) { - setInvalidText(`Field 'Default Value' is invalid: ${err.message}.`); - return undefined; - } - if (props.arg.default !== undefined && props.arg.default.value === argDefault.value) { - argDefault = undefined; - } - } - } - - let argPrompt = undefined; - if (hasPrompt === false) { - if (props.arg.prompt !== undefined) { - argPrompt = null; - } - } else if (hasPrompt === true) { - if (promptMsg === undefined) { - setInvalidText(`Field 'Prompt Message' is undefined.`); - return undefined; - } else { - const msg = promptMsg.trim(); - if (msg.length < 1) { - setInvalidText(`Field 'Prompt Message' is empty.`); - return undefined; - } - if (!msg.endsWith(":")) { - setInvalidText(`Field 'Prompt Message' must end with a colon.`); - return undefined; - } - argPrompt = { - msg: msg, - confirm: promptConfirm, - }; - } - } - - return { - options: names, - singularOptions: sNames, - stage: stage, - group: gName, - hide: hide, - help: { - short: sHelp, - lines: lines, - }, - default: argDefault, - prompt: argPrompt, - configurationKey: argCfgKey, - supportEnumExtension: supportEnumExtension, - }; - }; - - const handleModify = async () => { - const data = verifyModification(); - if (data === undefined) { - return; - } - - setUpdating(true); - - const argumentUrl = `${props.commandUrl}/Arguments/${props.arg.var}`; - - try { - await commandApi.updateCommandArgument(argumentUrl, data); - setUpdating(false); - await props.onClose(true); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - const handleDisplaySimilar = async () => { - if (verifyModification() === undefined) { - return; - } - - setUpdating(true); - - try { - const res = await commandApi.findSimilarArguments(props.commandUrl, props.arg.var); - setUpdating(false); - const { tree, expandedIds } = BuildArgSimilarTree(res); - setArgSimilarTree(tree); - setArgSimilarTreeExpandedIds(expandedIds); - setArgSimilarTreeArgIdsUpdated([]); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - const handleDisableSimilar = () => { - setArgSimilarTree(undefined); - setArgSimilarTreeExpandedIds([]); - }; - - const onSimilarTreeUpdated = (newTree: ArgSimilarTree) => { - setArgSimilarTree(newTree); - }; - - const onSimilarTreeExpandedIdsUpdated = (expandedIds: string[]) => { - setArgSimilarTreeExpandedIds(expandedIds); - }; - - const handleModifySimilar = async () => { - const data = verifyModification(); - if (data === undefined) { - 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); - updatedIds.push(argId); - setArgSimilarTreeArgIdsUpdated([...updatedIds]); - } catch (err: any) { - console.error(err); - invalidText += errorHandlerApi.getErrorMessage(err); - } - } - } - - if (invalidText.length > 0) { - setInvalidText(invalidText); - setUpdating(false); - } else { - setUpdating(false); - await props.onClose(true); - } - }; - - useEffect(() => { - const { arg, clsArgDefineMap } = props; - setIsClientArg(arg.var.startsWith("$Client.")); - - setOptions(arg.options.join(" ")); - if (arg.type.startsWith("array")) { - setSingularOptions((arg as CMDArrayArg).singularOptions?.join(" ") ?? ""); - } else if (arg.type.startsWith("@") && clsArgDefineMap[(arg as CMDClsArg).clsName].type.startsWith("array")) { - setSingularOptions((arg as CMDClsArg).singularOptions?.join(" ") ?? ""); - } else { - setSingularOptions(undefined); - } - - if ( - arg.type === "object" || - arg.type.startsWith("dict<") || - arg.type.startsWith("array<") || - arg.type.startsWith("@") - ) { - setHasPrompt(undefined); - } else { - setHasPrompt(arg.prompt !== undefined); - if (arg.prompt !== undefined) { - setPromptMsg(arg.prompt.msg); - setPromptConfirm(undefined); - } - } - if (arg.type === "password") { - setPromptConfirm((arg as CMDPasswordArg).prompt?.confirm ?? false); - } - setStage(props.arg.stage); - setGroup(props.arg.group); - setHide(props.arg.hide); - setSupportEnumExtension(props.arg.supportEnumExtension || false); - setShortHelp(props.arg.help?.short ?? ""); - setLongHelp(props.arg.help?.lines?.join("\n") ?? ""); - setConfigurationKey(props.arg.configurationKey ?? ""); - setUpdating(false); - setArgSimilarTree(undefined); - setArgSimilarTreeExpandedIds([]); - - if ( - arg.type === "object" || - arg.type.startsWith("dict<") || - arg.type.startsWith("array<") || - arg.type.startsWith("@") - ) { - setDefaultValueInJson(true); - if (props.arg.default !== undefined && props.arg.default !== null) { - setHasDefault(true); - setDefaultValue(JSON.stringify(props.arg.default.value)); - } else { - setHasDefault(false); - setDefaultValue(undefined); - } - } else { - setDefaultValueInJson(false); - if (props.arg.default !== undefined && props.arg.default !== null) { - setHasDefault(true); - setDefaultValue(props.arg.default.value.toString()); - } else { - setHasDefault(false); - setDefaultValue(undefined); - } - } - }, [props.arg]); - - return ( - - {!argSimilarTree && ( - <> - {isClientArg ? "Modify Client Argument" : "Modify Argument"} - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - { - setOptions(event.target.value); - }} - margin="normal" - required - /> - {singularOptions !== undefined && ( - { - setSingularOptions(event.target.value); - }} - margin="normal" - /> - )} - {!isClientArg && ( - <> - { - setGroup(event.target.value); - }} - margin="normal" - /> - - Stage - - { - setStage(event.target.value); - }} - > - } label="Stable" sx={{ ml: 4 }} /> - } label="Preview" sx={{ ml: 4 }} /> - } label="Experimental" sx={{ ml: 4 }} /> - - - {!props.arg.required && ( - <> - - Hide Argument - - { - setHide(!hide); - }} - /> - - )} - - {props.arg.hasEnum && ( - <> - - Support Enum Extension - - { - setSupportEnumExtension(!supportEnumExtension); - }} - /> - - )} - - )} - {hasDefault !== undefined && ( - <> - - Default Value - - - { - setHasDefault(!hasDefault); - setDefaultValue(undefined); - }} - /> - - - )} - - {hasPrompt !== undefined && ( - <> - - Prompt Input - - - { - setHasPrompt(!hasPrompt); - setPromptMsg(undefined); - }} - /> - - - - - )} - {!isClientArg && ( - { - setConfigurationKey(event.target.value); - }} - margin="normal" - /> - )} - - { - setShortHelp(event.target.value); - }} - margin="normal" - required - /> - { - setLongHelp(event.target.value); - }} - margin="normal" - /> - - - )} - - {argSimilarTree && ( - <> - Modify Similar Arguments - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - - )} - - {updating && ( - - - - )} - {!updating && !argSimilarTree && ( - <> - - {/* cls argument should flatten similar. Customer should unwrap cls argument before to modify it*/} - {!props.arg.var.startsWith("@") && ( - - )} - {/* TODO: support unwrap and update */} - {!isClientArg && } - - )} - {!updating && argSimilarTree && ( - <> - - - - )} - - - ); -} - -function FlattenDialog(props: { - commandUrl: string; - arg: CMDArg; - clsArgDefineMap: ClsArgDefinitionMap; - open: boolean; - onClose: (flattened: boolean) => Promise; -}) { - const [updating, setUpdating] = useState(false); - const [invalidText, setInvalidText] = useState(undefined); - const [subArgOptions, setSubArgOptions] = useState<{ var: string; options: string }[]>([]); - const [argSimilarTree, setArgSimilarTree] = useState(undefined); - const [argSimilarTreeExpandedIds, setArgSimilarTreeExpandedIds] = useState([]); - const [argSimilarTreeArgIdsUpdated, setArgSimilarTreeArgIdsUpdated] = useState([]); - - useEffect(() => { - const { arg, clsArgDefineMap } = props; - let subArgs; - if (arg.type.startsWith("@")) { - const clsName = (arg as CMDClsArg).clsName; - subArgs = (clsArgDefineMap[clsName] as CMDObjectArgBase).args; - } else { - subArgs = (arg as CMDObjectArg).args; - } - const subArgOptions = subArgs.map((value) => { - return { - var: value.var, - options: value.options.join(" "), - }; - }); - - setSubArgOptions(subArgOptions); - setArgSimilarTree(undefined); - setArgSimilarTreeExpandedIds([]); - }, [props.arg]); - - const handleClose = () => { - setInvalidText(undefined); - props.onClose(false); - }; - - const verifyFlatten = () => { - setInvalidText(undefined); - const argOptions: { [argVar: string]: string[] } = {}; - let invalidText: string | undefined = undefined; - - subArgOptions.forEach((arg, idx) => { - const names = arg.options.split(" ").filter((n) => n.length > 0); - if (names.length < 1) { - invalidText = `Prop ${idx + 1} option name is required.`; - return undefined; - } - - for (const idx in names) { - const piece = names[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - invalidText = `Invalid 'Prop ${idx + 1} option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `; - return undefined; - } - } - argOptions[arg.var] = names; - }); - if (invalidText !== undefined) { - setInvalidText(invalidText); - return undefined; - } - - return { - subArgsOptions: argOptions, - }; - }; - - const handleFlatten = async () => { - const data = verifyFlatten(); - if (data === undefined) { - return; - } - - setUpdating(true); - - const flattenUrl = `${props.commandUrl}/Arguments/${props.arg.var}/Flatten`; - - try { - await commandApi.flattenArgument(flattenUrl, data); - setUpdating(false); - await props.onClose(true); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - const handleDisplaySimilar = async () => { - if (verifyFlatten() === undefined) { - return; - } - - setUpdating(true); - - try { - const res = await commandApi.findSimilarArguments(props.commandUrl, props.arg.var); - setUpdating(false); - const { tree, expandedIds } = BuildArgSimilarTree(res); - setArgSimilarTree(tree); - setArgSimilarTreeExpandedIds(expandedIds); - setArgSimilarTreeArgIdsUpdated([]); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - const handleDisableSimilar = () => { - setArgSimilarTree(undefined); - setArgSimilarTreeExpandedIds([]); - }; - - const onSimilarTreeUpdated = (newTree: ArgSimilarTree) => { - setArgSimilarTree(newTree); - }; - - const onSimilarTreeExpandedIdsUpdated = (expandedIds: string[]) => { - setArgSimilarTreeExpandedIds(expandedIds); - }; - - const handleFlattenSimilar = async () => { - const data = verifyFlatten(); - if (data === undefined) { - 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) { - const flattenUrl = `${argId}/Flatten`; - try { - await commandApi.flattenArgument(flattenUrl, data); - updatedIds.push(argId); - setArgSimilarTreeArgIdsUpdated([...updatedIds]); - } catch (err: any) { - console.error(err); - invalidText += errorHandlerApi.getErrorMessage(err); - } - } - } - - if (invalidText.length > 0) { - setInvalidText(invalidText); - setUpdating(false); - } else { - setUpdating(false); - await props.onClose(true); - } - }; - - const buildSubArgText = (arg: { var: string; options: string }, idx: number) => { - return ( - { - const options = subArgOptions.map((value) => { - if (value.var === arg.var) { - return { - ...value, - options: event.target.value, - }; - } else { - return value; - } - }); - setSubArgOptions(options); - }} - margin="normal" - required - /> - ); - }; - - return ( - - {!argSimilarTree && ( - <> - Flatten Props - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - {subArgOptions.map(buildSubArgText)} - - - )} - - {argSimilarTree && ( - <> - Flatten Similar Argument Props - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - - )} - - {updating && ( - - - - )} - {!updating && !argSimilarTree && ( - <> - - {!props.arg.type.startsWith("@") && } - {props.arg.type.startsWith("@") && } - - - )} - {!updating && argSimilarTree && ( - <> - - - - )} - - - ); -} - -function UnwrapClsDialog(props: { - commandUrl: string; - arg: CMDArg; - open: boolean; - onClose: (unwrapped: boolean) => Promise; -}) { - const [updating, setUpdating] = useState(false); - const [invalidText, setInvalidText] = useState(undefined); - - const handleClose = () => { - setInvalidText(undefined); - props.onClose(false); - }; - - const handleUnwrap = async () => { - setUpdating(true); - - let argVar = props.arg.var; - if (props.arg.type.startsWith("array")) { - if ((props.arg as CMDArrayArg).item?.type.startsWith("@")) { - argVar += "[]"; - } - } else if (props.arg.type.startsWith("dict")) { - if ((props.arg as CMDDictArg).item?.type.startsWith("@")) { - argVar += "{}"; - } - } - - const flattenUrl = `${props.commandUrl}/Arguments/${argVar}/UnwrapClass`; - - try { - await commandApi.unwrapClassArgument(flattenUrl); - setUpdating(false); - await props.onClose(true); - } catch (err: any) { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - } - }; - - return ( - - Unwrap Class Type - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - {props.arg.type} - - - {updating && ( - - - - )} - {!updating && ( - <> - - - - )} - - - ); -} - -const PropArgTypeTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 10, - fontWeight: 400, -})); - -const PropRequiredTypography = styled(Typography)(() => ({ - color: "#dba339", - fontFamily: "'Work Sans', sans-serif", - fontSize: 10, - fontWeight: 400, -})); - -const PropHiddenTypography = styled(Typography)(() => ({ - color: "#8888C3", - fontFamily: "'Work Sans', sans-serif", - fontSize: 10, - fontWeight: 400, -})); - -const ArgGroupTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 18, - fontWeight: 200, -})); - -const PropArgOptionTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 700, -})); - -const PropHiddenArgOptionTypography = styled(Typography)(() => ({ - color: "#8888C3", - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 700, -})); - -const PropArgShortSummaryTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 14, - fontStyle: "italic", - fontWeight: 400, -})); - -function ArgumentPropsReviewer(props: { - title: string; - args: CMDArg[]; - onFlatten?: () => void; - onAddSubcommand?: () => void; - selectedArg?: CMDArg; - depth: number; - onSelectSubArg: (subArgVar: string) => void; -}) { - const groupArgs: { [name: string]: CMDArg[] } = {}; - if (props.args !== undefined) { - props.args.forEach((arg) => { - const groupName: string = arg.group.length > 0 ? arg.group : ""; - if (!(groupName in groupArgs)) { - groupArgs[groupName] = []; - } - groupArgs[groupName].push(arg); - }); - } - - const groups: ArgGroup[] = []; - - for (const groupName in groupArgs) { - groupArgs[groupName].sort((a, b) => { - if (a.required && !b.required) { - return -1; - } else if (!a.required && b.required) { - return 1; - } - return a.options[0].localeCompare(b.options[0]); - }); - groups.push({ - name: groupName, - args: groupArgs[groupName], - }); - } - groups.sort((a, b) => a.name.localeCompare(b.name)); - - const checkCanAddSubcommand = () => { - if (props.selectedArg && props.args.length > 0) { - return true; - } - return false; - }; - - const buildArg = (arg: CMDArg, idx: number) => { - const argOptionsString = spliceArgOptionsString(arg, props.depth); - return ( - - - { - props.onSelectSubArg(arg.var); - }} - > - {!arg.hide && {argOptionsString}} - {arg.hide && ( - {argOptionsString} - )} - - - - {arg.stage === "Preview" && ( - {arg.stage} - )} - {arg.stage === "Experimental" && ( - {arg.stage} - )} - - - - {`/${arg.type}/`} - - {arg.required && [Required]} - {arg.hide && [Hidden]} - - {arg.help && ( - - {arg.help.short} - - )} - - - ); - }; - - const buildArgGroup = (group: ArgGroup, idx: number) => { - return ( - - - - {group.name.length > 0 ? `${group.name} Group` : "Default Group"} - - - - {group.args.map(buildArg)} - - - ); - }; - - if (groups.length === 0) { - return <>; - } - - return ( - - - {props.title} - {props.onFlatten !== undefined && ( - - )} - - {/* {props.onUnflatten !== undefined && } */} - - {props.onAddSubcommand !== undefined && checkCanAddSubcommand() && ( - - )} - - {groups.map(buildArgGroup)} - - ); -} - -interface ArgGroup { - name: string; - args: CMDArg[]; -} - -type CMDArgHelp = { - short: string; - lines?: string[]; - refCommands?: string[]; -}; - -type CMDArgDefault = { - value: T | null; -}; - -type CMDArgBlank = { - value: T | null; -}; - -type CMDArgEnumItem = { - name: string; - hide: boolean; - value: T; -}; - -type CMDArgEnum = { - items: CMDArgEnumItem[]; -}; - -interface CMDArgPromptInput { - msg: string; -} - -interface CMDPasswordArgPromptInput extends CMDArgPromptInput { - confirm: boolean; -} - -interface CMDArgBase { - type: string; - nullable: boolean; - blank?: CMDArgBlank; -} - -interface CMDArg extends CMDArgBase { - var: string; - options: string[]; - - required: boolean; - stage: "Stable" | "Preview" | "Experimental"; - hide: boolean; - group: string; - help?: CMDArgHelp; - - default?: CMDArgDefault; - idPart?: string; - prompt?: CMDArgPromptInput; - configurationKey?: string; - supportEnumExtension?: boolean; - hasEnum?: boolean; -} - -interface CMDArgBaseT extends CMDArgBase { - blank?: CMDArgBlank; -} - -interface CMDArgT extends CMDArg { - default?: CMDArgDefault; - blank?: CMDArgBlank; -} - -// type: starts with "@" -interface CMDClsArgBase extends CMDArgBase { - clsName: string; -} - -interface CMDClsArg extends CMDClsArgBase, CMDArg { - singularOptions?: string[]; // for list use only -} - -// type: string -interface CMDStringArgBase extends CMDArgBaseT { - enum?: CMDArgEnum; - // fmt?: CMDStringFormat -} - -interface CMDStringArg extends CMDStringArgBase, CMDArgT {} - -// // type: byte -// interface CMDByteArgBase extends CMDStringArgBase { } - -// // type: binary -// interface CMDBinaryArgBase extends CMDStringArgBase { } - -// // type: duration -// interface CMDDurationArgBase extends CMDStringArgBase { } - -// // type: date As defined by full-date - https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 -// interface CMDDateArgBase extends CMDStringArgBase { } - -// // type: dateTime As defined by date-time - https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 -// interface CMDDateTimeArgBase extends CMDStringArgBase { } - -// interface CMDTimeArgBase extends CMDStringArgBase { } - -// // type: uuid -// interface CMDUuidArgBase extends CMDStringArgBase { } - -// type: password -interface CMDPasswordArgBase extends CMDStringArgBase {} -interface CMDPasswordArg extends CMDPasswordArgBase, CMDStringArg { - prompt?: CMDPasswordArgPromptInput; -} - -// // type: SubscriptionId -// interface CMDSubscriptionIdArgBase extends CMDStringArgBase { } - -// // type: ResourceGroupName -// interface CMDResourceGroupNameArgBase extends CMDStringArgBase { } - -// // type: ResourceId -// interface CMDResourceIdNameArgBase extends CMDStringArgBase { } - -// // type: ResourceLocation -// interface CMDResourceLocationNameArgBase extends CMDStringArgBase { } - -interface CMDNumberArgBase extends CMDArgBaseT { - enum?: CMDArgEnum; - // fmt?: CMDIntegerFormat -} -interface CMDNumberArg extends CMDNumberArgBase, CMDArgT {} - -// // type: integer -// interface CMDIntegerArgBase extends CMDNumberArgBase { } - -// // type: integer32 -// interface CMDInteger32ArgBase extends CMDNumberArgBase { } - -// // type: integer32 -// interface CMDInteger64ArgBase extends CMDNumberArgBase { } - -// // type: float -// interface CMDFloatArgBase extends CMDNumberArgBase { } - -// // type: float32 -// interface CMDFloat32ArgBase extends CMDNumberArgBase { } - -// // type: float64 -// interface CMDFloat64ArgBase extends CMDNumberArgBase { } - -// // type: boolean -// interface CMDBooleanArgBase extends CMDArgBaseT { } - -// type: object -interface CMDObjectArgBase extends CMDArgBase { - // fmt?: CMDObjectFormat - args: CMDArg[]; -} - -interface CMDObjectArg extends CMDObjectArgBase, CMDArg {} -// type: dict -interface CMDDictArgBase extends CMDArgBase { - item?: CMDArgBase; - anyType: boolean; -} -interface CMDDictArg extends CMDDictArgBase, CMDArg {} - -// type: array -interface CMDArrayArgBase extends CMDArgBase { - // fmt?: CMDArrayFormat - item: CMDArgBase; -} - -interface CMDArrayArg extends CMDArrayArgBase, CMDArg { - singularOptions?: string[]; -} - -type ClsArgDefinitionMap = { - [clsName: string]: CMDArgBase; -}; - -function decodeArgEnumItem(response: any): CMDArgEnumItem { - return { - name: response.name, - hide: response.hide ?? false, - value: response.value as T, - }; -} - -function decodeArgEnum(response: any): CMDArgEnum { - const argEnum: CMDArgEnum = { - items: response.items.map((item: any) => decodeArgEnumItem(item)), - }; - return argEnum; -} - -function decodeArgBlank(response: any | undefined): CMDArgBlank | undefined { - if (response === undefined || response === null) { - return undefined; - } - - return { - value: response.value as T | null, - }; -} - -function decodeArgDefault(response: any | undefined): CMDArgDefault | undefined { - if (response === undefined || response === null) { - return undefined; - } - - return { - value: response.value as T | null, - }; -} - -function decodeArgPromptInput(response: any): CMDArgPromptInput | undefined { - if (response === undefined || response === null) { - return undefined; - } - - return { - msg: response.msg as string, - }; -} - -function decodePasswordArgPromptInput(response: any): CMDPasswordArgPromptInput | undefined { - if (response === undefined || response === null) { - return undefined; - } - - return { - msg: response.msg as string, - confirm: (response.confirm ?? false) as boolean, - }; -} - -function decodeArgBase(response: any): { - argBase: CMDArgBase; - clsDefineMap: ClsArgDefinitionMap; -} { - let argBase: any = { - type: response.type, - nullable: (response.nullable ?? false) as boolean, - }; - - let clsDefineMap: ClsArgDefinitionMap = {}; - - switch (response.type) { - case "byte": - case "binary": - case "duration": - case "date": - case "dateTime": - case "time": - case "uuid": - case "password": - case "SubscriptionId": - case "ResourceGroupName": - case "ResourceId": - case "ResourceLocation": - case "string": - if (response.enum) { - argBase = { - ...argBase, - enum: decodeArgEnum(response.enum), - }; - } - if (response.blank) { - argBase = { - ...argBase, - blank: decodeArgBlank(response.blank), - }; - } - break; - case "integer32": - case "integer64": - case "integer": - if (response.enum) { - argBase = { - ...argBase, - enum: decodeArgEnum(response.enum), - }; - } - if (response.blank) { - argBase = { - ...argBase, - blank: decodeArgBlank(response.blank), - }; - } - break; - case "float32": - case "float64": - case "float": - if (response.enum) { - argBase = { - ...argBase, - enum: decodeArgEnum(response.enum), - }; - } - if (response.blank) { - argBase = { - ...argBase, - blank: decodeArgBlank(response.blank), - }; - } - break; - case "boolean": - if (response.blank) { - argBase = { - ...argBase, - blank: decodeArgBlank(response.blank), - }; - } - break; - case "any": - if (response.blank) { - argBase = { - ...argBase, - blank: decodeArgBlank(response.blank), - }; - } - break; - case "object": - if (response.args && Array.isArray(response.args) && response.args.length > 0) { - const args: CMDArg[] = response.args.map((resSubArg: any) => { - const subArgParse = decodeArg(resSubArg); - clsDefineMap = { - ...clsDefineMap, - ...subArgParse.clsDefineMap, - }; - return subArgParse.arg; - }); - argBase = { - ...argBase, - args: args, - }; - } else if (response.additionalProps && response.additionalProps.item) { - // Convert additionalProps to dict argBaseType - const itemArgBaseParse = decodeArgBase(response.additionalProps.item); - clsDefineMap = { - ...clsDefineMap, - ...itemArgBaseParse.clsDefineMap, - }; - const argBaseType = `dict`; - argBase = { - ...argBase, - type: argBaseType, - item: itemArgBaseParse.argBase, - anyType: false, - }; - } else if (response.additionalProps && response.additionalProps.anyType) { - const argBaseType = `dict`; - argBase = { - ...argBase, - type: argBaseType, - anyType: true, - }; - } - - if (response.cls) { - const clsName = response.cls; - clsDefineMap[clsName] = argBase; - argBase = { - type: `@${response.cls}`, - clsName: clsName, - }; - } - break; - default: - if (response.type.startsWith("array<")) { - if (response.item) { - const itemArgBaseParse = decodeArgBase(response.item); - clsDefineMap = { - ...clsDefineMap, - ...itemArgBaseParse.clsDefineMap, - }; - const argBaseType = `array<${itemArgBaseParse.argBase.type}>`; - argBase = { - ...argBase, - type: argBaseType, - item: itemArgBaseParse.argBase, - }; - } else { - throw Error("Invalid array object. Item is not defined"); - } - - if (response.cls) { - const clsName = response.cls; - clsDefineMap[clsName] = argBase; - argBase = { - type: `@${response.cls}`, - clsName: clsName, - }; - } - } else if (response.type.startsWith("@")) { - argBase["clsName"] = response.type.slice(1); - } else { - console.error(`Unknown type '${response.type}'`); - throw Error(`Unknown type '${response.type}'`); - } - } - - return { - argBase: argBase, - clsDefineMap: clsDefineMap, - }; -} - -function decodeArgHelp(response: any): CMDArgHelp { - return { - short: response.short, - lines: response.lines, - refCommands: response.refCommands, - }; -} - -function decodeArg(response: any): { - arg: CMDArg; - clsDefineMap: ClsArgDefinitionMap; -} { - const { argBase, clsDefineMap } = decodeArgBase(response); - const options = (response.options as string[]).sort((a, b) => a.length - b.length).reverse(); - const help = response.help ? decodeArgHelp(response.help) : undefined; - const prompt = response.prompt ? decodeArgPromptInput(response.prompt) : undefined; - - let arg: any = { - ...argBase, - var: response.var as string, - options: options, - required: (response.required ?? false) as boolean, - stage: (response.stage ?? "Stable") as "Stable" | "Preview" | "Experimental", - hide: (response.hide ?? false) as boolean, - group: (response.group ?? "") as string, - help: help, - idPart: response.idPart, - prompt: prompt, - configurationKey: response.configurationKey, - supportEnumExtension: response.enum?.supportExtension || response.item?.enum?.supportExtension || false, - hasEnum: response.enum?.items?.length > 0 || response.item?.enum?.items?.length > 0 || false, - }; - - switch (argBase.type) { - case "byte": - case "binary": - case "duration": - case "date": - case "dateTime": - case "time": - case "uuid": - case "SubscriptionId": - case "ResourceGroupName": - case "ResourceId": - case "ResourceLocation": - case "string": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "password": - if (response.prompt) { - arg = { - ...arg, - prompt: decodePasswordArgPromptInput(response.prompt), - }; - } - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "integer32": - case "integer64": - case "integer": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "float32": - case "float64": - case "float": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "boolean": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "any": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - case "object": - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - break; - default: - if (argBase.type.startsWith("dict<")) { - // dict type - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - } else if (argBase.type.startsWith("array<")) { - // array type - if (response.singularOptions) { - arg = { - ...arg, - singularOptions: response.singularOptions as string[], - }; - } - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault>(response.default), - }; - } - } else if (argBase.type.startsWith("@")) { - if (response.singularOptions) { - arg = { - ...arg, - singularOptions: response.singularOptions as string[], - }; - } - if (response.default) { - arg = { - ...arg, - default: decodeArgDefault(response.default), - }; - } - } else { - console.error(`Unknown type '${argBase.type}'`); - throw Error(`Unknown type '${argBase.type}'`); - } - } - - return { - arg: arg, - clsDefineMap: clsDefineMap, - }; -} - -function convertArgDefaultText(defaultText: string, argType: string): any { - switch (argType) { - case "byte": - case "binary": - case "duration": - case "date": - case "dateTime": - case "time": - case "uuid": - case "password": - case "SubscriptionId": - case "ResourceGroupName": - case "ResourceId": - case "ResourceLocation": - case "string": - if (defaultText.trim().length === 0) { - throw Error(`Not supported empty value: '${defaultText}'`); - } - return defaultText.trim(); - case "integer32": - case "integer64": - case "integer": - if (Number.isNaN(parseInt(defaultText.trim()))) { - throw Error(`Not supported default value for integer type: '${defaultText}'`); - } - return parseInt(defaultText.trim()); - case "float32": - case "float64": - case "float": - if (Number.isNaN(parseFloat(defaultText.trim()))) { - throw Error(`Not supported default value for float type: '${defaultText}'`); - } - return parseFloat(defaultText.trim()); - case "boolean": - switch (defaultText.trim().toLowerCase()) { - case "true": - case "yes": - return true; - case "false": - case "no": - return false; - default: - throw Error(`Not supported default value for boolean type: '${defaultText}'`); - } - case "any": - let trimmed = defaultText.trim().toLowerCase(); - if (Number.isInteger(parseInt(trimmed))) { - return parseInt(trimmed); - } - if (!Number.isNaN(parseFloat(trimmed))) { - return parseFloat(trimmed); - } - switch (trimmed) { - case "null": - return null; - case "true": - case "yes": - return true; - case "false": - case "no": - return false; - default: - return defaultText.trim(); - } - case "object": { - const de = JSON.parse(defaultText.trim()); - // TODO: verify object - return de; - } - default: - if (argType.startsWith("array")) { - const de = JSON.parse(defaultText.trim()); - // TODO: verify array - return de; - } else if (argType.startsWith("dict")) { - const de = JSON.parse(defaultText.trim()); - // TODO: verify dict - return de; - } - throw Error(`Not supported type: ${argType}`); - } -} - -const DecodeArgs = (argGroups: any[]): { args: CMDArg[]; clsArgDefineMap: ClsArgDefinitionMap } => { - let clsDefineMap: ClsArgDefinitionMap = {}; - const args: CMDArg[] = []; - argGroups.forEach((argGroup: any) => { - args.push( - ...argGroup.args.map((resArg: any) => { - const argDecode = decodeArg(resArg); - clsDefineMap = { - ...clsDefineMap, - ...argDecode.clsDefineMap, - }; - return argDecode.arg; - }), - ); - }); - return { - args: args, - clsArgDefineMap: clsDefineMap, - }; -}; - -export default WSEditorCommandArgumentsContent; -export { DecodeArgs }; -export type { ClsArgDefinitionMap, CMDArg }; diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx deleted file mode 100644 index 8f32a257..00000000 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ /dev/null @@ -1,1995 +0,0 @@ -import { - styled, - Alert, - Box, - Button, - Card, - CardActions, - CardContent, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - Accordion, - InputLabel, - LinearProgress, - Radio, - RadioGroup, - TextField, - Typography, - TypographyProps, - AccordionDetails, - IconButton, - Input, - InputAdornment, - AccordionSummaryProps, - FormLabel, - Switch, - ButtonBase, - FormLabelProps, -} from "@mui/material"; -import React, { useState, useEffect } from "react"; -import MuiAccordionSummary from "@mui/material/AccordionSummary"; -import { - NameTypography, - ShortHelpTypography, - ShortHelpPlaceHolderTypography, - LongHelpTypography, - StableTypography, - PreviewTypography, - ExperimentalTypography, - SubtitleTypography, - CardTitleTypography, -} from "./WSEditorTheme"; -import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; -import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; -import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; -import DataObjectIcon from "@mui/icons-material/DataObject"; -import LabelIcon from "@mui/icons-material/Label"; -import { commandApi, errorHandlerApi } from "../../services"; -import WSEditorCommandArgumentsContent, { - ClsArgDefinitionMap, - CMDArg, - DecodeArgs, -} from "./WSEditorCommandArgumentsContent"; -import EditIcon from "@mui/icons-material/Edit"; -import CloseIcon from "@mui/icons-material/Close"; -import Stack from "@mui/material/Stack"; -import { ExampleItemSelector } from "./WSEditorExamplePicker"; - -interface Plane { - name: string; - displayName: string; - moduleOptions?: string[]; -} - -interface Example { - name: string; - commands: string[]; -} - -interface ObjectOutput { - type: "object"; - ref: string; - clientFlatten: boolean; -} - -interface ArrayOutput { - type: "array"; - ref: string; - clientFlatten: boolean; - nextLink: string; -} - -interface StringOutput { - type: "string"; - ref: string; - value: string; -} - -type Output = ObjectOutput | ArrayOutput | StringOutput; - -function isObjectOutput(output: Output): output is ObjectOutput { - return output.type === "object"; -} - -function isArrayOutput(output: Output): output is ArrayOutput { - return output.type === "array"; -} - -// function isStringOutput(output: Output): output is StringOutput { -// return output.type === "string"; -// } - -interface Resource { - id: string; - version: string; - subresource?: string; - swagger: string; -} - -interface Command { - id: string; - names: string[]; - help?: { - short: string; - lines?: string[]; - }; - stage: "Stable" | "Preview" | "Experimental"; - version: string; - examples?: Example[]; - outputs?: Output[]; - resources: Resource[]; - - // additional property - confirmation?: string; - args?: CMDArg[]; - clsArgDefineMap?: ClsArgDefinitionMap; -} - -// interface ClientConfig { -// args?: CMDArg[] -// } - -interface ResponseCommand { - names: string[]; - help?: { - short: string; - lines?: string[]; - }; - stage?: "Stable" | "Preview" | "Experimental"; - version: string; - examples?: Example[]; - resources: Resource[]; - outputs?: Output[]; - confirmation?: string; - argGroups?: any[]; -} - -interface ResponseCommands { - [name: string]: ResponseCommand; -} - -interface WSEditorCommandContentProps { - workspaceUrl: string; - previewCommand: Command; - reloadTimestamp: number; - onUpdateCommand: (command: Command | null) => void; -} - -interface WSEditorCommandContentState { - command?: Command; - displayCommandDialog: boolean; - displayExampleDialog: boolean; - displayOutputDialog: boolean; - displayCommandDeleteDialog: boolean; - displayAddSubcommandDialog: boolean; - subcommandDefaultGroupNames?: string[]; - subcommandArgVar?: string; - subcommandSubArgOptions?: { var: string; options: string }[]; - exampleIdx?: number; - outputIdx?: number; - loading: boolean; -} - -const commandPrefix = "az "; - -const ExampleCommandHeaderTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const ExampleCommandBodyTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const ExampleEditTypography = styled(Typography)(() => ({ - color: "#5d64cf", - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const ExampleAccordionSummary = styled((props: AccordionSummaryProps) => ( - } {...props} /> -))(() => ({ - "flexDirection": "row-reverse", - "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { - transform: "rotate(0deg)", - }, -})); - -const OutputTypeTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 10, - fontWeight: 400, -})); - -const OutputRefTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 700, -})); - -const OutputFlagTypography = styled(Typography)(() => ({ - color: "#8888C3", - fontFamily: "'Work Sans', sans-serif", - fontSize: 10, - fontWeight: 400, -})); - -const OutputEditTypography = styled(Typography)(() => ({ - color: "#5d64cf", - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -const OutputDialogLabel = styled(FormLabel)(() => ({ - fontSize: 12, -})); - -const OutputDialogMainTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 18, - fontWeight: 400, -})); - -class WSEditorCommandContent extends React.Component { - constructor(props: WSEditorCommandContentProps) { - super(props); - this.state = { - command: undefined, - displayCommandDialog: false, - displayExampleDialog: false, - displayOutputDialog: false, - displayCommandDeleteDialog: false, - displayAddSubcommandDialog: false, - loading: false, - }; - } - - loadCommand = async () => { - this.setState({ loading: true }); - const { workspaceUrl, previewCommand } = this.props; - const commandNames = previewCommand.names; - const leafUrl = - `${workspaceUrl}/CommandTree/Nodes/aaz/` + - commandNames.slice(0, -1).join("/") + - "/Leaves/" + - commandNames[commandNames.length - 1]; - try { - const commandData = await commandApi.getCommand(leafUrl); - const command = DecodeResponseCommand(commandData); - if (command.id === this.props.previewCommand.id) { - this.setState({ - loading: false, - command: command, - }); - } - } catch (err: any) { - this.setState({ loading: false }); - console.error(err); - return; - } - }; - - componentDidMount() { - this.loadCommand(); - } - - componentDidUpdate(prevProps: WSEditorCommandContentProps) { - if ( - prevProps.workspaceUrl !== this.props.workspaceUrl || - prevProps.previewCommand.id !== this.props.previewCommand.id || - prevProps.reloadTimestamp !== this.props.reloadTimestamp - ) { - if (prevProps.previewCommand.id !== this.props.previewCommand.id) { - this.setState({ command: undefined }); - } - this.loadCommand(); - } - } - - onCommandDialogDisplay = () => { - this.setState({ - displayCommandDialog: true, - }); - }; - - onCommandDeleteDialogDisplay = () => { - this.setState({ - displayCommandDeleteDialog: true, - }); - }; - - handleCommandDialogClose = (newCommand?: Command) => { - if (newCommand) { - this.props.onUpdateCommand(newCommand!); - } - this.setState({ - displayCommandDialog: false, - }); - }; - - handleCommandDeleteDialogClose = (deleted: boolean) => { - if (deleted) { - this.props.onUpdateCommand(null); - } - this.setState({ - displayCommandDeleteDialog: false, - }); - }; - - onExampleDialogDisplay = (idx?: number) => { - this.setState({ - displayExampleDialog: true, - exampleIdx: idx, - }); - }; - - handleExampleDialogClose = (newCommand?: Command) => { - if (newCommand) { - this.props.onUpdateCommand(newCommand!); - } - this.setState({ - displayExampleDialog: false, - }); - }; - - onOutputDialogDisplay = (idx?: number) => { - this.setState({ - displayOutputDialog: true, - outputIdx: idx, - }); - }; - - handleOutputDialogClose = (newCommand?: Command) => { - if (newCommand) { - this.props.onUpdateCommand(newCommand!); - } - this.setState({ - displayOutputDialog: false, - }); - }; - - onAddSubcommandDialogDisplay = ( - argVar: string, - subArgOptions: { var: string; options: string }[], - argStackNames: string[], - ) => { - this.setState({ - displayAddSubcommandDialog: true, - subcommandArgVar: argVar, - subcommandSubArgOptions: subArgOptions, - subcommandDefaultGroupNames: [...this.props.previewCommand.names.slice(0, -1), ...argStackNames], - }); - }; - - handleAddSubcommandDisplayClose = (add: boolean) => { - if (add) { - this.props.onUpdateCommand(this.state.command!); - } - this.setState({ - displayAddSubcommandDialog: false, - subcommandArgVar: undefined, - subcommandDefaultGroupNames: undefined, - }); - }; - - render() { - const { workspaceUrl, previewCommand } = this.props; - const commandNames = previewCommand.names; - const name = commandPrefix + commandNames.join(" "); - const commandUrl = - `${workspaceUrl}/CommandTree/Nodes/aaz/` + - commandNames.slice(0, -1).join("/") + - "/Leaves/" + - commandNames[commandNames.length - 1]; - - const { - command, - displayCommandDialog, - displayExampleDialog, - displayOutputDialog, - displayCommandDeleteDialog, - displayAddSubcommandDialog, - exampleIdx, - outputIdx, - loading, - } = this.state; - - const buildExampleView = (example: Example, idx: number) => { - const buildCommand = (exampleCommand: string, cmdIdx: number) => { - return ( - - - - {commandPrefix} - - - {exampleCommand} - - - ); - }; - return ( - { - this.onExampleDialogDisplay(idx); - }} - > - - - {example.name} - {/* */} - - - - - {example.commands.map(buildCommand)} - - - ); - }; - - const buildCommandCard = () => { - const shortHelp = (command ?? previewCommand).help?.short; - const longHelp = (command ?? previewCommand).help?.lines?.join("\n"); - const lines: string[] = (command ?? previewCommand).help?.lines ?? []; - const stage = (command ?? previewCommand).stage; - const version = (command ?? previewCommand).version; - - return ( - - - - [ COMMAND ] - - {stage === "Stable" && {`v${version}`}} - {stage === "Preview" && {`v${version}`}} - {stage === "Experimental" && ( - {`v${version}`} - )} - - - {name} - {shortHelp && {shortHelp} } - {!shortHelp && ( - - Please add command short summary! - - )} - {longHelp && ( - - {lines.map((line, idx) => ( - {line} - ))} - - )} - - - {loading && ( - - - - )} - {!loading && ( - - - - - )} - - - ); - }; - - const buildArgumentsCard = () => { - return ( - - - - ); - }; - - const buildExampleCard = () => { - const examples = command!.examples ?? []; - return ( - - - - [ EXAMPLE ] - - {examples.length > 0 && {examples.map(buildExampleView)}} - - - - - - - ); - }; - - return ( - - - {buildCommandCard()} - {command !== undefined && command.args !== undefined && buildArgumentsCard()} - {command !== undefined && buildExampleCard()} - {command !== undefined && command.outputs !== undefined && ( - - )} - - {command !== undefined && displayCommandDialog && ( - - )} - {command !== undefined && displayExampleDialog && ( - - )} - {command !== undefined && displayOutputDialog && ( - - )} - {command !== undefined && displayCommandDeleteDialog && ( - - )} - {command !== undefined && displayAddSubcommandDialog && ( - - )} - - ); - } -} - -function CommandDeleteDialog(props: { - workspaceUrl: string; - open: boolean; - command: Command; - onClose: (deleted: boolean) => void; -}) { - const [updating, setUpdating] = React.useState(false); - const [relatedCommands, setRelatedCommands] = React.useState([]); - - const getUrls = () => { - const urls: string[] = []; - - props.command.resources.forEach((resource) => { - const resourceId = btoa(resource.id); - const version = btoa(resource.version); - if (resource.subresource !== undefined) { - const subresource = btoa(resource.subresource); - // TODO: delete list command together with crud - // if (resource.subresource.endsWith('[]') || resource.subresource.endsWith('{}')) { - // let subresource2 = btoa(resource.subresource.slice(0, -2)) - // urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources/${subresource2}`) - // } else { - // let subresource2 = btoa(resource.subresource + '[]'); - // urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources/${subresource2}`) - // subresource2 = btoa(resource.subresource + '{}'); - // urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources/${subresource2}`) - // } - urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources/${subresource}`); - } else { - urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}`); - } - }); - return urls; - }; - - React.useEffect(() => { - setRelatedCommands([]); - const urls = getUrls(); - const promisesAll = urls.map(async (url) => { - return await commandApi.getCommandsForResource(url); - }); - Promise.all(promisesAll) - .then((responses) => { - const commands = new Set(); - responses.forEach((responseCommands: ResponseCommand[]) => { - responseCommands - .map((responseCommand) => DecodeResponseCommand(responseCommand)) - .forEach((cmd) => { - commands.add(cmd.names.join(" ")); - }); - }); - - const cmdNames: string[] = []; - commands.forEach((cmdName) => cmdNames.push(cmdName)); - cmdNames.sort((a, b) => a.localeCompare(b)); - setRelatedCommands(cmdNames); - }) - .catch((err) => { - console.error(err); - }); - }, [props.command]); - - const handleClose = () => { - props.onClose(false); - }; - const handleDelete = () => { - setUpdating(true); - const urls = getUrls(); - const promisesAll = urls.map(async (url) => { - return await commandApi.deleteResource(url); - }); - Promise.all(promisesAll) - .then(() => { - setUpdating(false); - props.onClose(true); - }) - .catch((err) => { - setUpdating(false); - console.error(err); - }); - }; - return ( - - Delete Commands - - {relatedCommands.map((command, idx) => ( - {`${commandPrefix}${command}`} - ))} - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); -} - -interface CommandDialogProps { - workspaceUrl: string; - open: boolean; - command: Command; - onClose: (newCommand?: Command) => void; -} - -interface CommandDialogState { - name: string; - stage: string; - shortHelp: string; - longHelp: string; - invalidText?: string; - confirmation: string; - updating: boolean; -} - -class CommandDialog extends React.Component { - constructor(props: CommandDialogProps) { - super(props); - this.state = { - name: this.props.command.names.join(" "), - shortHelp: this.props.command.help?.short ?? "", - longHelp: this.props.command.help?.lines?.join("\n") ?? "", - stage: this.props.command.stage, - confirmation: this.props.command.confirmation ?? "", - updating: false, - }; - } - - handleModify = async () => { - let { name, shortHelp, longHelp, confirmation } = this.state; - const { stage } = this.state; - - const { workspaceUrl, command } = this.props; - - name = name.trim(); - shortHelp = shortHelp.trim(); - longHelp = longHelp.trim(); - confirmation = confirmation.trim(); - - const names = name.split(" ").filter((n) => n.length > 0); - - this.setState({ - invalidText: undefined, - }); - - if (names.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); - return; - } - - for (const idx in names) { - const piece = names[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - this.setState({ - invalidText: `Invalid Name part: '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `, - }); - return; - } - } - - if (shortHelp.length < 1) { - this.setState({ - invalidText: `Field 'Short Summary' is required.`, - }); - return; - } - - let lines: string[] | null = null; - if (longHelp.length > 1) { - lines = longHelp.split("\n").filter((l) => l.length > 0); - } - - this.setState({ - updating: true, - }); - - const leafUrl = - `${workspaceUrl}/CommandTree/Nodes/aaz/` + - command.names.slice(0, -1).join("/") + - "/Leaves/" + - command.names[command.names.length - 1]; - - try { - const commandData = await commandApi.updateCommand(leafUrl, { - help: { - short: shortHelp, - lines: lines, - }, - stage: stage, - confirmation: confirmation, - }); - - const name = names.join(" "); - if (name === command.names.join(" ")) { - const cmd = DecodeResponseCommand(commandData); - this.setState({ - updating: false, - }); - this.props.onClose(cmd); - } else { - const renamedData = await commandApi.renameCommand(leafUrl, name); - const cmd = DecodeResponseCommand(renamedData); - this.setState({ - updating: false, - }); - this.props.onClose(cmd); - } - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); - } - }; - - handleClose = () => { - this.setState({ - invalidText: undefined, - }); - this.props.onClose(); - }; - - render() { - const { name, shortHelp, longHelp, invalidText, updating, stage, confirmation } = this.state; - return ( - - Command - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - Stage - - { - this.setState({ - stage: event.target.value, - }); - }} - > - } label="Stable" sx={{ ml: 4 }} /> - } label="Preview" sx={{ ml: 4 }} /> - } label="Experimental" sx={{ ml: 4 }} /> - - - { - this.setState({ - name: event.target.value, - }); - }} - margin="normal" - required - /> - { - this.setState({ - shortHelp: event.target.value, - }); - }} - margin="normal" - required - /> - { - this.setState({ - longHelp: event.target.value, - }); - }} - margin="normal" - /> - { - this.setState({ - confirmation: event.target.value, - }); - }} - margin="normal" - /> - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); - } -} - -// function CommandDeleteDialog - -interface ExampleDialogProps { - workspaceUrl: string; - open: boolean; - command: Command; - idx?: number; - onClose: (newCommand?: Command) => void; -} - -interface ExampleDialogState { - name: string; - exampleCommands: string[]; - isAdd: boolean; - invalidText?: string; - updating: boolean; - source?: string; - exampleOptions: Example[]; -} - -const ExampleCommandTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 400, -})); - -class ExampleDialog extends React.Component { - constructor(props: ExampleDialogProps) { - super(props); - const examples: Example[] = this.props.command.examples ?? []; - if (this.props.idx === undefined) { - this.state = { - name: "", - exampleCommands: [""], - isAdd: true, - invalidText: undefined, - updating: false, - source: undefined, - exampleOptions: [], - }; - } else { - const example = examples[this.props.idx]; - this.state = { - name: example.name, - exampleCommands: example.commands, - isAdd: false, - invalidText: undefined, - updating: false, - source: undefined, - exampleOptions: [], - }; - } - } - - onUpdateExamples = async (examples: Example[]) => { - const { workspaceUrl, command } = this.props; - - const leafUrl = - `${workspaceUrl}/CommandTree/Nodes/aaz/` + - command.names.slice(0, -1).join("/") + - "/Leaves/" + - command.names[command.names.length - 1]; - - this.setState({ - updating: true, - }); - - try { - const responseData = await commandApi.updateCommandExamples(leafUrl, examples); - const cmd = DecodeResponseCommand(responseData); - this.setState({ - updating: false, - }); - this.props.onClose(cmd); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - updating: false, - }); - } - }; - - handleDelete = () => { - const { command } = this.props; - let examples: Example[] = command.examples ?? []; - const idx = this.props.idx!; - examples = [...examples.slice(0, idx), ...examples.slice(idx + 1)]; - this.onUpdateExamples(examples); - }; - - handleModify = () => { - const { command } = this.props; - let { name, exampleCommands } = this.state; - let examples: Example[] = command.examples ?? []; - const idx = this.props.idx!; - - name = name.trim(); - if (name.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); - return; - } - exampleCommands = exampleCommands - .map((cmd) => { - return cmd - .split("\n") - .map((cmdLine) => cmdLine.trim()) - .filter((cmdLine) => cmdLine.length > 0) - .join(" ") - .trim(); - }) - .filter((cmd) => cmd.length > 0); - - if (exampleCommands.length < 1) { - this.setState({ - invalidText: `Field 'Commands' is required.`, - }); - return; - } - - const newExample: Example = { - name: name, - commands: exampleCommands, - }; - - examples = [...examples.slice(0, idx), newExample, ...examples.slice(idx + 1)]; - - this.onUpdateExamples(examples); - }; - - handleAdd = () => { - const { command } = this.props; - let { name, exampleCommands } = this.state; - const examples: Example[] = command.examples ?? []; - - name = name.trim(); - if (name.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); - return; - } - exampleCommands = exampleCommands - .map((cmd) => { - return cmd - .split("\n") - .map((cmdLine) => cmdLine.trim()) - .filter((cmdLine) => cmdLine.length > 0) - .join(" ") - .trim(); - }) - .filter((cmd) => cmd.length > 0); - - if (exampleCommands.length < 1) { - this.setState({ - invalidText: `Field 'Commands' is required.`, - }); - return; - } - - const newExample: Example = { - name: name, - commands: exampleCommands, - }; - examples.push(newExample); - - this.onUpdateExamples(examples); - }; - - handleClose = () => { - this.setState({ - invalidText: undefined, - }); - this.props.onClose(); - }; - - onModifyExampleCommand = (cmd: string, idx: number) => { - this.setState((preState) => { - return { - ...preState, - exampleCommands: [...preState.exampleCommands.slice(0, idx), cmd, ...preState.exampleCommands.slice(idx + 1)], - }; - }); - }; - - onRemoveExampleCommand = (idx: number) => { - this.setState((preState) => { - const exampleCommands: string[] = [ - ...preState.exampleCommands.slice(0, idx), - ...preState.exampleCommands.slice(idx + 1), - ]; - if (exampleCommands.length === 0) { - exampleCommands.push(""); - } - return { - ...preState, - exampleCommands: exampleCommands, - }; - }); - }; - - onAddExampleCommand = () => { - this.setState((preState) => { - return { - ...preState, - exampleCommands: [...preState.exampleCommands, ""], - }; - }); - }; - - loadSwaggerExamples = async () => { - try { - let { workspaceUrl, command } = this.props; - - const leafUrl = - `${workspaceUrl}/CommandTree/Nodes/aaz/` + - command.names.slice(0, -1).join("/") + - "/Leaves/" + - command.names[command.names.length - 1]; - - this.setState({ - source: "swagger", - updating: true, - }); - const examples = await commandApi.generateSwaggerExamples(leafUrl); - this.setState({ - exampleOptions: examples, - updating: false, - }); - if (examples.length > 0) { - this.onExampleSelectorUpdate(examples[0].name); - } - } catch (err: any) { - console.error(err.response); - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - }; - - onExampleSelectorUpdate = (exampleDisplayName: string | null) => { - let example = this.state.exampleOptions.find((v) => v.name === exampleDisplayName) ?? undefined; - - if (example === undefined) { - this.setState({ - name: exampleDisplayName ?? "", - }); - } else { - this.setState({ - name: example?.name ?? "", - exampleCommands: example?.commands ?? [""], - }); - } - }; - - render() { - const { name, exampleCommands, isAdd, invalidText, updating, source, exampleOptions } = this.state; - - const selectedName = name; - - const buildExampleInput = (cmd: string, idx: number) => { - return ( - - this.onRemoveExampleCommand(idx)} aria-label="remove"> - - - { - this.onModifyExampleCommand(event.target.value, idx); - }} - sx={{ flexGrow: 1 }} - placeholder="Input a command here." - startAdornment={ - - {commandPrefix} - - } - /> - - ); - }; - - return ( - - - {isAdd ? "Add Example" : "Modify Example"} - - - - - - {isAdd && source === undefined && ( - - - {/* - */} - - )} - {(!isAdd || source != undefined) && ( - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - {!isAdd && ( - - { - this.setState({ - name: event.target.value, - }); - }} - margin="normal" - required - /> - - )} - {source === "swagger" && ( - - v.name)} - value={selectedName} - onValueUpdate={this.onExampleSelectorUpdate} - /> - - )} - - Commands - - {exampleCommands.map(buildExampleInput)} - - - - - One more command - - - )} - - {(!isAdd || source != undefined) && ( - - {updating && ( - - - - )} - {!updating && ( - - {!isAdd && ( - - - - - )} - {isAdd && } - - )} - - )} - - ); - } -} - -function AddSubcommandDialog(props: { - workspaceUrl: string; - command: Command; - argVar: string; - subArgOptions: { var: string; options: string }[]; - defaultGroupNames: string[]; - open: boolean; - onClose: (added: boolean) => void; -}) { - const [updating, setUpdating] = useState(false); - const [invalidText, setInvalidText] = useState(undefined); - const [commandGroupName, setCommandGroupName] = useState(""); - const [refArgsOptions, setRefArgsOptions] = useState<{ var: string; options: string }[]>([]); - - useEffect(() => { - setCommandGroupName(props.defaultGroupNames.join(" ")); - setRefArgsOptions(props.subArgOptions); - }, [props.argVar, props.defaultGroupNames]); - - const handleClose = () => { - setInvalidText(undefined); - props.onClose(false); - }; - - const verifyAddSubresource = () => { - setInvalidText(undefined); - const argOptions: { [argVar: string]: string[] } = {}; - let invalidText: string | undefined = undefined; - refArgsOptions.forEach((arg, idx) => { - const names = arg.options.split(" ").filter((n) => n.length > 0); - if (names.length < 1) { - invalidText = `Prop ${idx + 1} option name is required.`; - return undefined; - } - - for (const idx in names) { - const piece = names[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - invalidText = `Invalid 'Prop ${idx + 1} option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `; - return undefined; - } - } - argOptions[arg.var] = names; - }); - - const names = commandGroupName.split(" ").filter((n) => n.length > 0); - if (names.length < 1) { - invalidText = "Invalid Command group name"; - return; - } - - if (invalidText !== undefined) { - setInvalidText(invalidText); - return undefined; - } - - return { - commandGroupName: names.join(" "), - refArgsOptions: argOptions, - }; - }; - - const handleAddSubresource = async () => { - const urls = props.command.resources.map((resource) => { - const resourceId = btoa(resource.id); - const version = btoa(resource.version); - return `${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources`; - }); - - if (urls.length !== 1) { - setInvalidText(`Cannot create subcommands, command contains ${props.command.resources.length} resources`); - return; - } - - const data = verifyAddSubresource(); - if (data === undefined) { - return; - } - - setUpdating(true); - - try { - await commandApi.createSubresource(urls[0], { - ...data, - arg: props.argVar, - }); - props.onClose(true); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - setInvalidText(`ResponseError: ${message}`); - setUpdating(false); - } - }; - - const buildRefArgText = (arg: { var: string; options: string }, idx: number) => { - return ( - { - const options = refArgsOptions.map((value) => { - if (value.var === arg.var) { - return { - ...value, - options: event.target.value, - }; - } else { - return value; - } - }); - setRefArgsOptions(options); - }} - margin="normal" - required - /> - ); - }; - - return ( - - Add Subcommands - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - Subcommand Group - { - setCommandGroupName(event.target.value); - }} - /> - {refArgsOptions.length > 0 && ( - <> - Argument Options - {refArgsOptions.map(buildRefArgText)} - - )} - - - {updating && ( - - - - )} - {!updating && ( - <> - - - - )} - - - ); -} - -function OutputCard(props: { command: Command; onOutputDialogDisplay: (idx: number) => void }) { - const outputs = props.command!.outputs!; - - const buildBaseOutputView = ( - idx: number, - refName: string, - type: string, - flags: string[], - onClick?: React.MouseEventHandler | undefined, - ) => { - return ( - - - - JSON - - - - - - {refName} - - - - - {`/${type}/`} - - {flags.map((flag, idx) => { - return {`[${flag}]`}; - })} - - - - - ); - }; - - const buildObjectOutputView = ( - output: ObjectOutput, - idx: number, - onClick?: React.MouseEventHandler | undefined, - ) => { - return buildBaseOutputView( - idx, - output.ref, - output.type, - output.clientFlatten ? ["Flattened"] : ["Unflattened"], - onClick, - ); - }; - - const buildArrayOutputView = ( - output: ArrayOutput, - idx: number, - onClick?: React.MouseEventHandler | undefined, - ) => { - return buildBaseOutputView( - idx, - output.ref, - output.type, - output.clientFlatten ? ["Flattened"] : ["Unflattened"], - onClick, - ); - }; - - const buildStringOutputView = ( - output: StringOutput, - idx: number, - onClick?: React.MouseEventHandler | undefined, - ) => { - const title = output.ref ? output.ref : output.value; - return buildBaseOutputView(idx, title, output.type, [], onClick); - }; - - const buildOutputView = (output: Output, idx: number) => { - const onClick = () => { - props.onOutputDialogDisplay(idx); - }; - switch (output.type) { - case "object": - return buildObjectOutputView(output, idx, onClick); - case "array": - return buildArrayOutputView(output, idx, onClick); - case "string": - return buildStringOutputView(output, idx, onClick); - } - }; - - return ( - - - - [ OUTPUT ] - - {outputs.length > 0 && outputs.map(buildOutputView)} - - - ); -} - -function OutputDialog(props: { - workspaceUrl: string; - command: Command; - idx?: number; - open: boolean; - onClose: (newCommand?: Command) => void; -}) { - const [updating, setUpdating] = useState(false); - const [invalidText, setInvalidText] = useState(undefined); - const outputs = props.command.outputs ?? []; - const output = outputs[props.idx!]; - const [flatten, setFlatten] = useState(output.type !== "string" ? output.clientFlatten : false); - const flattenLabelContent = flatten ? "Flattened" : "Unflattened"; - - const handleClose = () => { - setInvalidText(undefined); - props.onClose(); - }; - - const handleUpdateOutput = async () => { - setInvalidText(undefined); - setUpdating(true); - - if (isObjectOutput(output) || isArrayOutput(output)) { - let commandNames = props.command.names; - const leafUrl = - `${props.workspaceUrl}/CommandTree/Nodes/aaz/` + - 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 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}`); - setInvalidText(`Invalid output type for flatten switch: ${output.type}`); - } - }; - - return ( - - JSON Format Output - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - {(output.type !== "string" || output.ref !== undefined) && ( - - Output Reference - {output.ref} - - )} - {output.type == "string" && output.ref == undefined && ( - - Output Value - {output.value} - - )} - {output.type == "array" && output.nextLink !== undefined && ( - - Next Link Reference - {output.nextLink} - - )} - {(output.type !== "string" || output.ref !== undefined) && ( - - Client Flatten - - { - setFlatten(event.target.checked); - }} - /> - } - label={{flattenLabelContent}} - labelPlacement="end" - /> - - - )} - - - {updating && ( - - - - )} - {!updating && } - - - - ); -} - -const DecodeResponseCommand = (command: ResponseCommand): Command => { - let cmd: Command = { - id: "command:" + command.names.join("/"), - names: command.names, - help: command.help, - stage: command.stage ?? "Stable", - examples: command.examples, - outputs: command.outputs, - resources: command.resources, - version: command.version, - }; - - if (command.confirmation) { - cmd.confirmation = command.confirmation; - } - - if (command.argGroups) { - cmd = { - ...cmd, - ...DecodeArgs(command.argGroups!), - }; - } - - return cmd; -}; - -export default WSEditorCommandContent; - -export { DecodeResponseCommand }; - -export type { Plane, Command, Resource, ResponseCommand, ResponseCommands, Example }; diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx deleted file mode 100644 index 2f48f363..00000000 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import { - Alert, - Box, - Button, - Card, - CardActions, - CardContent, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, - InputLabel, - LinearProgress, - Radio, - RadioGroup, - TextField, - Typography, -} from "@mui/material"; -import { commandApi, errorHandlerApi } from "../../services"; -import * as React from "react"; -import { ResponseCommands } from "./WSEditorCommandContent"; -import { - NameTypography, - ShortHelpTypography, - ShortHelpPlaceHolderTypography, - LongHelpTypography, - StableTypography, - PreviewTypography, - ExperimentalTypography, -} from "./WSEditorTheme"; - -interface CommandGroup { - id: string; - names: string[]; - stage: "Stable" | "Preview" | "Experimental"; - help?: { - short: string; - lines?: string[]; - }; - canDelete: boolean; -} - -interface ResponseCommandGroup { - names: string[]; - stage?: "Stable" | "Preview" | "Experimental"; - help?: { - short: string; - lines?: string[]; - }; - commands?: ResponseCommands; - commandGroups?: ResponseCommandGroups; -} - -interface ResponseCommandGroups { - [name: string]: ResponseCommandGroup; -} - -const commandPrefix = "az "; - -interface WSEditorCommandGroupContentProps { - workspaceUrl: string; - commandGroup: CommandGroup; - reloadTimestamp: number; - onUpdateCommandGroup: (commandGroup: CommandGroup | null) => void; -} - -interface WSEditorCommandGroupContentState { - displayCommandGroupDialog: boolean; - displayCommandGroupDeleteDialog: boolean; -} - -class WSEditorCommandGroupContent extends React.Component< - WSEditorCommandGroupContentProps, - WSEditorCommandGroupContentState -> { - constructor(props: WSEditorCommandGroupContentProps) { - super(props); - this.state = { - displayCommandGroupDialog: false, - displayCommandGroupDeleteDialog: false, - }; - } - - onCommandGroupDialogDisplay = () => { - this.setState({ - displayCommandGroupDialog: true, - }); - }; - - onCommandGroupDeleteDialogDisplay = () => { - this.setState({ - displayCommandGroupDeleteDialog: true, - }); - }; - - handleCommandGroupDialogClose = (newCommandGroup?: CommandGroup) => { - this.setState({ - displayCommandGroupDialog: false, - }); - if (newCommandGroup) { - this.props.onUpdateCommandGroup(newCommandGroup!); - } - }; - - handleCommandGroupDeleteDialogClose = (deleted: boolean) => { - this.setState({ - displayCommandGroupDeleteDialog: false, - }); - if (deleted) { - this.props.onUpdateCommandGroup(null); - } - }; - - render() { - const { workspaceUrl, commandGroup } = this.props; - const name = commandPrefix + this.props.commandGroup.names.join(" "); - const shortHelp = this.props.commandGroup.help?.short; - const longHelp = this.props.commandGroup.help?.lines?.join("\n"); - const lines: string[] = this.props.commandGroup.help?.lines ?? []; - const stage = this.props.commandGroup.stage; - const { displayCommandGroupDialog, displayCommandGroupDeleteDialog } = this.state; - return ( - - - - - - - [ GROUP ] - - - {stage === "Stable" && {stage}} - {stage === "Preview" && {stage}} - {stage === "Experimental" && ( - {stage} - )} - - - {name} - {shortHelp && {shortHelp} } - {!shortHelp && ( - - Please add command group short summary! - - )} - {longHelp && ( - - {lines.map((line, idx) => ( - {line} - ))} - - )} - - - - - - - - - - {displayCommandGroupDialog && ( - - )} - {displayCommandGroupDeleteDialog && ( - - )} - - ); - } -} - -function CommandGroupDeleteDialog(props: { - workspaceUrl: string; - open: boolean; - commandGroup: CommandGroup; - onClose: (deleted: boolean) => void; -}) { - const [updating, setUpdating] = React.useState(false); - - const handleClose = () => { - props.onClose(false); - }; - const handleDelete = async () => { - const nodeUrl = `${props.workspaceUrl}/CommandTree/Nodes/aaz/` + props.commandGroup.names.join("/"); - setUpdating(true); - - try { - await commandApi.deleteCommandGroup(nodeUrl); - setUpdating(false); - props.onClose(true); - } catch (err: any) { - setUpdating(false); - console.error(err); - } - }; - - return ( - - Delete Command Group - - {`${commandPrefix}${props.commandGroup.names.join(" ")}`} - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); -} - -interface CommandGroupDialogProps { - workspaceUrl: string; - open: boolean; - commandGroup: CommandGroup; - onClose: (newCommandGroup?: CommandGroup) => void; -} - -interface CommandGroupDialogState { - name: string; - stage: string; - shortHelp: string; - longHelp: string; - invalidText?: string; - updating: boolean; -} - -class CommandGroupDialog extends React.Component { - constructor(props: CommandGroupDialogProps) { - super(props); - this.state = { - name: this.props.commandGroup.names.join(" "), - shortHelp: this.props.commandGroup.help?.short ?? "", - longHelp: this.props.commandGroup.help?.lines?.join("\n") ?? "", - stage: this.props.commandGroup.stage, - updating: false, - }; - } - - handleModify = async () => { - let { name, shortHelp, longHelp } = this.state; - const { stage } = this.state; - const { workspaceUrl, commandGroup } = this.props; - - name = name.trim(); - shortHelp = shortHelp.trim(); - longHelp = longHelp.trim(); - - const names = name.split(" ").filter((n) => n.length > 0); - - this.setState({ - invalidText: undefined, - }); - - if (names.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); - return; - } - - for (const idx in names) { - const piece = names[idx]; - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { - this.setState({ - invalidText: `Invalid Name part: '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `, - }); - return; - } - } - - if (shortHelp.length < 1) { - this.setState({ - invalidText: `Field 'Short Summary' is required.`, - }); - } - - let lines: string[] = []; - if (longHelp.length > 1) { - lines = longHelp.split("\n").filter((l) => l.length > 0); - } - - this.setState({ - updating: true, - }); - - const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/` + commandGroup.names.join("/"); - - try { - const res = await commandApi.updateCommandGroup(nodeUrl, { - help: { - short: shortHelp, - lines: lines, - }, - stage: stage, - }); - - const name = names.join(" "); - if (name === commandGroup.names.join(" ")) { - const cmdGroup = DecodeResponseCommandGroup(res); - this.setState({ - updating: false, - }); - this.props.onClose(cmdGroup); - } else { - const renameRes = await commandApi.renameCommandGroup(nodeUrl, name); - const cmdGroup = DecodeResponseCommandGroup(renameRes); - this.setState({ - updating: false, - }); - this.props.onClose(cmdGroup); - } - } catch (err: any) { - console.error(err); - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - }; - - handleClose = () => { - this.setState({ - invalidText: undefined, - }); - this.props.onClose(); - }; - - render() { - const { name, shortHelp, longHelp, invalidText, updating, stage } = this.state; - return ( - - Command Group - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - - - Stage - - { - this.setState({ - stage: event.target.value, - }); - }} - > - } label="Stable" sx={{ ml: 4 }} /> - } label="Preview" sx={{ ml: 4 }} /> - } label="Experimental" sx={{ ml: 4 }} /> - - - { - this.setState({ - name: event.target.value, - }); - }} - margin="normal" - required - /> - { - this.setState({ - shortHelp: event.target.value, - }); - }} - margin="normal" - required - /> - { - this.setState({ - longHelp: event.target.value, - }); - }} - margin="normal" - /> - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); - } -} - -const DecodeResponseCommandGroup = (commandGroup: ResponseCommandGroup): CommandGroup => { - return { - id: "group:" + commandGroup.names.join("/"), - names: commandGroup.names, - help: commandGroup.help, - stage: commandGroup.stage ?? "Stable", - canDelete: true, - }; -}; - -export default WSEditorCommandGroupContent; - -export { DecodeResponseCommandGroup }; -export type { CommandGroup, ResponseCommandGroup, ResponseCommandGroups }; diff --git a/src/web/src/views/workspace/WSEditorCommandTree.tsx b/src/web/src/views/workspace/WSEditorCommandTree.tsx deleted file mode 100644 index 1d9ced96..00000000 --- a/src/web/src/views/workspace/WSEditorCommandTree.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import * as React from "react"; -import TreeView from "@mui/lab/TreeView"; -import TreeItem from "@mui/lab/TreeItem"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import { styled, Box, IconButton, Menu, MenuItem, Tooltip, Typography, TypographyProps } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import MoreHorizSharpIcon from "@mui/icons-material/MoreHorizSharp"; - -interface CommandTreeLeaf { - id: string; - names: string[]; -} - -interface CommandTreeNode { - id: string; - names: string[]; - nodes?: CommandTreeNode[]; - leaves?: CommandTreeLeaf[]; - canDelete: boolean; -} - -interface WSEditorCommandTreeProps { - commandTreeNodes: CommandTreeNode[]; - selected: string; - expanded: string[]; - onSelected: (nodeId: string) => void; - onToggle: (nodeIds: string[]) => void; - onAdd: () => void; - onReload: () => void; - onEditClientConfig?: () => void; -} - -interface WSEditorCommandTreeState { - openMore: boolean; -} - -const HeaderTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 16, - fontWeight: 600, -})); - -class WSEditorCommandTree extends React.Component { - constructor(props: WSEditorCommandTreeProps) { - super(props); - this.state = { - openMore: false, - }; - } - - moreButtonRef = React.createRef(); - - onNodeSelected = (_event: React.SyntheticEvent, nodeIds: string[] | string) => { - if (typeof nodeIds === "string") { - this.props.onSelected(nodeIds); - } - }; - - onNodeToggle = (_event: React.SyntheticEvent, nodeIds: string[]) => { - this.props.onToggle(nodeIds); - }; - - handleMoreClick = () => { - this.setState((preState) => { - return { - ...preState, - openMore: !preState.openMore, - }; - }); - }; - - handleEditClientConfig = () => { - this.setState({ openMore: false }); - this.props.onEditClientConfig!(); - }; - - render() { - const { commandTreeNodes, selected, onAdd, onReload, expanded, onEditClientConfig } = this.props; - const { openMore } = this.state; - - const renderLeaf = (leaf: CommandTreeLeaf) => { - const leafName = leaf.names[leaf.names.length - 1]; - return ( - { - if (selected !== leaf.id) { - this.onNodeSelected(event, leaf.id); - } - event.stopPropagation(); - event.preventDefault(); - }} - /> - ); - }; - - const renderNode = (node: CommandTreeNode) => { - const nodeName = node.names[node.names.length - 1]; - return ( - { - if (selected !== node.id || expanded.indexOf(node.id) === -1) { - this.onNodeSelected(event, node.id); - this.onNodeToggle(event, [...expanded, node.id]); - } else { - this.onNodeToggle( - event, - expanded.filter((v) => v !== node.id), - ); - } - event.stopPropagation(); - event.preventDefault(); - }} - > - {Array.isArray(node.leaves) ? node.leaves.map((leaf) => renderLeaf(leaf)) : null} - {Array.isArray(node.nodes) ? node.nodes.map((subNode) => renderNode(subNode)) : null} - - ); - }; - - return ( - - - Command Tree - - - - - - - - - - - - {onEditClientConfig !== undefined && ( - <> - - - - - - { - this.setState({ openMore: false }); - }} - MenuListProps={{ - "aria-labelledby": "more-button", - }} - > - Edit Client Config - - - )} - - } - defaultExpandIcon={} - selected={selected} - expanded={expanded} - sx={{ - flexGrow: 1, - overflowY: "auto", - mt: 1, - ml: 3, - mr: 3, - }} - > - {commandTreeNodes.map((node) => renderNode(node))} - - - ); - } -} - -export default WSEditorCommandTree; - -export type { CommandTreeNode, CommandTreeLeaf }; diff --git a/src/web/src/views/workspace/WSEditorExamplePicker.tsx b/src/web/src/views/workspace/WSEditorExamplePicker.tsx deleted file mode 100644 index 0d791c25..00000000 --- a/src/web/src/views/workspace/WSEditorExamplePicker.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react"; -import { Box, Autocomplete, TextField } from "@mui/material"; - -interface ExampleItemsSelectorProps { - commonPrefix: string; - options: string[]; - name: string; - value: string | null; - onValueUpdate: (value: string | null) => void; -} - -class ExampleItemSelector extends React.Component { - constructor(props: ExampleItemsSelectorProps) { - super(props); - this.state = { - value: this.props.options.length === 1 ? this.props.options[0] : null, - }; - } - - render() { - const { name, options, commonPrefix, value } = this.props; - return ( - { - this.props.onValueUpdate(newValue); - }} - getOptionLabel={(option) => { - return option.replace(commonPrefix, ""); - }} - renderOption={(props, option) => { - return ( - - {option.replace(commonPrefix, "")} - - ); - }} - selectOnFocus - // clearOnBlur - freeSolo - renderInput={(params) => ( - - )} - /> - ); - } -} - -export { ExampleItemSelector }; diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx deleted file mode 100644 index deee1633..00000000 --- a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx +++ /dev/null @@ -1,957 +0,0 @@ -import * as React from "react"; -import { - Typography, - Box, - AppBar, - Toolbar, - IconButton, - Button, - Autocomplete, - TextField, - Backdrop, - CircularProgress, - List, - ListSubheader, - ListItem, - ListItemButton, - ListItemIcon, - Checkbox, - ListItemText, - Alert, - Paper, - InputBase, - Select, - MenuItem, - FormControl, - InputLabel, - FormHelperText, -} from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; -import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; -import EditorPageLayout from "../../components/EditorPageLayout"; -import { styled } from "@mui/material/styles"; -import { getTypespecRPResources, getTypespecRPResourcesOperations } from "../../typespec"; - -interface WSEditorSwaggerPickerProps { - workspaceName: string; - plane: string; - onClose: (updated: boolean) => void; -} - -interface WSEditorSwaggerPickerState { - loading: boolean; - - plane: string; - - defaultModule: string | null; - defaultResourceProvider: string | null; - defaultSource: string | null; - // preVersion: string | null - - moduleOptions: string[]; - resourceProviderOptions: string[]; - versionOptions: string[]; - - versionResourceIdMap: VersionResourceIdMap; - resourceMap: ResourceMap; - resourceOptions: Resource[]; - - existingResources: Set; - selectedResources: Set; - selectedResourceInheritanceAAZVersionMap: ResourceInheritanceAAZVersionMap; - preferredAAZVersion: string | null; - - moduleOptionsCommonPrefix: string; - resourceProviderOptionsCommonPrefix: string; - - selectedModule: string | null; - selectedResourceProvider: string | null; - selectedVersion: string | null; - - updateOptions: string[]; - updateOption: string; - invalidText?: string; - filterText: string; - realFilterText: string; -} - -type ResourceVersionOperations = { - [Named: string]: string; -}; - -type ResourceVersion = { - version: string; - operations: ResourceVersionOperations; - file: string; - id: string; - path: string; -}; - -type Resource = { - id: string; - versions: ResourceVersion[]; - aazVersions: string[] | null; -}; - -type AAZResource = { - id: string; - versions: string[] | null; -}; - -type VersionResourceIdMap = { - [version: string]: Resource[]; -}; - -type ResourceInheritanceAAZVersionMap = { - [id: string]: string | null; -}; - -type ResourceMap = { - [id: string]: Resource; -}; - -const MiddlePadding = styled(Box)(() => ({ - height: "2vh", -})); - -const MiddlePadding2 = styled(Box)(() => ({ - height: "8vh", -})); - -const UpdateOptions = ["Default", "Generic(Get&Put) First", "Patch First", "No update command"]; - -class WSEditorSwaggerPicker extends React.Component { - constructor(props: WSEditorSwaggerPickerProps) { - super(props); - this.state = { - loading: false, - invalidText: undefined, - defaultModule: null, - defaultResourceProvider: null, - defaultSource: null, - // preVersion: null, - existingResources: new Set(), - - plane: this.props.plane, - moduleOptionsCommonPrefix: "", - resourceProviderOptionsCommonPrefix: "", - moduleOptions: [], - versionOptions: [], - resourceProviderOptions: [], - selectedResources: new Set(), - selectedResourceInheritanceAAZVersionMap: {}, - preferredAAZVersion: null, - resourceOptions: [], - versionResourceIdMap: {}, - resourceMap: {}, - selectedModule: null, - selectedResourceProvider: null, - selectedVersion: null, - updateOptions: UpdateOptions, - updateOption: UpdateOptions[0], - filterText: "", - realFilterText: "", - }; - } - - componentDidMount() { - this.loadWorkspaceResources().then(async () => { - await this.loadSwaggerModules(this.props.plane); - try { - const swaggerDefault = await workspaceApi.getSwaggerDefault(this.props.workspaceName); - // default module name - if (swaggerDefault.modNames === null || swaggerDefault.modNames.length == 0) { - return; - } - const moduleValueUrl = `/Swagger/Specs/${this.props.plane}/` + swaggerDefault.modNames.join("/"); - if (this.state.moduleOptions.findIndex((v) => v === moduleValueUrl) == -1) { - return; - } - let rpUrl = null; - if (swaggerDefault.rpName !== null && swaggerDefault.rpName.length > 0) { - rpUrl = `${moduleValueUrl}/ResourceProviders/${swaggerDefault.rpName}`; - if (swaggerDefault.source === "TypeSpec") { - rpUrl += `/TypeSpec`; - } - } - this.setState({ - defaultModule: moduleValueUrl, - defaultSource: swaggerDefault.source, - selectedModule: moduleValueUrl, - moduleOptions: [moduleValueUrl], // only the default module selectable. - }); - await this.loadResourceProviders(moduleValueUrl, rpUrl); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - }); - } - - handleClose = () => { - this.props.onClose(false); - }; - - handleSubmit = () => { - this.addSwagger(); - }; - - loadSwaggerModules = async (plane: string) => { - try { - const options = await specsApi.getSwaggerModules(plane); - this.setState((preState) => { - return { - ...preState, - moduleOptions: options, - moduleOptionsCommonPrefix: `/Swagger/Specs/${plane}/`, - preModuleName: null, - }; - }); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - }; - - loadResourceProviders = async (moduleUrl: string | null, preferredRP: string | null) => { - if (moduleUrl != null) { - const defaultSource = this.state.defaultSource; - try { - let options = await specsApi.getResourceProvidersWithType(moduleUrl, defaultSource ?? undefined); - let selectedResourceProvider = options.length === 1 ? options[0] : null; - let defaultResourceProvider = null; - if (preferredRP !== null && options.findIndex((v) => v === preferredRP) >= 0) { - selectedResourceProvider = preferredRP; - defaultResourceProvider = preferredRP; - options = [preferredRP]; // only the default resource provider selectable. - } - this.setState({ - defaultResourceProvider: defaultResourceProvider, - resourceProviderOptions: options, - resourceProviderOptionsCommonPrefix: `${moduleUrl}/ResourceProviders/`, - }); - await this.onResourceProviderUpdate(selectedResourceProvider); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - } else { - this.setState({ - resourceProviderOptions: [], - }); - this.onResourceProviderUpdate(null); - } - }; - - loadWorkspaceResources = async () => { - try { - const resources = await workspaceApi.getWorkspaceResourcesByName(this.props.workspaceName); - const existingResources = new Set(); - if (resources && Array.isArray(resources) && resources.length > 0) { - resources.forEach((resource: any) => { - existingResources.add(resource.id); - }); - } - this.setState({ - existingResources: existingResources, - }); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - }; - - loadResources = async (resourceProviderUrl: string | null) => { - if (resourceProviderUrl != null) { - this.setState({ - invalidText: undefined, - loading: true, - }); - let data; - if (resourceProviderUrl.endsWith("/TypeSpec")) { - this.setState({ - loading: false, - versionOptions: [], - }); - data = await getTypespecRPResources(resourceProviderUrl); - // console.log(data); - } else { - try { - data = await specsApi.getProviderResources(resourceProviderUrl); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - } - try { - // const resourceIdVersionMap: ResourceIdVersionMap = {} - const versionResourceIdMap: VersionResourceIdMap = {}; - const versionOptions: string[] = []; - // const aazVersionOptions: string[] = [] - const resourceMap: ResourceMap = {}; - const resourceIdList: string[] = []; - data.forEach((resource: Resource) => { - // resource.versions.sort((a, b) => a.version.localeCompare(b.version)); - resourceIdList.push(resource.id); - resourceMap[resource.id] = resource; - resourceMap[resource.id].aazVersions = null; - - const resourceVersions = resource.versions.map((v) => v.version); - // resourceIdVersionMap[resource.id] = versions; - resourceVersions.forEach((v) => { - if (!(v in versionResourceIdMap)) { - versionResourceIdMap[v] = []; - versionOptions.push(v); - } - versionResourceIdMap[v].push(resource); - }); - }); - versionOptions.sort((a, b) => a.localeCompare(b)).reverse(); - let selectVersion = null; - if (versionOptions.length > 0) { - selectVersion = versionOptions[0]; - } - - const filterData = await specsApi.filterResourcesByPlane(this.props.plane, resourceIdList); - filterData.resources.forEach((aazResource: AAZResource) => { - if (aazResource.versions) { - resourceMap[aazResource.id].aazVersions = aazResource.versions; - } - }); - this.setState({ - loading: false, - versionResourceIdMap: versionResourceIdMap, - resourceMap: resourceMap, - versionOptions: versionOptions, - }); - this.onVersionUpdate(selectVersion); - } catch (err: any) { - console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - } else { - this.setState({ - versionOptions: [], - }); - this.onVersionUpdate(null); - } - }; - - addSwagger = async () => { - const { - selectedResources, - selectedVersion, - selectedModule, - moduleOptionsCommonPrefix, - updateOption, - resourceMap, - selectedResourceInheritanceAAZVersionMap, - defaultResourceProvider, - } = this.state; - const resources: { id: string; options: { update_by?: string; aaz_version: string | null } }[] = []; - const resourceOptionMap: { [key: string]: [value: { update_by?: string; aaz_version: string | null }] } = {}; - selectedResources.forEach((resourceId) => { - const res: any = { - id: resourceId, - options: { - aaz_version: selectedResourceInheritanceAAZVersionMap[resourceId], - }, - }; - if (updateOption === UpdateOptions[1]) { - // generic first - const resource = resourceMap[resourceId]; - const operations = resource.versions.find((v) => v.version === selectedVersion)?.operations; - if (operations) { - let hasGet = false; - let hasPut = false; - for (const opName in operations) { - if (operations[opName].toLowerCase() === "put") { - hasPut = true; - } else if (operations[opName].toLowerCase() === "get") { - hasGet = true; - } - } - if (hasGet && hasPut) { - res.options.update_by = "GenericOnly"; - } - } - } else if (updateOption === UpdateOptions[2]) { - // patch first - const resource = resourceMap[resourceId]; - const operations = resource.versions.find((v) => v.version === selectedVersion)?.operations; - if (operations) { - for (const opName in operations) { - if (operations[opName].toLowerCase() === "patch") { - res.options.update_by = "PatchOnly"; - break; - } - } - } - } else if (updateOption === UpdateOptions[3]) { - // No update command generation - res.options.update_by = "None"; - } - resourceOptionMap[resourceId] = res.options; - resources.push(res); - }); - - const requestBody = { - module: selectedModule!.slice().replace(moduleOptionsCommonPrefix, ""), - version: selectedVersion, - resources: resources, - }; - - this.setState({ - invalidText: undefined, - loading: true, - }); - - if (defaultResourceProvider?.endsWith("TypeSpec")) { - const requestEmitterObj = JSON.parse(JSON.stringify(requestBody)); - requestEmitterObj.resourceProviderUrl = defaultResourceProvider; - console.log("requestEmitterObj: ", requestEmitterObj); - try { - const res = await getTypespecRPResourcesOperations(requestEmitterObj); - console.log("emitter getTypespecRPResourceOperations res: ", res); - console.log("resourceOptionMap: ", resourceOptionMap); - const addTypespecData = { - version: selectedVersion, - resources: res.map((item: { id: string; [key: string]: any }) => { - if (item.id in resourceOptionMap) { - item.options = resourceOptionMap[item.id]; - } - return item; - }), - }; - console.log("addTypespec data: ", addTypespecData); - try { - await workspaceApi.addTypespecResources(this.props.workspaceName, addTypespecData); - this.setState({ - loading: false, - }); - this.props.onClose(true); - } catch (err: any) { - console.error(err); - this.setState({ - loading: false, - }); - this.props.onClose(false); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - } catch (err: any) { - this.setState({ - loading: false, - }); - this.props.onClose(true); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - } else { - try { - await workspaceApi.addSwaggerResources(this.props.workspaceName, requestBody); - this.setState({ - loading: false, - }); - this.props.onClose(true); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); - } - } - }; - - onModuleSelectorUpdate = async (moduleValueUrl: string | null) => { - if (this.state.selectedModule !== moduleValueUrl) { - this.setState({ - selectedModule: moduleValueUrl, - }); - await this.loadResourceProviders(moduleValueUrl, null); - } else { - this.setState({ - selectedModule: moduleValueUrl, - }); - } - }; - - onResourceProviderUpdate = async (resourceProviderUrl: string | null) => { - if (this.state.selectedResourceProvider !== resourceProviderUrl) { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - await this.loadResources(resourceProviderUrl); - } else { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - } - }; - - onVersionUpdate = (version: string | null) => { - this.setState((preState) => { - let selectedResources = preState.selectedResources; - let resourceOptions: Resource[] = []; - let selectedResourceInheritanceAAZVersionMap = preState.selectedResourceInheritanceAAZVersionMap; - if (version != null) { - selectedResources = new Set(); - selectedResourceInheritanceAAZVersionMap = {}; - resourceOptions = [...preState.versionResourceIdMap[version]] - .sort((a, b) => a.id.localeCompare(b.id)) - .filter((r) => !preState.existingResources.has(r.id)); - resourceOptions.forEach((r) => { - if (preState.selectedResources.has(r.id)) { - selectedResources.add(r.id); - if (r.aazVersions && r.aazVersions.findIndex((v) => v === version) >= 0) { - selectedResourceInheritanceAAZVersionMap[r.id] = version; - } else { - selectedResourceInheritanceAAZVersionMap[r.id] = preState.selectedResourceInheritanceAAZVersionMap[r.id]; - } - } - }); - } - return { - ...preState, - resourceOptions: resourceOptions, - selectedVersion: version, - preferredAAZVersion: version, - selectedResources: selectedResources, - selectedResourceInheritanceAAZVersionMap: selectedResourceInheritanceAAZVersionMap, - }; - }); - }; - - onUpdateOptionUpdate = (updateOption: string | null) => { - this.setState({ - updateOption: updateOption ?? UpdateOptions[0], - }); - }; - - onResourceItemClick = (resourceId: string) => { - return () => { - this.setState((preState) => { - const selectedResources = new Set(preState.selectedResources); - const selectedResourceInheritanceAAZVersionMap = { ...preState.selectedResourceInheritanceAAZVersionMap }; - if (selectedResources.has(resourceId)) { - selectedResources.delete(resourceId); - delete selectedResourceInheritanceAAZVersionMap[resourceId]; - } else if (!preState.existingResources.has(resourceId)) { - selectedResources.add(resourceId); - const aazVersions = preState.resourceMap[resourceId].aazVersions; - let inheritanceAAZVersion = null; - if (aazVersions) { - if (aazVersions.findIndex((v) => v === preState.preferredAAZVersion) >= 0) { - inheritanceAAZVersion = preState.preferredAAZVersion; - } else { - inheritanceAAZVersion = aazVersions[0]; - } - } - selectedResourceInheritanceAAZVersionMap[resourceId] = inheritanceAAZVersion; - } - - return { - ...preState, - selectedResources: selectedResources, - selectedResourceInheritanceAAZVersionMap: selectedResourceInheritanceAAZVersionMap, - }; - }); - }; - }; - - onSelectedAllClick = () => { - this.setState((preState) => { - const selectedResources = new Set(preState.selectedResources); - let selectedResourceInheritanceAAZVersionMap = { ...preState.selectedResourceInheritanceAAZVersionMap }; - if (selectedResources.size === preState.resourceOptions.length) { - selectedResources.clear(); - selectedResourceInheritanceAAZVersionMap = {}; - } else { - preState.resourceOptions.forEach((r) => { - selectedResources.add(r.id); - const aazVersions = preState.resourceMap[r.id].aazVersions; - let inheritanceAAZVersion = null; - if (aazVersions) { - if (aazVersions.findIndex((v) => v === preState.preferredAAZVersion) >= 0) { - inheritanceAAZVersion = preState.preferredAAZVersion; - } else { - inheritanceAAZVersion = aazVersions[0]; - } - } - selectedResourceInheritanceAAZVersionMap[r.id] = inheritanceAAZVersion; - }); - } - return { - ...preState, - selectedResources: selectedResources, - selectedResourceInheritanceAAZVersionMap: selectedResourceInheritanceAAZVersionMap, - }; - }); - }; - - onResourceInheritanceAAZVersionUpdate = (resourceId: string, aazVersion: string | null) => { - this.setState((preState) => { - const selectedResourceInheritanceAAZVersionMap = { ...preState.selectedResourceInheritanceAAZVersionMap }; - selectedResourceInheritanceAAZVersionMap[resourceId] = aazVersion; - let preferredAAZVersion = preState.preferredAAZVersion; - if (aazVersion !== null) { - preferredAAZVersion = aazVersion; - } - - return { - ...preState, - selectedResourceInheritanceAAZVersionMap: selectedResourceInheritanceAAZVersionMap, - preferredAAZVersion: preferredAAZVersion, - }; - }); - }; - - render() { - const { - selectedResources, - existingResources, - resourceOptions, - resourceMap, - selectedVersion, - selectedModule, - selectedResourceInheritanceAAZVersionMap, - filterText, - realFilterText, - } = this.state; - return ( - - - - - - - - Add Resources - - - - - - Swagger Filters - - - - - - - - - - - - - - - Resource Url - - - - - 0 && selectedResources.size === resourceOptions.length} - indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} - tabIndex={-1} - disableRipple - inputProps={{ "aria-labelledby": "SelectAll" }} - /> - - - - { - const reg = /\{.*?\}/g; - this.setState({ - filterText: event.target.value, - realFilterText: event.target.value.toLocaleLowerCase().replace(reg, "{}"), - }); - }} - /> - - - - } - > - {resourceOptions.length > 0 && ( - - {resourceOptions - .filter((option) => { - if (realFilterText.trim().length > 0) { - return option.id.indexOf(realFilterText) > -1; - } else { - return true; - } - }) - .map((option) => { - const labelId = `resource-${option.id}`; - const selected = selectedResources.has(option.id); - const inheritanceOptions = resourceMap[option.id]?.aazVersions; - let selectedInheritance = null; - if (selectedResourceInheritanceAAZVersionMap !== null) { - selectedInheritance = selectedResourceInheritanceAAZVersionMap[option.id]; - } - return ( - - - - - - - - {selected && ( - - Inheritance - - Inherit modification from exported command models in aaz - - )} - - ); - })} - - )} - - - theme.zIndex.drawer + 1 }} open={this.state.loading}> - {this.state.invalidText !== undefined && ( - { - this.setState({ - invalidText: undefined, - loading: false, - }); - }} - > - {this.state.invalidText} - - )} - {this.state.invalidText === undefined && } - - - ); - } -} - -interface SwaggerItemsSelectorProps { - commonPrefix: string; - options: string[]; - name: string; - value: string | null; - onValueUpdate: (value: string | null) => void; -} - -class SwaggerItemSelector extends React.Component { - constructor(props: SwaggerItemsSelectorProps) { - super(props); - this.state = { - value: this.props.options.length === 1 ? this.props.options[0] : null, - }; - } - - render() { - const { name, options, commonPrefix, value } = this.props; - return ( - { - this.props.onValueUpdate(newValue); - }} - getOptionLabel={(option) => { - return option.replace(commonPrefix, ""); - }} - renderOption={(props, option) => { - return ( - - {option.replace(commonPrefix, "")} - - ); - }} - selectOnFocus - clearOnBlur - renderInput={(params) => ( - - )} - /> - ); - } -} - -export default WSEditorSwaggerPicker; -export { SwaggerItemSelector }; diff --git a/src/web/src/views/workspace/WSEditorToolBar.tsx b/src/web/src/views/workspace/WSEditorToolBar.tsx deleted file mode 100644 index baba8b8d..00000000 --- a/src/web/src/views/workspace/WSEditorToolBar.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Box, AppBar, Button, IconButton, styled, Toolbar, Typography, Tooltip, TypographyProps } from "@mui/material"; -import HomeIcon from "@mui/icons-material/Home"; -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { grey } from "@mui/material/colors"; - -import * as React from "react"; - -const ArgEditTypography = styled(Typography)(() => ({ - color: "#ffffff", - fontFamily: "'Work Sans', sans-serif", - fontSize: 14, - fontWeight: 400, -})); - -interface WSEditorToolBarProps { - workspaceName: string; - onHomePage: () => void; - onGenerate: () => void; - onDelete: () => void; - onModify: () => void; -} - -class WSEditorToolBar extends React.Component { - render() { - const { workspaceName, onHomePage, onGenerate, onDelete, onModify } = this.props; - return ( - - theme.zIndex.drawer + 1, - }} - > - - - - - WORKSPACE - - - - - {workspaceName} - - - - - - - - - - - - - - ); - } -} - -export default WSEditorToolBar; diff --git a/src/web/src/views/workspace/WorkspaceInstruction.tsx b/src/web/src/views/workspace/WorkspaceInstruction.tsx deleted file mode 100644 index aabd9c7c..00000000 --- a/src/web/src/views/workspace/WorkspaceInstruction.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { Typography, Box } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import WorkspaceSelector from "./WorkspaceSelector"; -import { AppAppBar } from "../../components/AppAppBar"; -import PageLayout from "../../components/PageLayout"; - -const MiddlePadding = styled(Box)(() => ({ - height: "6vh", -})); - -class WorkspaceInstruction extends React.Component { - render() { - return ( - - - - - - - - Please select a Workspace - - - - - - - - - ); - } -} - -export default WorkspaceInstruction; diff --git a/src/web/src/views/workspace/WorkspacePage.tsx b/src/web/src/views/workspace/WorkspacePage.tsx deleted file mode 100644 index 509025a4..00000000 --- a/src/web/src/views/workspace/WorkspacePage.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from "react"; -import withRoot from "../../withRoot"; -import { Outlet } from "react-router"; - -class WorkspacePage extends React.Component { - render() { - return ( - - - - ); - } -} - -export default withRoot(WorkspacePage); diff --git a/src/web/src/views/workspace/WorkspaceSelector.tsx b/src/web/src/views/workspace/WorkspaceSelector.tsx deleted file mode 100644 index eec94d23..00000000 --- a/src/web/src/views/workspace/WorkspaceSelector.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import { - Box, - Autocomplete, - createFilterOptions, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - Button, - InputLabel, - Alert, -} from "@mui/material"; -import * as React from "react"; -import { SwaggerItemSelector } from "./WSEditorSwaggerPicker"; -import styled from "@emotion/styled"; -import { Plane } from "./WSEditorCommandContent"; -import { workspaceApi, specsApi, errorHandlerApi, type Workspace as WorkspaceType } from "../../services"; - -interface WorkspaceSelectorProps { - name: string; -} - -interface WorkspaceSelectorState { - options: any[]; - value: WorkspaceType | null; - openDialog: boolean; - newWorkspaceName: string; -} - -interface InputType { - inputValue: string; - title: string; -} - -const filter = createFilterOptions(); - -class WorkspaceSelector extends React.Component { - constructor(props: WorkspaceSelectorProps) { - super(props); - this.state = { - options: [], - value: null, - openDialog: false, - newWorkspaceName: "", - }; - } - - componentDidMount() { - this.loadWorkspaces(); - } - - loadWorkspaces = async () => { - try { - const options = await workspaceApi.getWorkspaces(); - this.setState({ - options: options, - }); - } catch (err: any) { - console.error(err); - } - }; - - handleDialogClose = (value: any | null) => { - this.setState({ - newWorkspaceName: "", - openDialog: false, - }); - if (value != null) { - this.onValueUpdated(value); - } - }; - - onValueUpdated = (value: any) => { - this.setState({ - value: value, - }); - if (value.url) { - window.location.href = `/?#/workspace/${value.name}`; - } - }; - - render() { - const { options, value, openDialog, newWorkspaceName } = this.state; - const { name } = this.props; - return ( - - { - if (typeof newValue === "string") { - // timeout to avoid instant validation of the dialog's form. - setTimeout(() => { - this.setState({ - openDialog: true, - newWorkspaceName: newValue, - }); - }); - } else if (newValue && newValue.inputValue) { - this.setState({ - openDialog: true, - newWorkspaceName: newValue.inputValue, - }); - } else { - this.onValueUpdated(newValue); - } - }} - filterOptions={(options, params: any) => { - const filtered = filter(options, params); - if (params.inputValue !== "" && -1 === options.findIndex((e) => e.name === params.inputValue)) { - filtered.push({ - inputValue: params.inputValue, - title: `Create "${params.inputValue}"`, - }); - } - return filtered; - }} - getOptionLabel={(option) => { - if (typeof option === "string") { - return option; - } - if (option.title) { - return option.title; - } - return option.name; - }} - renderOption={(props, option) => { - const labelName = option && option.title ? option.title : option.name; - return ( - - {labelName} - - ); - }} - selectOnFocus - clearOnBlur - renderInput={(params) => ( - - )} - /> - {openDialog && ( - - )} - - ); - } -} - -interface WorkspaceCreateDialogProps { - openDialog: boolean; - name: string; - onClose: (value: any | null) => void; -} - -interface WorkspaceCreateDialogState { - loading: boolean; - - invalidText?: string; - workspaceName: string; - - planes: Plane[]; - planeOptions: string[]; - selectedPlane: string | null; - - moduleOptions: string[]; - moduleOptionsCommonPrefix: string; - selectedModule: string | null; - - resourceProviderOptions: string[]; - resourceProviderOptionsCommonPrefix: string; - selectedResourceProvider: string | null; -} - -class WorkspaceCreateDialog extends React.Component { - constructor(props: WorkspaceCreateDialogProps) { - super(props); - this.state = { - loading: false, - invalidText: undefined, - workspaceName: props.name, - - planes: [], - planeOptions: [], - selectedPlane: null, - - moduleOptions: [], - moduleOptionsCommonPrefix: "", - selectedModule: null, - - resourceProviderOptions: [], - resourceProviderOptionsCommonPrefix: "", - selectedResourceProvider: null, - }; - } - - componentDidMount(): void { - this.loadPlanes().then(async () => { - if (this.state.planes.length > 0) { - await this.onPlaneSelectorUpdate(this.state.planes[0].name); - } - }); - } - - loadPlanes = async () => { - try { - this.setState({ - loading: true, - }); - - const planes = await specsApi.getPlanes(); - const planeOptions: string[] = planes.map((v) => v.displayName); - this.setState({ - planes: planes, - planeOptions: planeOptions, - loading: false, - }); - if (planeOptions.length > 0) { - await this.onPlaneSelectorUpdate(planeOptions[0]); - } - } catch (err: any) { - console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - }; - - onPlaneSelectorUpdate = async (planeDisplayName: string | null) => { - const plane = this.state.planes.find((v) => v.displayName === planeDisplayName) ?? null; - if (this.state.selectedPlane !== (plane?.displayName ?? null)) { - if (!plane) { - return; - } - this.setState({ - selectedPlane: plane?.displayName ?? null, - }); - await this.loadSwaggerModules(plane); - } else { - this.setState({ - selectedPlane: plane?.displayName ?? null, - }); - } - }; - - loadSwaggerModules = async (plane: Plane | null) => { - if (plane !== null) { - if (plane!.moduleOptions?.length) { - this.setState({ - moduleOptions: plane!.moduleOptions!, - moduleOptionsCommonPrefix: `/Swagger/Specs/${plane!.name}/`, - }); - await this.onModuleSelectionUpdate(null); - } else { - try { - this.setState({ - loading: true, - }); - const options = await specsApi.getModulesForPlane(plane!.name); - this.setState((preState) => { - const planes = preState.planes; - const index = planes.findIndex((v) => v.name === plane!.name); - planes[index].moduleOptions = options; - return { - ...preState, - loading: false, - planes: planes, - moduleOptions: options, - moduleOptionsCommonPrefix: `/Swagger/Specs/${plane!.name}/`, - }; - }); - await this.onModuleSelectionUpdate(null); - } catch (err: any) { - console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - } - } else { - this.setState({ - moduleOptions: [], - moduleOptionsCommonPrefix: "", - }); - await this.onModuleSelectionUpdate(null); - } - }; - - onModuleSelectionUpdate = async (moduleValueUrl: string | null) => { - if (this.state.selectedModule !== moduleValueUrl) { - this.setState({ - selectedModule: moduleValueUrl, - }); - await this.loadResourceProviders(moduleValueUrl); - } else { - this.setState({ - selectedModule: moduleValueUrl, - }); - } - }; - - loadResourceProviders = async (moduleUrl: string | null) => { - if (moduleUrl !== null) { - try { - this.setState({ - loading: true, - }); - const options = await specsApi.getResourceProviders(moduleUrl); - const selectedResourceProvider = options.length === 1 ? options[0] : null; - this.setState({ - loading: false, - resourceProviderOptions: options, - resourceProviderOptionsCommonPrefix: `${moduleUrl}/ResourceProviders/`, - }); - this.onResourceProviderUpdate(selectedResourceProvider); - } catch (err: any) { - console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - } else { - this.setState({ - resourceProviderOptions: [], - resourceProviderOptionsCommonPrefix: "", - }); - this.onResourceProviderUpdate(null); - } - }; - - onResourceProviderUpdate = (resourceProviderUrl: string | null) => { - if (this.state.selectedResourceProvider !== resourceProviderUrl) { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - } else { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - } - }; - - verifyCreate = () => { - this.setState({ invalidText: undefined }); - let { workspaceName, selectedModule, selectedResourceProvider } = this.state; - const { selectedPlane, planes, moduleOptionsCommonPrefix, resourceProviderOptionsCommonPrefix } = this.state; - workspaceName = workspaceName.trim(); - if (workspaceName.length < 1) { - this.setState({ invalidText: `'Workspace Name' is required.` }); - return undefined; - } - - const plane = planes.find((v) => v.displayName === selectedPlane)?.name ?? null; - if (plane === null) { - this.setState({ invalidText: `Please select 'Plane'.` }); - return undefined; - } - - selectedModule = selectedModule ? selectedModule.replace(moduleOptionsCommonPrefix, "") : null; - if (selectedModule === null) { - this.setState({ invalidText: `Please select 'Module'.` }); - return undefined; - } - - selectedResourceProvider = selectedResourceProvider - ? selectedResourceProvider.replace(resourceProviderOptionsCommonPrefix, "") - : null; - if (selectedResourceProvider === null) { - this.setState({ invalidText: `Please select 'Resource Provider'.` }); - return undefined; - } - let source = "OpenAPI"; - if (selectedResourceProvider.endsWith("/TypeSpec")) { - selectedResourceProvider = selectedResourceProvider.replace("/TypeSpec", ""); - source = "TypeSpec"; - } - return { - name: workspaceName, - plane: plane, - modNames: selectedModule, - resourceProvider: selectedResourceProvider, - source: source, - }; - }; - - handleCreate = async () => { - const data = this.verifyCreate(); - if (data === undefined) { - return; - } - this.setState({ loading: true }); - try { - const workspace = await workspaceApi.createWorkspace(data); - this.setState({ loading: false }); - this.props.onClose(workspace); - } catch (err: any) { - console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); - } - }; - - handleClose = () => { - this.props.onClose(null); - }; - - render(): React.ReactNode { - const { invalidText, loading, selectedPlane, selectedModule, selectedResourceProvider, workspaceName } = this.state; - - return ( - - Create a new workspace - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - API Specs - - - - - - - - { - this.setState({ - workspaceName: event.target.value, - }); - }} - label="Workspace Name" - type="text" - variant="standard" - /> - - - - - - - - - ); - } -} - -const MiddlePadding = styled(Box)(() => ({ - height: "1.5vh", -})); - -export default WorkspaceSelector; diff --git a/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx deleted file mode 100644 index 677111ee..00000000 --- a/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import TreeView from "@mui/lab/TreeView"; -import TreeItem from "@mui/lab/TreeItem"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import { Checkbox, FormControlLabel } from "@mui/material"; - -interface ArgSimilarArg { - id: string; - var: string; - display: string; - indexes: string[]; - isSelected: boolean; -} - -interface ArgSimilarCommand { - id: string; - name: string; - args: ArgSimilarArg[]; - total: number; - selectedCount: number; -} - -interface ArgSimilarGroup { - id: string; - name: string; - groups?: ArgSimilarGroup[]; - commands?: ArgSimilarCommand[]; - total: number; - selectedCount: number; -} - -interface ArgSimilarTree { - root: ArgSimilarGroup; - selectedArgIds: string[]; -} - -interface ResponseArgSimilarCommand { - id: string; - args: { - [argVar: string]: string[]; - }; -} - -interface ResponseArgSimilarGroup { - id: string; - commandGroups?: { - [name: string]: ResponseArgSimilarGroup; - }; - commands?: { - [name: string]: ResponseArgSimilarCommand; - }; -} - -function decodeResponseArgSimilarCommand( - responseCommand: ResponseArgSimilarCommand, - commandName: string, -): ArgSimilarCommand { - let command: ArgSimilarCommand = { - id: responseCommand.id, - name: commandName, - args: [], - total: 0, - selectedCount: 0, - }; - for (const argVar in responseCommand.args) { - const arg: ArgSimilarArg = { - id: `${command.id}/Arguments/${argVar}`, - var: argVar, - indexes: responseCommand.args[argVar], - display: "", - isSelected: false, - }; - if (arg.indexes.length > 1) { - arg.display = `[${arg.var}] ${arg.indexes - .map((idx) => { - if (idx[1] === "." || idx[1] === "[" || idx[1] === "{") { - return `-${idx}`; - } else { - return `--${idx}`; - } - }) - .join(" ")}`; - } else if (arg.indexes.length === 1) { - let idx = arg.indexes[0]; - if (idx[1] === "." || idx[1] === "[" || idx[1] === "{") { - arg.display = `-${idx}`; - } else { - arg.display = `--${idx}`; - } - } - command.args.push(arg); - } - command.total = command.args.length; - return command; -} - -function decodeResponseArgSimilarGroup(responseGroup: ResponseArgSimilarGroup, groupName: string): ArgSimilarGroup { - let group: ArgSimilarGroup = { - id: responseGroup.id, - name: groupName, - total: 0, - selectedCount: 0, - }; - - if (typeof responseGroup.commandGroups === "object" && responseGroup.commandGroups !== null) { - group.groups = []; - for (const name in responseGroup.commandGroups) { - const subGroup = decodeResponseArgSimilarGroup(responseGroup.commandGroups[name], name); - group.groups.push(subGroup); - group.total += subGroup.total; - } - } - - if (typeof responseGroup.commands === "object" && responseGroup.commands !== null) { - group.commands = []; - for (const name in responseGroup.commands) { - const command = decodeResponseArgSimilarCommand(responseGroup.commands[name], name); - group.commands.push(command); - group.total += command.total; - } - } - if (group.commands === undefined && group.groups !== undefined && group.groups.length === 1) { - group = group.groups[0]; - group.name = `${groupName} ${group.name}`; - } - return group; -} - -function gatherNodeIds(group: ArgSimilarGroup): string[] { - let nodeIds: string[] = [group.id]; - if (group.commands !== undefined) { - group.commands.forEach((command) => { - nodeIds.push(command.id); - }); - } - if (group.groups !== undefined) { - group.groups.forEach((subGroup) => { - nodeIds = [...nodeIds, ...gatherNodeIds(subGroup)]; - }); - } - return nodeIds; -} - -function BuildArgSimilarTree(response: any): { tree: ArgSimilarTree; expandedIds: string[] } { - const tree = { - root: decodeResponseArgSimilarGroup(response.data.aaz, "az"), - selectedArgIds: [], - }; - const expandedIds = gatherNodeIds(tree.root); - const newTree = updateSelectionStateForArgSimilarTree(tree, new Set([tree.root.id])); - return { - tree: newTree, - expandedIds: expandedIds, - }; -} - -function updateSelectionStateForArgSimilarCommand( - command: ArgSimilarCommand, - selectedIds: Set, -): { command: ArgSimilarCommand; selectedArgIds: string[] } { - let newSelectedIds: string[] = []; - let newCommand = { - ...command, - args: command.args.map((arg) => { - let isSelected = selectedIds.has(arg.id); - if (!isSelected) { - const idParts = arg.id.split("/"); - for (let idx = 1; idx < idParts.length; idx += 1) { - let newId = idParts.slice(0, idx + 1).join("/"); - if (selectedIds.has(newId)) { - isSelected = true; - break; - } - } - } - if (isSelected === true) { - newSelectedIds.push(arg.id); - } - - let newArg: ArgSimilarArg = { - ...arg, - indexes: [...arg.indexes], - isSelected: isSelected, - }; - return newArg; - }), - }; - - newCommand.selectedCount = newSelectedIds.length; - - return { - command: newCommand, - selectedArgIds: newSelectedIds, - }; -} - -function updateSelectionStateForArgSimilarGroup( - group: ArgSimilarGroup, - selectedIds: Set, -): { group: ArgSimilarGroup; selectedArgIds: string[] } { - let newSelectedIds: string[] = []; - let newGroup = { - ...group, - groups: group.groups?.map((subGroup) => { - const { group: newSubGroup, selectedArgIds: subSelectedIds } = updateSelectionStateForArgSimilarGroup( - subGroup, - selectedIds, - ); - newSelectedIds = [...newSelectedIds, ...subSelectedIds]; - return newSubGroup; - }), - commands: group.commands?.map((command) => { - const { command: newCommand, selectedArgIds: subSelectedIds } = updateSelectionStateForArgSimilarCommand( - command, - selectedIds, - ); - newSelectedIds = [...newSelectedIds, ...subSelectedIds]; - return newCommand; - }), - }; - - newGroup.selectedCount = newSelectedIds.length; - - return { - group: newGroup, - selectedArgIds: newSelectedIds, - }; -} - -function updateSelectionStateForArgSimilarTree(tree: ArgSimilarTree, selectedIds: Set): ArgSimilarTree { - const { group, selectedArgIds } = updateSelectionStateForArgSimilarGroup(tree.root, selectedIds); - return { - root: group, - selectedArgIds: selectedArgIds, - }; -} - -function WSECArgumentSimilarPicker(props: { - tree: ArgSimilarTree; - expandedIds: string[]; - updatedIds: string[]; - onTreeUpdated: (tree: ArgSimilarTree) => void; - onToggle: (nodeIds: string[]) => void; -}) { - const onCheckItem = (itemId: string, select: boolean) => { - let selectedIds: Set; - if (select) { - selectedIds = new Set(props.tree.selectedArgIds).add(itemId); - } else { - selectedIds = new Set(props.tree.selectedArgIds.filter((id) => id !== itemId && !id.startsWith(`${itemId}/`))); - } - props.onTreeUpdated(updateSelectionStateForArgSimilarTree(props.tree, selectedIds)); - }; - - const onNodeToggle = (event: React.SyntheticEvent, nodeIds: string[]) => { - props.onToggle(nodeIds); - event.stopPropagation(); - event.preventDefault(); - }; - - const renderArg = (arg: ArgSimilarArg) => { - const isUpdated = props.updatedIds.indexOf(arg.id) !== -1; - return ( - { - onCheckItem(arg.id, !arg.isSelected); - event.stopPropagation(); - event.preventDefault(); - }} - disabled={isUpdated} - /> - } - label={arg.display} - sx={{ - paddingLeft: 1, - }} - /> - } - /> - ); - }; - - const renderCommand = (command: ArgSimilarCommand) => { - return ( - 0 && command.selectedCount === command.total} - indeterminate={command.selectedCount > 0 && command.selectedCount < command.total} - onClick={(event) => { - onCheckItem(command.id, !(command.selectedCount > 0 && command.selectedCount === command.total)); - event.stopPropagation(); - event.preventDefault(); - }} - /> - } - label={command.name} - sx={{ - paddingLeft: 1, - }} - /> - } - > - {Array.isArray(command.args) ? command.args.map((arg) => renderArg(arg)) : null} - - ); - }; - - const renderGroup = (group: ArgSimilarGroup) => { - return ( - 0 && group.selectedCount === group.total} - indeterminate={group.selectedCount > 0 && group.selectedCount < group.total} - onClick={(event) => { - onCheckItem(group.id, !(group.selectedCount > 0 && group.selectedCount === group.total)); - event.stopPropagation(); - event.preventDefault(); - }} - /> - } - label={group.name} - sx={{ - paddingLeft: 1, - }} - /> - } - > - {Array.isArray(group.commands) ? group.commands.map((command) => renderCommand(command)) : null} - {Array.isArray(group.groups) ? group.groups.map((subGroup) => renderGroup(subGroup)) : null} - - ); - }; - - return ( - <> - } - defaultExpandIcon={} - onNodeToggle={onNodeToggle} - selected={[]} - expanded={props.expandedIds} - > - {renderGroup(props.tree.root)} - - - ); -} - -export default WSECArgumentSimilarPicker; -export { BuildArgSimilarTree }; -export type { ArgSimilarTree, ArgSimilarGroup, ArgSimilarCommand, ArgSimilarArg }; diff --git a/src/web/src/views/workspace/common/SwaggerItemSelector.tsx b/src/web/src/views/workspace/common/SwaggerItemSelector.tsx new file mode 100644 index 00000000..1476d822 --- /dev/null +++ b/src/web/src/views/workspace/common/SwaggerItemSelector.tsx @@ -0,0 +1,55 @@ +import React, { useCallback } from "react"; +import { Box, Autocomplete, TextField } from "@mui/material"; + +interface SwaggerItemsSelectorProps { + commonPrefix: string; + options: string[]; + name: string; + value: string | null; + onValueUpdate: (value: string | null) => void; +} + +const SwaggerItemSelector: React.FC = ({ + commonPrefix, + options, + name, + value, + onValueUpdate, +}) => { + const getOptionLabel = useCallback( + (option: string) => { + return option.replace(commonPrefix, ""); + }, + [commonPrefix], + ); + + const renderOption = useCallback( + (props: any, option: string) => { + return ( + + {option.replace(commonPrefix, "")} + + ); + }, + [commonPrefix], + ); + + return ( + { + onValueUpdate(newValue); + }} + getOptionLabel={getOptionLabel} + renderOption={renderOption} + selectOnFocus + clearOnBlur + renderInput={(params) => } + /> + ); +}; + +export default SwaggerItemSelector; +export type { SwaggerItemsSelectorProps }; diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx new file mode 100644 index 00000000..316bddd7 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -0,0 +1,313 @@ +import React, { useEffect, useCallback } from "react"; +import { Box, Dialog, Slide, Drawer, Toolbar } from "@mui/material"; +import { useParams } from "react-router"; +import { TransitionProps } from "@mui/material/transitions"; +import WSEditorSwaggerPicker from "../../components/WSEditorSwaggerPicker"; +import WSEditorToolBar from "./WSEditorToolBar"; +import WSEditorCommandTree from "./WSEditorCommandTree"; +import WSEditorCommandGroupContent from "../WSEditorCommandGroupContent"; +import WSEditorCommandContent from "../WSEditorCommandContent"; +import WSEditorClientConfigDialog from "./WSEditorClientConfig"; +import type { CommandGroup, Command } from "../../interfaces"; +import WSEditorExportDialog from "./WSEditorExportDialog"; +import WSEditorDeleteDialog from "./WSEditorDeleteDialog"; +import WSEditorSwaggerReloadDialog from "./WSEditorSwaggerReloadDialog"; +import WSRenameDialog from "./WSRenameDialog"; +import { useDialogManager, useWorkspaceData, useTreeState } from "../../hooks/index"; + +interface WSEditorProps { + params: { + workspaceName: string; + }; +} + +const swaggerResourcePickerTransition = React.forwardRef(function swaggerResourcePickerTransition( + props: TransitionProps & { children: React.ReactElement }, + ref: React.Ref, +) { + return ; +}); + +const drawerWidth = 300; + +const WSEditor = ({ params }: WSEditorProps) => { + const { workspaceName } = params; + + const dialogManager = useDialogManager(); + const workspace = useWorkspaceData(workspaceName); + const treeState = useTreeState(workspace.commandMap, workspace.commandGroupMap, workspace.commandTree); + + useEffect(() => { + workspace.loadWorkspace(); + }, [workspace.loadWorkspace]); + + useEffect(() => { + if (!workspace.reloadTimestamp) return; + + const checkClientConfig = async () => { + if (workspace.clientConfigurable) { + try { + const clientConfig = await workspace.getWorkspaceClientConfig(workspace.workspaceUrl); + if (!clientConfig) { + dialogManager.openClientConfigDialog(); + } + } catch (error) { + console.error(error); + } + } + }; + + checkClientConfig(); + + if (workspace.commandTree.length === 0) { + dialogManager.openSwaggerResourcePicker(); + } + }, [workspace.reloadTimestamp]); + + useEffect(() => { + if (Object.keys(workspace.commandGroupMap).length > 0) { + treeState.updateExpanded(workspace.commandGroupMap, undefined, true); + } + }, [workspace.commandGroupMap, treeState.updateExpanded]); + + const handleSwaggerReloadDialogClose = useCallback( + async (reloaded: boolean) => { + if (reloaded) { + await workspace.loadWorkspace(); + } + dialogManager.closeSwaggerReloadDialog(); + }, + [workspace.loadWorkspace, dialogManager.closeSwaggerReloadDialog], + ); + + const handleSwaggerResourcePickerClose = useCallback( + (updated: boolean) => { + if (updated) { + workspace.loadWorkspace(); + } + dialogManager.closeSwaggerResourcePicker(); + }, + [workspace.loadWorkspace, dialogManager.closeSwaggerResourcePicker], + ); + + const handleBackToHomepage = useCallback((blank: boolean) => { + if (blank) { + window.open("/?#/workspace", "_blank"); + } else { + window.location.href = "/?#/workspace"; + } + }, []); + + const handleGenerate = useCallback(() => { + dialogManager.openExportDialog(); + }, [dialogManager]); + + const handleGenerationClose = useCallback( + (_exported: boolean, showClientConfigDialog: boolean) => { + dialogManager.closeExportDialog(); + if (showClientConfigDialog) { + dialogManager.openClientConfigDialog(); + } + }, + [dialogManager], + ); + + const handleDelete = useCallback(() => { + dialogManager.openDeleteDialog(); + }, [dialogManager]); + + const handleDeleteClose = useCallback( + (deleted: boolean) => { + dialogManager.closeDeleteDialog(); + if (deleted) { + handleBackToHomepage(false); + } + }, + [dialogManager, handleBackToHomepage], + ); + + const handleModify = useCallback(() => { + dialogManager.openModifyDialog(); + }, [dialogManager]); + + const handleModifyClose = useCallback( + (newWSName: string | null) => { + dialogManager.closeModifyDialog(); + if (!newWSName) { + return; + } + setTimeout(() => { + const target_url = `/?#/workspace/` + newWSName; + window.location.href = target_url; + window.location.reload(); + }); + }, + [dialogManager], + ); + + const handleCommandGroupUpdate = useCallback( + async (commandGroup: CommandGroup | null) => { + if (commandGroup) { + await workspace.loadWorkspace(); + treeState.setSelected(commandGroup); + } else { + treeState.setSelected(null); + await workspace.loadWorkspace(); + } + }, + [workspace.loadWorkspace, treeState.setSelected], + ); + + const handleCommandUpdate = useCallback( + async (command: Command | null) => { + if (command) { + await workspace.loadWorkspace(); + treeState.setSelected(command); + } else { + treeState.setSelected(null); + await workspace.loadWorkspace(); + } + }, + [workspace.loadWorkspace, treeState.setSelected], + ); + + const handleClientConfigDialogClose = useCallback( + (updated: boolean) => { + dialogManager.closeClientConfigDialog(); + if (updated) { + workspace.loadWorkspace(); + } + }, + [dialogManager.closeClientConfigDialog, workspace.loadWorkspace], + ); + + const expandedIds: string[] = Array.from(treeState.expanded); + + return ( + <> + { + handleBackToHomepage(true); + }} + onGenerate={handleGenerate} + onDelete={handleDelete} + onModify={handleModify} + /> + + + + + {treeState.selected != null && ( + + )} + + + + + {treeState.selected != null && treeState.selected.id.startsWith("group:") && ( + + )} + {treeState.selected != null && treeState.selected.id.startsWith("command:") && ( + + )} + + + + + + + {dialogManager.showModifyDialog && ( + + )} + {dialogManager.showDeleteDialog && ( + + )} + {dialogManager.showExportDialog && ( + + )} + {dialogManager.showSwaggerReloadDialog && ( + + )} + {dialogManager.showClientConfigDialog && ( + + )} + + ); +}; + +const WSEditorWrapper = (props: any) => { + const params = useParams(); + + return ; +}; + +export { WSEditorWrapper as WSEditor }; +export default WSEditor; diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx similarity index 98% rename from src/web/src/views/workspace/WSEditorClientConfig.tsx rename to src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx index e6d2a257..a0cad89c 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorClientConfig.tsx @@ -19,12 +19,12 @@ import { Tabs, Tab, } from "@mui/material"; -import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; +import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; -import { Plane, Resource } from "./WSEditorCommandContent"; -import { SwaggerItemSelector } from "./WSEditorSwaggerPicker"; +import SwaggerItemSelector from "../../common/SwaggerItemSelector"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import type { Plane, Resource } from "../../interfaces"; interface WSEditorClientConfigDialogProps { workspaceUrl: string; @@ -453,7 +453,6 @@ class WSEditorClientConfigDialog extends React.Component< isAdd: false, }); } catch (err: any) { - // catch 404 error if (errorHandlerApi.isHttpError(err, 404)) { this.setState({ isAdd: true, @@ -492,7 +491,6 @@ class WSEditorClientConfigDialog extends React.Component< templateAzureChinaCloud = templateAzureChinaCloud.trim(); templateAzureUSGovernment = templateAzureUSGovernment.trim(); templateAzureGermanCloud = templateAzureGermanCloud.trim(); - // verify template url using regex, like https://{vaultName}.vault.azure.net const templateRegex = /^https:\/\/((\{[a-zA-Z0-9]+\})|([^{}.]+))(.((\{[a-zA-Z0-9]+\})|([^{}.]+)))*(\/)?$/; if (!templateRegex.test(templateAzureCloud)) { this.setState({ @@ -546,7 +544,6 @@ class WSEditorClientConfigDialog extends React.Component< selectorIndex: cloudMetadataSelectorIndex, }; if (cloudMetadataPrefixTemplate.length > 0) { - // verify template url using regex, like https://{vaultName} if (!templateRegex.test(cloudMetadataPrefixTemplate)) { this.setState({ invalidText: "Cloud Metadata Prefix is invalid.", diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorCommandTree.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorCommandTree.tsx new file mode 100644 index 00000000..6f888738 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorCommandTree.tsx @@ -0,0 +1,210 @@ +import React, { useState, useCallback, useRef } from "react"; +import TreeView from "@mui/lab/TreeView"; +import TreeItem from "@mui/lab/TreeItem"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { styled, Box, IconButton, Menu, MenuItem, Tooltip, Typography, TypographyProps } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import MoreHorizSharpIcon from "@mui/icons-material/MoreHorizSharp"; + +interface CommandTreeLeaf { + id: string; + names: string[]; +} + +interface CommandTreeNode { + id: string; + names: string[]; + nodes?: CommandTreeNode[]; + leaves?: CommandTreeLeaf[]; + canDelete: boolean; +} + +interface WSEditorCommandTreeProps { + commandTreeNodes: CommandTreeNode[]; + selected: string; + expanded: string[]; + onSelected: (nodeId: string) => void; + onToggle: (nodeIds: string[]) => void; + onAdd: () => void; + onReload: () => void; + onEditClientConfig?: () => void; +} + +const HeaderTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 16, + fontWeight: 600, +})); + +const WSEditorCommandTree: React.FC = ({ + commandTreeNodes, + selected, + expanded, + onSelected, + onToggle, + onAdd, + onReload, + onEditClientConfig, +}) => { + const [openMore, setOpenMore] = useState(false); + const moreButtonRef = useRef(null); + + const handleNodeSelected = useCallback( + (_event: React.SyntheticEvent, nodeIds: string[] | string) => { + if (typeof nodeIds === "string") { + onSelected(nodeIds); + } + }, + [onSelected], + ); + + const handleNodeToggle = useCallback( + (_event: React.SyntheticEvent, nodeIds: string[]) => { + onToggle(nodeIds); + }, + [onToggle], + ); + + const handleMoreClick = useCallback(() => { + setOpenMore((prevOpenMore) => !prevOpenMore); + }, []); + + const handleEditClientConfig = useCallback(() => { + setOpenMore(false); + onEditClientConfig!(); + }, [onEditClientConfig]); + + const renderLeaf = useCallback( + (leaf: CommandTreeLeaf) => { + const leafName = leaf.names[leaf.names.length - 1]; + return ( + { + if (selected !== leaf.id) { + handleNodeSelected(event, leaf.id); + } + event.stopPropagation(); + event.preventDefault(); + }} + /> + ); + }, + [selected, handleNodeSelected], + ); + + const renderNode = useCallback( + (node: CommandTreeNode): React.ReactElement => { + const nodeName = node.names[node.names.length - 1]; + return ( + { + if (selected !== node.id || expanded.indexOf(node.id) === -1) { + handleNodeSelected(event, node.id); + handleNodeToggle(event, [...expanded, node.id]); + } else { + handleNodeToggle( + event, + expanded.filter((v) => v !== node.id), + ); + } + event.stopPropagation(); + event.preventDefault(); + }} + > + {Array.isArray(node.leaves) ? node.leaves.map((leaf) => renderLeaf(leaf)) : null} + {Array.isArray(node.nodes) ? node.nodes.map((subNode) => renderNode(subNode)) : null} + + ); + }, + [selected, expanded, handleNodeSelected, handleNodeToggle, renderLeaf], + ); + + return ( + + + Command Tree + + + + + + + + + + + + {onEditClientConfig !== undefined && ( + <> + + + + + + { + setOpenMore(false); + }} + MenuListProps={{ + "aria-labelledby": "more-button", + }} + > + Edit Client Config + + + )} + + } + defaultExpandIcon={} + selected={selected} + expanded={expanded} + sx={{ + flexGrow: 1, + overflowY: "auto", + mt: 1, + ml: 3, + mr: 3, + }} + > + {commandTreeNodes.map((node) => renderNode(node))} + + + ); +}; + +export default WSEditorCommandTree; + +export type { CommandTreeNode, CommandTreeLeaf }; diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx new file mode 100644 index 00000000..fe05557a --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx @@ -0,0 +1,87 @@ +import React, { useState, Fragment } from "react"; +import { + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + LinearProgress, + Button, + TextField, + Alert, +} from "@mui/material"; +import { workspaceApi, errorHandlerApi } from "../../../../services"; + +interface WSEditorDeleteDialogProps { + workspaceName: string; + open: boolean; + onClose: (deleted: boolean) => void; +} + +const WSEditorDeleteDialog: React.FC = ({ workspaceName, open, onClose }) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [confirmName, setConfirmName] = useState(undefined); + + const handleClose = () => { + onClose(false); + }; + + const handleDelete = async () => { + setUpdating(true); + try { + await workspaceApi.deleteWorkspace(workspaceName); + setUpdating(false); + onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + return ( + + Delete '{workspaceName}' workspace? + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + { + setConfirmName(event.target.value); + }} + margin="normal" + required + /> + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default WSEditorDeleteDialog; diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx new file mode 100644 index 00000000..3b0942e1 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorExportDialog.tsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect, Fragment } from "react"; +import { Box, Dialog, DialogTitle, DialogContent, DialogActions, LinearProgress, Button, Alert } from "@mui/material"; +import { workspaceApi, errorHandlerApi } from "../../../../services"; + +interface WSEditorExportDialogProps { + workspaceUrl: string; + open: boolean; + clientConfigurable: boolean; + onClose: (exported: boolean, showClientConfigDialog: boolean) => void; +} + +const WSEditorExportDialog: React.FC = ({ + workspaceUrl, + open, + clientConfigurable, + onClose, +}) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [clientConfigOOD, setClientConfigOOD] = useState(false); + + useEffect(() => { + if (clientConfigurable) { + verifyClientConfig(); + } + }, [clientConfigurable]); + + const handleClose = () => { + onClose(false, false); + }; + + const verifyClientConfig = async () => { + setUpdating(true); + try { + await workspaceApi.verifyClientConfig(workspaceUrl); + setClientConfigOOD(false); + setUpdating(false); + } catch (err: any) { + // catch 409 error + if (errorHandlerApi.isHttpError(err, 409)) { + setInvalidText(`The client config in this workspace is out of date. Please refresh it first.`); + setClientConfigOOD(true); + setUpdating(false); + return; + } else { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + } + }; + + const inheritClientConfig = async () => { + setUpdating(true); + try { + await workspaceApi.inheritClientConfig(workspaceUrl); + setClientConfigOOD(false); + setUpdating(false); + onClose(false, true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleExport = async () => { + setUpdating(true); + + try { + await workspaceApi.generateWorkspace(workspaceUrl); + setUpdating(false); + onClose(false, false); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + return ( + + Export workspace command models to AAZ Repo + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + {updating && ( + + + + )} + {!updating && ( + + {clientConfigOOD && } + {!clientConfigOOD && } + {!clientConfigOOD && } + + )} + + + ); +}; + +export default WSEditorExportDialog; diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx new file mode 100644 index 00000000..fab095eb --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect, Fragment } from "react"; +import { + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + LinearProgress, + Button, + List, + ListSubheader, + Paper, + ListItemButton, + ListItemIcon, + Checkbox, + ListItemText, + ListItem, + Alert, +} from "@mui/material"; +import type { Resource } from "../../interfaces"; +import { getTypespecRPResourcesOperations } from "../../../../typespec"; +import { workspaceApi, errorHandlerApi } from "../../../../services"; + +interface WSEditorSwaggerReloadDialogProps { + workspaceName: string; + workspaceUrl: string; + open: boolean; + source: string; + onClose: (exported: boolean) => void; +} + +const WSEditorSwaggerReloadDialog: React.FC = ({ + workspaceName, + workspaceUrl, + open, + source, + onClose, +}) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [resourceOptions, setResourceOptions] = useState([]); + const [selectedResources, setSelectedResources] = useState>(new Set()); + + useEffect(() => { + loadResourceOptions(); + }, []); + + const loadResourceOptions = async () => { + setInvalidText(undefined); + setUpdating(true); + try { + const resources: Resource[] = await workspaceApi.getWorkspaceResources(workspaceUrl); + setUpdating(false); + setResourceOptions(resources); + setSelectedResources(new Set(resources.map((resource) => resource.id))); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleClose = () => { + onClose(false); + }; + + const handleReload = async () => { + const data = { + resources: resourceOptions + .filter((option) => selectedResources.has(option.id)) + .map((option) => { + return { + id: option.id, + version: option.version, + }; + }), + }; + + if (data.resources.length === 0) { + onClose(false); + return; + } + + setInvalidText(undefined); + setUpdating(true); + + try { + if (source.toLowerCase() === "typespec") { + const swaggerDefault = await workspaceApi.getWorkspaceSwaggerDefault(workspaceName); + const { modNames, rpName, source: swaggerSource } = swaggerDefault; + if ( + !modNames || + modNames.length === 0 || + !rpName || + !swaggerSource || + swaggerSource.toLowerCase() !== "typespec" + ) { + setInvalidText("Invalid workspace info"); + setUpdating(false); + return; + } + const resourceProviderUrl = + "/Swagger/Specs/" + + swaggerDefault.plane + + "/" + + swaggerDefault.modNames.join("/") + + `/ResourceProviders/${swaggerDefault.rpName}/TypeSpec`; + const requestBody = { + version: resourceOptions[0].version, + resources: data.resources, + resourceProviderUrl: resourceProviderUrl, + }; + const emitterOptionRes = await getTypespecRPResourcesOperations(requestBody); + if (emitterOptionRes.length === 0) { + setInvalidText("Invalid resource operation emitter info"); + setUpdating(false); + return; + } + data.resources = emitterOptionRes; + await workspaceApi.reloadTypespecResources(workspaceUrl, data); + } else { + await workspaceApi.reloadSwaggerResources(workspaceUrl, data); + } + + setUpdating(false); + onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const onSelectedAllClick = () => { + setSelectedResources(selectedResources.size > 0 ? new Set() : new Set(resourceOptions.map((op) => op.id))); + }; + + const onResourceItemClick = (resourceId: string) => { + return () => { + setSelectedResources((prev) => { + const newSelectedResources = new Set(prev); + if (newSelectedResources.has(resourceId)) { + newSelectedResources.delete(resourceId); + } else { + newSelectedResources.add(resourceId); + } + return newSelectedResources; + }); + }; + }; + + return ( + + Reload {source.toLowerCase() === "typespec" ? "TypeSpec" : "Swagger"} Resources + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + + + + 0 && selectedResources.size === resourceOptions.length} + indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} + tabIndex={-1} + disableRipple + inputProps={{ "aria-labelledby": "SelectAll" }} + /> + + + + + + + } + > + {resourceOptions.length > 0 && ( + + {resourceOptions.map((option) => { + const labelId = `resource-${option.id}`; + const selected = selectedResources.has(option.id); + return ( + + + + + + + + + ); + })} + + )} + + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default WSEditorSwaggerReloadDialog; diff --git a/src/web/src/views/workspace/WSEditorTheme.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorTheme.tsx similarity index 100% rename from src/web/src/views/workspace/WSEditorTheme.tsx rename to src/web/src/views/workspace/components/WSEditor/WSEditorTheme.tsx diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx new file mode 100644 index 00000000..af7da4ac --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx @@ -0,0 +1,86 @@ +import { Box, AppBar, Button, IconButton, styled, Toolbar, Typography, Tooltip, TypographyProps } from "@mui/material"; +import HomeIcon from "@mui/icons-material/Home"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { grey } from "@mui/material/colors"; + +import React, { Fragment } from "react"; + +const ArgEditTypography = styled(Typography)(() => ({ + color: "#ffffff", + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +interface WSEditorToolBarProps { + workspaceName: string; + onHomePage: () => void; + onGenerate: () => void; + onDelete: () => void; + onModify: () => void; +} + +const WSEditorToolBar: React.FC = ({ + workspaceName, + onHomePage, + onGenerate, + onDelete, + onModify, +}) => { + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + + WORKSPACE + + + + + {workspaceName} + + + + + + + + + + + + + + ); +}; + +export default WSEditorToolBar; diff --git a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx new file mode 100644 index 00000000..e701f569 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx @@ -0,0 +1,98 @@ +import React, { useState, Fragment } from "react"; +import { + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + LinearProgress, + Button, + TextField, + Alert, +} from "@mui/material"; +import { workspaceApi, errorHandlerApi } from "../../../../services"; + +interface WSRenameDialogProps { + workspaceUrl: string; + workspaceName: string; + open: boolean; + onClose: (newWSName: string | null) => void; +} + +const WSRenameDialog: React.FC = ({ workspaceUrl, workspaceName, open, onClose }) => { + const [newWSName, setNewWSName] = useState(workspaceName); + const [invalidText, setInvalidText] = useState(undefined); + const [updating, setUpdating] = useState(false); + + const handleModify = async () => { + const nName = newWSName.trim(); + if (nName.length < 1) { + setInvalidText(`Field 'Name' is required.`); + return; + } + + 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); + } catch (err: any) { + setUpdating(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + } + }; + + const handleClose = () => { + setInvalidText(undefined); + onClose(null); + }; + + return ( + + Rename Workspace + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + { + setNewWSName(event.target.value); + }} + margin="normal" + required + /> + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default WSRenameDialog; diff --git a/src/web/src/views/workspace/components/WSEditor/index.ts b/src/web/src/views/workspace/components/WSEditor/index.ts new file mode 100644 index 00000000..b25ec00e --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditor/index.ts @@ -0,0 +1,9 @@ +export { WSEditor, WSEditor as default } from "./WSEditor"; +export { default as WSEditorToolBar } from "./WSEditorToolBar"; +export { default as WSEditorCommandTree } from "./WSEditorCommandTree"; +export type { CommandTreeNode, CommandTreeLeaf } from "./WSEditorCommandTree"; +export { default as WSEditorExportDialog } from "./WSEditorExportDialog"; +export { default as WSEditorDeleteDialog } from "./WSEditorDeleteDialog"; +export { default as WSEditorSwaggerReloadDialog } from "./WSEditorSwaggerReloadDialog"; +export { default as WSRenameDialog } from "./WSRenameDialog"; +export { default as WSEditorClientConfigDialog } from "./WSEditorClientConfig"; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgNavBar.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgNavBar.tsx new file mode 100644 index 00000000..7480a4c7 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgNavBar.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Box, ButtonBase, styled, Typography, TypographyProps } from "@mui/material"; +import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; + +interface ArgIdx { + var: string; + displayKey: string; +} + +interface ArgNavBarProps { + argIdxStack: ArgIdx[]; + onChangeArgIdStack: (end: number) => void; +} + +const NavBarItemTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const NavBarItemHightLightedTypography = styled(NavBarItemTypography)(() => ({ + color: "#5d64cf", +})); + +const ArgNavBar: React.FC = ({ argIdxStack, onChangeArgIdStack }) => { + return ( + + + { + onChangeArgIdStack(0); + }} + > + + + {argIdxStack.slice(0, -1).map((argIdx: ArgIdx, index: number) => ( + { + onChangeArgIdStack(index + 1); + }} + > + + {index > 0 ? `.${argIdx.displayKey}` : argIdx.displayKey} + + + ))} + { + onChangeArgIdStack(argIdxStack.length); + }} + > + + {argIdxStack.length > 1 + ? `.${argIdxStack[argIdxStack.length - 1].displayKey}` + : argIdxStack[argIdxStack.length - 1].displayKey} + + + + + ); +}; + +export default ArgNavBar; +export type { ArgNavBarProps, ArgIdx }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx new file mode 100644 index 00000000..f9f647b0 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx @@ -0,0 +1,749 @@ +import { + Alert, + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + InputLabel, + LinearProgress, + Radio, + RadioGroup, + Switch, + TextField, +} from "@mui/material"; + +import { commandApi, errorHandlerApi } from "../../../../services"; +import React, { useEffect, useState } from "react"; +import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; +import { convertArgDefaultText } from "../../utils/convertArgDefaultText"; + +interface CMDClsArg { + var: string; + type: string; + clsName: string; + options: string[]; + singularOptions?: string[]; + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: { + short: string; + lines?: string[]; + }; + default?: { + value: any; + }; + prompt?: { + msg: string; + confirm?: boolean; + }; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +interface CMDArrayArg { + var: string; + type: string; + options: string[]; + singularOptions?: string[]; + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: { + short: string; + lines?: string[]; + }; + default?: { + value: any; + }; + prompt?: { + msg: string; + }; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +interface CMDPasswordArg { + var: string; + type: string; + options: string[]; + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: { + short: string; + lines?: string[]; + }; + default?: { + value: any; + }; + prompt?: { + msg: string; + confirm?: boolean; + }; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +interface CMDArg { + var: string; + type: string; + options: string[]; + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: { + short: string; + lines?: string[]; + }; + default?: { + value: any; + }; + prompt?: { + msg: string; + }; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +type ClsArgDefinitionMap = { + [clsName: string]: any; +}; + +interface ArgumentDialogProps { + commandUrl: string; + arg: CMDArg; + clsArgDefineMap: ClsArgDefinitionMap; + open: boolean; + onClose: (updated: boolean) => Promise; +} + +const ArgumentDialog: React.FC = (props) => { + const [updating, setUpdating] = useState(false); + const [stage, setStage] = useState(""); + const [invalidText, setInvalidText] = useState(undefined); + const [options, setOptions] = useState(""); + const [singularOptions, setSingularOptions] = useState(undefined); + const [group, setGroup] = useState(""); + const [hide, setHide] = useState(false); + const [supportEnumExtension, setSupportEnumExtension] = useState(false); + const [shortHelp, setShortHelp] = useState(""); + const [longHelp, setLongHelp] = useState(""); + const [argSimilarTree, setArgSimilarTree] = useState(undefined); + const [argSimilarTreeExpandedIds, setArgSimilarTreeExpandedIds] = useState([]); + const [argSimilarTreeArgIdsUpdated, setArgSimilarTreeArgIdsUpdated] = useState([]); + const [hasDefault, setHasDefault] = useState(false); + const [defaultValue, setDefaultValue] = useState(undefined); + const [defaultValueInJson, setDefaultValueInJson] = useState(false); + const [hasPrompt, setHasPrompt] = useState(false); + const [promptMsg, setPromptMsg] = useState(undefined); + const [promptConfirm, setPromptConfirm] = useState(undefined); + const [configurationKey, setConfigurationKey] = useState(""); + const [isClientArg, setIsClientArg] = useState(false); + + const handleClose = () => { + setInvalidText(undefined); + props.onClose(false); + }; + + const verifyModification = () => { + setInvalidText(undefined); + const name = options.trim(); + const sName = singularOptions?.trim() ?? undefined; + const sHelp = shortHelp.trim(); + const lHelp = longHelp.trim(); + const gName = group.trim(); + const cfgKey = configurationKey.trim(); + + const names = name.split(" ").filter((n) => n.length > 0); + const sNames = sName?.split(" ").filter((n) => n.length > 0) ?? undefined; + + if (names.length < 1) { + setInvalidText(`Argument 'Option names' is required.`); + return undefined; + } + + for (const idx in names) { + const piece = names[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + setInvalidText(`Invalid 'Option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `); + return undefined; + } + } + + if (sNames) { + for (const idx in sNames) { + const piece = sNames[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + setInvalidText( + `Invalid 'Singular option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `, + ); + return undefined; + } + } + } + + if ( + sHelp.length < 1 && + names.find((n) => { + return n === "subscription" || n === "resource-group"; + }) === undefined + ) { + setInvalidText(`Field 'Short Summary' is required.`); + return undefined; + } + + let lines: string[] | null = null; + if (lHelp.length > 1) { + lines = lHelp.split("\n").filter((l) => l.length > 0); + } + + let argCfgKey: string | null = null; + if (cfgKey.length > 0) { + argCfgKey = cfgKey; + } + + let argDefault = undefined; + if (hasDefault === false) { + if (props.arg.default !== undefined) { + argDefault = null; + } + } else if (hasDefault === true) { + if (defaultValue === undefined) { + setInvalidText(`Field 'Default Value' is undefined.`); + return undefined; + } else { + try { + let argType = props.arg.type; + if (argType.startsWith("@")) { + argType = props.clsArgDefineMap[(props.arg as CMDClsArg).clsName].type; + } + argDefault = { + value: convertArgDefaultText(defaultValue!, argType), + }; + } catch (err: any) { + setInvalidText(`Field 'Default Value' is invalid: ${err.message}.`); + return undefined; + } + if (props.arg.default !== undefined && props.arg.default.value === argDefault.value) { + argDefault = undefined; + } + } + } + + let argPrompt = undefined; + if (hasPrompt === false) { + if (props.arg.prompt !== undefined) { + argPrompt = null; + } + } else if (hasPrompt === true) { + if (promptMsg === undefined) { + setInvalidText(`Field 'Prompt Message' is undefined.`); + return undefined; + } else { + const msg = promptMsg.trim(); + if (msg.length < 1) { + setInvalidText(`Field 'Prompt Message' is empty.`); + return undefined; + } + if (!msg.endsWith(":")) { + setInvalidText(`Field 'Prompt Message' must end with a colon.`); + return undefined; + } + argPrompt = { + msg: msg, + confirm: promptConfirm, + }; + } + } + + return { + options: names, + singularOptions: sNames, + stage: stage, + group: gName, + hide: hide, + help: { + short: sHelp, + lines: lines, + }, + default: argDefault, + prompt: argPrompt, + configurationKey: argCfgKey, + supportEnumExtension: supportEnumExtension, + }; + }; + + const handleModify = async () => { + const data = verifyModification(); + if (data === undefined) { + return; + } + + setUpdating(true); + + const argumentUrl = `${props.commandUrl}/Arguments/${props.arg.var}`; + + try { + await commandApi.updateCommandArgument(argumentUrl, data); + setUpdating(false); + await props.onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleDisplaySimilar = async () => { + if (verifyModification() === undefined) { + return; + } + + setUpdating(true); + + try { + const res = await commandApi.findSimilarArguments(props.commandUrl, props.arg.var); + setUpdating(false); + const { tree, expandedIds } = BuildArgSimilarTree(res); + setArgSimilarTree(tree); + setArgSimilarTreeExpandedIds(expandedIds); + setArgSimilarTreeArgIdsUpdated([]); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleDisableSimilar = () => { + setArgSimilarTree(undefined); + setArgSimilarTreeExpandedIds([]); + }; + + const onSimilarTreeUpdated = (newTree: ArgSimilarTree) => { + setArgSimilarTree(newTree); + }; + + const onSimilarTreeExpandedIdsUpdated = (expandedIds: string[]) => { + setArgSimilarTreeExpandedIds(expandedIds); + }; + + const handleModifySimilar = async () => { + const data = verifyModification(); + if (data === undefined) { + 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); + updatedIds.push(argId); + setArgSimilarTreeArgIdsUpdated([...updatedIds]); + } catch (err: any) { + console.error(err); + invalidText += errorHandlerApi.getErrorMessage(err); + } + } + } + + if (invalidText.length > 0) { + setInvalidText(invalidText); + setUpdating(false); + } else { + setUpdating(false); + await props.onClose(true); + } + }; + + useEffect(() => { + const { arg, clsArgDefineMap } = props; + setIsClientArg(arg.var.startsWith("$Client.")); + + setOptions(arg.options.join(" ")); + if (arg.type.startsWith("array")) { + setSingularOptions((arg as CMDArrayArg).singularOptions?.join(" ") ?? ""); + } else if (arg.type.startsWith("@") && clsArgDefineMap[(arg as CMDClsArg).clsName].type.startsWith("array")) { + setSingularOptions((arg as CMDClsArg).singularOptions?.join(" ") ?? ""); + } else { + setSingularOptions(undefined); + } + + if ( + arg.type === "object" || + arg.type.startsWith("dict<") || + arg.type.startsWith("array<") || + arg.type.startsWith("@") + ) { + setHasPrompt(undefined); + } else { + setHasPrompt(arg.prompt !== undefined); + if (arg.prompt !== undefined) { + setPromptMsg(arg.prompt.msg); + setPromptConfirm(undefined); + } + } + if (arg.type === "password") { + setPromptConfirm((arg as CMDPasswordArg).prompt?.confirm ?? false); + } + setStage(props.arg.stage); + setGroup(props.arg.group); + setHide(props.arg.hide); + setSupportEnumExtension(props.arg.supportEnumExtension || false); + setShortHelp(props.arg.help?.short ?? ""); + setLongHelp(props.arg.help?.lines?.join("\n") ?? ""); + setConfigurationKey(props.arg.configurationKey ?? ""); + setUpdating(false); + setArgSimilarTree(undefined); + setArgSimilarTreeExpandedIds([]); + + if ( + arg.type === "object" || + arg.type.startsWith("dict<") || + arg.type.startsWith("array<") || + arg.type.startsWith("@") + ) { + setDefaultValueInJson(true); + if (props.arg.default !== undefined && props.arg.default !== null) { + setHasDefault(true); + setDefaultValue(JSON.stringify(props.arg.default.value)); + } else { + setHasDefault(false); + setDefaultValue(undefined); + } + } else { + setDefaultValueInJson(false); + if (props.arg.default !== undefined && props.arg.default !== null) { + setHasDefault(true); + setDefaultValue(props.arg.default.value.toString()); + } else { + setHasDefault(false); + setDefaultValue(undefined); + } + } + }, [props.arg]); + + return ( + + {!argSimilarTree && ( + <> + {isClientArg ? "Modify Client Argument" : "Modify Argument"} + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + { + setOptions(event.target.value); + }} + margin="normal" + required + /> + {singularOptions !== undefined && ( + { + setSingularOptions(event.target.value); + }} + margin="normal" + /> + )} + {!isClientArg && ( + <> + { + setGroup(event.target.value); + }} + margin="normal" + /> + + Stage + + { + setStage(event.target.value); + }} + > + } label="Stable" sx={{ ml: 4 }} /> + } label="Preview" sx={{ ml: 4 }} /> + } label="Experimental" sx={{ ml: 4 }} /> + + + {!props.arg.required && ( + <> + + Hide Argument + + { + setHide(!hide); + }} + /> + + )} + + {props.arg.hasEnum && ( + <> + + Support Enum Extension + + { + setSupportEnumExtension(!supportEnumExtension); + }} + /> + + )} + + )} + {hasDefault !== undefined && ( + <> + + Default Value + + + { + setHasDefault(!hasDefault); + setDefaultValue(undefined); + }} + /> + + + )} + + {hasPrompt !== undefined && ( + <> + + Prompt Input + + + { + setHasPrompt(!hasPrompt); + setPromptMsg(undefined); + }} + /> + + + + + )} + {!isClientArg && ( + { + setConfigurationKey(event.target.value); + }} + margin="normal" + /> + )} + + { + setShortHelp(event.target.value); + }} + margin="normal" + required + /> + { + setLongHelp(event.target.value); + }} + margin="normal" + /> + + + )} + + {argSimilarTree && ( + <> + Modify Similar Arguments + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + + )} + + {updating && ( + + + + )} + {!updating && !argSimilarTree && ( + <> + + {!props.arg.var.startsWith("@") && ( + + )} + {!isClientArg && } + + )} + {!updating && argSimilarTree && ( + <> + + + + )} + + + ); +}; + +export default ArgumentDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx new file mode 100644 index 00000000..f8b84c03 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useState } from "react"; +import { Box } from "@mui/material"; +import { ExperimentalTypography, PreviewTypography, StableTypography } from "../WSEditor/WSEditorTheme"; +import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; +import ArgNavBar, { type ArgIdx } from "./ArgNavBar"; +import ArgumentReviewer from "./ArgumentReviewer"; +import type { + CMDArg, + CMDArgBase, + CMDClsArg, + CMDObjectArg, + CMDDictArgBase, + CMDArrayArgBase, + ClsArgDefinitionMap, +} from "../../utils/decodeArgs"; + +interface ArgumentNavigationProps { + commandUrl: string; + args: CMDArg[]; + clsArgDefineMap: ClsArgDefinitionMap; + onEdit: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; + onFlatten: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; + onUnwrap: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; + onAddSubcommand: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; +} + +const ArgumentNavigation: React.FC = ({ + commandUrl, + args, + clsArgDefineMap, + onEdit, + onFlatten, + onUnwrap, + onAddSubcommand, +}) => { + const [argIdxStack, setArgIdxStack] = useState([]); + + const getArgProps = ( + selectedArgBase: CMDArgBase, + ): { title: string; props: CMDArg[]; flattenArgVar: string | undefined } | undefined => { + if (selectedArgBase.type.startsWith("@")) { + const clsArgDefine = clsArgDefineMap[(selectedArgBase as CMDClsArg).clsName]; + const clsArgProps = getArgProps(clsArgDefine); + if (clsArgProps !== undefined && clsArgDefine.type === "object") { + clsArgProps!.flattenArgVar = (selectedArgBase as CMDClsArg).var; + } + return clsArgProps; + } + if (selectedArgBase.type === "object") { + return { + title: "Props", + props: (selectedArgBase as CMDObjectArg).args, + flattenArgVar: (selectedArgBase as CMDObjectArg).var, + }; + } else if (selectedArgBase.type.startsWith("dict<")) { + const item = (selectedArgBase as CMDDictArgBase).item; + const itemProps = item ? getArgProps(item) : undefined; + if (!itemProps) { + return undefined; + } + return { + title: "Dict Element Props", + props: itemProps.props, + flattenArgVar: undefined, + }; + } else if (selectedArgBase.type.startsWith("array<")) { + const itemProps = getArgProps((selectedArgBase as CMDArrayArgBase).item); + if (!itemProps) { + return undefined; + } + return { + title: "Array Element Props", + props: itemProps.props, + flattenArgVar: undefined, + }; + } else { + return undefined; + } + }; + + const getSelectedArg = (stack: ArgIdx[]): CMDArg | undefined => { + if (stack.length === 0) { + return undefined; + } else { + let argsArray: CMDArg[] = [...args]; + let selectedArg: CMDArg | undefined = undefined; + for (const i in stack) { + const argVar = stack[i].var; + selectedArg = argsArray.find((arg) => arg.var === argVar); + if (!selectedArg) { + break; + } + argsArray = getArgProps(selectedArg)?.props ?? []; + } + return selectedArg; + } + }; + + useEffect(() => { + setArgIdxStack([]); + }, [commandUrl]); + + useEffect(() => { + const stack = [...argIdxStack]; + while (stack.length > 0 && !getSelectedArg(stack)) { + stack.pop(); + } + if (stack.length !== argIdxStack.length) { + setArgIdxStack(stack); + } + }, [args, clsArgDefineMap]); + + const handleSelectSubArg = (subArgVar: string) => { + let subArg; + if (argIdxStack.length > 0) { + const arg = getSelectedArg(argIdxStack); + if (!arg) { + return; + } + subArg = getArgProps(arg)?.props.find((a) => a.var === subArgVar); + } else { + subArg = args.find((a: CMDArg) => a.var === subArgVar); + } + + if (!subArg) { + return; + } + const argIdx: ArgIdx = { + var: subArg.var, + displayKey: subArg.options[0], + }; + if (argIdxStack.length === 0) { + if (argIdx.displayKey.length === 1) { + argIdx.displayKey = `-${argIdx.displayKey}`; + } else { + argIdx.displayKey = `--${argIdx.displayKey}`; + } + } + + let argType = subArg.type; + if (argType.startsWith("@")) { + argType = clsArgDefineMap[(subArg as CMDClsArg).clsName].type; + } + if (argType.startsWith("dict<")) { + argIdx.displayKey += "{}"; + } else if (argType.startsWith("array<")) { + argIdx.displayKey += "[]"; + } + + setArgIdxStack([...argIdxStack, argIdx]); + }; + + const handleChangeArgIdStack = (end: number) => { + setArgIdxStack(argIdxStack.slice(0, end)); + }; + + const buildArgumentReviewer = () => { + const selectedArg = getSelectedArg(argIdxStack); + if (!selectedArg) { + return <>; + } + + const stage = selectedArg.stage; + + return ( + + + + {stage === "Stable" && {stage}} + {stage === "Preview" && {stage}} + {stage === "Experimental" && {stage}} + + { + onEdit(selectedArg, argIdxStack); + }} + onUnwrap={() => { + onUnwrap(selectedArg, argIdxStack); + }} + /> + + ); + }; + + const buildArgumentPropsReviewer = () => { + if (argIdxStack.length === 0) { + if (args.length === 0) { + return <>; + } + return ( + + ); + } else { + const selectedArg = getSelectedArg(argIdxStack); + if (!selectedArg) { + return <>; + } + const argProps = getArgProps(selectedArg); + if (!argProps) { + return <>; + } + const canFlatten = argProps.flattenArgVar !== undefined; + return ( + { + onFlatten(selectedArg!, argIdxStack); + } + : undefined + } + onAddSubcommand={() => { + onAddSubcommand(selectedArg!, argIdxStack); + }} + onSelectSubArg={handleSelectSubArg} + /> + ); + } + }; + + return ( + + {argIdxStack.length > 0 && {buildArgumentReviewer()}} + {buildArgumentPropsReviewer()} + + ); +}; + +export default ArgumentNavigation; +export type { ArgumentNavigationProps }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx new file mode 100644 index 00000000..b6732986 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { Box, Button, ButtonBase, styled, Typography, TypographyProps } from "@mui/material"; +import { ChevronRight } from "@mui/icons-material"; +import AddIcon from "@mui/icons-material/Add"; +import CallSplitSharpIcon from "@mui/icons-material/CallSplitSharp"; +import { SmallExperimentalTypography, SmallPreviewTypography, SubtitleTypography } from "../WSEditor/WSEditorTheme"; +import type { CMDArg } from "../../utils/decodeArgs"; +import { spliceArgOptionsString } from "../../utils/spliceArgOptionsString"; + +interface ArgGroup { + name: string; + args: CMDArg[]; +} + +interface ArgumentPropsReviewerProps { + title: string; + args: CMDArg[]; + onFlatten?: () => void; + onAddSubcommand?: () => void; + selectedArg?: CMDArg; + depth: number; + onSelectSubArg: (subArgVar: string) => void; +} + +const PropArgTypeTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 10, + fontWeight: 400, +})); + +const PropRequiredTypography = styled(Typography)(() => ({ + color: "#dba339", + fontFamily: "'Work Sans', sans-serif", + fontSize: 10, + fontWeight: 400, +})); + +const PropHiddenTypography = styled(Typography)(() => ({ + color: "#8888C3", + fontFamily: "'Work Sans', sans-serif", + fontSize: 10, + fontWeight: 400, +})); + +const ArgGroupTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 18, + fontWeight: 200, +})); + +const PropArgOptionTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +const PropHiddenArgOptionTypography = styled(Typography)(() => ({ + color: "#8888C3", + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +const PropArgShortSummaryTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 14, + fontStyle: "italic", + fontWeight: 400, +})); + +const ArgEditTypography = styled(Typography)(() => ({ + color: "#5d64cf", + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const ArgumentPropsReviewer: React.FC = (props) => { + const groupArgs: { [name: string]: CMDArg[] } = {}; + if (props.args !== undefined) { + props.args.forEach((arg) => { + const groupName: string = arg.group.length > 0 ? arg.group : ""; + if (!(groupName in groupArgs)) { + groupArgs[groupName] = []; + } + groupArgs[groupName].push(arg); + }); + } + + const groups: ArgGroup[] = []; + + for (const groupName in groupArgs) { + groupArgs[groupName].sort((a, b) => { + if (a.required && !b.required) { + return -1; + } else if (!a.required && b.required) { + return 1; + } + return a.options[0].localeCompare(b.options[0]); + }); + groups.push({ + name: groupName, + args: groupArgs[groupName], + }); + } + groups.sort((a, b) => a.name.localeCompare(b.name)); + + const checkCanAddSubcommand = () => { + if (props.selectedArg && props.args.length > 0) { + return true; + } + return false; + }; + + const buildArg = (arg: CMDArg, idx: number) => { + const argOptionsString = spliceArgOptionsString(arg, props.depth); + return ( + + + { + props.onSelectSubArg(arg.var); + }} + > + {!arg.hide && {argOptionsString}} + {arg.hide && ( + {argOptionsString} + )} + + + + {arg.stage === "Preview" && ( + {arg.stage} + )} + {arg.stage === "Experimental" && ( + {arg.stage} + )} + + + + {`/${arg.type}/`} + + {arg.required && [Required]} + {arg.hide && [Hidden]} + + {arg.help && ( + + {arg.help.short} + + )} + + + ); + }; + + const buildArgGroup = (group: ArgGroup, idx: number) => { + return ( + + + + {group.name.length > 0 ? `${group.name} Group` : "Default Group"} + + + + {group.args.map(buildArg)} + + + ); + }; + + if (groups.length === 0) { + return <>; + } + + return ( + + + {props.title} + {props.onFlatten !== undefined && ( + + )} + + {props.onAddSubcommand !== undefined && checkCanAddSubcommand() && ( + + )} + + {groups.map(buildArgGroup)} + + ); +}; + +export default ArgumentPropsReviewer; +export type { ArgumentPropsReviewerProps, ArgGroup }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx new file mode 100644 index 00000000..05739fd6 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from "react"; +import { Box, Button, styled, Typography, TypographyProps } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import ImportExportIcon from "@mui/icons-material/ImportExport"; +import { LongHelpTypography, ShortHelpPlaceHolderTypography, ShortHelpTypography } from "../WSEditor/WSEditorTheme"; +import type { CMDArg } from "."; +import { spliceArgOptionsString, type CMDArrayArg } from "../../utils/spliceArgOptionsString"; + +interface CMDDictArg extends CMDArg { + item?: any; + anyType: boolean; +} + +interface CMDStringArg extends CMDArg { + enum?: { + items: { name: string; hide: boolean; value: string }[]; + }; +} + +interface CMDNumberArg extends CMDArg { + enum?: { + items: { name: string; hide: boolean; value: number }[]; + }; +} + +interface ArgumentReviewerProps { + arg: CMDArg; + depth: number; + onEdit: () => void; + onUnwrap: () => void; +} + +const ArgNameTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 26, + fontWeight: 700, +})); + +const ArgTypeTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +const ArgRequiredTypography = styled(Typography)(() => ({ + color: "#fad105", + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 200, +})); + +const ArgEditTypography = styled(Typography)(() => ({ + color: "#5d64cf", + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const ArgChoicesTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 14, + fontWeight: 700, +})); + +const ArgumentReviewer: React.FC = ({ arg, depth, onEdit, onUnwrap }) => { + const [choices, setChoices] = useState([]); + + const buildArgOptionsString = () => { + const argOptionsString = spliceArgOptionsString(arg, depth - 1); + return {argOptionsString}; + }; + + useEffect(() => { + const newChoices: string[] = []; + if ((arg as CMDStringArg).enum) { + const items = (arg as CMDStringArg).enum!.items; + for (const idx in items) { + const enumItem = items[idx]; + newChoices.push(enumItem.name); + } + } else if ((arg as CMDNumberArg).enum) { + const items = (arg as CMDNumberArg).enum!.items; + for (const idx in items) { + const enumItem = items[idx]; + newChoices.push(enumItem.name); + } + } + setChoices(newChoices); + }, [arg]); + + const getUnwrapKeywords = () => { + if (arg.type.startsWith("@")) { + return "Unwrap"; + } else if (arg.type.startsWith("array")) { + if ((arg as CMDArrayArg).item?.type.startsWith("@")) { + return "Unwrap Element"; + } + } else if (arg.type.startsWith("dict")) { + if ((arg as CMDDictArg).item?.type.startsWith("@")) { + return "Unwrap Element"; + } + } + return null; + }; + + const getDefaultValueToString = () => { + if ( + arg.type === "object" || + arg.type.startsWith("dict<") || + arg.type.startsWith("array<") || + arg.type.startsWith("@") + ) { + if (arg.default !== undefined && arg.default !== null) { + return JSON.stringify(arg.default.value); + } + } else { + if (arg.default !== undefined && arg.default !== null) { + return arg.default.value.toString(); + } + } + return ""; + }; + + return ( + + + + {buildArgOptionsString()} + + + + + + {`/${arg.type}/`} + + {getUnwrapKeywords() !== null && ( + + )} + + {arg.required && [Required]} + + {(arg.default !== undefined || choices.length > 0 || arg.configurationKey !== undefined) && ( + + {choices.length > 0 && ( + {`Choices: ` + choices.join(", ")} + )} + {arg.default !== undefined && ( + {`Default: ${getDefaultValueToString()}`} + )} + {arg.configurationKey !== undefined && ( + {`ConfigurationKey: ${arg.configurationKey}`} + )} + + )} + {arg.help?.short && {arg.help?.short} } + {!arg.help?.short && ( + + Please add argument short summary! + + )} + {arg.help?.lines && ( + + {arg.help.lines.map((line, idx) => ( + {line} + ))} + + )} + + + ); +}; + +export default ArgumentReviewer; +export type { ArgumentReviewerProps }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx new file mode 100644 index 00000000..f2dca82c --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/FlattenDialog.tsx @@ -0,0 +1,331 @@ +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + TextField, +} from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { commandApi, errorHandlerApi } from "../../../../services"; +import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; + +interface FlattenDialogProps { + commandUrl: string; + arg: CMDArg; + clsArgDefineMap: ClsArgDefinitionMap; + open: boolean; + onClose: (flattened: boolean) => Promise; +} + +type CMDArgHelp = { + short: string; + lines?: string[]; + refCommands?: string[]; +}; + +type CMDArgDefault = { + value: T | null; +}; + +type CMDArgBlank = { + value: T | null; +}; + +interface CMDArgPromptInput { + msg: string; +} + +interface CMDArgBase { + type: string; + nullable: boolean; + blank?: CMDArgBlank; +} + +interface CMDArg extends CMDArgBase { + var: string; + options: string[]; + + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: CMDArgHelp; + + default?: CMDArgDefault; + idPart?: string; + prompt?: CMDArgPromptInput; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +interface CMDClsArgBase extends CMDArgBase { + clsName: string; +} + +interface CMDClsArg extends CMDClsArgBase, CMDArg { + singularOptions?: string[]; +} + +interface CMDObjectArgBase extends CMDArgBase { + args: CMDArg[]; +} + +interface CMDObjectArg extends CMDObjectArgBase, CMDArg {} + +type ClsArgDefinitionMap = { + [clsName: string]: CMDArgBase; +}; + +const FlattenDialog: React.FC = (props) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [subArgOptions, setSubArgOptions] = useState<{ var: string; options: string }[]>([]); + const [argSimilarTree, setArgSimilarTree] = useState(undefined); + const [argSimilarTreeExpandedIds, setArgSimilarTreeExpandedIds] = useState([]); + const [argSimilarTreeArgIdsUpdated, setArgSimilarTreeArgIdsUpdated] = useState([]); + + useEffect(() => { + const { arg, clsArgDefineMap } = props; + let subArgs; + if (arg.type.startsWith("@")) { + const clsName = (arg as CMDClsArg).clsName; + subArgs = (clsArgDefineMap[clsName] as CMDObjectArgBase).args; + } else { + subArgs = (arg as CMDObjectArg).args; + } + const subArgOptions = subArgs.map((value) => { + return { + var: value.var, + options: value.options.join(" "), + }; + }); + + setSubArgOptions(subArgOptions); + setArgSimilarTree(undefined); + setArgSimilarTreeExpandedIds([]); + }, [props.arg]); + + const handleClose = () => { + setInvalidText(undefined); + props.onClose(false); + }; + + const verifyFlatten = () => { + setInvalidText(undefined); + const argOptions: { [argVar: string]: string[] } = {}; + let invalidText: string | undefined = undefined; + + subArgOptions.forEach((arg, idx) => { + const names = arg.options.split(" ").filter((n) => n.length > 0); + if (names.length < 1) { + invalidText = `Prop ${idx + 1} option name is required.`; + return undefined; + } + + for (const idx in names) { + const piece = names[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + invalidText = `Invalid 'Prop ${idx + 1} option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `; + return undefined; + } + } + argOptions[arg.var] = names; + }); + if (invalidText !== undefined) { + setInvalidText(invalidText); + return undefined; + } + + return { + subArgsOptions: argOptions, + }; + }; + + const handleFlatten = async () => { + const data = verifyFlatten(); + if (data === undefined) { + return; + } + + setUpdating(true); + + const flattenUrl = `${props.commandUrl}/Arguments/${props.arg.var}/Flatten`; + + try { + await commandApi.flattenArgument(flattenUrl, data); + setUpdating(false); + await props.onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleDisplaySimilar = async () => { + if (verifyFlatten() === undefined) { + return; + } + + setUpdating(true); + + try { + const res = await commandApi.findSimilarArguments(props.commandUrl, props.arg.var); + setUpdating(false); + const { tree, expandedIds } = BuildArgSimilarTree(res); + setArgSimilarTree(tree); + setArgSimilarTreeExpandedIds(expandedIds); + setArgSimilarTreeArgIdsUpdated([]); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + const handleDisableSimilar = () => { + setArgSimilarTree(undefined); + setArgSimilarTreeExpandedIds([]); + }; + + const onSimilarTreeUpdated = (newTree: ArgSimilarTree) => { + setArgSimilarTree(newTree); + }; + + const onSimilarTreeExpandedIdsUpdated = (expandedIds: string[]) => { + setArgSimilarTreeExpandedIds(expandedIds); + }; + + const handleFlattenSimilar = async () => { + const data = verifyFlatten(); + if (data === undefined) { + 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) { + const flattenUrl = `${argId}/Flatten`; + try { + await commandApi.flattenArgument(flattenUrl, data); + updatedIds.push(argId); + setArgSimilarTreeArgIdsUpdated([...updatedIds]); + } catch (err: any) { + console.error(err); + invalidText += errorHandlerApi.getErrorMessage(err); + } + } + } + + if (invalidText.length > 0) { + setInvalidText(invalidText); + setUpdating(false); + } else { + setUpdating(false); + await props.onClose(true); + } + }; + + const buildSubArgText = (arg: { var: string; options: string }, idx: number) => { + return ( + { + const options = subArgOptions.map((value) => { + if (value.var === arg.var) { + return { + ...value, + options: event.target.value, + }; + } else { + return value; + } + }); + setSubArgOptions(options); + }} + margin="normal" + required + /> + ); + }; + + return ( + + {!argSimilarTree && ( + <> + Flatten Props + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + {subArgOptions.map(buildSubArgText)} + + + )} + + {argSimilarTree && ( + <> + Flatten Similar Argument Props + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + + )} + + {updating && ( + + + + )} + {!updating && !argSimilarTree && ( + <> + + {!props.arg.type.startsWith("@") && } + {props.arg.type.startsWith("@") && } + + + )} + {!updating && argSimilarTree && ( + <> + + + + )} + + + ); +}; + +export default FlattenDialog; +export type { CMDArg, ClsArgDefinitionMap }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx new file mode 100644 index 00000000..3b6fea94 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx @@ -0,0 +1,118 @@ +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + styled, + Typography, + TypographyProps, +} from "@mui/material"; + +import { commandApi, errorHandlerApi } from "../../../../services"; +import React, { useState } from "react"; + +const ArgTypeTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +interface CMDArrayArg { + var: string; + type: string; + item?: { + type: string; + }; +} + +interface CMDDictArg { + var: string; + type: string; + item?: { + type: string; + }; +} + +interface CMDArg { + var: string; + type: string; +} + +interface UnwrapClsDialogProps { + commandUrl: string; + arg: CMDArg; + open: boolean; + onClose: (unwrapped: boolean) => Promise; +} + +const UnwrapClsDialog: React.FC = (props) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + + const handleClose = () => { + setInvalidText(undefined); + props.onClose(false); + }; + + const handleUnwrap = async () => { + setUpdating(true); + + let argVar = props.arg.var; + if (props.arg.type.startsWith("array")) { + if ((props.arg as CMDArrayArg).item?.type.startsWith("@")) { + argVar += "[]"; + } + } else if (props.arg.type.startsWith("dict")) { + if ((props.arg as CMDDictArg).item?.type.startsWith("@")) { + argVar += "{}"; + } + } + + const flattenUrl = `${props.commandUrl}/Arguments/${argVar}/UnwrapClass`; + + try { + await commandApi.unwrapClassArgument(flattenUrl); + setUpdating(false); + await props.onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } + }; + + return ( + + Unwrap Class Type + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + {props.arg.type} + + + {updating && ( + + + + )} + {!updating && ( + <> + + + + )} + + + ); +}; + +export default UnwrapClsDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx new file mode 100644 index 00000000..e3df1723 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx @@ -0,0 +1,405 @@ +import React, { useCallback } from "react"; +import TreeView from "@mui/lab/TreeView"; +import TreeItem from "@mui/lab/TreeItem"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { Checkbox, FormControlLabel } from "@mui/material"; + +interface ArgSimilarArg { + id: string; + var: string; + display: string; + indexes: string[]; + isSelected: boolean; +} + +interface ArgSimilarCommand { + id: string; + name: string; + args: ArgSimilarArg[]; + total: number; + selectedCount: number; +} + +interface ArgSimilarGroup { + id: string; + name: string; + groups?: ArgSimilarGroup[]; + commands?: ArgSimilarCommand[]; + total: number; + selectedCount: number; +} + +interface ArgSimilarTree { + root: ArgSimilarGroup; + selectedArgIds: string[]; +} + +interface ResponseArgSimilarCommand { + id: string; + args: { + [argVar: string]: string[]; + }; +} + +interface ResponseArgSimilarGroup { + id: string; + commandGroups?: { + [name: string]: ResponseArgSimilarGroup; + }; + commands?: { + [name: string]: ResponseArgSimilarCommand; + }; +} + +const decodeResponseArgSimilarCommand = ( + responseCommand: ResponseArgSimilarCommand, + commandName: string, +): ArgSimilarCommand => { + const command: ArgSimilarCommand = { + id: responseCommand.id, + name: commandName, + args: [], + total: 0, + selectedCount: 0, + }; + + Object.entries(responseCommand.args).forEach(([argVar, indexes]) => { + const arg: ArgSimilarArg = { + id: `${command.id}/Arguments/${argVar}`, + var: argVar, + indexes, + display: "", + isSelected: false, + }; + + if (arg.indexes.length > 1) { + arg.display = `[${arg.var}] ${arg.indexes + .map((idx) => { + if (idx[1] === "." || idx[1] === "[" || idx[1] === "{") { + return `-${idx}`; + } else { + return `--${idx}`; + } + }) + .join(" ")}`; + } else if (arg.indexes.length === 1) { + const idx = arg.indexes[0]; + if (idx[1] === "." || idx[1] === "[" || idx[1] === "{") { + arg.display = `-${idx}`; + } else { + arg.display = `--${idx}`; + } + } + command.args.push(arg); + }); + + command.total = command.args.length; + return command; +}; + +const decodeResponseArgSimilarGroup = (responseGroup: ResponseArgSimilarGroup, groupName: string): ArgSimilarGroup => { + let group: ArgSimilarGroup = { + id: responseGroup.id, + name: groupName, + total: 0, + selectedCount: 0, + }; + + if (responseGroup.commandGroups && typeof responseGroup.commandGroups === "object") { + group.groups = Object.entries(responseGroup.commandGroups).map(([name, subGroup]) => { + const decodedSubGroup = decodeResponseArgSimilarGroup(subGroup, name); + group.total += decodedSubGroup.total; + return decodedSubGroup; + }); + } + + if (responseGroup.commands && typeof responseGroup.commands === "object") { + group.commands = Object.entries(responseGroup.commands).map(([name, command]) => { + const decodedCommand = decodeResponseArgSimilarCommand(command, name); + group.total += decodedCommand.total; + return decodedCommand; + }); + } + + if (!group.commands && group.groups?.length === 1) { + group = group.groups[0]; + group.name = `${groupName} ${group.name}`; + } + + return group; +}; + +const gatherNodeIds = (group: ArgSimilarGroup): string[] => { + const nodeIds: string[] = [group.id]; + + group.commands?.forEach((command) => { + nodeIds.push(command.id); + }); + + group.groups?.forEach((subGroup) => { + nodeIds.push(...gatherNodeIds(subGroup)); + }); + + return nodeIds; +}; + +const BuildArgSimilarTree = (response: any): { tree: ArgSimilarTree; expandedIds: string[] } => { + if (!response || !response.aaz) { + throw new Error("Invalid response: missing 'aaz' property"); + } + + const tree = { + root: decodeResponseArgSimilarGroup(response.aaz, "az"), + selectedArgIds: [], + }; + const expandedIds = gatherNodeIds(tree.root); + const newTree = updateSelectionStateForArgSimilarTree(tree, new Set([tree.root.id])); + return { + tree: newTree, + expandedIds, + }; +}; + +interface WSECArgumentSimilarPickerProps { + tree: ArgSimilarTree; + expandedIds: string[]; + updatedIds: string[]; + onTreeUpdated: (tree: ArgSimilarTree) => void; + onToggle: (nodeIds: string[]) => void; +} + +const updateSelectionStateForArgSimilarCommand = ( + command: ArgSimilarCommand, + selectedIds: Set, +): { command: ArgSimilarCommand; selectedArgIds: string[] } => { + const newSelectedIds: string[] = []; + const newCommand = { + ...command, + args: command.args.map((arg) => { + let isSelected = selectedIds.has(arg.id); + if (!isSelected) { + const idParts = arg.id.split("/"); + for (let idx = 1; idx < idParts.length; idx += 1) { + const newId = idParts.slice(0, idx + 1).join("/"); + if (selectedIds.has(newId)) { + isSelected = true; + break; + } + } + } + if (isSelected) { + newSelectedIds.push(arg.id); + } + + return { + ...arg, + indexes: [...arg.indexes], + isSelected, + }; + }), + }; + + newCommand.selectedCount = newSelectedIds.length; + + return { + command: newCommand, + selectedArgIds: newSelectedIds, + }; +}; + +const updateSelectionStateForArgSimilarGroup = ( + group: ArgSimilarGroup, + selectedIds: Set, +): { group: ArgSimilarGroup; selectedArgIds: string[] } => { + let newSelectedIds: string[] = []; + const newGroup = { + ...group, + groups: group.groups?.map((subGroup) => { + const { group: newSubGroup, selectedArgIds: subSelectedIds } = updateSelectionStateForArgSimilarGroup( + subGroup, + selectedIds, + ); + newSelectedIds.push(...subSelectedIds); + return newSubGroup; + }), + commands: group.commands?.map((command) => { + const { command: newCommand, selectedArgIds: subSelectedIds } = updateSelectionStateForArgSimilarCommand( + command, + selectedIds, + ); + newSelectedIds.push(...subSelectedIds); + return newCommand; + }), + }; + + newGroup.selectedCount = newSelectedIds.length; + + return { + group: newGroup, + selectedArgIds: newSelectedIds, + }; +}; + +const updateSelectionStateForArgSimilarTree = (tree: ArgSimilarTree, selectedIds: Set): ArgSimilarTree => { + const { group, selectedArgIds } = updateSelectionStateForArgSimilarGroup(tree.root, selectedIds); + return { + root: group, + selectedArgIds, + }; +}; + +const WSECArgumentSimilarPicker: React.FC = ({ + tree, + expandedIds, + updatedIds, + onTreeUpdated, + onToggle, +}) => { + const onCheckItem = useCallback( + (itemId: string, select: boolean) => { + let selectedIds: Set; + if (select) { + selectedIds = new Set(tree.selectedArgIds).add(itemId); + } else { + selectedIds = new Set(tree.selectedArgIds.filter((id) => id !== itemId && !id.startsWith(`${itemId}/`))); + } + onTreeUpdated(updateSelectionStateForArgSimilarTree(tree, selectedIds)); + }, + [tree, onTreeUpdated], + ); + + const onNodeToggle = useCallback( + (event: React.SyntheticEvent, nodeIds: string[]) => { + onToggle(nodeIds); + event.stopPropagation(); + event.preventDefault(); + }, + [onToggle], + ); + + const renderArg = useCallback( + (arg: ArgSimilarArg) => { + const isUpdated = updatedIds.includes(arg.id); + return ( + { + onCheckItem(arg.id, !arg.isSelected); + event.stopPropagation(); + event.preventDefault(); + }} + disabled={isUpdated} + /> + } + label={arg.display} + sx={{ + paddingLeft: 1, + }} + /> + } + /> + ); + }, + [updatedIds, onCheckItem], + ); + + const renderCommand = useCallback( + (command: ArgSimilarCommand) => { + return ( + 0 && command.selectedCount === command.total} + indeterminate={command.selectedCount > 0 && command.selectedCount < command.total} + onClick={(event) => { + onCheckItem(command.id, !(command.selectedCount > 0 && command.selectedCount === command.total)); + event.stopPropagation(); + event.preventDefault(); + }} + /> + } + label={command.name} + sx={{ + paddingLeft: 1, + }} + /> + } + > + {command.args?.map((arg) => renderArg(arg))} + + ); + }, + [onCheckItem, renderArg], + ); + + const renderGroup = useCallback( + (group: ArgSimilarGroup): React.ReactElement => { + return ( + 0 && group.selectedCount === group.total} + indeterminate={group.selectedCount > 0 && group.selectedCount < group.total} + onClick={(event) => { + onCheckItem(group.id, !(group.selectedCount > 0 && group.selectedCount === group.total)); + event.stopPropagation(); + event.preventDefault(); + }} + /> + } + label={group.name} + sx={{ + paddingLeft: 1, + }} + /> + } + > + {group.commands?.map((command) => renderCommand(command))} + {group.groups?.map((subGroup) => renderGroup(subGroup))} + + ); + }, + [onCheckItem, renderCommand], + ); + + return ( + } + defaultExpandIcon={} + onNodeToggle={onNodeToggle} + selected={[]} + expanded={expandedIds} + > + {renderGroup(tree.root)} + + ); +}; + +export default WSECArgumentSimilarPicker; +export { BuildArgSimilarTree }; +export type { ArgSimilarTree, ArgSimilarGroup, ArgSimilarCommand, ArgSimilarArg }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx new file mode 100644 index 00000000..865d6c7f --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx @@ -0,0 +1,198 @@ +import { Box, CardContent } from "@mui/material"; + +import pluralize from "pluralize"; +import React, { useState } from "react"; + +import ArgumentNavigation from "./ArgumentNavigation"; +import ArgumentDialog from "./ArgumentDialog"; +import UnwrapClsDialog from "./UnwrapClsDialog"; +import FlattenDialog from "./FlattenDialog"; + +import { CardTitleTypography } from "../WSEditor/WSEditorTheme"; +import { + type ClsArgDefinitionMap, + type CMDArg, + type CMDArgBase, + type CMDObjectArg, + type CMDClsArg, + type CMDDictArgBase, + type CMDArrayArgBase, + type CMDObjectArgBase, +} from "../../utils/decodeArgs"; + +interface WSEditorCommandArgumentsContentProps { + commandUrl: string; + args: CMDArg[]; + clsArgDefineMap: ClsArgDefinitionMap; + onReloadArgs: () => Promise; + onAddSubCommand: (argVar: string, subArgOptions: { var: string; options: string }[], argStackNames: string[]) => void; +} + +interface ArgIdx { + var: string; + displayKey: string; +} + +const WSEditorCommandArgumentsContent: React.FC = ({ + commandUrl, + args, + clsArgDefineMap, + onReloadArgs, + onAddSubCommand, +}) => { + const [displayArgumentDialog, setDisplayArgumentDialog] = useState(false); + const [editArg, setEditArg] = useState(undefined); + const [, setEditArgIdxStack] = useState(undefined); + const [displayFlattenDialog, setDisplayFlattenDialog] = useState(false); + const [displayUnwrapClsDialog, setDisplayUnwrapClsDialog] = useState(false); + + const handleArgumentDialogClose = async (updated: boolean) => { + if (updated) { + onReloadArgs(); + } + setDisplayArgumentDialog(false); + setEditArg(undefined); + setEditArgIdxStack(undefined); + }; + + const handleEditArgument = (arg: CMDArg, argIdxStack: ArgIdx[]) => { + setEditArg(arg); + setEditArgIdxStack(argIdxStack); + setDisplayArgumentDialog(true); + }; + + const handleFlattenDialogClose = async (flattened: boolean) => { + if (flattened) { + onReloadArgs(); + } + setDisplayFlattenDialog(false); + setEditArg(undefined); + setEditArgIdxStack(undefined); + }; + + const handleArgumentFlatten = (arg: CMDArg, argIdxStack: ArgIdx[]) => { + setEditArg(arg); + setEditArgIdxStack(argIdxStack); + setDisplayFlattenDialog(true); + }; + + const handleUnwrapClsDialogClose = async (unwrapped: boolean) => { + if (unwrapped) { + onReloadArgs(); + } + setDisplayUnwrapClsDialog(false); + setEditArg(undefined); + setEditArgIdxStack(undefined); + }; + + const handleUnwrapClsArgument = (arg: CMDArg, argIdxStack: ArgIdx[]) => { + setEditArg(arg); + setEditArgIdxStack(argIdxStack); + setDisplayUnwrapClsDialog(true); + }; + + const handleAddSubcommand = (arg: CMDArg, argIdxStack: ArgIdx[]) => { + const argVar = arg.var; + const argStackNames = argIdxStack.map((argIdx) => { + let name = argIdx.displayKey; + while (name.startsWith("-")) { + name = name.slice(1); + } + if (name.endsWith("[]") || name.endsWith("{}")) { + name = name.slice(0, name.length - 2); + name = pluralize.singular(name); + } + return name; + }); + let a: CMDArgBase | undefined = arg; + if (a.type.startsWith("@")) { + const clsName = (a as CMDClsArg).clsName; + a = clsArgDefineMap[clsName]; + } + if (a.type.startsWith("dict<")) { + a = (a as CMDDictArgBase).item; + } else if (a.type.startsWith("array<")) { + a = (a as CMDArrayArgBase).item; + } + let subArgOptions: { var: string; options: string }[] = []; + if (a !== undefined) { + let subArgs; + if (a.type.startsWith("@")) { + const clsName = (a as CMDClsArg).clsName; + subArgs = (clsArgDefineMap[clsName] as CMDObjectArgBase).args; + } else { + subArgs = (a as CMDObjectArg).args; + } + subArgOptions = subArgs.map((value) => { + return { + var: value.var, + options: value.options.join(" "), + }; + }); + } + + onAddSubCommand(argVar, subArgOptions, argStackNames); + }; + + return ( + + + + [ ARGUMENT ] + + + + + {displayArgumentDialog && ( + + )} + {displayFlattenDialog && ( + + )} + {displayUnwrapClsDialog && ( + + )} + + ); +}; + +export default WSEditorCommandArgumentsContent; diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/index.ts b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/index.ts new file mode 100644 index 00000000..a90a8255 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/index.ts @@ -0,0 +1,12 @@ +export { default } from "./WSEditorCommandArgumentsContent"; +export { DecodeArgs } from "../../utils/decodeArgs"; +export type { ClsArgDefinitionMap, CMDArg } from "../../utils/decodeArgs"; +export { default as ArgumentDialog } from "./ArgumentDialog"; +export { default as FlattenDialog } from "./FlattenDialog"; +export { default as UnwrapClsDialog } from "./UnwrapClsDialog"; +export { default as ArgumentNavigation } from "./ArgumentNavigation"; +export { default as ArgumentPropsReviewer } from "./ArgumentPropsReviewer"; +export { default as ArgNavBar } from "./ArgNavBar"; +export { default as ArgumentReviewer } from "./ArgumentReviewer"; +export { default as WSECArgumentSimilarPicker } from "./WSECArgumentSimilarPicker"; +export type { ArgIdx } from "./ArgNavBar"; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx new file mode 100644 index 00000000..fb05d1ab --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/AddSubcommandDialog.tsx @@ -0,0 +1,201 @@ +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormLabel, + LinearProgress, + TextField, +} from "@mui/material"; +import React, { useState, useEffect } from "react"; +import { commandApi, errorHandlerApi } from "../../../../services"; +import type { Command } from "../../interfaces"; + +export interface AddSubcommandDialogProps { + workspaceUrl: string; + command: Command; + argVar: string; + subArgOptions: { var: string; options: string }[]; + defaultGroupNames: string[]; + open: boolean; + onClose: (added: boolean) => void; +} + +const AddSubcommandDialog: React.FC = ({ + workspaceUrl, + command, + argVar, + subArgOptions, + defaultGroupNames, + open, + onClose, +}) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [commandGroupName, setCommandGroupName] = useState(""); + const [refArgsOptions, setRefArgsOptions] = useState<{ var: string; options: string }[]>([]); + + useEffect(() => { + setCommandGroupName(defaultGroupNames.join(" ")); + setRefArgsOptions(subArgOptions); + }, [argVar, defaultGroupNames, subArgOptions]); + + const handleClose = () => { + setInvalidText(undefined); + onClose(false); + }; + + const verifyAddSubresource = () => { + setInvalidText(undefined); + const argOptions: { [argVar: string]: string[] } = {}; + let invalidText: string | undefined = undefined; + refArgsOptions.forEach((arg, idx) => { + const names = arg.options.split(" ").filter((n) => n.length > 0); + if (names.length < 1) { + invalidText = `Prop ${idx + 1} option name is required.`; + return undefined; + } + + for (const idx in names) { + const piece = names[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + invalidText = `Invalid 'Prop ${idx + 1} option name': '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `; + return undefined; + } + } + argOptions[arg.var] = names; + }); + + const names = commandGroupName.split(" ").filter((n) => n.length > 0); + if (names.length < 1) { + invalidText = "Invalid Command group name"; + return; + } + + if (invalidText !== undefined) { + setInvalidText(invalidText); + return undefined; + } + + return { + commandGroupName: names.join(" "), + refArgsOptions: argOptions, + }; + }; + + const handleAddSubresource = async () => { + const urls = command.resources.map((resource) => { + const resourceId = btoa(resource.id); + const version = btoa(resource.version); + return `${workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources`; + }); + + if (urls.length !== 1) { + setInvalidText(`Cannot create subcommands, command contains ${command.resources.length} resources`); + return; + } + + const data = verifyAddSubresource(); + if (data === undefined) { + return; + } + + setUpdating(true); + + try { + await commandApi.createSubresource(urls[0], { + ...data, + arg: argVar, + }); + onClose(true); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + setUpdating(false); + } + }; + + const buildRefArgText = (arg: { var: string; options: string }, idx: number) => { + return ( + { + const options = refArgsOptions.map((value) => { + if (value.var === arg.var) { + return { + ...value, + options: event.target.value, + }; + } else { + return value; + } + }); + setRefArgsOptions(options); + }} + margin="normal" + required + /> + ); + }; + + return ( + + Add Subcommands + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + Subcommand Group + { + setCommandGroupName(event.target.value); + }} + /> + {refArgsOptions.length > 0 && ( + <> + Argument Options + {refArgsOptions.map(buildRefArgText)} + + )} + + + {updating && ( + + + + )} + {!updating && ( + <> + + + + )} + + + ); +}; + +export default AddSubcommandDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx new file mode 100644 index 00000000..795909db --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx @@ -0,0 +1,114 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + Typography, +} from "@mui/material"; +import React, { useState, useEffect } from "react"; +import { commandApi } from "../../../../services"; +import { COMMAND_PREFIX } from "../../../../constants"; +import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; +import type { Command, ResponseCommand } from "../../interfaces"; + +export interface CommandDeleteDialogProps { + workspaceUrl: string; + open: boolean; + command: Command; + onClose: (deleted: boolean) => void; +} + +const CommandDeleteDialog: React.FC = (props) => { + const [updating, setUpdating] = useState(false); + const [relatedCommands, setRelatedCommands] = useState([]); + + const getUrls = () => { + const urls: string[] = []; + + props.command.resources.forEach((resource) => { + const resourceId = btoa(resource.id); + const version = btoa(resource.version); + if (resource.subresource !== undefined) { + const subresource = btoa(resource.subresource); + urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}/Subresources/${subresource}`); + } else { + urls.push(`${props.workspaceUrl}/Resources/${resourceId}/V/${version}`); + } + }); + return urls; + }; + + useEffect(() => { + const fetchRelatedCommands = async () => { + setRelatedCommands([]); + const urls = getUrls(); + + try { + const responses = await Promise.all(urls.map((url) => commandApi.getCommandsForResource(url))); + + const commands = new Set(); + responses.forEach((responseCommands: ResponseCommand[]) => { + responseCommands + .map((responseCommand) => DecodeResponseCommand(responseCommand)) + .forEach((cmd) => { + commands.add(cmd.names.join(" ")); + }); + }); + + const cmdNames = Array.from(commands).sort((a, b) => a.localeCompare(b)); + setRelatedCommands(cmdNames); + } catch (err) { + console.error(err); + } + }; + + fetchRelatedCommands(); + }, [props.command]); + + const handleClose = () => { + props.onClose(false); + }; + + const handleDelete = async () => { + setUpdating(true); + const urls = getUrls(); + + try { + await Promise.all(urls.map((url) => commandApi.deleteResource(url))); + setUpdating(false); + props.onClose(true); + } catch (err) { + setUpdating(false); + console.error(err); + } + }; + + return ( + + Delete Commands + + {relatedCommands.map((command, idx) => ( + {`${COMMAND_PREFIX}${command}`} + ))} + + + {updating && ( + + + + )} + {!updating && ( + <> + + + + )} + + + ); +}; + +export default CommandDeleteDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx new file mode 100644 index 00000000..837038d1 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDialog.tsx @@ -0,0 +1,210 @@ +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + InputLabel, + LinearProgress, + Radio, + RadioGroup, + TextField, +} from "@mui/material"; +import React, { useState, useCallback } from "react"; +import { commandApi, errorHandlerApi } from "../../../../services"; +import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; +import type { Command } from "../../interfaces"; + +export interface CommandDialogProps { + workspaceUrl: string; + open: boolean; + command: Command; + onClose: (newCommand?: Command) => void; +} + +const CommandDialog: React.FC = ({ workspaceUrl, open, command, onClose }) => { + 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(); + let trimmedShortHelp = shortHelp.trim(); + let trimmedLongHelp = longHelp.trim(); + let trimmedConfirmation = confirmation.trim(); + + const names = trimmedName.split(" ").filter((n) => n.length > 0); + + setInvalidText(undefined); + + if (names.length < 1) { + setInvalidText(`Field 'Name' is required.`); + return; + } + + for (const idx in names) { + const piece = names[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + setInvalidText(`Invalid Name part: '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `); + return; + } + } + + if (trimmedShortHelp.length < 1) { + setInvalidText(`Field 'Short Summary' is required.`); + return; + } + + let lines: string[] | null = null; + if (trimmedLongHelp.length > 1) { + lines = trimmedLongHelp.split("\n").filter((l) => l.length > 0); + } + + setUpdating(true); + + const leafUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + command.names.slice(0, -1).join("/") + + "/Leaves/" + + command.names[command.names.length - 1]; + + try { + const commandData = await commandApi.updateCommand(leafUrl, { + help: { + short: trimmedShortHelp, + lines: lines, + }, + stage: stage, + confirmation: trimmedConfirmation, + }); + + 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 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]); + + const handleClose = useCallback(() => { + setInvalidText(undefined); + onClose(); + }, [onClose]); + + return ( + + Command + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + Stage + + { + setStage(event.target.value); + }} + > + } label="Stable" sx={{ ml: 4 }} /> + } label="Preview" sx={{ ml: 4 }} /> + } label="Experimental" sx={{ ml: 4 }} /> + + + { + setName(event.target.value); + }} + margin="normal" + required + /> + { + setShortHelp(event.target.value); + }} + margin="normal" + required + /> + { + setLongHelp(event.target.value); + }} + margin="normal" + /> + { + setConfirmation(event.target.value); + }} + margin="normal" + /> + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default CommandDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx new file mode 100644 index 00000000..479bea6f --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleDialog.tsx @@ -0,0 +1,384 @@ +import { + styled, + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Input, + InputAdornment, + InputLabel, + LinearProgress, + TextField, + Typography, + TypographyProps, + Stack, +} from "@mui/material"; +import React, { useState, useCallback, useEffect } from "react"; +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 { COMMAND_PREFIX } from "../../../../constants"; +import { ExampleItemSelector } from "./ExampleItemSelector"; +import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; +import type { Command, Example } from "../../interfaces"; + +export interface ExampleDialogProps { + workspaceUrl: string; + open: boolean; + command: Command; + idx?: number; + onClose: (newCommand?: Command) => void; +} + +const ExampleCommandTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 400, +})); + +const ExampleDialog: React.FC = ({ workspaceUrl, open, command, idx, onClose }) => { + 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([]); + + useEffect(() => { + const examples: Example[] = command.examples ?? []; + if (idx === undefined) { + setName(""); + setExampleCommands([""]); + setIsAdd(true); + setInvalidText(undefined); + setUpdating(false); + setSource(undefined); + setExampleOptions([]); + } else { + const example = examples[idx]; + setName(example.name); + setExampleCommands(example.commands); + setIsAdd(false); + setInvalidText(undefined); + setUpdating(false); + setSource(undefined); + setExampleOptions([]); + } + }, [command.examples, idx]); + + const onUpdateExamples = useCallback( + async (examples: Example[]) => { + const leafUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + command.names.slice(0, -1).join("/") + + "/Leaves/" + + command.names[command.names.length - 1]; + + setUpdating(true); + + try { + const responseData = await commandApi.updateCommandExamples(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], + ); + + const handleDelete = useCallback(() => { + let examples: Example[] = command.examples ?? []; + const currentIdx = idx!; + examples = [...examples.slice(0, currentIdx), ...examples.slice(currentIdx + 1)]; + onUpdateExamples(examples); + }, [command.examples, idx, onUpdateExamples]); + + const handleModify = useCallback(() => { + let trimmedName = name.trim(); + let examples: Example[] = command.examples ?? []; + const currentIdx = idx!; + + if (trimmedName.length < 1) { + setInvalidText(`Field 'Name' is required.`); + return; + } + + const processedCommands = exampleCommands + .map((cmd) => { + return cmd + .split("\n") + .map((cmdLine) => cmdLine.trim()) + .filter((cmdLine) => cmdLine.length > 0) + .join(" ") + .trim(); + }) + .filter((cmd) => cmd.length > 0); + + if (processedCommands.length < 1) { + setInvalidText(`Field 'Commands' is required.`); + return; + } + + const newExample: Example = { + name: trimmedName, + commands: processedCommands, + }; + + examples = [...examples.slice(0, currentIdx), newExample, ...examples.slice(currentIdx + 1)]; + onUpdateExamples(examples); + }, [name, exampleCommands, command.examples, idx, onUpdateExamples]); + + const handleAdd = useCallback(() => { + let trimmedName = name.trim(); + const examples: Example[] = command.examples ?? []; + + if (trimmedName.length < 1) { + setInvalidText(`Field 'Name' is required.`); + return; + } + + const processedCommands = exampleCommands + .map((cmd) => { + return cmd + .split("\n") + .map((cmdLine) => cmdLine.trim()) + .filter((cmdLine) => cmdLine.length > 0) + .join(" ") + .trim(); + }) + .filter((cmd) => cmd.length > 0); + + if (processedCommands.length < 1) { + setInvalidText(`Field 'Commands' is required.`); + return; + } + + const newExample: Example = { + name: trimmedName, + commands: processedCommands, + }; + examples.push(newExample); + onUpdateExamples(examples); + }, [name, exampleCommands, command.examples, onUpdateExamples]); + + const handleClose = useCallback(() => { + setInvalidText(undefined); + onClose(); + }, [onClose]); + + const onModifyExampleCommand = useCallback((cmd: string, cmdIdx: number) => { + setExampleCommands((prevCommands) => [...prevCommands.slice(0, cmdIdx), cmd, ...prevCommands.slice(cmdIdx + 1)]); + }, []); + + const onRemoveExampleCommand = useCallback((cmdIdx: number) => { + setExampleCommands((prevCommands) => { + const newCommands = [...prevCommands.slice(0, cmdIdx), ...prevCommands.slice(cmdIdx + 1)]; + if (newCommands.length === 0) { + newCommands.push(""); + } + return newCommands; + }); + }, []); + + const onAddExampleCommand = useCallback(() => { + setExampleCommands((prevCommands) => [...prevCommands, ""]); + }, []); + + const loadSwaggerExamples = useCallback(async () => { + try { + const leafUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + command.names.slice(0, -1).join("/") + + "/Leaves/" + + 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); + } + } catch (err: any) { + console.error(err.response); + setUpdating(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + }, [workspaceUrl, command.names]); + + const onExampleSelectorUpdate = useCallback( + (exampleDisplayName: string | null) => { + const example = exampleOptions.find((v) => v.name === exampleDisplayName) ?? undefined; + + if (example === undefined) { + setName(exampleDisplayName ?? ""); + } else { + setName(example?.name ?? ""); + setExampleCommands(example?.commands ?? [""]); + } + }, + [exampleOptions], + ); + + const buildExampleInput = useCallback( + (cmd: string, cmdIdx: number) => { + return ( + + onRemoveExampleCommand(cmdIdx)} aria-label="remove"> + + + { + onModifyExampleCommand(event.target.value, cmdIdx); + }} + sx={{ flexGrow: 1 }} + placeholder="Input a command here." + startAdornment={ + + {COMMAND_PREFIX} + + } + /> + + ); + }, + [onRemoveExampleCommand, onModifyExampleCommand], + ); + + const selectedName = name; + + return ( + + + {isAdd ? "Add Example" : "Modify Example"} + + + + + + {isAdd && source === undefined && ( + + + + )} + {(!isAdd || source != undefined) && ( + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + {!isAdd && ( + + { + setName(event.target.value); + }} + margin="normal" + required + /> + + )} + {source === "swagger" && ( + + v.name)} + value={selectedName} + onValueUpdate={onExampleSelectorUpdate} + /> + + )} + + Commands + + {exampleCommands.map(buildExampleInput)} + + + + + One more command + + + )} + + {(!isAdd || source != undefined) && ( + + {updating && ( + + + + )} + {!updating && ( + + {!isAdd && ( + + + + + )} + {isAdd && } + + )} + + )} + + ); +}; + +export default ExampleDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleItemSelector.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleItemSelector.tsx new file mode 100644 index 00000000..c0e22cf2 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/ExampleItemSelector.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from "react"; +import { Box, Autocomplete, TextField } from "@mui/material"; + +interface ExampleItemsSelectorProps { + commonPrefix: string; + options: string[]; + name: string; + value: string | null; + onValueUpdate: (value: string | null) => void; +} + +const ExampleItemSelector: React.FC = ({ + commonPrefix, + options, + name, + value, + onValueUpdate, +}) => { + const getOptionLabel = useCallback( + (option: string) => { + return option.replace(commonPrefix, ""); + }, + [commonPrefix], + ); + + const renderOption = useCallback( + (props: any, option: string) => { + return ( + + {option.replace(commonPrefix, "")} + + ); + }, + [commonPrefix], + ); + + return ( + { + onValueUpdate(newValue); + }} + getOptionLabel={getOptionLabel} + renderOption={renderOption} + selectOnFocus + freeSolo + renderInput={(params) => } + /> + ); +}; + +export { ExampleItemSelector }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/OutputCard.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputCard.tsx new file mode 100644 index 00000000..e6ff7bae --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputCard.tsx @@ -0,0 +1,256 @@ +import React, { useCallback } from "react"; +import { Box, Button, Card, CardContent, Typography, ButtonBase, styled, TypographyProps } from "@mui/material"; +import DataObjectIcon from "@mui/icons-material/DataObject"; +import EditIcon from "@mui/icons-material/Edit"; +import { SubtitleTypography, CardTitleTypography } from "../WSEditor/WSEditorTheme"; + +interface Example { + name: string; + commands: string[]; +} + +interface ObjectOutput { + type: "object"; + ref: string; + clientFlatten: boolean; +} + +interface ArrayOutput { + type: "array"; + ref: string; + clientFlatten: boolean; + nextLink: string; +} + +interface StringOutput { + type: "string"; + ref: string; + value: string; +} + +type Output = ObjectOutput | ArrayOutput | StringOutput; + +interface Resource { + id: string; + version: string; + subresource?: string; + swagger: string; +} + +interface Command { + id: string; + names: string[]; + help?: { + short: string; + lines?: string[]; + }; + stage: "Stable" | "Preview" | "Experimental"; + version: string; + examples?: Example[]; + outputs?: Output[]; + resources: Resource[]; + confirmation?: string; + args?: any[]; + clsArgDefineMap?: any; +} + +interface OutputCardProps { + command: Command; + onOutputDialogDisplay: (idx: number) => void; +} + +const OutputTypeTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 10, + fontWeight: 400, +})); + +const OutputRefTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +const OutputFlagTypography = styled(Typography)(() => ({ + color: "#8888C3", + fontFamily: "'Work Sans', sans-serif", + fontSize: 10, + fontWeight: 400, +})); + +const OutputEditTypography = styled(Typography)(() => ({ + color: "#5d64cf", + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const OutputCard: React.FC = ({ command, onOutputDialogDisplay }) => { + const outputs = command.outputs!; + + const buildBaseOutputView = useCallback( + ( + idx: number, + refName: string, + type: string, + flags: string[], + onClick?: React.MouseEventHandler | undefined, + ) => ( + + + + JSON + + + + + + {refName} + + + + + {`/${type}/`} + + {flags.map((flag, idx) => ( + {`[${flag}]`} + ))} + + + + + ), + [], + ); + + const buildObjectOutputView = useCallback( + (output: ObjectOutput, idx: number, onClick?: React.MouseEventHandler | undefined) => + buildBaseOutputView( + idx, + output.ref, + output.type, + output.clientFlatten ? ["Flattened"] : ["Unflattened"], + onClick, + ), + [buildBaseOutputView], + ); + + const buildArrayOutputView = useCallback( + (output: ArrayOutput, idx: number, onClick?: React.MouseEventHandler | undefined) => + buildBaseOutputView( + idx, + output.ref, + output.type, + output.clientFlatten ? ["Flattened"] : ["Unflattened"], + onClick, + ), + [buildBaseOutputView], + ); + + const buildStringOutputView = useCallback( + (output: StringOutput, idx: number, onClick?: React.MouseEventHandler | undefined) => { + const title = output.ref ? output.ref : output.value; + return buildBaseOutputView(idx, title, output.type, [], onClick); + }, + [buildBaseOutputView], + ); + + const buildOutputView = useCallback( + (output: Output, idx: number) => { + const onClick = () => { + onOutputDialogDisplay(idx); + }; + switch (output.type) { + case "object": + return buildObjectOutputView(output, idx, onClick); + case "array": + return buildArrayOutputView(output, idx, onClick); + case "string": + return buildStringOutputView(output, idx, onClick); + } + }, + [onOutputDialogDisplay, buildObjectOutputView, buildArrayOutputView, buildStringOutputView], + ); + + return ( + + + + [ OUTPUT ] + + {outputs.length > 0 && + outputs.map((output, idx) =>
{buildOutputView(output, idx)}
)} +
+
+ ); +}; + +export default OutputCard; +export type { OutputCardProps, Command, Output, ObjectOutput, ArrayOutput, StringOutput, Example, Resource }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx new file mode 100644 index 00000000..b5750163 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/OutputDialog.tsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormLabel, + LinearProgress, + Switch, + Typography, + TypographyProps, + FormLabelProps, +} from "@mui/material"; +import { styled } from "@mui/material"; +import { commandApi, errorHandlerApi } from "../../../../services"; +import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; + +interface ObjectOutput { + type: "object"; + ref: string; + clientFlatten: boolean; +} + +interface ArrayOutput { + type: "array"; + ref: string; + clientFlatten: boolean; + nextLink: string; +} + +interface StringOutput { + type: "string"; + ref: string; + value: string; +} + +type Output = ObjectOutput | ArrayOutput | StringOutput; + +function isObjectOutput(output: Output): output is ObjectOutput { + return output.type === "object"; +} + +function isArrayOutput(output: Output): output is ArrayOutput { + return output.type === "array"; +} + +interface Command { + id: string; + names: string[]; + help?: { + short: string; + lines?: string[]; + }; + stage: "Stable" | "Preview" | "Experimental"; + version: string; + outputs?: Output[]; + resources: any[]; +} + +const OutputDialogLabel = styled(FormLabel)(() => ({ + fontSize: 12, +})); + +const OutputDialogMainTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 18, + fontWeight: 400, +})); + +interface OutputDialogProps { + workspaceUrl: string; + command: Command; + idx?: number; + open: boolean; + onClose: (newCommand?: Command) => void; +} + +const OutputDialog: React.FC = (props) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const outputs = props.command.outputs ?? []; + const output = outputs[props.idx!]; + const [flatten, setFlatten] = useState(output.type !== "string" ? output.clientFlatten : false); + const flattenLabelContent = flatten ? "Flattened" : "Unflattened"; + + const handleClose = () => { + setInvalidText(undefined); + props.onClose(); + }; + + const handleUpdateOutput = async () => { + setInvalidText(undefined); + setUpdating(true); + + if (isObjectOutput(output) || isArrayOutput(output)) { + let commandNames = props.command.names; + const leafUrl = + `${props.workspaceUrl}/CommandTree/Nodes/aaz/` + + 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 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}`); + setInvalidText(`Invalid output type for flatten switch: ${output.type}`); + } + }; + + return ( + + JSON Format Output + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + {(output.type !== "string" || output.ref !== undefined) && ( + + Output Reference + {output.ref} + + )} + {output.type == "string" && output.ref == undefined && ( + + Output Value + {output.value} + + )} + {output.type == "array" && output.nextLink !== undefined && ( + + Next Link Reference + {output.nextLink} + + )} + {(output.type !== "string" || output.ref !== undefined) && ( + + Client Flatten + + { + setFlatten(event.target.checked); + }} + /> + } + label={{flattenLabelContent}} + labelPlacement="end" + /> + + + )} + + + {updating && ( + + + + )} + {!updating && } + + + + ); +}; + +export default OutputDialog; +export type { Output, ObjectOutput, ArrayOutput, StringOutput, OutputDialogProps }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx new file mode 100644 index 00000000..69168afb --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -0,0 +1,588 @@ +import { + styled, + Box, + Button, + Card, + CardActions, + CardContent, + Accordion, + LinearProgress, + Typography, + TypographyProps, + AccordionDetails, + AccordionSummaryProps, +} from "@mui/material"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import MuiAccordionSummary from "@mui/material/AccordionSummary"; +import { + NameTypography, + ShortHelpTypography, + ShortHelpPlaceHolderTypography, + LongHelpTypography, + StableTypography, + PreviewTypography, + ExperimentalTypography, + SubtitleTypography, + CardTitleTypography, +} from "../WSEditor/WSEditorTheme"; +import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; +import LabelIcon from "@mui/icons-material/Label"; +import EditIcon from "@mui/icons-material/Edit"; +import { commandApi } from "../../../../services"; +import { COMMAND_PREFIX } from "../../../../constants"; +import WSEditorCommandArgumentsContent from "../WSEditorCommandArgumentsContent"; +import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; +import ExampleDialog from "./ExampleDialog"; +import AddSubcommandDialog from "./AddSubcommandDialog"; +import CommandDeleteDialog from "./CommandDeleteDialog"; +import CommandDialog from "./CommandDialog"; +import OutputCard from "./OutputCard"; +import OutputDialog from "./OutputDialog"; +import type { Command, Example } from "../../interfaces"; + +interface WSEditorCommandContentProps { + workspaceUrl: string; + previewCommand: Command; + reloadTimestamp: number; + onUpdateCommand: (command: Command | null) => void; +} + +const ExampleCommandHeaderTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const ExampleCommandBodyTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const ExampleEditTypography = styled(Typography)(() => ({ + color: "#5d64cf", + fontFamily: "'Work Sans', sans-serif", + fontSize: 14, + fontWeight: 400, +})); + +const ExampleAccordionSummary = styled((props: AccordionSummaryProps) => ( + } {...props} /> +))(() => ({ + "flexDirection": "row-reverse", + "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { + transform: "rotate(0deg)", + }, +})); + +const WSEditorCommandContent: React.FC = ({ + workspaceUrl, + previewCommand, + reloadTimestamp, + onUpdateCommand, +}) => { + const [command, setCommand] = useState(undefined); + const [displayCommandDialog, setDisplayCommandDialog] = useState(false); + const [displayExampleDialog, setDisplayExampleDialog] = useState(false); + const [displayOutputDialog, setDisplayOutputDialog] = useState(false); + const [displayCommandDeleteDialog, setDisplayCommandDeleteDialog] = useState(false); + const [displayAddSubcommandDialog, setDisplayAddSubcommandDialog] = useState(false); + const [subcommandDefaultGroupNames, setSubcommandDefaultGroupNames] = useState(undefined); + const [subcommandArgVar, setSubcommandArgVar] = useState(undefined); + const [subcommandSubArgOptions, setSubcommandSubArgOptions] = useState< + { var: string; options: string }[] | undefined + >(undefined); + const [exampleIdx, setExampleIdx] = useState(undefined); + const [outputIdx, setOutputIdx] = useState(undefined); + const [loading, setLoading] = useState(false); + const lastLoadRef = useRef(""); + + useEffect(() => { + const loadCommand = async () => { + const requestKey = `${workspaceUrl}-${previewCommand.id}-${reloadTimestamp}`; + + if (lastLoadRef.current === requestKey) { + return; + } + + lastLoadRef.current = requestKey; + setLoading(true); + + const commandNames = previewCommand.names; + const leafUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + commandNames.slice(0, -1).join("/") + + "/Leaves/" + + commandNames[commandNames.length - 1]; + try { + const commandData = await commandApi.getCommand(leafUrl); + const cmd = DecodeResponseCommand(commandData); + if (cmd.id === previewCommand.id) { + setCommand(cmd); + } + } catch (err: any) { + console.error(err); + } finally { + setLoading(false); + } + }; + + if (command?.id !== previewCommand.id) { + setCommand(undefined); + } + loadCommand(); + }, [workspaceUrl, previewCommand.id, reloadTimestamp]); + + const reloadCommand = useCallback(async () => { + lastLoadRef.current = ""; + setLoading(true); + const commandNames = previewCommand.names; + const leafUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + commandNames.slice(0, -1).join("/") + + "/Leaves/" + + commandNames[commandNames.length - 1]; + try { + const commandData = await commandApi.getCommand(leafUrl); + const cmd = DecodeResponseCommand(commandData); + if (cmd.id === previewCommand.id) { + setCommand(cmd); + } + } catch (err: any) { + console.error(err); + } finally { + setLoading(false); + } + }, [workspaceUrl, previewCommand.id, previewCommand.names]); + + const onCommandDialogDisplay = useCallback(() => { + setDisplayCommandDialog(true); + }, []); + + const onCommandDeleteDialogDisplay = useCallback(() => { + setDisplayCommandDeleteDialog(true); + }, []); + + const handleCommandDialogClose = useCallback( + (newCommand?: Command) => { + if (newCommand) { + onUpdateCommand(newCommand); + } + setDisplayCommandDialog(false); + }, + [onUpdateCommand], + ); + + const handleCommandDeleteDialogClose = useCallback( + (deleted: boolean) => { + if (deleted) { + onUpdateCommand(null); + } + setDisplayCommandDeleteDialog(false); + }, + [onUpdateCommand], + ); + + const onExampleDialogDisplay = useCallback((idx?: number) => { + setDisplayExampleDialog(true); + setExampleIdx(idx); + }, []); + + const handleExampleDialogClose = useCallback( + (newCommand?: Command) => { + if (newCommand) { + onUpdateCommand(newCommand); + } + setDisplayExampleDialog(false); + }, + [onUpdateCommand], + ); + + const onOutputDialogDisplay = useCallback((idx?: number) => { + setDisplayOutputDialog(true); + setOutputIdx(idx); + }, []); + + const handleOutputDialogClose = useCallback( + (newCommand?: Command) => { + if (newCommand) { + onUpdateCommand(newCommand); + } + setDisplayOutputDialog(false); + }, + [onUpdateCommand], + ); + + const onAddSubcommandDialogDisplay = useCallback( + (argVar: string, subArgOptions: { var: string; options: string }[], argStackNames: string[]) => { + setDisplayAddSubcommandDialog(true); + setSubcommandArgVar(argVar); + setSubcommandSubArgOptions(subArgOptions); + setSubcommandDefaultGroupNames([...previewCommand.names.slice(0, -1), ...argStackNames]); + }, + [previewCommand.names], + ); + + const handleAddSubcommandDisplayClose = useCallback( + (add: boolean) => { + if (add && command) { + onUpdateCommand(command); + } + setDisplayAddSubcommandDialog(false); + setSubcommandArgVar(undefined); + setSubcommandDefaultGroupNames(undefined); + }, + [command, onUpdateCommand], + ); + + const commandNames = previewCommand.names; + const name = COMMAND_PREFIX + commandNames.join(" "); + const commandUrl = + `${workspaceUrl}/CommandTree/Nodes/aaz/` + + commandNames.slice(0, -1).join("/") + + "/Leaves/" + + commandNames[commandNames.length - 1]; + + const buildExampleView = useCallback( + (example: Example, idx: number) => { + const buildCommand = (exampleCommand: string, cmdIdx: number) => { + return ( + + + + {COMMAND_PREFIX} + + + {exampleCommand} + + + ); + }; + return ( + { + onExampleDialogDisplay(idx); + }} + > + + + {example.name} + + + + + {example.commands.map(buildCommand)} + + + ); + }, + [onExampleDialogDisplay], + ); + + const buildCommandCard = useCallback(() => { + const shortHelp = (command ?? previewCommand).help?.short; + const longHelp = (command ?? previewCommand).help?.lines?.join("\n"); + const stage = (command ?? previewCommand).stage; + const version = (command ?? previewCommand).version; + + return ( + + + + [ COMMAND ] + + {stage === "Stable" && {`v${version}`}} + {stage === "Preview" && {`v${version}`}} + {stage === "Experimental" && ( + {`v${version}`} + )} + + {name} + {shortHelp && {shortHelp} } + {!shortHelp && ( + + Please add command short summary! + + )} + {longHelp && ( + + {longHelp + .split("\n") + .filter((l) => l.length > 0) + .map((line, idx) => ( + {line} + ))} + + )} + + + {loading && ( + + + + )} + {!loading && ( + + + + + )} + + + ); + }, [command, previewCommand, name, onCommandDialogDisplay, loading, onCommandDeleteDialogDisplay]); + + const buildArgumentsCard = useCallback(() => { + return ( + + + + ); + }, [commandUrl, command, reloadCommand, onAddSubcommandDialogDisplay]); + + const buildExampleCard = useCallback(() => { + const examples = command!.examples ?? []; + return ( + + + + [ EXAMPLE ] + + {examples.length > 0 && {examples.map(buildExampleView)}} + + + + + + + ); + }, [command, buildExampleView, onExampleDialogDisplay]); + + return ( + + + {buildCommandCard()} + {command !== undefined && command.args !== undefined && buildArgumentsCard()} + {command !== undefined && buildExampleCard()} + {command !== undefined && command.outputs !== undefined && ( + + )} + + {command !== undefined && displayCommandDialog && ( + + )} + {command !== undefined && displayExampleDialog && ( + + )} + {command !== undefined && displayOutputDialog && ( + + )} + {command !== undefined && displayCommandDeleteDialog && ( + + )} + {command !== undefined && displayAddSubcommandDialog && ( + + )} + + ); +}; + +export default WSEditorCommandContent; diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/index.ts b/src/web/src/views/workspace/components/WSEditorCommandContent/index.ts new file mode 100644 index 00000000..2c051631 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/index.ts @@ -0,0 +1,3 @@ +export { default } from "./WSEditorCommandContent"; +export { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; +export type { Command, Resource, ResponseCommand, ResponseCommands, Example, Plane } from "../../interfaces"; diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx new file mode 100644 index 00000000..5999a67d --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx @@ -0,0 +1,72 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + Typography, +} from "@mui/material"; +import { commandApi } from "../../../../services"; +import * as React from "react"; +import { COMMAND_PREFIX } from "../../../../constants"; +import type { CommandGroup } from "../../interfaces"; + +interface CommandGroupDeleteDialogProps { + workspaceUrl: string; + open: boolean; + commandGroup: CommandGroup; + onClose: (deleted: boolean) => void; +} + +const CommandGroupDeleteDialog: React.FC = ({ + workspaceUrl, + open, + commandGroup, + onClose, +}) => { + const [updating, setUpdating] = React.useState(false); + + const handleClose = React.useCallback(() => { + onClose(false); + }, [onClose]); + + const handleDelete = React.useCallback(async () => { + const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/${commandGroup.names.join("/")}`; + setUpdating(true); + + try { + await commandApi.deleteCommandGroup(nodeUrl); + setUpdating(false); + onClose(true); + } catch (err: any) { + setUpdating(false); + console.error(err); + } + }, [workspaceUrl, commandGroup.names, onClose]); + + return ( + + Delete Command Group + + {`${COMMAND_PREFIX}${commandGroup.names.join(" ")}`} + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default CommandGroupDeleteDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx new file mode 100644 index 00000000..1021c14d --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/CommandGroupDialog.tsx @@ -0,0 +1,199 @@ +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + InputLabel, + LinearProgress, + Radio, + RadioGroup, + TextField, +} from "@mui/material"; +import { commandApi, errorHandlerApi } from "../../../../services"; +import * as React from "react"; +import { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import type { CommandGroup } from "../../interfaces"; + +interface CommandGroupDialogProps { + workspaceUrl: string; + open: boolean; + commandGroup: CommandGroup; + onClose: (newCommandGroup?: CommandGroup) => void; +} + +const CommandGroupDialog: React.FC = ({ workspaceUrl, open, commandGroup, onClose }) => { + const [name, setName] = React.useState(commandGroup.names.join(" ")); + const [stage, setStage] = React.useState(commandGroup.stage); + 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); + + React.useEffect(() => { + setName(commandGroup.names.join(" ")); + setStage(commandGroup.stage); + setShortHelp(commandGroup.help?.short ?? ""); + setLongHelp(commandGroup.help?.lines?.join("\n") ?? ""); + setInvalidText(undefined); + setUpdating(false); + }, [commandGroup]); + + const handleModify = React.useCallback(async () => { + let trimmedName = name.trim(); + let trimmedShortHelp = shortHelp.trim(); + let trimmedLongHelp = longHelp.trim(); + + const names = trimmedName.split(" ").filter((n: string) => n.length > 0); + + setInvalidText(undefined); + + if (names.length < 1) { + setInvalidText(`Field 'Name' is required.`); + return; + } + + for (const idx in names) { + const piece = names[idx]; + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(piece)) { + setInvalidText(`Invalid Name part: '${piece}'. Supported regular expression is: [a-z0-9]+(-[a-z0-9]+)* `); + return; + } + } + + if (trimmedShortHelp.length < 1) { + setInvalidText(`Field 'Short Summary' is required.`); + return; + } + + let lines: string[] = []; + if (trimmedLongHelp.length > 1) { + 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, { + help: { + short: trimmedShortHelp, + lines: lines, + }, + stage: stage, + }); + + 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 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]); + + const handleClose = React.useCallback(() => { + setInvalidText(undefined); + onClose(); + }, [onClose]); + + return ( + + Command Group + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + Stage + + ) => { + setStage(event.target.value); + }} + > + } label="Stable" sx={{ ml: 4 }} /> + } label="Preview" sx={{ ml: 4 }} /> + } label="Experimental" sx={{ ml: 4 }} /> + + + ) => { + setName(event.target.value); + }} + margin="normal" + required + /> + ) => { + setShortHelp(event.target.value); + }} + margin="normal" + required + /> + ) => { + setLongHelp(event.target.value); + }} + margin="normal" + /> + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default CommandGroupDialog; diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx new file mode 100644 index 00000000..317764a2 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx @@ -0,0 +1,198 @@ +import { Box, Button, Card, CardActions, CardContent, Typography } from "@mui/material"; +import * as React from "react"; +import CommandGroupDialog from "./CommandGroupDialog"; +import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; +import { COMMAND_PREFIX } from "../../../../constants"; +import { + NameTypography, + ShortHelpTypography, + ShortHelpPlaceHolderTypography, + LongHelpTypography, + StableTypography, + PreviewTypography, + ExperimentalTypography, +} from "../WSEditor/WSEditorTheme"; +import type { CommandGroup, ResponseCommandGroup } from "../../interfaces"; + +interface WSEditorCommandGroupContentProps { + workspaceUrl: string; + commandGroup: CommandGroup; + reloadTimestamp: number; + onUpdateCommandGroup: (commandGroup: CommandGroup | null) => void; +} + +const WSEditorCommandGroupContent: React.FC = ({ + workspaceUrl, + commandGroup, + onUpdateCommandGroup, +}) => { + const [displayCommandGroupDialog, setDisplayCommandGroupDialog] = React.useState(false); + const [displayCommandGroupDeleteDialog, setDisplayCommandGroupDeleteDialog] = React.useState(false); + + const onCommandGroupDialogDisplay = React.useCallback(() => { + setDisplayCommandGroupDialog(true); + }, []); + + const onCommandGroupDeleteDialogDisplay = React.useCallback(() => { + setDisplayCommandGroupDeleteDialog(true); + }, []); + + const handleCommandGroupDialogClose = React.useCallback( + (newCommandGroup?: CommandGroup) => { + setDisplayCommandGroupDialog(false); + if (newCommandGroup) { + onUpdateCommandGroup(newCommandGroup); + } + }, + [onUpdateCommandGroup], + ); + + const handleCommandGroupDeleteDialogClose = React.useCallback( + (deleted: boolean) => { + setDisplayCommandGroupDeleteDialog(false); + if (deleted) { + onUpdateCommandGroup(null); + } + }, + [onUpdateCommandGroup], + ); + + const name = COMMAND_PREFIX + commandGroup.names.join(" "); + const shortHelp = commandGroup.help?.short; + const longHelp = commandGroup.help?.lines?.join("\n"); + const lines: string[] = commandGroup.help?.lines ?? []; + const stage = commandGroup.stage; + + return ( + + + + + + + [ GROUP ] + + + {stage === "Stable" && {stage}} + {stage === "Preview" && {stage}} + {stage === "Experimental" && ( + {stage} + )} + + + {name} + {shortHelp && {shortHelp} } + {!shortHelp && ( + + Please add command group short summary! + + )} + {longHelp && ( + + {lines.map((line, idx) => ( + {line} + ))} + + )} + + + + + + + + + + {displayCommandGroupDialog && ( + + )} + {displayCommandGroupDeleteDialog && ( + + )} + + ); +}; + +const DecodeResponseCommandGroup = (commandGroup: ResponseCommandGroup): CommandGroup => { + return { + id: "group:" + commandGroup.names.join("/"), + names: commandGroup.names, + help: commandGroup.help, + stage: commandGroup.stage ?? "Stable", + canDelete: true, + }; +}; + +export default WSEditorCommandGroupContent; + +export { DecodeResponseCommandGroup }; diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.ts b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.ts new file mode 100644 index 00000000..5be990b4 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.ts @@ -0,0 +1 @@ +export { default, DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx new file mode 100644 index 00000000..c83406d8 --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -0,0 +1,820 @@ +import * as React from "react"; +import { useState, useEffect, useCallback } from "react"; +import { + Typography, + Box, + AppBar, + Toolbar, + IconButton, + Button, + Backdrop, + CircularProgress, + List, + ListSubheader, + ListItem, + ListItemButton, + ListItemIcon, + Checkbox, + ListItemText, + Alert, + Paper, + InputBase, + Select, + MenuItem, + FormControl, + InputLabel, + FormHelperText, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; +import EditorPageLayout from "../../../../components/EditorPageLayout"; +import { styled } from "@mui/material/styles"; +import { getTypespecRPResources, getTypespecRPResourcesOperations } from "../../../../typespec"; +import SwaggerItemSelector from "../../common/SwaggerItemSelector"; +import { useResourceFilter } from "../../hooks/useResourceFilter"; + +interface WSEditorSwaggerPickerProps { + workspaceName: string; + plane: string; + onClose: (updated: boolean) => void; +} + +type ResourceVersionOperations = { + [Named: string]: string; +}; + +type ResourceVersion = { + version: string; + operations: ResourceVersionOperations; + file: string; + id: string; + path: string; +}; + +type Resource = { + id: string; + versions: ResourceVersion[]; + aazVersions: string[] | null; +}; + +type AAZResource = { + id: string; + versions: string[] | null; +}; + +type VersionResourceIdMap = { + [version: string]: Resource[]; +}; + +type ResourceInheritanceAAZVersionMap = { + [id: string]: string | null; +}; + +type ResourceMap = { + [id: string]: Resource; +}; + +const MiddlePadding = styled(Box)(() => ({ + height: "2vh", +})); + +const MiddlePadding2 = styled(Box)(() => ({ + height: "8vh", +})); + +const UpdateOptions = ["Default", "Generic(Get&Put) First", "Patch First", "No update command"]; + +const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwaggerPickerProps) => { + const { filterText, updateFilter, filterResources } = useResourceFilter(); + + const [loading, setLoading] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [_defaultModule, setDefaultModule] = useState(null); + const [defaultResourceProvider, setDefaultResourceProvider] = useState(null); + const [defaultSource, setDefaultSource] = useState(null); + const [existingResources, setExistingResources] = useState>(new Set()); + const [moduleOptionsCommonPrefix, setModuleOptionsCommonPrefix] = useState(""); + const [resourceProviderOptionsCommonPrefix, setResourceProviderOptionsCommonPrefix] = useState(""); + const [moduleOptions, setModuleOptions] = useState([]); + const [versionOptions, setVersionOptions] = useState([]); + const [resourceProviderOptions, setResourceProviderOptions] = useState([]); + const [selectedResources, setSelectedResources] = useState>(new Set()); + const [selectedResourceInheritanceAAZVersionMap, setSelectedResourceInheritanceAAZVersionMap] = + useState({}); + const [preferredAAZVersion, setPreferredAAZVersion] = useState(null); + const [resourceOptions, setResourceOptions] = useState([]); + const [versionResourceIdMap, setVersionResourceIdMap] = useState({}); + const [resourceMap, setResourceMap] = useState({}); + const [selectedModule, setSelectedModule] = useState(null); + const [selectedResourceProvider, setSelectedResourceProvider] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + const [updateOptions] = useState(UpdateOptions); + const [updateOption, setUpdateOption] = useState(UpdateOptions[0]); + + useEffect(() => { + const initializeComponent = async () => { + await loadWorkspaceResources(); + + try { + const allModules = await specsApi.getSwaggerModules(plane); + setModuleOptions(allModules); + setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane}/`); + + const swaggerDefault = await workspaceApi.getSwaggerDefault(workspaceName); + if (swaggerDefault.modNames === null || swaggerDefault.modNames.length == 0) { + return; + } + + const moduleValueUrl = `/Swagger/Specs/${plane}/` + swaggerDefault.modNames.join("/"); + if (allModules.findIndex((v) => v === moduleValueUrl) == -1) { + return; + } + + let rpUrl = null; + if (swaggerDefault.rpName !== null && swaggerDefault.rpName.length > 0) { + rpUrl = `${moduleValueUrl}/ResourceProviders/${swaggerDefault.rpName}`; + if (swaggerDefault.source === "TypeSpec") { + rpUrl += `/TypeSpec`; + } + } + + setDefaultModule(moduleValueUrl); + setDefaultSource(swaggerDefault.source); + setSelectedModule(moduleValueUrl); + setModuleOptions([moduleValueUrl]); + await loadResourceProviders(moduleValueUrl, rpUrl, swaggerDefault.source); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + }; + + initializeComponent(); + }, []); + + const handleClose = useCallback(() => { + onClose(false); + }, [onClose]); + + const loadResourceProviders = useCallback( + async (moduleUrl: string | null, preferredRP: string | null, sourceOverride?: string) => { + if (moduleUrl != null) { + try { + const typeParam = sourceOverride ?? defaultSource ?? undefined; + let options = await specsApi.getResourceProvidersWithType(moduleUrl, typeParam); + let selectedResourceProvider = options.length === 1 ? options[0] : null; + let defaultResourceProviderVal = null; + if (preferredRP !== null && options.findIndex((v) => v === preferredRP) >= 0) { + selectedResourceProvider = preferredRP; + defaultResourceProviderVal = preferredRP; + options = [preferredRP]; + } + setDefaultResourceProvider(defaultResourceProviderVal); + setResourceProviderOptions(options); + setResourceProviderOptionsCommonPrefix(`${moduleUrl}/ResourceProviders/`); + setSelectedResourceProvider(selectedResourceProvider); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + } else { + setResourceProviderOptions([]); + setSelectedResourceProvider(null); + setVersionOptions([]); + setResourceOptions([]); + setSelectedVersion(null); + } + }, + [defaultSource], + ); + + const loadWorkspaceResources = useCallback(async () => { + try { + const resources = await workspaceApi.getWorkspaceResourcesByName(workspaceName); + const existingResourcesSet = new Set(); + if (resources && Array.isArray(resources) && resources.length > 0) { + resources.forEach((resource: any) => { + existingResourcesSet.add(resource.id); + }); + } + setExistingResources(existingResourcesSet); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + }, [workspaceName]); + + const loadResources = useCallback( + async (resourceProviderUrl: string | null) => { + if (resourceProviderUrl != null) { + setInvalidText(undefined); + setLoading(true); + let data; + if (resourceProviderUrl.endsWith("/TypeSpec")) { + try { + data = await getTypespecRPResources(resourceProviderUrl); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + setLoading(false); + return; + } + } else { + try { + data = await specsApi.getProviderResources(resourceProviderUrl); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + setLoading(false); + return; + } + } + try { + if (!data || !Array.isArray(data)) { + setInvalidText("No resources found or invalid data format"); + setLoading(false); + return; + } + const versionResourceIdMapLocal: VersionResourceIdMap = {}; + const versionOptionsLocal: string[] = []; + const resourceMapLocal: ResourceMap = {}; + const resourceIdList: string[] = []; + data.forEach((resource: Resource) => { + resourceIdList.push(resource.id); + resourceMapLocal[resource.id] = resource; + resourceMapLocal[resource.id].aazVersions = null; + + const resourceVersions = resource.versions.map((v) => v.version); + resourceVersions.forEach((v) => { + if (!(v in versionResourceIdMapLocal)) { + versionResourceIdMapLocal[v] = []; + versionOptionsLocal.push(v); + } + versionResourceIdMapLocal[v].push(resource); + }); + }); + versionOptionsLocal.sort((a, b) => a.localeCompare(b)).reverse(); + let selectVersion = null; + if (versionOptionsLocal.length > 0) { + selectVersion = versionOptionsLocal[0]; + } + + const filterData = await specsApi.filterResourcesByPlane(plane, resourceIdList); + filterData.resources.forEach((aazResource: AAZResource) => { + if (aazResource.versions) { + resourceMapLocal[aazResource.id].aazVersions = aazResource.versions; + } + }); + setLoading(false); + setVersionResourceIdMap(versionResourceIdMapLocal); + setResourceMap(resourceMapLocal); + setVersionOptions(versionOptionsLocal); + + if ( + selectVersion != null && + versionResourceIdMapLocal[selectVersion] && + Array.isArray(versionResourceIdMapLocal[selectVersion]) + ) { + const newResourceOptions = [...versionResourceIdMapLocal[selectVersion]] + .sort((a, b) => a.id.localeCompare(b.id)) + .filter((r) => !existingResources.has(r.id)); + setResourceOptions(newResourceOptions); + setSelectedVersion(selectVersion); + setPreferredAAZVersion(selectVersion); + setSelectedResources(new Set()); + setSelectedResourceInheritanceAAZVersionMap({}); + } + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setLoading(false); + } + } else { + setVersionOptions([]); + onVersionUpdate(null); + } + }, + [plane, existingResources], + ); + + // Effect to load resources when selectedResourceProvider changes + useEffect(() => { + if (selectedResourceProvider) { + loadResources(selectedResourceProvider); + } else { + setVersionOptions([]); + setResourceOptions([]); + setSelectedVersion(null); + } + }, [selectedResourceProvider, loadResources]); + + const addSwagger = useCallback(async () => { + if (!selectedModule || !selectedVersion || selectedResources.size < 1) { + console.warn("Cannot submit: missing required values", { + selectedModule, + selectedVersion, + selectedResourcesSize: selectedResources.size, + }); + return; + } + + const resources: { id: string; options: { update_by?: string; aaz_version: string | null } }[] = []; + const resourceOptionMap: { [key: string]: { update_by?: string; aaz_version: string | null } } = {}; + selectedResources.forEach((resourceId: string) => { + const res: any = { + id: resourceId, + options: { + aaz_version: selectedResourceInheritanceAAZVersionMap[resourceId], + }, + }; + if (updateOption === UpdateOptions[1]) { + const resource = resourceMap[resourceId]; + const operations = resource.versions.find((v: any) => v.version === selectedVersion)?.operations; + if (operations) { + let hasGet = false; + let hasPut = false; + for (const opName in operations) { + if (operations[opName].toLowerCase() === "put") { + hasPut = true; + } else if (operations[opName].toLowerCase() === "get") { + hasGet = true; + } + } + if (hasGet && hasPut) { + res.options.update_by = "GenericOnly"; + } + } + } else if (updateOption === UpdateOptions[2]) { + const resource = resourceMap[resourceId]; + const operations = resource.versions.find((v: any) => v.version === selectedVersion)?.operations; + if (operations) { + for (const opName in operations) { + if (operations[opName].toLowerCase() === "patch") { + res.options.update_by = "PatchOnly"; + break; + } + } + } + } else if (updateOption === UpdateOptions[3]) { + res.options.update_by = "None"; + } + resourceOptionMap[resourceId] = res.options; + resources.push(res); + }); + + const requestBody = { + module: selectedModule.replace(moduleOptionsCommonPrefix, ""), + version: selectedVersion, + resources: resources, + }; + + setInvalidText(undefined); + setLoading(true); + + if (defaultResourceProvider?.endsWith("TypeSpec")) { + const requestEmitterObj = JSON.parse(JSON.stringify(requestBody)); + requestEmitterObj.resourceProviderUrl = defaultResourceProvider; + console.log("requestEmitterObj: ", requestEmitterObj); + try { + const res = await getTypespecRPResourcesOperations(requestEmitterObj); + console.log("emitter getTypespecRPResourceOperations res: ", res); + console.log("resourceOptionMap: ", resourceOptionMap); + const addTypespecData = { + version: selectedVersion, + resources: res.map((item: { id: string; [key: string]: any }) => { + if (item.id in resourceOptionMap) { + item.options = resourceOptionMap[item.id]; + } + return item; + }), + }; + console.log("addTypespec data: ", addTypespecData); + try { + await workspaceApi.addTypespecResources(workspaceName, addTypespecData); + setLoading(false); + onClose(true); + } catch (err: any) { + console.error(err); + setLoading(false); + onClose(false); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + } catch (err: any) { + setLoading(false); + onClose(true); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + } else { + try { + await workspaceApi.addSwaggerResources(workspaceName, requestBody); + setLoading(false); + onClose(true); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + } + }, [ + selectedResources, + selectedVersion, + selectedModule, + moduleOptionsCommonPrefix, + updateOption, + resourceMap, + selectedResourceInheritanceAAZVersionMap, + defaultResourceProvider, + workspaceName, + onClose, + ]); + + const handleSubmit = useCallback(() => { + addSwagger(); + }, [addSwagger]); + + const onModuleSelectorUpdate = useCallback( + async (moduleValueUrl: string | null) => { + if (selectedModule !== moduleValueUrl) { + setSelectedModule(moduleValueUrl); + await loadResourceProviders(moduleValueUrl, null); + } else { + setSelectedModule(moduleValueUrl); + } + }, + [selectedModule, loadResourceProviders], + ); + + const onResourceProviderUpdate = useCallback( + async (resourceProviderUrl: string | null) => { + if (selectedResourceProvider !== resourceProviderUrl) { + setSelectedResourceProvider(resourceProviderUrl); + await loadResources(resourceProviderUrl); + } else { + setSelectedResourceProvider(resourceProviderUrl); + } + }, + [selectedResourceProvider, loadResources], + ); + + const onVersionUpdate = useCallback( + (version: string | null) => { + let newSelectedResources = selectedResources; + let newResourceOptions: Resource[] = []; + let newSelectedResourceInheritanceAAZVersionMap = selectedResourceInheritanceAAZVersionMap; + + if (version != null && versionResourceIdMap[version] && Array.isArray(versionResourceIdMap[version])) { + newSelectedResources = new Set(); + newSelectedResourceInheritanceAAZVersionMap = {}; + newResourceOptions = [...versionResourceIdMap[version]] + .sort((a, b) => a.id.localeCompare(b.id)) + .filter((r) => !existingResources.has(r.id)); + newResourceOptions.forEach((r) => { + if (selectedResources.has(r.id)) { + newSelectedResources.add(r.id); + if (r.aazVersions && r.aazVersions.findIndex((v: string) => v === version) >= 0) { + newSelectedResourceInheritanceAAZVersionMap[r.id] = version; + } else { + newSelectedResourceInheritanceAAZVersionMap[r.id] = selectedResourceInheritanceAAZVersionMap[r.id]; + } + } + }); + } + + setResourceOptions(newResourceOptions); + setSelectedVersion(version); + setPreferredAAZVersion(version); + setSelectedResources(newSelectedResources); + setSelectedResourceInheritanceAAZVersionMap(newSelectedResourceInheritanceAAZVersionMap); + }, + [selectedResources, selectedResourceInheritanceAAZVersionMap, versionResourceIdMap, existingResources], + ); + + const onUpdateOptionUpdate = useCallback((updateOption: string | null) => { + setUpdateOption(updateOption ?? UpdateOptions[0]); + }, []); + + const onResourceItemClick = useCallback( + (resourceId: string) => { + return () => { + const newSelectedResources = new Set(selectedResources); + const newSelectedResourceInheritanceAAZVersionMap = { ...selectedResourceInheritanceAAZVersionMap }; + + if (newSelectedResources.has(resourceId)) { + newSelectedResources.delete(resourceId); + delete newSelectedResourceInheritanceAAZVersionMap[resourceId]; + } else if (!existingResources.has(resourceId)) { + newSelectedResources.add(resourceId); + const aazVersions = resourceMap[resourceId].aazVersions; + let inheritanceAAZVersion = null; + if (aazVersions) { + if (aazVersions.findIndex((v: string) => v === preferredAAZVersion) >= 0) { + inheritanceAAZVersion = preferredAAZVersion; + } else { + inheritanceAAZVersion = aazVersions[0]; + } + } + newSelectedResourceInheritanceAAZVersionMap[resourceId] = inheritanceAAZVersion; + } + + setSelectedResources(newSelectedResources); + setSelectedResourceInheritanceAAZVersionMap(newSelectedResourceInheritanceAAZVersionMap); + }; + }, + [selectedResources, selectedResourceInheritanceAAZVersionMap, existingResources, resourceMap, preferredAAZVersion], + ); + + const onSelectedAllClick = useCallback(() => { + const newSelectedResources = new Set(selectedResources); + let newSelectedResourceInheritanceAAZVersionMap = { ...selectedResourceInheritanceAAZVersionMap }; + if (newSelectedResources.size === resourceOptions.length) { + newSelectedResources.clear(); + newSelectedResourceInheritanceAAZVersionMap = {}; + } else { + resourceOptions.forEach((r) => { + newSelectedResources.add(r.id); + const aazVersions = resourceMap[r.id].aazVersions; + let inheritanceAAZVersion = null; + if (aazVersions) { + if (aazVersions.findIndex((v: string) => v === preferredAAZVersion) >= 0) { + inheritanceAAZVersion = preferredAAZVersion; + } else { + inheritanceAAZVersion = aazVersions[0]; + } + } + newSelectedResourceInheritanceAAZVersionMap[r.id] = inheritanceAAZVersion; + }); + } + + setSelectedResources(newSelectedResources); + setSelectedResourceInheritanceAAZVersionMap(newSelectedResourceInheritanceAAZVersionMap); + }, [selectedResources, selectedResourceInheritanceAAZVersionMap, resourceOptions, resourceMap, preferredAAZVersion]); + + const onResourceInheritanceAAZVersionUpdate = useCallback( + (resourceId: string, aazVersion: string | null) => { + const newSelectedResourceInheritanceAAZVersionMap = { ...selectedResourceInheritanceAAZVersionMap }; + newSelectedResourceInheritanceAAZVersionMap[resourceId] = aazVersion; + let newPreferredAAZVersion = preferredAAZVersion; + if (aazVersion !== null) { + newPreferredAAZVersion = aazVersion; + } + + setSelectedResourceInheritanceAAZVersionMap(newSelectedResourceInheritanceAAZVersionMap); + setPreferredAAZVersion(newPreferredAAZVersion); + }, + [selectedResourceInheritanceAAZVersionMap, preferredAAZVersion], + ); + + return ( + + + + + + + + Add Resources + + + + + + Swagger Filters + + + + + + + + + + + + + + + Resource Url + + + + + 0 && selectedResources.size === resourceOptions.length} + indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} + tabIndex={-1} + disableRipple + inputProps={{ "aria-labelledby": "SelectAll" }} + /> + + + + { + updateFilter(event.target.value); + }} + /> + + + + } + > + {resourceOptions.length > 0 && ( + + {filterResources(resourceOptions).map((option) => { + const labelId = `resource-${option.id}`; + const selected = selectedResources.has(option.id); + const inheritanceOptions = resourceMap[option.id]?.aazVersions; + let selectedInheritance = null; + if (selectedResourceInheritanceAAZVersionMap !== null) { + selectedInheritance = selectedResourceInheritanceAAZVersionMap[option.id]; + } + return ( + + + + + + + + {selected && ( + + Inheritance + + Inherit modification from exported command models in aaz + + )} + + ); + })} + + )} + + + theme.zIndex.drawer + 1 }} open={loading}> + {invalidText !== undefined && ( + { + setInvalidText(undefined); + setLoading(false); + }} + > + {invalidText} + + )} + {invalidText === undefined && } + + + ); +}; + +export default WSEditorSwaggerPicker; diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/index.ts b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/index.ts new file mode 100644 index 00000000..abe3fadc --- /dev/null +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/index.ts @@ -0,0 +1,2 @@ +export { default } from "./WSEditorSwaggerPicker"; +export { default as WSEditorSwaggerPicker } from "./WSEditorSwaggerPicker"; diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx new file mode 100644 index 00000000..b72525e5 --- /dev/null +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx @@ -0,0 +1,329 @@ +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Button, + InputLabel, + Alert, +} from "@mui/material"; +import React, { useState, useEffect, useCallback } from "react"; +import SwaggerItemSelector from "../../common/SwaggerItemSelector"; +import styled from "@emotion/styled"; +import { workspaceApi, specsApi, errorHandlerApi } from "../../../../services"; +import type { Plane } from "../../interfaces"; + +interface WorkspaceCreateDialogProps { + openDialog: boolean; + name: string; + onClose: (value: any | null) => void; +} + +const WorkspaceCreateDialog: React.FC = ({ openDialog, name, onClose }) => { + const [loading, setLoading] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [workspaceName, setWorkspaceName] = useState(name); + + const [planes, setPlanes] = useState([]); + const [planeOptions, setPlaneOptions] = useState([]); + const [selectedPlane, setSelectedPlane] = useState(null); + + const [moduleOptions, setModuleOptions] = useState([]); + const [moduleOptionsCommonPrefix, setModuleOptionsCommonPrefix] = useState(""); + const [selectedModule, setSelectedModule] = useState(null); + + const [resourceProviderOptions, setResourceProviderOptions] = useState([]); + const [resourceProviderOptionsCommonPrefix, setResourceProviderOptionsCommonPrefix] = useState(""); + const [selectedResourceProvider, setSelectedResourceProvider] = useState(null); + + useEffect(() => { + loadPlanes(); + }, []); + + const loadPlanes = useCallback(async () => { + try { + setLoading(true); + + const planesData = await specsApi.getPlanes(); + const planeOptionsData: string[] = planesData.map((v) => v.displayName); + setPlanes(planesData); + setPlaneOptions(planeOptionsData); + setLoading(false); + if (planeOptionsData.length > 0) { + await onPlaneSelectorUpdateWithData(planeOptionsData[0], planesData); + } + } catch (err: any) { + console.error(err); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + }, []); + + const onPlaneSelectorUpdateWithData = useCallback( + async (planeDisplayName: string | null, freshPlanesData: Plane[]) => { + const plane = freshPlanesData.find((v: Plane) => v.displayName === planeDisplayName) ?? null; + + if (selectedPlane !== (plane?.displayName ?? null)) { + if (!plane) { + return; + } + setSelectedPlane(plane?.displayName ?? null); + await loadSwaggerModules(plane); + } else { + setSelectedPlane(plane?.displayName ?? null); + } + }, + [selectedPlane], + ); + + const onPlaneSelectorUpdate = useCallback( + async (planeDisplayName: string | null) => { + const plane = planes.find((v: Plane) => v.displayName === planeDisplayName) ?? null; + + if (selectedPlane !== (plane?.displayName ?? null)) { + if (!plane) { + return; + } + setSelectedPlane(plane?.displayName ?? null); + await loadSwaggerModules(plane); + } else { + setSelectedPlane(plane?.displayName ?? null); + } + }, + [planes, selectedPlane], + ); + + const loadSwaggerModules = useCallback(async (plane: Plane | null) => { + if (plane !== null) { + if (plane.moduleOptions?.length) { + setModuleOptions(plane.moduleOptions); + setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane.name}/`); + await onModuleSelectionUpdate(null); + } else { + try { + setLoading(true); + const options = await specsApi.getModulesForPlane(plane.name); + setPlanes((prevPlanes) => { + const updatedPlanes = [...prevPlanes]; + const index = updatedPlanes.findIndex((v: Plane) => v.name === plane.name); + updatedPlanes[index].moduleOptions = options; + return updatedPlanes; + }); + setLoading(false); + setModuleOptions(options); + setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane.name}/`); + await onModuleSelectionUpdate(null); + } catch (err: any) { + console.error(err); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + } + } else { + setModuleOptions([]); + setModuleOptionsCommonPrefix(""); + await onModuleSelectionUpdate(null); + } + }, []); + + const onModuleSelectionUpdate = useCallback( + async (moduleValueUrl: string | null) => { + if (selectedModule !== moduleValueUrl) { + setSelectedModule(moduleValueUrl); + await loadResourceProviders(moduleValueUrl); + } else { + setSelectedModule(moduleValueUrl); + } + }, + [selectedModule], + ); + + const loadResourceProviders = useCallback(async (moduleUrl: string | null) => { + if (moduleUrl !== null) { + try { + setLoading(true); + const options = await specsApi.getResourceProviders(moduleUrl); + const selectedResourceProviderData = options.length === 1 ? options[0] : null; + setLoading(false); + setResourceProviderOptions(options); + setResourceProviderOptionsCommonPrefix(`${moduleUrl}/ResourceProviders/`); + onResourceProviderUpdate(selectedResourceProviderData); + } catch (err: any) { + console.error(err); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + } else { + setResourceProviderOptions([]); + setResourceProviderOptionsCommonPrefix(""); + onResourceProviderUpdate(null); + } + }, []); + + const onResourceProviderUpdate = useCallback( + (resourceProviderUrl: string | null) => { + if (selectedResourceProvider !== resourceProviderUrl) { + setSelectedResourceProvider(resourceProviderUrl); + } else { + setSelectedResourceProvider(resourceProviderUrl); + } + }, + [selectedResourceProvider], + ); + + const verifyCreate = useCallback(() => { + setInvalidText(undefined); + let wsName = workspaceName.trim(); + let selModule = selectedModule; + let selResourceProvider = selectedResourceProvider; + + if (wsName.length < 1) { + setInvalidText(`'Workspace Name' is required.`); + return undefined; + } + + const plane = planes.find((v: Plane) => v.displayName === selectedPlane)?.name ?? null; + if (plane === null) { + setInvalidText(`Please select 'Plane'.`); + return undefined; + } + + selModule = selModule ? selModule.replace(moduleOptionsCommonPrefix, "") : null; + if (selModule === null) { + setInvalidText(`Please select 'Module'.`); + return undefined; + } + + selResourceProvider = selResourceProvider + ? selResourceProvider.replace(resourceProviderOptionsCommonPrefix, "") + : null; + if (selResourceProvider === null) { + setInvalidText(`Please select 'Resource Provider'.`); + return undefined; + } + let source = "OpenAPI"; + if (selResourceProvider.endsWith("/TypeSpec")) { + selResourceProvider = selResourceProvider.replace("/TypeSpec", ""); + source = "TypeSpec"; + } + return { + name: wsName, + plane: plane, + modNames: selModule, + resourceProvider: selResourceProvider, + source: source, + }; + }, [ + workspaceName, + selectedModule, + selectedResourceProvider, + planes, + selectedPlane, + moduleOptionsCommonPrefix, + resourceProviderOptionsCommonPrefix, + ]); + + const handleCreate = useCallback(async () => { + const data = verifyCreate(); + if (data === undefined) { + return; + } + setLoading(true); + try { + const workspace = await workspaceApi.createWorkspace(data); + setLoading(false); + onClose(workspace); + } catch (err: any) { + console.error(err); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } + }, [verifyCreate, onClose]); + + const handleClose = useCallback(() => { + onClose(null); + }, [onClose]); + + return ( + + Create a new workspace + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + API Specs + + + + + + + + ) => { + setWorkspaceName(event.target.value); + }} + label="Workspace Name" + type="text" + variant="standard" + /> + + + + + + + + + ); +}; + +const MiddlePadding = styled(Box)(() => ({ + height: "1.5vh", +})); + +export default WorkspaceCreateDialog; diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceInstruction.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceInstruction.tsx new file mode 100644 index 00000000..5dd9dfd8 --- /dev/null +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceInstruction.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Typography, Box } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { WorkspaceSelector } from "."; +import { AppNavBar } from "../../../../components/AppNavBar"; +import PageLayout from "../../../../components/PageLayout"; + +const MiddlePadding = styled(Box)(() => ({ + height: "6vh", +})); + +const WorkspaceInstruction: React.FC = () => { + return ( + + + + + + + + Please select a Workspace + + + + + + + + + ); +}; + +export default WorkspaceInstruction; diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceSelector.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceSelector.tsx new file mode 100644 index 00000000..4cb8b6df --- /dev/null +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceSelector.tsx @@ -0,0 +1,119 @@ +import { Box, Autocomplete, createFilterOptions, TextField } from "@mui/material"; +import React, { useState, useEffect, Fragment } from "react"; +import { workspaceApi, type Workspace as WorkspaceType } from "../../../../services"; +import { WorkspaceCreateDialog } from "."; + +interface WorkspaceSelectorProps { + name: string; +} + +interface InputType { + inputValue: string; + title: string; +} + +const filter = createFilterOptions(); + +const WorkspaceSelector: React.FC = ({ name }) => { + const [options, setOptions] = useState([]); + const [value, setValue] = useState(null); + const [openDialog, setOpenDialog] = useState(false); + const [newWorkspaceName, setNewWorkspaceName] = useState(""); + + useEffect(() => { + loadWorkspaces(); + }, []); + + const loadWorkspaces = async () => { + try { + const workspaces = await workspaceApi.getWorkspaces(); + setOptions(workspaces); + } catch (err: any) { + console.error(err); + } + }; + + const handleDialogClose = (value: any | null) => { + setNewWorkspaceName(""); + setOpenDialog(false); + if (value != null) { + onValueUpdated(value); + } + }; + + const onValueUpdated = (value: any) => { + setValue(value); + if (value.url) { + window.location.href = `/?#/workspace/${value.name}`; + } + }; + + return ( + + { + if (typeof newValue === "string") { + setTimeout(() => { + setOpenDialog(true); + setNewWorkspaceName(newValue); + }); + } else if (newValue && newValue.inputValue) { + setOpenDialog(true); + setNewWorkspaceName(newValue.inputValue); + } else { + onValueUpdated(newValue); + } + }} + filterOptions={(options, params: any) => { + const filtered = filter(options, params); + if (params.inputValue !== "" && -1 === options.findIndex((e) => e.name === params.inputValue)) { + filtered.push({ + inputValue: params.inputValue, + title: `Create "${params.inputValue}"`, + }); + } + return filtered; + }} + getOptionLabel={(option) => { + if (typeof option === "string") { + return option; + } + if (option.title) { + return option.title; + } + return option.name; + }} + renderOption={(props, option) => { + const labelName = option && option.title ? option.title : option.name; + return ( + + {labelName} + + ); + }} + selectOnFocus + clearOnBlur + renderInput={(params) => ( + + )} + /> + {openDialog && ( + + )} + + ); +}; + +export default WorkspaceSelector; diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/index.ts b/src/web/src/views/workspace/components/WorkspaceInstruction/index.ts new file mode 100644 index 00000000..dab3f259 --- /dev/null +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/index.ts @@ -0,0 +1,6 @@ +export { default as WorkspaceInstruction } from "./WorkspaceInstruction"; +export { default as WorkspaceSelector } from "./WorkspaceSelector"; +export { default as WorkspaceCreateDialog } from "./WorkspaceCreateDialog"; + +import WorkspaceInstruction from "./WorkspaceInstruction"; +export default WorkspaceInstruction; diff --git a/src/web/src/views/workspace/components/WorkspacePage.tsx b/src/web/src/views/workspace/components/WorkspacePage.tsx new file mode 100644 index 00000000..a525e682 --- /dev/null +++ b/src/web/src/views/workspace/components/WorkspacePage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Outlet } from "react-router"; + +const WorkspacePage: React.FC = () => { + return ( + + + + ); +}; + +export default WorkspacePage; diff --git a/src/web/src/views/workspace/hooks/index.ts b/src/web/src/views/workspace/hooks/index.ts new file mode 100644 index 00000000..6602c7c4 --- /dev/null +++ b/src/web/src/views/workspace/hooks/index.ts @@ -0,0 +1,3 @@ +export { useDialogManager } from "./useDialogManager"; +export { useWorkspaceData } from "./useWorkspaceData"; +export { useTreeState } from "./useTreeState"; diff --git a/src/web/src/views/workspace/hooks/useDialogManager.ts b/src/web/src/views/workspace/hooks/useDialogManager.ts new file mode 100644 index 00000000..1e8df572 --- /dev/null +++ b/src/web/src/views/workspace/hooks/useDialogManager.ts @@ -0,0 +1,100 @@ +import { useState, useCallback } from "react"; + +interface DialogStates { + showSwaggerResourcePicker: boolean; + showSwaggerReloadDialog: boolean; + showClientConfigDialog: boolean; + showExportDialog: boolean; + showDeleteDialog: boolean; + showModifyDialog: boolean; +} + +interface DialogActions { + openSwaggerResourcePicker: () => void; + openSwaggerReloadDialog: () => void; + openClientConfigDialog: () => void; + openExportDialog: () => void; + openDeleteDialog: () => void; + openModifyDialog: () => void; + closeSwaggerResourcePicker: () => void; + closeSwaggerReloadDialog: () => void; + closeClientConfigDialog: () => void; + closeExportDialog: () => void; + closeDeleteDialog: () => void; + closeModifyDialog: () => void; +} + +export function useDialogManager(): DialogStates & DialogActions { + const [dialogStates, setDialogStates] = useState({ + showSwaggerResourcePicker: false, + showSwaggerReloadDialog: false, + showClientConfigDialog: false, + showExportDialog: false, + showDeleteDialog: false, + showModifyDialog: false, + }); + + const openSwaggerResourcePicker = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showSwaggerResourcePicker: true })); + }, []); + + const openSwaggerReloadDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showSwaggerReloadDialog: true })); + }, []); + + const openClientConfigDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showClientConfigDialog: true })); + }, []); + + const openExportDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showExportDialog: true })); + }, []); + + const openDeleteDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showDeleteDialog: true })); + }, []); + + const openModifyDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showModifyDialog: true })); + }, []); + + const closeSwaggerResourcePicker = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showSwaggerResourcePicker: false })); + }, []); + + const closeSwaggerReloadDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showSwaggerReloadDialog: false })); + }, []); + + const closeClientConfigDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showClientConfigDialog: false })); + }, []); + + const closeExportDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showExportDialog: false })); + }, []); + + const closeDeleteDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showDeleteDialog: false })); + }, []); + + const closeModifyDialog = useCallback(() => { + setDialogStates((prev) => ({ ...prev, showModifyDialog: false })); + }, []); + + return { + ...dialogStates, + openSwaggerResourcePicker, + openSwaggerReloadDialog, + openClientConfigDialog, + openExportDialog, + openDeleteDialog, + openModifyDialog, + closeSwaggerResourcePicker, + closeSwaggerReloadDialog, + closeClientConfigDialog, + closeExportDialog, + closeDeleteDialog, + closeModifyDialog, + }; +} diff --git a/src/web/src/views/workspace/hooks/useResourceFilter.ts b/src/web/src/views/workspace/hooks/useResourceFilter.ts new file mode 100644 index 00000000..a1acf021 --- /dev/null +++ b/src/web/src/views/workspace/hooks/useResourceFilter.ts @@ -0,0 +1,28 @@ +import { useState, useCallback } from "react"; + +export const useResourceFilter = () => { + const [filterText, setFilterText] = useState(""); + const [realFilterText, setRealFilterText] = useState(""); + + const updateFilter = useCallback((newFilterText: string) => { + const reg = /\{.*?\}/g; + setFilterText(newFilterText); + setRealFilterText(newFilterText.toLocaleLowerCase().replace(reg, "{}")); + }, []); + + const filterResources = useCallback( + (resources: any[]) => { + if (realFilterText.trim().length > 0) { + return resources.filter((resource) => resource.id.toLowerCase().indexOf(realFilterText) > -1); + } + return resources; + }, + [realFilterText], + ); + + return { + filterText, + updateFilter, + filterResources, + }; +}; diff --git a/src/web/src/views/workspace/hooks/useTreeState.ts b/src/web/src/views/workspace/hooks/useTreeState.ts new file mode 100644 index 00000000..4e1a39b8 --- /dev/null +++ b/src/web/src/views/workspace/hooks/useTreeState.ts @@ -0,0 +1,153 @@ +import { useState, useCallback, useEffect } from "react"; +import type { Command, CommandGroup } from "../interfaces"; + +interface CommandGroupMap { + [id: string]: CommandGroup; +} + +interface CommandMap { + [id: string]: Command; +} + +interface UseTreeStateReturn { + selected: Command | CommandGroup | null; + expanded: Set; + handleCommandTreeSelect: (nodeId: string) => void; + handleCommandTreeToggle: (nodeIds: string[]) => void; + updateExpanded: ( + commandGroupMap: CommandGroupMap, + selected?: Command | CommandGroup | null, + autoExpandAll?: boolean, + ) => void; + setSelected: (selected: Command | CommandGroup | null) => void; +} + +interface CommandTreeNode { + id: string; + names: string[]; + canDelete: boolean; + leaves?: CommandTreeLeaf[]; + nodes?: CommandTreeNode[]; +} + +interface CommandTreeLeaf { + id: string; + names: string[]; +} + +export function useTreeState( + commandMap: CommandMap, + commandGroupMap: CommandGroupMap, + commandTree: CommandTreeNode[], +): UseTreeStateReturn { + const [selected, setSelected] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + + const handleCommandTreeSelect = useCallback( + (nodeId: string) => { + if (nodeId.startsWith("command:")) { + const selectedCommand = commandMap[nodeId]; + setSelected(selectedCommand); + } else if (nodeId.startsWith("group:")) { + const selectedGroup = commandGroupMap[nodeId]; + setSelected(selectedGroup); + } + }, + [commandMap, commandGroupMap], + ); + + const handleCommandTreeToggle = useCallback((nodeIds: string[]) => { + const newExpanded = new Set(nodeIds); + setExpanded(newExpanded); + }, []); + + const updateExpanded = useCallback( + (newCommandGroupMap: CommandGroupMap, newSelected?: Command | CommandGroup | null, autoExpandAll?: boolean) => { + setExpanded((prevExpanded) => { + const newExpanded = new Set(); + + prevExpanded.forEach((value) => { + if (value in newCommandGroupMap) { + newExpanded.add(value); + } + }); + + if (autoExpandAll) { + for (const groupId in newCommandGroupMap) { + newExpanded.add(groupId); + + const parts = groupId.split("/"); + for (let i = 1; i < parts.length; i++) { + const parentPath = parts.slice(0, i + 1).join("/"); + newExpanded.add(parentPath); + } + } + } else { + for (const groupId in newCommandGroupMap) { + if (!(groupId in commandGroupMap)) { + newExpanded.add(groupId); + + const parts = groupId.split("/"); + for (let i = 1; i < parts.length; i++) { + const parentPath = parts.slice(0, i + 1).join("/"); + newExpanded.add(parentPath); + } + } + } + } + + if (newSelected) { + let expandedId = newSelected.id; + if (expandedId.startsWith("command:")) { + expandedId = expandedId.replace("command:", "group:").split("/").slice(0, -1).join("/"); + } + const expandedIdParts = expandedId.split("/"); + expandedIdParts.forEach((_value, idx) => { + newExpanded.add(expandedIdParts.slice(0, idx + 1).join("/")); + }); + } + + return newExpanded; + }); + }, + [commandGroupMap], + ); + + useEffect(() => { + if (!selected && commandTree.length > 0) { + const firstGroupId = commandTree[0].id; + const firstGroup = commandGroupMap[firstGroupId]; + if (firstGroup) { + setSelected(firstGroup); + } + } + }, [selected, commandTree, commandGroupMap]); + + useEffect(() => { + if (selected && Object.keys(commandGroupMap).length > 0) { + setExpanded((prevExpanded) => { + const newExpanded = new Set(prevExpanded); + + let expandedId = selected.id; + if (expandedId.startsWith("command:")) { + expandedId = expandedId.replace("command:", "group:").split("/").slice(0, -1).join("/"); + } + const expandedIdParts = expandedId.split("/"); + expandedIdParts.forEach((_value, idx) => { + newExpanded.add(expandedIdParts.slice(0, idx + 1).join("/")); + }); + + return newExpanded; + }); + } + }, [selected, commandGroupMap]); + + return { + selected, + expanded, + handleCommandTreeSelect, + handleCommandTreeToggle, + updateExpanded, + setSelected, + }; +} diff --git a/src/web/src/views/workspace/hooks/useWorkspaceData.ts b/src/web/src/views/workspace/hooks/useWorkspaceData.ts new file mode 100644 index 00000000..f85f5096 --- /dev/null +++ b/src/web/src/views/workspace/hooks/useWorkspaceData.ts @@ -0,0 +1,164 @@ +import { useState, useCallback } from "react"; +import { workspaceApi, specsApi } from "../../../services"; +import type { + CommandGroup, + ResponseCommandGroup, + ResponseCommandGroups, + Command, + ResponseCommand, +} from "../interfaces"; +import { DecodeResponseCommand } from "../components/WSEditorCommandContent"; +import { DecodeResponseCommandGroup } from "../components/WSEditorCommandGroupContent"; + +interface CommandGroupMap { + [id: string]: CommandGroup; +} + +interface CommandMap { + [id: string]: Command; +} + +interface CommandTreeNode { + id: string; + names: string[]; + canDelete: boolean; + leaves?: CommandTreeLeaf[]; + nodes?: CommandTreeNode[]; +} + +interface CommandTreeLeaf { + id: string; + names: string[]; +} + +interface WorkspaceData { + name: string; + workspaceUrl: string; + plane: string; + source: string; + clientConfigurable: boolean; + commandMap: CommandMap; + commandGroupMap: CommandGroupMap; + commandTree: CommandTreeNode[]; + reloadTimestamp: number | null; +} + +interface UseWorkspaceDataReturn extends WorkspaceData { + loadWorkspace: () => Promise; + getWorkspaceClientConfig: (workspaceUrl: string) => Promise; +} + +export function useWorkspaceData(workspaceName: string): UseWorkspaceDataReturn { + const [workspaceData, setWorkspaceData] = useState({ + name: workspaceName, + workspaceUrl: `/AAZ/Editor/Workspaces/${workspaceName}`, + plane: "", + source: "", + clientConfigurable: false, + commandMap: {}, + commandGroupMap: {}, + commandTree: [], + reloadTimestamp: null, + }); + const [isLoading, setIsLoading] = useState(false); + + const getWorkspaceClientConfig = useCallback(async (workspaceUrl: string) => { + return await workspaceApi.getWorkspaceClientConfig(workspaceUrl); + }, []); + + const loadWorkspace = useCallback(async () => { + if (isLoading) return; // Prevent concurrent calls + + setIsLoading(true); + try { + const planeNames = await specsApi.getPlaneNames(); + const workspaceResponse = await workspaceApi.getWorkspace(`/AAZ/Editor/Workspaces/${workspaceName}`); + const reloadTimestamp = Date.now(); + const commandMap: CommandMap = {}; + const commandGroupMap: CommandGroupMap = {}; + + const buildCommand = (command_1: ResponseCommand): CommandTreeLeaf => { + const cmd: Command = DecodeResponseCommand(command_1); + commandMap[cmd.id] = cmd; + return { + id: cmd.id, + names: [...cmd.names], + }; + }; + + const buildCommandGroup = (commandGroup_1: ResponseCommandGroup): CommandTreeNode => { + const group: CommandGroup = DecodeResponseCommandGroup(commandGroup_1); + + commandGroupMap[group.id] = group; + + const node: CommandTreeNode = { + id: group.id, + names: [...group.names], + canDelete: group.canDelete, + }; + + if (typeof commandGroup_1.commands === "object" && commandGroup_1.commands !== null) { + node["leaves"] = []; + + for (const name in commandGroup_1.commands) { + const subLeave = buildCommand(commandGroup_1.commands[name]); + node["leaves"].push(subLeave); + } + node["leaves"].sort((a, b) => a.id.localeCompare(b.id)); + if (node["leaves"].length > 0) { + node.canDelete = false; + } + } + + if (typeof commandGroup_1.commandGroups === "object" && commandGroup_1.commandGroups !== null) { + node["nodes"] = []; + for (const name_1 in commandGroup_1.commandGroups) { + const subNode = buildCommandGroup(commandGroup_1.commandGroups[name_1]); + node["nodes"].push(subNode); + if (!subNode.canDelete) { + node.canDelete = false; + } + } + node["nodes"].sort((a_1, b_1) => a_1.id.localeCompare(b_1.id)); + } + + if ((node["leaves"]?.length ?? 0) > 1) { + node.canDelete = false; + } + group.canDelete = node.canDelete; + return node; + }; + + const commandTree: CommandTreeNode[] = []; + + if (workspaceResponse.commandTree.commandGroups) { + const cmdGroups: ResponseCommandGroups = workspaceResponse.commandTree.commandGroups; + for (const key in cmdGroups) { + commandTree.push(buildCommandGroup(cmdGroups[key])); + } + commandTree.sort((a_2, b_2) => a_2.id.localeCompare(b_2.id)); + } + + const clientConfigurable = !planeNames.includes(workspaceResponse.plane); + + setWorkspaceData((prev) => ({ + ...prev, + plane: workspaceResponse.plane, + source: workspaceResponse.source, + clientConfigurable, + commandTree, + reloadTimestamp, + commandMap, + commandGroupMap, + })); + } catch (err) { + console.error(err); + } + }, [workspaceName]); + + return { + ...workspaceData, + loadWorkspace, + getWorkspaceClientConfig, + }; +} diff --git a/src/web/src/views/workspace/interfaces/commandGroups.ts b/src/web/src/views/workspace/interfaces/commandGroups.ts new file mode 100644 index 00000000..ebab49a9 --- /dev/null +++ b/src/web/src/views/workspace/interfaces/commandGroups.ts @@ -0,0 +1,27 @@ +import type { ResponseCommands } from "./commands"; + +export interface CommandGroup { + id: string; + names: string[]; + stage: "Stable" | "Preview" | "Experimental"; + help?: { + short: string; + lines?: string[]; + }; + canDelete: boolean; +} + +export interface ResponseCommandGroup { + names: string[]; + stage?: "Stable" | "Preview" | "Experimental"; + help?: { + short: string; + lines?: string[]; + }; + commands?: ResponseCommands; + commandGroups?: ResponseCommandGroups; +} + +export interface ResponseCommandGroups { + [name: string]: ResponseCommandGroup; +} diff --git a/src/web/src/views/workspace/interfaces/commands.ts b/src/web/src/views/workspace/interfaces/commands.ts new file mode 100644 index 00000000..ef51e3de --- /dev/null +++ b/src/web/src/views/workspace/interfaces/commands.ts @@ -0,0 +1,57 @@ +import type { Output } from "../components/WSEditorCommandContent/OutputDialog"; +import type { CMDArg, ClsArgDefinitionMap } from "../components/WSEditorCommandArgumentsContent"; + +export interface Plane { + name: string; + displayName: string; + moduleOptions?: string[]; +} + +export interface Example { + name: string; + commands: string[]; +} + +export interface Resource { + id: string; + version: string; + subresource?: string; + swagger: string; +} + +export interface Command { + id: string; + names: string[]; + help?: { + short: string; + lines?: string[]; + }; + stage: "Stable" | "Preview" | "Experimental"; + version: string; + examples?: Example[]; + outputs?: Output[]; + resources: Resource[]; + + confirmation?: string; + args?: CMDArg[]; + clsArgDefineMap?: ClsArgDefinitionMap; +} + +export interface ResponseCommand { + names: string[]; + help?: { + short: string; + lines?: string[]; + }; + stage?: "Stable" | "Preview" | "Experimental"; + version: string; + examples?: Example[]; + resources: Resource[]; + outputs?: Output[]; + confirmation?: string; + argGroups?: any[]; +} + +export interface ResponseCommands { + [name: string]: ResponseCommand; +} diff --git a/src/web/src/views/workspace/interfaces/index.ts b/src/web/src/views/workspace/interfaces/index.ts new file mode 100644 index 00000000..24989707 --- /dev/null +++ b/src/web/src/views/workspace/interfaces/index.ts @@ -0,0 +1,3 @@ +export type { Plane, Example, Resource, Command, ResponseCommand, ResponseCommands } from "./commands"; + +export type { CommandGroup, ResponseCommandGroup, ResponseCommandGroups } from "./commandGroups"; diff --git a/src/web/src/views/workspace/utils/convertArgDefaultText.ts b/src/web/src/views/workspace/utils/convertArgDefaultText.ts new file mode 100644 index 00000000..f34247b6 --- /dev/null +++ b/src/web/src/views/workspace/utils/convertArgDefaultText.ts @@ -0,0 +1,112 @@ +export type SupportedArgType = + | "byte" + | "binary" + | "duration" + | "date" + | "dateTime" + | "time" + | "uuid" + | "password" + | "SubscriptionId" + | "ResourceGroupName" + | "ResourceId" + | "ResourceLocation" + | "string" + | "integer32" + | "integer64" + | "integer" + | "float32" + | "float64" + | "float" + | "boolean" + | "any" + | "object" + | `array<${string}>` + | `dict<${string}>`; + +export type ConvertedValue = string | number | boolean | null | object | any[]; + +export interface ConvertArgDefaultTextParams { + defaultText: string; + argType: SupportedArgType | string; +} + +export function convertArgDefaultText(defaultText: string, argType: string): ConvertedValue { + switch (argType) { + case "byte": + case "binary": + case "duration": + case "date": + case "dateTime": + case "time": + case "uuid": + case "password": + case "SubscriptionId": + case "ResourceGroupName": + case "ResourceId": + case "ResourceLocation": + case "string": + if (defaultText.trim().length === 0) { + throw Error(`Not supported empty value: '${defaultText}'`); + } + return defaultText.trim(); + case "integer32": + case "integer64": + case "integer": + if (Number.isNaN(parseInt(defaultText.trim())) || defaultText.trim().includes(".")) { + throw Error(`Not supported default value for integer type: '${defaultText}'`); + } + return parseInt(defaultText.trim()); + case "float32": + case "float64": + case "float": + if (Number.isNaN(parseFloat(defaultText.trim()))) { + throw Error(`Not supported default value for float type: '${defaultText}'`); + } + return parseFloat(defaultText.trim()); + case "boolean": + switch (defaultText.trim().toLowerCase()) { + case "true": + case "yes": + return true; + case "false": + case "no": + return false; + default: + throw Error(`Not supported default value for boolean type: '${defaultText}'`); + } + case "any": + let trimmed = defaultText.trim().toLowerCase(); + if (!trimmed.includes(".") && Number.isInteger(parseInt(trimmed))) { + return parseInt(trimmed); + } + if (!Number.isNaN(parseFloat(trimmed))) { + return parseFloat(trimmed); + } + switch (trimmed) { + case "null": + return null; + case "true": + case "yes": + return true; + case "false": + case "no": + return false; + default: + return defaultText.trim(); + } + case "object": { + const de = JSON.parse(defaultText.trim()); + return de; + } + default: + if (argType.startsWith("array")) { + const de = JSON.parse(defaultText.trim()); + return de; + } else if (argType.startsWith("dict")) { + const de = JSON.parse(defaultText.trim()); + return de; + } + throw Error(`Not supported type: ${argType}`); + } +} diff --git a/src/web/src/views/workspace/utils/decodeArgs.ts b/src/web/src/views/workspace/utils/decodeArgs.ts new file mode 100644 index 00000000..97dd0ade --- /dev/null +++ b/src/web/src/views/workspace/utils/decodeArgs.ts @@ -0,0 +1,505 @@ +type CMDArgHelp = { + short: string; + lines?: string[]; + refCommands?: string[]; +}; + +type CMDArgDefault = { + value: T | null; +}; + +type CMDArgBlank = { + value: T | null; +}; + +type CMDArgEnumItem = { + name: string; + hide: boolean; + value: T; +}; + +type CMDArgEnum = { + items: CMDArgEnumItem[]; +}; + +interface CMDArgPromptInput { + msg: string; +} + +interface CMDPasswordArgPromptInput extends CMDArgPromptInput { + confirm: boolean; +} + +interface CMDArgBase { + type: string; + nullable: boolean; + blank?: CMDArgBlank; +} + +interface CMDArg extends CMDArgBase { + var: string; + options: string[]; + + required: boolean; + stage: "Stable" | "Preview" | "Experimental"; + hide: boolean; + group: string; + help?: CMDArgHelp; + + default?: CMDArgDefault; + idPart?: string; + prompt?: CMDArgPromptInput; + configurationKey?: string; + supportEnumExtension?: boolean; + hasEnum?: boolean; +} + +interface CMDClsArgBase extends CMDArgBase { + clsName: string; +} + +interface CMDClsArg extends CMDClsArgBase, CMDArg { + singularOptions?: string[]; +} + +interface CMDObjectArgBase extends CMDArgBase { + args: CMDArg[]; +} + +interface CMDObjectArg extends CMDObjectArgBase, CMDArg {} + +interface CMDDictArgBase extends CMDArgBase { + item?: CMDArgBase; + anyType: boolean; +} +interface CMDArrayArgBase extends CMDArgBase { + item: CMDArgBase; +} + +type ClsArgDefinitionMap = { + [clsName: string]: CMDArgBase; +}; + +function decodeArgEnumItem(response: any): CMDArgEnumItem { + return { + name: response.name, + hide: response.hide ?? false, + value: response.value as T, + }; +} + +function decodeArgEnum(response: any): CMDArgEnum { + const argEnum: CMDArgEnum = { + items: response.items.map((item: any) => decodeArgEnumItem(item)), + }; + return argEnum; +} + +function decodeArgBlank(response: any | undefined): CMDArgBlank | undefined { + if (response === undefined || response === null) { + return undefined; + } + + return { + value: response.value as T | null, + }; +} + +function decodeArgDefault(response: any | undefined): CMDArgDefault | undefined { + if (response === undefined || response === null) { + return undefined; + } + + return { + value: response.value as T | null, + }; +} + +function decodeArgPromptInput(response: any): CMDArgPromptInput | undefined { + if (response === undefined || response === null) { + return undefined; + } + + return { + msg: response.msg as string, + }; +} + +function decodePasswordArgPromptInput(response: any): CMDPasswordArgPromptInput | undefined { + if (response === undefined || response === null) { + return undefined; + } + + return { + msg: response.msg as string, + confirm: (response.confirm ?? false) as boolean, + }; +} + +function decodeArgBase(response: any): { + argBase: CMDArgBase; + clsDefineMap: ClsArgDefinitionMap; +} { + let argBase: any = { + type: response.type, + nullable: (response.nullable ?? false) as boolean, + }; + + let clsDefineMap: ClsArgDefinitionMap = {}; + + switch (response.type) { + case "byte": + case "binary": + case "duration": + case "date": + case "dateTime": + case "time": + case "uuid": + case "password": + case "SubscriptionId": + case "ResourceGroupName": + case "ResourceId": + case "ResourceLocation": + case "string": + if (response.enum) { + argBase = { + ...argBase, + enum: decodeArgEnum(response.enum), + }; + } + if (response.blank) { + argBase = { + ...argBase, + blank: decodeArgBlank(response.blank), + }; + } + break; + case "integer32": + case "integer64": + case "integer": + if (response.enum) { + argBase = { + ...argBase, + enum: decodeArgEnum(response.enum), + }; + } + if (response.blank) { + argBase = { + ...argBase, + blank: decodeArgBlank(response.blank), + }; + } + break; + case "float32": + case "float64": + case "float": + if (response.enum) { + argBase = { + ...argBase, + enum: decodeArgEnum(response.enum), + }; + } + if (response.blank) { + argBase = { + ...argBase, + blank: decodeArgBlank(response.blank), + }; + } + break; + case "boolean": + if (response.blank) { + argBase = { + ...argBase, + blank: decodeArgBlank(response.blank), + }; + } + break; + case "any": + if (response.blank) { + argBase = { + ...argBase, + blank: decodeArgBlank(response.blank), + }; + } + break; + case "object": + if (response.args && Array.isArray(response.args) && response.args.length > 0) { + const args: CMDArg[] = response.args.map((resSubArg: any) => { + const subArgParse = decodeArg(resSubArg); + clsDefineMap = { + ...clsDefineMap, + ...subArgParse.clsDefineMap, + }; + return subArgParse.arg; + }); + argBase = { + ...argBase, + args: args, + }; + } else if (response.additionalProps && response.additionalProps.item) { + const itemArgBaseParse = decodeArgBase(response.additionalProps.item); + clsDefineMap = { + ...clsDefineMap, + ...itemArgBaseParse.clsDefineMap, + }; + const argBaseType = `dict`; + argBase = { + ...argBase, + type: argBaseType, + item: itemArgBaseParse.argBase, + anyType: false, + }; + } else if (response.additionalProps && response.additionalProps.anyType) { + const argBaseType = `dict`; + argBase = { + ...argBase, + type: argBaseType, + anyType: true, + }; + } + + if (response.cls) { + const clsName = response.cls; + clsDefineMap[clsName] = argBase; + argBase = { + type: `@${response.cls}`, + clsName: clsName, + }; + } + break; + default: + if (response.type.startsWith("array<")) { + if (response.item) { + const itemArgBaseParse = decodeArgBase(response.item); + clsDefineMap = { + ...clsDefineMap, + ...itemArgBaseParse.clsDefineMap, + }; + const argBaseType = `array<${itemArgBaseParse.argBase.type}>`; + argBase = { + ...argBase, + type: argBaseType, + item: itemArgBaseParse.argBase, + }; + } else { + throw Error("Invalid array object. Item is not defined"); + } + + if (response.cls) { + const clsName = response.cls; + clsDefineMap[clsName] = argBase; + argBase = { + type: `@${response.cls}`, + clsName: clsName, + }; + } + } else if (response.type.startsWith("@")) { + argBase["clsName"] = response.type.slice(1); + } else { + console.error(`Unknown type '${response.type}'`); + throw Error(`Unknown type '${response.type}'`); + } + } + + return { + argBase: argBase, + clsDefineMap: clsDefineMap, + }; +} + +function decodeArgHelp(response: any): CMDArgHelp { + return { + short: response.short, + lines: response.lines, + refCommands: response.refCommands, + }; +} + +function decodeArg(response: any): { + arg: CMDArg; + clsDefineMap: ClsArgDefinitionMap; +} { + const { argBase, clsDefineMap } = decodeArgBase(response); + const options = (response.options as string[]).sort((a, b) => a.length - b.length).reverse(); + const help = response.help ? decodeArgHelp(response.help) : undefined; + const prompt = response.prompt ? decodeArgPromptInput(response.prompt) : undefined; + + let arg: any = { + ...argBase, + var: response.var as string, + options: options, + required: (response.required ?? false) as boolean, + stage: (response.stage ?? "Stable") as "Stable" | "Preview" | "Experimental", + hide: (response.hide ?? false) as boolean, + group: (response.group ?? "") as string, + help: help, + idPart: response.idPart, + prompt: prompt, + configurationKey: response.configurationKey, + supportEnumExtension: response.enum?.supportExtension || response.item?.enum?.supportExtension || false, + hasEnum: response.enum?.items?.length > 0 || response.item?.enum?.items?.length > 0 || false, + }; + + switch (argBase.type) { + case "byte": + case "binary": + case "duration": + case "date": + case "dateTime": + case "time": + case "uuid": + case "SubscriptionId": + case "ResourceGroupName": + case "ResourceId": + case "ResourceLocation": + case "string": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "password": + if (response.prompt) { + arg = { + ...arg, + prompt: decodePasswordArgPromptInput(response.prompt), + }; + } + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "integer32": + case "integer64": + case "integer": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "float32": + case "float64": + case "float": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "boolean": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "any": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + case "object": + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + break; + default: + if (argBase.type.startsWith("dict<")) { + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + } else if (argBase.type.startsWith("array<")) { + if (response.singularOptions) { + arg = { + ...arg, + singularOptions: response.singularOptions as string[], + }; + } + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault>(response.default), + }; + } + } else if (argBase.type.startsWith("@")) { + if (response.singularOptions) { + arg = { + ...arg, + singularOptions: response.singularOptions as string[], + }; + } + if (response.default) { + arg = { + ...arg, + default: decodeArgDefault(response.default), + }; + } + } else { + console.error(`Unknown type '${argBase.type}'`); + throw Error(`Unknown type '${argBase.type}'`); + } + } + + return { + arg: arg, + clsDefineMap: clsDefineMap, + }; +} + +const DecodeArgs = (argGroups: any[]): { args: CMDArg[]; clsArgDefineMap: ClsArgDefinitionMap } => { + let clsDefineMap: ClsArgDefinitionMap = {}; + const args: CMDArg[] = []; + argGroups.forEach((argGroup: any) => { + args.push( + ...argGroup.args.map((resArg: any) => { + const argDecode = decodeArg(resArg); + clsDefineMap = { + ...clsDefineMap, + ...argDecode.clsDefineMap, + }; + return argDecode.arg; + }), + ); + }); + return { + args: args, + clsArgDefineMap: clsDefineMap, + }; +}; + +export { DecodeArgs }; +export type { + ClsArgDefinitionMap, + CMDArg, + CMDArgBase, + CMDObjectArg, + CMDClsArg, + CMDDictArgBase, + CMDArrayArgBase, + CMDObjectArgBase, + CMDArgHelp, + CMDArgDefault, + CMDArgBlank, + CMDArgEnumItem, + CMDArgEnum, + CMDArgPromptInput, + CMDPasswordArgPromptInput, +}; diff --git a/src/web/src/views/workspace/utils/decodeResponseCommand.ts b/src/web/src/views/workspace/utils/decodeResponseCommand.ts new file mode 100644 index 00000000..64f38119 --- /dev/null +++ b/src/web/src/views/workspace/utils/decodeResponseCommand.ts @@ -0,0 +1,30 @@ +import { DecodeArgs } from "./decodeArgs"; +import type { Command, ResponseCommand } from "../interfaces"; + +const DecodeResponseCommand = (command: ResponseCommand): Command => { + let cmd: Command = { + id: "command:" + command.names.join("/"), + names: command.names, + help: command.help, + stage: command.stage ?? "Stable", + examples: command.examples, + outputs: command.outputs, + resources: command.resources, + version: command.version, + }; + + if (command.confirmation) { + cmd.confirmation = command.confirmation; + } + + if (command.argGroups) { + cmd = { + ...cmd, + ...DecodeArgs(command.argGroups!), + }; + } + + return cmd; +}; + +export { DecodeResponseCommand }; diff --git a/src/web/src/views/workspace/utils/spliceArgOptionsString.ts b/src/web/src/views/workspace/utils/spliceArgOptionsString.ts new file mode 100644 index 00000000..0623e7c5 --- /dev/null +++ b/src/web/src/views/workspace/utils/spliceArgOptionsString.ts @@ -0,0 +1,68 @@ +import type { CMDArg } from "./decodeArgs"; + +interface CMDArrayArg extends CMDArg { + item: any; + singularOptions?: string[]; +} + +interface CMDClsArg extends CMDArg { + clsName: string; + singularOptions?: string[]; +} + +interface SpliceArgOptionsStringFunction { + (arg: CMDArg, depth: number): string; +} + +const spliceArgOptionsString: SpliceArgOptionsStringFunction = (arg: CMDArg, depth: number) => { + let optionsString = arg.options + .map((option: string) => { + if (depth === 0) { + if (option.length === 1) { + return "-" + option; + } else { + return "--" + option; + } + } else { + return "." + option; + } + }) + .join(" "); + + if ((arg as CMDArrayArg).singularOptions) { + const singularOptionString = (arg as CMDArrayArg) + .singularOptions!.map((option: string) => { + if (depth === 0) { + if (option.length === 1) { + return "-" + option; + } else { + return "--" + option; + } + } else { + return "." + option; + } + }) + .join(" "); + optionsString += ` (${singularOptionString})`; + } else if ((arg as CMDClsArg).singularOptions) { + const singularOptionString = (arg as CMDClsArg) + .singularOptions!.map((option: string) => { + if (depth === 0) { + if (option.length === 1) { + return "-" + option; + } else { + return "--" + option; + } + } else { + return "." + option; + } + }) + .join(" "); + optionsString += ` (${singularOptionString})`; + } + + return optionsString; +}; + +export { spliceArgOptionsString }; +export type { CMDArrayArg, CMDClsArg, SpliceArgOptionsStringFunction }; diff --git a/src/web/src/withRoot.tsx b/src/web/src/withRoot.tsx deleted file mode 100644 index e20287fa..00000000 --- a/src/web/src/withRoot.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; -import { ThemeProvider } from "@mui/material/styles"; -import CssBaseline from "@mui/material/CssBaseline"; -import theme from "./theme"; - -export default function withRoot

(Component: React.ComponentType

) { - function WithRoot(props: P & { children?: React.ReactNode }) { - return ( - - {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} - - - - ); - } - - return WithRoot; -}