From 0d70ddffd121e15dba66fa64fee9b021111e1129 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 13 Oct 2025 15:37:11 +1100 Subject: [PATCH 001/107] refactor: update WorkspaceInstruction to use function-based react --- .../views/workspace/WorkspaceInstruction.tsx | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/src/web/src/views/workspace/WorkspaceInstruction.tsx b/src/web/src/views/workspace/WorkspaceInstruction.tsx index aabd9c7c..53b3ccf8 100644 --- a/src/web/src/views/workspace/WorkspaceInstruction.tsx +++ b/src/web/src/views/workspace/WorkspaceInstruction.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import { Typography, Box } from "@mui/material"; import { styled } from "@mui/material/styles"; import WorkspaceSelector from "./WorkspaceSelector"; @@ -9,43 +9,41 @@ const MiddlePadding = styled(Box)(() => ({ height: "6vh", })); -class WorkspaceInstruction extends React.Component { - render() { - return ( - - - +const WorkspaceInstruction: React.FC = () => { + return ( + + + + + - - - - Please select a Workspace - - - - - + + Please select a Workspace + + + - - - ); - } -} + + + + + ); +}; export default WorkspaceInstruction; From 1b1318aed1387de9ca936f8b95a6e9f05f0df058 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 13 Oct 2025 15:51:14 +1100 Subject: [PATCH 002/107] refactor: update WorkspacePage to use class-based --- src/web/src/views/workspace/WorkspacePage.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/web/src/views/workspace/WorkspacePage.tsx b/src/web/src/views/workspace/WorkspacePage.tsx index 509025a4..5f8fceef 100644 --- a/src/web/src/views/workspace/WorkspacePage.tsx +++ b/src/web/src/views/workspace/WorkspacePage.tsx @@ -1,15 +1,13 @@ -import * as React from "react"; +import React from "react"; import withRoot from "../../withRoot"; import { Outlet } from "react-router"; -class WorkspacePage extends React.Component { - render() { - return ( - - - - ); - } -} +const WorkspacePage: React.FC = () => { + return ( + + + + ); +}; export default withRoot(WorkspacePage); From 6fb255fef1c6292d6ad795c584d5f58b8385b2eb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 13 Oct 2025 16:02:07 +1100 Subject: [PATCH 003/107] refactor: extract workspacecreatedialog to own component --- .../views/workspace/WorkspaceCreateDialog.tsx | 366 +++++++++++++++++ .../src/views/workspace/WorkspaceSelector.tsx | 368 +----------------- 2 files changed, 369 insertions(+), 365 deletions(-) create mode 100644 src/web/src/views/workspace/WorkspaceCreateDialog.tsx diff --git a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx new file mode 100644 index 00000000..4d1f6bad --- /dev/null +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -0,0 +1,366 @@ +import { + Box, + 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 } from "../../services"; + +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 WorkspaceCreateDialog; diff --git a/src/web/src/views/workspace/WorkspaceSelector.tsx b/src/web/src/views/workspace/WorkspaceSelector.tsx index eec94d23..cd783672 100644 --- a/src/web/src/views/workspace/WorkspaceSelector.tsx +++ b/src/web/src/views/workspace/WorkspaceSelector.tsx @@ -1,21 +1,7 @@ -import { - Box, - Autocomplete, - createFilterOptions, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - Button, - InputLabel, - Alert, -} from "@mui/material"; +import { Box, Autocomplete, createFilterOptions, TextField } 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"; +import { workspaceApi, type Workspace as WorkspaceType } from "../../services"; +import WorkspaceCreateDialog from "./WorkspaceCreateDialog"; interface WorkspaceSelectorProps { name: string; @@ -157,352 +143,4 @@ class WorkspaceSelector extends React.Component 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; From a4376b2931600736d8af2ad0cf2f2dea129fdc6c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 09:36:28 +1100 Subject: [PATCH 004/107] refactor: WorkspaceSelector to function-based --- .../src/views/workspace/WorkspaceSelector.tsx | 194 ++++++++---------- 1 file changed, 84 insertions(+), 110 deletions(-) diff --git a/src/web/src/views/workspace/WorkspaceSelector.tsx b/src/web/src/views/workspace/WorkspaceSelector.tsx index cd783672..348b14b2 100644 --- a/src/web/src/views/workspace/WorkspaceSelector.tsx +++ b/src/web/src/views/workspace/WorkspaceSelector.tsx @@ -7,13 +7,6 @@ interface WorkspaceSelectorProps { name: string; } -interface WorkspaceSelectorState { - options: any[]; - value: WorkspaceType | null; - openDialog: boolean; - newWorkspaceName: string; -} - interface InputType { inputValue: string; title: string; @@ -21,126 +14,107 @@ interface InputType { const filter = createFilterOptions(); -class WorkspaceSelector extends React.Component { - constructor(props: WorkspaceSelectorProps) { - super(props); - this.state = { - options: [], - value: null, - openDialog: false, - newWorkspaceName: "", - }; - } +const WorkspaceSelector: React.FC = ({ name }) => { + const [options, setOptions] = React.useState([]); + const [value, setValue] = React.useState(null); + const [openDialog, setOpenDialog] = React.useState(false); + const [newWorkspaceName, setNewWorkspaceName] = React.useState(""); - componentDidMount() { - this.loadWorkspaces(); - } + React.useEffect(() => { + loadWorkspaces(); + }, []); - loadWorkspaces = async () => { + const loadWorkspaces = async () => { try { - const options = await workspaceApi.getWorkspaces(); - this.setState({ - options: options, - }); + const workspaces = await workspaceApi.getWorkspaces(); + setOptions(workspaces); } catch (err: any) { console.error(err); } }; - handleDialogClose = (value: any | null) => { - this.setState({ - newWorkspaceName: "", - openDialog: false, - }); + const handleDialogClose = (value: any | null) => { + setNewWorkspaceName(""); + setOpenDialog(false); if (value != null) { - this.onValueUpdated(value); + onValueUpdated(value); } }; - onValueUpdated = (value: any) => { - this.setState({ - value: value, - }); + const onValueUpdated = (value: any) => { + setValue(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 && ( - + return ( + + { + if (typeof newValue === "string") { + // timeout to avoid instant validation of the dialog's form. + 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; From 7ed997b0e25b4216fd3dba7e2c3451ff6419276d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 11:39:37 +1100 Subject: [PATCH 005/107] refactor: WSEditorCommandGroupContent to function-based --- .../workspace/CommandGroupDeleteDialog.tsx | 65 ++++ .../views/workspace/CommandGroupDialog.tsx | 234 ++++++++++++++ .../workspace/WSEditorCommandGroupContent.tsx | 285 +----------------- 3 files changed, 302 insertions(+), 282 deletions(-) create mode 100644 src/web/src/views/workspace/CommandGroupDeleteDialog.tsx create mode 100644 src/web/src/views/workspace/CommandGroupDialog.tsx diff --git a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx new file mode 100644 index 00000000..f5ef9316 --- /dev/null +++ b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx @@ -0,0 +1,65 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + Typography, +} from "@mui/material"; +import { commandApi } from "../../services"; +import * as React from "react"; +import { CommandGroup } from "./WSEditorCommandGroupContent"; + +const commandPrefix = "az "; + +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 && ( + + + + + )} + + + ); +} + +export default CommandGroupDeleteDialog; diff --git a/src/web/src/views/workspace/CommandGroupDialog.tsx b/src/web/src/views/workspace/CommandGroupDialog.tsx new file mode 100644 index 00000000..0060fa26 --- /dev/null +++ b/src/web/src/views/workspace/CommandGroupDialog.tsx @@ -0,0 +1,234 @@ +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 { CommandGroup, DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; + +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 && ( + + + + + )} + + + ); + } +} + +export default CommandGroupDialog; diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx index 2f48f363..8936a96d 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx @@ -1,25 +1,8 @@ -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 { Box, Button, Card, CardActions, CardContent, Typography } from "@mui/material"; import * as React from "react"; import { ResponseCommands } from "./WSEditorCommandContent"; +import CommandGroupDialog from "./CommandGroupDialog"; +import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; import { NameTypography, ShortHelpTypography, @@ -241,268 +224,6 @@ class WSEditorCommandGroupContent extends React.Component< } } -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("/"), From 37771cbf8ce6eaee49a3cd895d80d3db13472294 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 11:49:59 +1100 Subject: [PATCH 006/107] refactor: CommandGroupDeleteDialog --- .../workspace/CommandGroupDeleteDialog.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx index f5ef9316..7275c84e 100644 --- a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx +++ b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx @@ -14,36 +14,44 @@ import { CommandGroup } from "./WSEditorCommandGroupContent"; const commandPrefix = "az "; -function CommandGroupDeleteDialog(props: { +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 = () => { - props.onClose(false); - }; - const handleDelete = async () => { - const nodeUrl = `${props.workspaceUrl}/CommandTree/Nodes/aaz/` + props.commandGroup.names.join("/"); + 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); - props.onClose(true); + onClose(true); } catch (err: any) { setUpdating(false); console.error(err); } - }; + }, [workspaceUrl, commandGroup.names, onClose]); return ( - + Delete Command Group - {`${commandPrefix}${props.commandGroup.names.join(" ")}`} + {`${commandPrefix}${commandGroup.names.join(" ")}`} {updating && ( @@ -60,6 +68,6 @@ function CommandGroupDeleteDialog(props: { ); -} +}; export default CommandGroupDeleteDialog; From d6460fa9d80fb42901cbe768371189b668e499a2 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 12:04:08 +1100 Subject: [PATCH 007/107] refactor: CommandGroupDialog to function-based --- .../views/workspace/CommandGroupDialog.tsx | 308 ++++++++---------- 1 file changed, 136 insertions(+), 172 deletions(-) diff --git a/src/web/src/views/workspace/CommandGroupDialog.tsx b/src/web/src/views/workspace/CommandGroupDialog.tsx index 0060fa26..35d5cca9 100644 --- a/src/web/src/views/workspace/CommandGroupDialog.tsx +++ b/src/web/src/views/workspace/CommandGroupDialog.tsx @@ -24,211 +24,175 @@ interface CommandGroupDialogProps { 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, - }); +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) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); + setInvalidText(`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]+)* `, - }); + setInvalidText(`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.`, - }); + if (trimmedShortHelp.length < 1) { + setInvalidText(`Field 'Short Summary' is required.`); + return; } let lines: string[] = []; - if (longHelp.length > 1) { - lines = longHelp.split("\n").filter((l) => l.length > 0); + if (trimmedLongHelp.length > 1) { + lines = trimmedLongHelp.split("\n").filter((l: string) => l.length > 0); } - this.setState({ - updating: true, - }); + setUpdating(true); - const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/` + commandGroup.names.join("/"); + const nodeUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/${commandGroup.names.join("/")}`; try { const res = await commandApi.updateCommandGroup(nodeUrl, { help: { - short: shortHelp, + short: trimmedShortHelp, lines: lines, }, stage: stage, }); - const name = names.join(" "); - if (name === commandGroup.names.join(" ")) { + const finalName = names.join(" "); + if (finalName === commandGroup.names.join(" ")) { const cmdGroup = DecodeResponseCommandGroup(res); - this.setState({ - updating: false, - }); - this.props.onClose(cmdGroup); + setUpdating(false); + onClose(cmdGroup); } else { - const renameRes = await commandApi.renameCommandGroup(nodeUrl, name); + const renameRes = await commandApi.renameCommandGroup(nodeUrl, finalName); const cmdGroup = DecodeResponseCommandGroup(renameRes); - this.setState({ - updating: false, - }); - this.props.onClose(cmdGroup); + setUpdating(false); + onClose(cmdGroup); } } catch (err: any) { console.error(err); - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setUpdating(false); + setInvalidText(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 && ( - - - - - )} - - - ); - } -} + }, [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; From a89367cb14dca212742d99c819898cce4db023b6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 12:16:33 +1100 Subject: [PATCH 008/107] refactor: WSEditorCommandGroupContent to function-based --- .../workspace/WSEditorCommandGroupContent.tsx | 292 +++++++++--------- 1 file changed, 139 insertions(+), 153 deletions(-) diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx index 8936a96d..73524d9c 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx @@ -39,6 +39,7 @@ interface ResponseCommandGroups { [name: string]: ResponseCommandGroup; } +// @TODO: export to a constants file, this is used in multiple components. const commandPrefix = "az "; interface WSEditorCommandGroupContentProps { @@ -48,181 +49,166 @@ interface WSEditorCommandGroupContentProps { 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 ( - - = ({ + 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 = commandPrefix + 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} - ))} - + + [ 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 && ( - - )} - - ); - } -} + Edit + + + + + + + {displayCommandGroupDialog && ( + + )} + {displayCommandGroupDeleteDialog && ( + + )} + + ); +}; const DecodeResponseCommandGroup = (commandGroup: ResponseCommandGroup): CommandGroup => { return { From c027765ce09c02892b658646548a270d78beeacd Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 12:35:12 +1100 Subject: [PATCH 009/107] refactor: export command_prefix to own file --- src/web/src/constants/index.ts | 4 ++++ .../src/views/workspace/CommandGroupDeleteDialog.tsx | 5 ++--- .../src/views/workspace/WSEditorCommandContent.tsx | 11 +++++------ .../views/workspace/WSEditorCommandGroupContent.tsx | 6 ++---- 4 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 src/web/src/constants/index.ts 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/views/workspace/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx index 7275c84e..7589e481 100644 --- a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx +++ b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx @@ -11,8 +11,7 @@ import { import { commandApi } from "../../services"; import * as React from "react"; import { CommandGroup } from "./WSEditorCommandGroupContent"; - -const commandPrefix = "az "; +import { COMMAND_PREFIX } from "../../constants"; interface CommandGroupDeleteDialogProps { workspaceUrl: string; @@ -51,7 +50,7 @@ const CommandGroupDeleteDialog: React.FC = ({ Delete Command Group - {`${commandPrefix}${commandGroup.names.join(" ")}`} + {`${COMMAND_PREFIX}${commandGroup.names.join(" ")}`} {updating && ( diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index 5c794f38..a0038f20 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -48,6 +48,7 @@ import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArro import DataObjectIcon from "@mui/icons-material/DataObject"; import LabelIcon from "@mui/icons-material/Label"; import { commandApi, errorHandlerApi } from "../../services"; +import { COMMAND_PREFIX } from "../../constants"; import WSEditorCommandArgumentsContent, { ClsArgDefinitionMap, CMDArg, @@ -173,8 +174,6 @@ interface WSEditorCommandContentState { loading: boolean; } -const commandPrefix = "az "; - const ExampleCommandHeaderTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, fontFamily: "'Work Sans', sans-serif", @@ -389,7 +388,7 @@ class WSEditorCommandContent extends React.Component - {commandPrefix} + {COMMAND_PREFIX} Delete Commands {relatedCommands.map((command, idx) => ( - {`${commandPrefix}${command}`} + {`${COMMAND_PREFIX}${command}`} ))} @@ -1381,7 +1380,7 @@ class ExampleDialog extends React.Component - {commandPrefix} + {COMMAND_PREFIX} } /> diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx index 73524d9c..a24cda08 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { ResponseCommands } from "./WSEditorCommandContent"; import CommandGroupDialog from "./CommandGroupDialog"; import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; +import { COMMAND_PREFIX } from "../../constants"; import { NameTypography, ShortHelpTypography, @@ -39,9 +40,6 @@ interface ResponseCommandGroups { [name: string]: ResponseCommandGroup; } -// @TODO: export to a constants file, this is used in multiple components. -const commandPrefix = "az "; - interface WSEditorCommandGroupContentProps { workspaceUrl: string; commandGroup: CommandGroup; @@ -85,7 +83,7 @@ const WSEditorCommandGroupContent: React.FC = [onUpdateCommandGroup], ); - const name = commandPrefix + commandGroup.names.join(" "); + 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 ?? []; From 4a5c25d7b7f18857569ef115ae98570b3b7cc695 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 13:11:32 +1100 Subject: [PATCH 010/107] refactor: WorkspaceCreateDialog --- .../views/workspace/WorkspaceCreateDialog.tsx | 485 ++++++++---------- 1 file changed, 217 insertions(+), 268 deletions(-) diff --git a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx index 4d1f6bad..3a3ac8b9 100644 --- a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -9,7 +9,7 @@ import { InputLabel, Alert, } from "@mui/material"; -import * as React from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { SwaggerItemSelector } from "./WSEditorSwaggerPicker"; import styled from "@emotion/styled"; import { Plane } from "./WSEditorCommandContent"; @@ -21,343 +21,292 @@ interface WorkspaceCreateDialogProps { onClose: (value: any | null) => void; } -interface WorkspaceCreateDialogState { - loading: boolean; +const WorkspaceCreateDialog: React.FC = ({ openDialog, name, onClose }) => { + const [loading, setLoading] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [workspaceName, setWorkspaceName] = useState(name); - invalidText?: string; - workspaceName: string; + const [planes, setPlanes] = useState([]); + const [planeOptions, setPlaneOptions] = useState([]); + const [selectedPlane, setSelectedPlane] = useState(null); - planes: Plane[]; - planeOptions: string[]; - selectedPlane: string | null; + const [moduleOptions, setModuleOptions] = useState([]); + const [moduleOptionsCommonPrefix, setModuleOptionsCommonPrefix] = useState(""); + const [selectedModule, setSelectedModule] = useState(null); - moduleOptions: string[]; - moduleOptionsCommonPrefix: string; - selectedModule: string | null; + const [resourceProviderOptions, setResourceProviderOptions] = useState([]); + const [resourceProviderOptionsCommonPrefix, setResourceProviderOptionsCommonPrefix] = useState(""); + const [selectedResourceProvider, setSelectedResourceProvider] = useState(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); + useEffect(() => { + loadPlanes().then(async () => { + if (planes.length > 0) { + await onPlaneSelectorUpdate(planes[0].name); } }); - } + }, []); - loadPlanes = async () => { + const loadPlanes = useCallback(async () => { try { - this.setState({ - loading: true, - }); + setLoading(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]); + const planesData = await specsApi.getPlanes(); + const planeOptionsData: string[] = planesData.map((v) => v.displayName); + setPlanes(planesData); + setPlaneOptions(planeOptionsData); + setLoading(false); + if (planeOptionsData.length > 0) { + await onPlaneSelectorUpdate(planeOptionsData[0]); } } catch (err: any) { console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setLoading(false); + setInvalidText(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; + 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); } - this.setState({ - selectedPlane: plane?.displayName ?? null, - }); - await this.loadSwaggerModules(plane); - } else { - this.setState({ - selectedPlane: plane?.displayName ?? null, - }); - } - }; + }, + [planes, selectedPlane], + ); - loadSwaggerModules = async (plane: Plane | null) => { + const loadSwaggerModules = useCallback(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); + if (plane.moduleOptions?.length) { + setModuleOptions(plane.moduleOptions); + setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane.name}/`); + await onModuleSelectionUpdate(null); } else { try { - this.setState({ - loading: true, + 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; }); - 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); + setLoading(false); + setModuleOptions(options); + setModuleOptionsCommonPrefix(`/Swagger/Specs/${plane.name}/`); + await onModuleSelectionUpdate(null); } catch (err: any) { console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); } } } else { - this.setState({ - moduleOptions: [], - moduleOptionsCommonPrefix: "", - }); - await this.onModuleSelectionUpdate(null); + setModuleOptions([]); + setModuleOptionsCommonPrefix(""); + await 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, - }); - } - }; + const onModuleSelectionUpdate = useCallback( + async (moduleValueUrl: string | null) => { + if (selectedModule !== moduleValueUrl) { + setSelectedModule(moduleValueUrl); + await loadResourceProviders(moduleValueUrl); + } else { + setSelectedModule(moduleValueUrl); + } + }, + [selectedModule], + ); - loadResourceProviders = async (moduleUrl: string | null) => { + const loadResourceProviders = useCallback(async (moduleUrl: string | null) => { if (moduleUrl !== null) { try { - this.setState({ - loading: true, - }); + setLoading(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); + const selectedResourceProviderData = options.length === 1 ? options[0] : null; + setLoading(false); + setResourceProviderOptions(options); + setResourceProviderOptionsCommonPrefix(`${moduleUrl}/ResourceProviders/`); + onResourceProviderUpdate(selectedResourceProviderData); } catch (err: any) { console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); } } else { - this.setState({ - resourceProviderOptions: [], - resourceProviderOptionsCommonPrefix: "", - }); - this.onResourceProviderUpdate(null); + setResourceProviderOptions([]); + setResourceProviderOptionsCommonPrefix(""); + onResourceProviderUpdate(null); } - }; + }, []); - onResourceProviderUpdate = (resourceProviderUrl: string | null) => { - if (this.state.selectedResourceProvider !== resourceProviderUrl) { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - } else { - this.setState({ - selectedResourceProvider: resourceProviderUrl, - }); - } - }; + const onResourceProviderUpdate = useCallback( + (resourceProviderUrl: string | null) => { + if (selectedResourceProvider !== resourceProviderUrl) { + setSelectedResourceProvider(resourceProviderUrl); + } else { + setSelectedResourceProvider(resourceProviderUrl); + } + }, + [selectedResourceProvider], + ); - 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.` }); + 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) => v.displayName === selectedPlane)?.name ?? null; + const plane = planes.find((v: Plane) => v.displayName === selectedPlane)?.name ?? null; if (plane === null) { - this.setState({ invalidText: `Please select 'Plane'.` }); + setInvalidText(`Please select 'Plane'.`); return undefined; } - selectedModule = selectedModule ? selectedModule.replace(moduleOptionsCommonPrefix, "") : null; - if (selectedModule === null) { - this.setState({ invalidText: `Please select 'Module'.` }); + selModule = selModule ? selModule.replace(moduleOptionsCommonPrefix, "") : null; + if (selModule === null) { + setInvalidText(`Please select 'Module'.`); return undefined; } - selectedResourceProvider = selectedResourceProvider - ? selectedResourceProvider.replace(resourceProviderOptionsCommonPrefix, "") + selResourceProvider = selResourceProvider + ? selResourceProvider.replace(resourceProviderOptionsCommonPrefix, "") : null; - if (selectedResourceProvider === null) { - this.setState({ invalidText: `Please select 'Resource Provider'.` }); + if (selResourceProvider === null) { + setInvalidText(`Please select 'Resource Provider'.`); return undefined; } let source = "OpenAPI"; - if (selectedResourceProvider.endsWith("/TypeSpec")) { - selectedResourceProvider = selectedResourceProvider.replace("/TypeSpec", ""); + if (selResourceProvider.endsWith("/TypeSpec")) { + selResourceProvider = selResourceProvider.replace("/TypeSpec", ""); source = "TypeSpec"; } return { - name: workspaceName, + name: wsName, plane: plane, - modNames: selectedModule, - resourceProvider: selectedResourceProvider, + modNames: selModule, + resourceProvider: selResourceProvider, source: source, }; - }; + }, [ + workspaceName, + selectedModule, + selectedResourceProvider, + planes, + selectedPlane, + moduleOptionsCommonPrefix, + resourceProviderOptionsCommonPrefix, + ]); - handleCreate = async () => { - const data = this.verifyCreate(); + const handleCreate = useCallback(async () => { + const data = verifyCreate(); if (data === undefined) { return; } - this.setState({ loading: true }); + setLoading(true); try { const workspace = await workspaceApi.createWorkspace(data); - this.setState({ loading: false }); - this.props.onClose(workspace); + setLoading(false); + onClose(workspace); } catch (err: any) { console.error(err); - this.setState({ - loading: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setLoading(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); } - }; - - handleClose = () => { - this.props.onClose(null); - }; + }, [verifyCreate, onClose]); - render(): React.ReactNode { - const { invalidText, loading, selectedPlane, selectedModule, selectedResourceProvider, workspaceName } = this.state; + const handleClose = useCallback(() => { + onClose(null); + }, [onClose]); - return ( - - Create a new workspace - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - API Specs - - - - - - - - { - this.setState({ - workspaceName: event.target.value, - }); - }} - label="Workspace Name" - type="text" - variant="standard" + 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", From b5415f3986f1c634e0298ce6e86695025f8209f6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 14:00:00 +1100 Subject: [PATCH 011/107] refactor: WSEditorCommandArgumentsContent --- .../WSEditorCommandArgumentsContent.tsx | 193 ++++++++++-------- 1 file changed, 110 insertions(+), 83 deletions(-) diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index b88c364e..ec786ff1 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -44,13 +44,21 @@ import { SubtitleTypography, } from "./WSEditorTheme"; -function WSEditorCommandArgumentsContent(props: { +interface WSEditorCommandArgumentsContentProps { commandUrl: string; args: CMDArg[]; clsArgDefineMap: ClsArgDefinitionMap; onReloadArgs: () => Promise; onAddSubCommand: (argVar: string, subArgOptions: { var: string; options: string }[], argStackNames: string[]) => void; -}) { +} + +const WSEditorCommandArgumentsContent: React.FC = ({ + commandUrl, + args, + clsArgDefineMap, + onReloadArgs, + onAddSubCommand, +}) => { const [displayArgumentDialog, setDisplayArgumentDialog] = useState(false); const [editArg, setEditArg] = useState(undefined); const [, setEditArgIdxStack] = useState(undefined); @@ -59,7 +67,7 @@ function WSEditorCommandArgumentsContent(props: { const handleArgumentDialogClose = async (updated: boolean) => { if (updated) { - props.onReloadArgs(); + onReloadArgs(); } setDisplayArgumentDialog(false); setEditArg(undefined); @@ -74,7 +82,7 @@ function WSEditorCommandArgumentsContent(props: { const handleFlattenDialogClose = async (flattened: boolean) => { if (flattened) { - props.onReloadArgs(); + onReloadArgs(); } setDisplayFlattenDialog(false); setEditArg(undefined); @@ -89,7 +97,7 @@ function WSEditorCommandArgumentsContent(props: { const handleUnwrapClsDialogClose = async (unwrapped: boolean) => { if (unwrapped) { - props.onReloadArgs(); + onReloadArgs(); } setDisplayUnwrapClsDialog(false); setEditArg(undefined); @@ -118,7 +126,7 @@ function WSEditorCommandArgumentsContent(props: { let a: CMDArgBase | undefined = arg; if (a.type.startsWith("@")) { const clsName = (a as CMDClsArg).clsName; - a = props.clsArgDefineMap[clsName]; + a = clsArgDefineMap[clsName]; } if (a.type.startsWith("dict<")) { a = (a as CMDDictArgBase).item; @@ -130,7 +138,7 @@ function WSEditorCommandArgumentsContent(props: { let subArgs; if (a.type.startsWith("@")) { const clsName = (a as CMDClsArg).clsName; - subArgs = (props.clsArgDefineMap[clsName] as CMDObjectArgBase).args; + subArgs = (clsArgDefineMap[clsName] as CMDObjectArgBase).args; } else { subArgs = (a as CMDObjectArg).args; } @@ -142,7 +150,7 @@ function WSEditorCommandArgumentsContent(props: { }); } - props.onAddSubCommand(argVar, subArgOptions, argStackNames); + onAddSubCommand(argVar, subArgOptions, argStackNames); }; return ( @@ -166,9 +174,9 @@ function WSEditorCommandArgumentsContent(props: { [ ARGUMENT ] )} {displayFlattenDialog && ( )} {displayUnwrapClsDialog && ( ); -} +}; interface ArgIdx { var: string; displayKey: string; } -function ArgumentNavigation(props: { +interface ArgumentNavigationProps { commandUrl: string; args: CMDArg[]; clsArgDefineMap: ClsArgDefinitionMap; @@ -219,14 +227,24 @@ function ArgumentNavigation(props: { 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 = props.clsArgDefineMap[(selectedArgBase as CMDClsArgBase).clsName]; + const clsArgDefine = clsArgDefineMap[(selectedArgBase as CMDClsArgBase).clsName]; const clsArgProps = getArgProps(clsArgDefine); if (clsArgProps !== undefined && clsArgDefine.type === "object") { clsArgProps!.flattenArgVar = (selectedArgBase as CMDClsArg).var; @@ -269,15 +287,15 @@ function ArgumentNavigation(props: { if (stack.length === 0) { return undefined; } else { - let args: CMDArg[] = [...props.args]; + let argsArray: CMDArg[] = [...args]; let selectedArg: CMDArg | undefined = undefined; for (const i in stack) { const argVar = stack[i].var; - selectedArg = args.find((arg) => arg.var === argVar); + selectedArg = argsArray.find((arg) => arg.var === argVar); if (!selectedArg) { break; } - args = getArgProps(selectedArg)?.props ?? []; + argsArray = getArgProps(selectedArg)?.props ?? []; } return selectedArg; } @@ -285,16 +303,17 @@ function ArgumentNavigation(props: { useEffect(() => { setArgIdxStack([]); - }, [props.commandUrl]); + }, [commandUrl]); useEffect(() => { - // update argument idx stack const stack = [...argIdxStack]; while (stack.length > 0 && !getSelectedArg(stack)) { stack.pop(); } - setArgIdxStack(stack); - }, [props.args, props.clsArgDefineMap]); + if (stack.length !== argIdxStack.length) { + setArgIdxStack(stack); + } + }, [args, clsArgDefineMap]); const handleSelectSubArg = (subArgVar: string) => { let subArg; @@ -305,7 +324,7 @@ function ArgumentNavigation(props: { } subArg = getArgProps(arg)?.props.find((a) => a.var === subArgVar); } else { - subArg = props.args.find((a) => a.var === subArgVar); + subArg = args.find((a: CMDArg) => a.var === subArgVar); } if (!subArg) { @@ -325,7 +344,7 @@ function ArgumentNavigation(props: { let argType = subArg.type; if (argType.startsWith("@")) { - argType = props.clsArgDefineMap[(subArg as CMDClsArg).clsName].type; + argType = clsArgDefineMap[(subArg as CMDClsArg).clsName].type; } if (argType.startsWith("dict<")) { argIdx.displayKey += "{}"; @@ -367,10 +386,10 @@ function ArgumentNavigation(props: { arg={selectedArg} depth={argIdxStack.length} onEdit={() => { - props.onEdit(selectedArg, argIdxStack); + onEdit(selectedArg, argIdxStack); }} onUnwrap={() => { - props.onUnwrap(selectedArg, argIdxStack); + onUnwrap(selectedArg, argIdxStack); }} /> @@ -379,13 +398,13 @@ function ArgumentNavigation(props: { const buildArgumentPropsReviewer = () => { if (argIdxStack.length === 0) { - if (props.args.length === 0) { + if (args.length === 0) { return <>; } return ( { - props.onFlatten(selectedArg!, argIdxStack); + onFlatten(selectedArg!, argIdxStack); } : undefined } onAddSubcommand={() => { - props.onAddSubcommand(selectedArg!, argIdxStack); + onAddSubcommand(selectedArg!, argIdxStack); }} onSelectSubArg={handleSelectSubArg} /> @@ -430,7 +449,7 @@ function ArgumentNavigation(props: { {buildArgumentPropsReviewer()} ); -} +}; const NavBarItemTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -443,7 +462,12 @@ const NavBarItemHightLightedTypography = styled(NavBarItemTypography) void }) { +interface ArgNavBarProps { + argIdxStack: ArgIdx[]; + onChangeArgIdStack: (end: number) => void; +} + +const ArgNavBar: React.FC = ({ argIdxStack, onChangeArgIdStack }) => { return ( { - props.onChangeArgIdStack(0); + onChangeArgIdStack(0); }} > - {props.argIdxStack.slice(0, -1).map((argIdx, index) => ( + {argIdxStack.slice(0, -1).map((argIdx: ArgIdx, index: number) => ( { - props.onChangeArgIdStack(index + 1); + onChangeArgIdStack(index + 1); }} > @@ -479,21 +503,21 @@ function ArgNavBar(props: { argIdxStack: ArgIdx[]; onChangeArgIdStack: (end: num ))} { - props.onChangeArgIdStack(props.argIdxStack.length); + onChangeArgIdStack(argIdxStack.length); }} > - {props.argIdxStack.length > 1 - ? `.${props.argIdxStack[props.argIdxStack.length - 1].displayKey}` - : props.argIdxStack[props.argIdxStack.length - 1].displayKey} + {argIdxStack.length > 1 + ? `.${argIdxStack[argIdxStack.length - 1].displayKey}` + : argIdxStack[argIdxStack.length - 1].displayKey} ); -} +}; const spliceArgOptionsString = (arg: CMDArg, depth: number) => { let optionsString = arg.options @@ -580,41 +604,48 @@ const ArgChoicesTypography = styled(Typography)(({ theme }) => fontWeight: 700, })); -function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => void; onUnwrap: () => void }) { +interface ArgumentReviewerProps { + arg: CMDArg; + depth: number; + onEdit: () => void; + onUnwrap: () => void; +} + +const ArgumentReviewer: React.FC = ({ arg, depth, onEdit, onUnwrap }) => { const [choices, setChoices] = useState([]); const buildArgOptionsString = () => { - const argOptionsString = spliceArgOptionsString(props.arg, props.depth - 1); + const argOptionsString = spliceArgOptionsString(arg, depth - 1); return {argOptionsString}; }; useEffect(() => { const newChoices: string[] = []; - if ((props.arg as CMDStringArg).enum) { - const items = (props.arg as CMDStringArg).enum!.items; + 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 ((props.arg as CMDNumberArg).enum) { - const items = (props.arg as CMDNumberArg).enum!.items; + } 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); - }, [props.arg]); + }, [arg]); const getUnwrapKeywords = () => { - if (props.arg.type.startsWith("@")) { + if (arg.type.startsWith("@")) { return "Unwrap"; - } else if (props.arg.type.startsWith("array")) { - if ((props.arg as CMDArrayArg).item?.type.startsWith("@")) { + } else if (arg.type.startsWith("array")) { + if ((arg as CMDArrayArg).item?.type.startsWith("@")) { return "Unwrap Element"; } - } else if (props.arg.type.startsWith("dict")) { - if ((props.arg as CMDDictArg).item?.type.startsWith("@")) { + } else if (arg.type.startsWith("dict")) { + if ((arg as CMDDictArg).item?.type.startsWith("@")) { return "Unwrap Element"; } } @@ -623,17 +654,17 @@ function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => voi const getDefaultValueToString = () => { if ( - props.arg.type === "object" || - props.arg.type.startsWith("dict<") || - props.arg.type.startsWith("array<") || - props.arg.type.startsWith("@") + arg.type === "object" || + arg.type.startsWith("dict<") || + arg.type.startsWith("array<") || + arg.type.startsWith("@") ) { - if (props.arg.default !== undefined && props.arg.default !== null) { - return JSON.stringify(props.arg.default.value); + if (arg.default !== undefined && arg.default !== null) { + return JSON.stringify(arg.default.value); } } else { - if (props.arg.default !== undefined && props.arg.default !== null) { - return props.arg.default.value.toString(); + if (arg.default !== undefined && arg.default !== null) { + return arg.default.value.toString(); } } return ""; @@ -664,7 +695,7 @@ function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => voi sx={{ flexShrink: 0, ml: 3 }} startIcon={} onClick={() => { - props.onEdit(); + onEdit(); }} > Edit @@ -688,23 +719,23 @@ function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => voi alignItems: "center", }} > - {`/${props.arg.type}/`} + {`/${arg.type}/`} {getUnwrapKeywords() !== null && ( )} - {props.arg.required && [Required]} + {arg.required && [Required]} - {(props.arg.default !== undefined || choices.length > 0 || props.arg.configurationKey !== undefined) && ( + {(arg.default !== undefined || choices.length > 0 || arg.configurationKey !== undefined) && ( voi {choices.length > 0 && ( {`Choices: ` + choices.join(", ")} )} - {props.arg.default !== undefined && ( + {arg.default !== undefined && ( {`Default: ${getDefaultValueToString()}`} )} - {props.arg.configurationKey !== undefined && ( - - {`ConfigurationKey: ${props.arg.configurationKey}`} - + {arg.configurationKey !== undefined && ( + {`ConfigurationKey: ${arg.configurationKey}`} )} )} - {props.arg.help?.short && ( - {props.arg.help?.short} - )} - {!props.arg.help?.short && ( + {arg.help?.short && {arg.help?.short} } + {!arg.help?.short && ( Please add argument short summary! )} - {props.arg.help?.lines && ( + {arg.help?.lines && ( - {props.arg.help.lines.map((line, idx) => ( + {arg.help.lines.map((line, idx) => ( {line} ))} @@ -745,7 +772,7 @@ function ArgumentReviewer(props: { arg: CMDArg; depth: number; onEdit: () => voi ); -} +}; function ArgumentDialog(props: { commandUrl: string; From 26b0f5588e27aeaf70544fba3d845582f10f699b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 14:13:15 +1100 Subject: [PATCH 012/107] refactor: WSECommandTree --- .../views/workspace/WSEditorCommandTree.tsx | 253 +++++++++--------- 1 file changed, 127 insertions(+), 126 deletions(-) diff --git a/src/web/src/views/workspace/WSEditorCommandTree.tsx b/src/web/src/views/workspace/WSEditorCommandTree.tsx index 1d9ced96..6f888738 100644 --- a/src/web/src/views/workspace/WSEditorCommandTree.tsx +++ b/src/web/src/views/workspace/WSEditorCommandTree.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +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"; @@ -32,10 +32,6 @@ interface WSEditorCommandTreeProps { onEditClientConfig?: () => void; } -interface WSEditorCommandTreeState { - openMore: boolean; -} - const HeaderTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, fontFamily: "'Work Sans', sans-serif", @@ -43,45 +39,46 @@ const HeaderTypography = styled(Typography)(({ theme }) => ({ 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 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) { - this.onNodeSelected(event, leaf.id); + handleNodeSelected(event, leaf.id); } event.stopPropagation(); event.preventDefault(); }} /> ); - }; + }, + [selected, handleNodeSelected], + ); - const renderNode = (node: CommandTreeNode) => { + 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) { - this.onNodeSelected(event, node.id); - this.onNodeToggle(event, [...expanded, node.id]); + handleNodeSelected(event, node.id); + handleNodeToggle(event, [...expanded, node.id]); } else { - this.onNodeToggle( + handleNodeToggle( event, expanded.filter((v) => v !== node.id), ); @@ -126,83 +126,84 @@ class WSEditorCommandTree extends React.Component renderNode(subNode)) : null} ); - }; - - return ( - - - Command Tree - - - - - - - - - - - - {onEditClientConfig !== undefined && ( - <> - - - - - - { - this.setState({ openMore: false }); - }} - MenuListProps={{ - "aria-labelledby": "more-button", - }} + }, + [selected, expanded, handleNodeSelected, handleNodeToggle, renderLeaf], + ); + + return ( + + + Command Tree + + + + + + + + + + + + {onEditClientConfig !== undefined && ( + <> + + - Edit Client Config - - - )} - - } - defaultExpandIcon={} - selected={selected} - expanded={expanded} - sx={{ - flexGrow: 1, - overflowY: "auto", - mt: 1, - ml: 3, - mr: 3, - }} - > - {commandTreeNodes.map((node) => renderNode(node))} - - - ); - } -} + + + + { + 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; From 7d6613c97f0fbcfad12ea383fd14c7740cecd121 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 14:24:44 +1100 Subject: [PATCH 013/107] refactor : WSEditorExamplePicker --- .../views/workspace/WSEditorExamplePicker.tsx | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/src/web/src/views/workspace/WSEditorExamplePicker.tsx b/src/web/src/views/workspace/WSEditorExamplePicker.tsx index 0d791c25..c0e22cf2 100644 --- a/src/web/src/views/workspace/WSEditorExamplePicker.tsx +++ b/src/web/src/views/workspace/WSEditorExamplePicker.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import { Box, Autocomplete, TextField } from "@mui/material"; interface ExampleItemsSelectorProps { @@ -9,49 +9,46 @@ interface ExampleItemsSelectorProps { 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, - }; - } +const ExampleItemSelector: React.FC = ({ + commonPrefix, + options, + name, + value, + onValueUpdate, +}) => { + const getOptionLabel = useCallback( + (option: string) => { + return option.replace(commonPrefix, ""); + }, + [commonPrefix], + ); - 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) => ( - - )} - /> - ); - } -} + 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 }; From b46c6da0c4e543711eea6974c7aeb2bf748c7cdb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 15:06:17 +1100 Subject: [PATCH 014/107] refactor: move SwaggerItemSelector to own file --- .../components/WSEditorSwaggerPicker.test.tsx | 3 +- .../views/workspace/SwaggerItemSelector.tsx | 63 +++++++++++++++++ .../views/workspace/WSEditorClientConfig.tsx | 2 +- .../views/workspace/WSEditorSwaggerPicker.tsx | 70 +------------------ .../views/workspace/WorkspaceCreateDialog.tsx | 2 +- 5 files changed, 70 insertions(+), 70 deletions(-) create mode 100644 src/web/src/views/workspace/SwaggerItemSelector.tsx diff --git a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx index 8a1feb93..43c58a15 100644 --- a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx +++ b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen, waitFor } from "@testing-library/react"; -import WSEditorSwaggerPicker, { SwaggerItemSelector } from "../../views/workspace/WSEditorSwaggerPicker"; +import WSEditorSwaggerPicker from "../../views/workspace/WSEditorSwaggerPicker"; +import SwaggerItemSelector from "../../views/workspace/SwaggerItemSelector"; import { render } from "../test-utils"; import { workspaceApi, specsApi } from "../../services"; diff --git a/src/web/src/views/workspace/SwaggerItemSelector.tsx b/src/web/src/views/workspace/SwaggerItemSelector.tsx new file mode 100644 index 00000000..f7458cdb --- /dev/null +++ b/src/web/src/views/workspace/SwaggerItemSelector.tsx @@ -0,0 +1,63 @@ +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/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index e6d2a257..1a321327 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -23,7 +23,7 @@ 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 "./SwaggerItemSelector"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; interface WSEditorClientConfigDialogProps { diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx index deee1633..e7a7a0e3 100644 --- a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx @@ -6,8 +6,6 @@ import { Toolbar, IconButton, Button, - Autocomplete, - TextField, Backdrop, CircularProgress, List, @@ -31,6 +29,7 @@ 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 "./SwaggerItemSelector"; interface WSEditorSwaggerPickerProps { workspaceName: string; @@ -46,7 +45,6 @@ interface WSEditorSwaggerPickerState { defaultModule: string | null; defaultResourceProvider: string | null; defaultSource: string | null; - // preVersion: string | null moduleOptions: string[]; resourceProviderOptions: string[]; @@ -129,7 +127,6 @@ class WSEditorSwaggerPicker extends React.Component v === preferredRP) >= 0) { selectedResourceProvider = preferredRP; defaultResourceProvider = preferredRP; - options = [preferredRP]; // only the default resource provider selectable. + options = [preferredRP]; } this.setState({ defaultResourceProvider: defaultResourceProvider, @@ -299,20 +295,16 @@ class WSEditorSwaggerPicker extends React.Component { - // 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] = []; @@ -375,7 +367,6 @@ class WSEditorSwaggerPicker extends React.Component v.version === selectedVersion)?.operations; if (operations) { @@ -393,7 +384,6 @@ class WSEditorSwaggerPicker extends React.Component v.version === selectedVersion)?.operations; if (operations) { @@ -405,7 +395,6 @@ class WSEditorSwaggerPicker extends React.Component 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/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx index 3a3ac8b9..28374c5c 100644 --- a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -10,7 +10,7 @@ import { Alert, } from "@mui/material"; import React, { useState, useEffect, useCallback } from "react"; -import { SwaggerItemSelector } from "./WSEditorSwaggerPicker"; +import SwaggerItemSelector from "./SwaggerItemSelector"; import styled from "@emotion/styled"; import { Plane } from "./WSEditorCommandContent"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; From dd31ed5dec910edadd312e3f72ac4bbcc9e6d00a Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 15:13:23 +1100 Subject: [PATCH 015/107] refactor: remove old comments --- .../views/workspace/SwaggerItemSelector.tsx | 10 +-- .../views/workspace/WSEditorClientConfig.tsx | 3 - .../WSEditorCommandArgumentsContent.tsx | 73 +------------------ .../workspace/WSEditorCommandContent.tsx | 27 ------- .../views/workspace/WSEditorSwaggerPicker.tsx | 1 - .../src/views/workspace/WorkspaceSelector.tsx | 3 +- 6 files changed, 4 insertions(+), 113 deletions(-) diff --git a/src/web/src/views/workspace/SwaggerItemSelector.tsx b/src/web/src/views/workspace/SwaggerItemSelector.tsx index f7458cdb..1476d822 100644 --- a/src/web/src/views/workspace/SwaggerItemSelector.tsx +++ b/src/web/src/views/workspace/SwaggerItemSelector.tsx @@ -46,15 +46,7 @@ const SwaggerItemSelector: React.FC = ({ renderOption={renderOption} selectOnFocus clearOnBlur - renderInput={(params) => ( - - )} + renderInput={(params) => } /> ); }; diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index 1a321327..5e8b60b6 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -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/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index ec786ff1..2e15a162 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -1956,13 +1956,6 @@ function ArgumentPropsReviewer(props: { )} - {/* {props.onUnflatten !== undefined && } */} - {props.onAddSubcommand !== undefined && checkCanAddSubcommand() && ( - {/* - */} )} {(!isAdd || source != undefined) && ( diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx index e7a7a0e3..cf3b127b 100644 --- a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx @@ -282,7 +282,6 @@ class WSEditorSwaggerPicker extends React.Component = ({ name }) => { autoHighlight onChange={(_event, newValue: any) => { if (typeof newValue === "string") { - // timeout to avoid instant validation of the dialog's form. setTimeout(() => { setOpenDialog(true); setNewWorkspaceName(newValue); @@ -105,7 +104,7 @@ const WorkspaceSelector: React.FC = ({ name }) => { label={name} inputProps={{ ...params.inputProps, - autoComplete: "new-password", // disable autocomplete and autofill + autoComplete: "new-password", }} /> )} From c1a9324de7b57e3fda8d34b267bfd9fee89e6761 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 15:16:32 +1100 Subject: [PATCH 016/107] refactor: remove old ocmments --- .../src/views/workspace/WSEditorCommandArgumentsContent.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index 2e15a162..ca59a00c 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -2246,7 +2246,6 @@ function decodeArgBase(response: any): { args: args, }; } else if (response.additionalProps && response.additionalProps.item) { - // Convert additionalProps to dict argBaseType const itemArgBaseParse = decodeArgBase(response.additionalProps.item); clsDefineMap = { ...clsDefineMap, @@ -2430,7 +2429,6 @@ function decodeArg(response: any): { break; default: if (argBase.type.startsWith("dict<")) { - // dict type if (response.default) { arg = { ...arg, @@ -2438,7 +2436,6 @@ function decodeArg(response: any): { }; } } else if (argBase.type.startsWith("array<")) { - // array type if (response.singularOptions) { arg = { ...arg, @@ -2542,17 +2539,14 @@ function convertArgDefaultText(defaultText: string, argType: string): any { } 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}`); From 16d182659d14329e43282df6d2a60ae5d5025d07 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 15:26:06 +1100 Subject: [PATCH 017/107] refactor: extract ArgumentPropsReviewer to own component --- .../views/workspace/ArgumentPropsReviewer.tsx | 333 ++++++++++++++++++ .../WSEditorCommandArgumentsContent.tsx | 263 +------------- 2 files changed, 334 insertions(+), 262 deletions(-) create mode 100644 src/web/src/views/workspace/ArgumentPropsReviewer.tsx diff --git a/src/web/src/views/workspace/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/ArgumentPropsReviewer.tsx new file mode 100644 index 00000000..24021dcc --- /dev/null +++ b/src/web/src/views/workspace/ArgumentPropsReviewer.tsx @@ -0,0 +1,333 @@ +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 "./WSEditorTheme"; +import type { CMDArg } from "./WSEditorCommandArgumentsContent"; + +interface CMDArrayArg extends CMDArg { + singularOptions?: string[]; +} + +interface CMDClsArg extends CMDArg { + singularOptions?: string[]; +} + +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 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: 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; +}; + +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/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index ca59a00c..58d0ca71 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -20,10 +20,7 @@ import { 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"; @@ -31,6 +28,7 @@ import { commandApi, errorHandlerApi } from "../../services"; import pluralize from "pluralize"; import React, { useEffect, useState } from "react"; import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; +import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; import { CardTitleTypography, ExperimentalTypography, @@ -38,10 +36,7 @@ import { PreviewTypography, ShortHelpPlaceHolderTypography, ShortHelpTypography, - SmallExperimentalTypography, - SmallPreviewTypography, StableTypography, - SubtitleTypography, } from "./WSEditorTheme"; interface WSEditorCommandArgumentsContentProps { @@ -1720,262 +1715,6 @@ function UnwrapClsDialog(props: { ); } -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.onAddSubcommand !== undefined && checkCanAddSubcommand() && ( - - )} - - {groups.map(buildArgGroup)} - - ); -} - -interface ArgGroup { - name: string; - args: CMDArg[]; -} - type CMDArgHelp = { short: string; lines?: string[]; From b2bf5a7c933feedf634973c6a8e10997da7e9a67 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 15:52:18 +1100 Subject: [PATCH 018/107] refactor: extract ArgumentNavigation to own file --- .../views/workspace/ArgumentNavigation.tsx | 615 ++++++++++++++++++ .../WSEditorCommandArgumentsContent.tsx | 586 +---------------- 2 files changed, 627 insertions(+), 574 deletions(-) create mode 100644 src/web/src/views/workspace/ArgumentNavigation.tsx diff --git a/src/web/src/views/workspace/ArgumentNavigation.tsx b/src/web/src/views/workspace/ArgumentNavigation.tsx new file mode 100644 index 00000000..73bc77d1 --- /dev/null +++ b/src/web/src/views/workspace/ArgumentNavigation.tsx @@ -0,0 +1,615 @@ +import React, { useEffect, useState } from "react"; +import { Box, Button, ButtonBase, styled, Typography, TypographyProps } from "@mui/material"; +import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; +import EditIcon from "@mui/icons-material/Edit"; +import ImportExportIcon from "@mui/icons-material/ImportExport"; +import { + ExperimentalTypography, + LongHelpTypography, + PreviewTypography, + ShortHelpPlaceHolderTypography, + ShortHelpTypography, + StableTypography, +} from "./WSEditorTheme"; +import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; +import type { CMDArg, ClsArgDefinitionMap } from "./WSEditorCommandArgumentsContent"; + +interface CMDArgBase { + type: string; + nullable: boolean; + blank?: any; +} + +interface CMDClsArg extends CMDArg { + clsName: string; + singularOptions?: string[]; +} + +interface CMDObjectArg extends CMDArg { + args: CMDArg[]; +} + +interface CMDDictArg extends CMDArg { + item?: any; + anyType: boolean; +} + +interface CMDArrayArg extends CMDArg { + item: any; + singularOptions?: string[]; +} + +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 ArgIdx { + var: string; + displayKey: string; +} + +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; +} + +interface ArgNavBarProps { + argIdxStack: ArgIdx[]; + onChangeArgIdStack: (end: number) => void; +} + +interface ArgumentReviewerProps { + arg: CMDArg; + depth: number; + onEdit: () => void; + onUnwrap: () => 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 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 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: 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; +}; + +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} + + + + + ); +}; + +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} + ))} + + )} + + + ); +}; + +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 CMDDictArg).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 CMDArrayArg).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, ArgIdx, ArgumentReviewerProps, ArgNavBarProps }; diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index 58d0ca71..95742406 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -2,7 +2,6 @@ import { Alert, Box, Button, - ButtonBase, CardContent, Checkbox, Dialog, @@ -20,24 +19,23 @@ import { Typography, TypographyProps, } from "@mui/material"; -import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; -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 ArgumentPropsReviewer from "./ArgumentPropsReviewer"; -import { - CardTitleTypography, - ExperimentalTypography, - LongHelpTypography, - PreviewTypography, - ShortHelpPlaceHolderTypography, - ShortHelpTypography, - StableTypography, -} from "./WSEditorTheme"; + +import ArgumentNavigation from "./ArgumentNavigation"; + +// Styled component for argument type display +const ArgTypeTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.primary.main, + fontFamily: "'Roboto Condensed', sans-serif", + fontSize: 16, + fontWeight: 700, +})); + +import { CardTitleTypography } from "./WSEditorTheme"; interface WSEditorCommandArgumentsContentProps { commandUrl: string; @@ -214,561 +212,6 @@ interface ArgIdx { displayKey: string; } -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 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 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()} - - ); -}; - -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", -})); - -interface ArgNavBarProps { - argIdxStack: ArgIdx[]; - onChangeArgIdStack: (end: number) => void; -} - -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} - - - - - ); -}; - -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, -})); - -interface ArgumentReviewerProps { - arg: CMDArg; - depth: number; - onEdit: () => void; - onUnwrap: () => void; -} - -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} - ))} - - )} - - - ); -}; - function ArgumentDialog(props: { commandUrl: string; arg: CMDArg; @@ -1799,11 +1242,6 @@ interface CMDPasswordArg extends CMDPasswordArgBase, CMDStringArg { prompt?: CMDPasswordArgPromptInput; } -interface CMDNumberArgBase extends CMDArgBaseT { - enum?: CMDArgEnum; -} -interface CMDNumberArg extends CMDNumberArgBase, CMDArgT {} - interface CMDObjectArgBase extends CMDArgBase { args: CMDArg[]; } From 7fb3c1825869c4ed3077b6b9671dd96a6a751dcd Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 17:07:25 +1100 Subject: [PATCH 019/107] refactor: extract ArgumentDialog to own file --- .../src/views/workspace/ArgumentDialog.tsx | 828 ++++++++++++++++++ .../WSEditorCommandArgumentsContent.tsx | 732 +--------------- 2 files changed, 829 insertions(+), 731 deletions(-) create mode 100644 src/web/src/views/workspace/ArgumentDialog.tsx diff --git a/src/web/src/views/workspace/ArgumentDialog.tsx b/src/web/src/views/workspace/ArgumentDialog.tsx new file mode 100644 index 00000000..d47a477d --- /dev/null +++ b/src/web/src/views/workspace/ArgumentDialog.tsx @@ -0,0 +1,828 @@ +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 "./argument/WSECArgumentSimilarPicker"; + +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; +} + +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()); + 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}`); + } +} + +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/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index 95742406..43db9815 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -3,18 +3,12 @@ import { Box, Button, CardContent, - Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, - FormControlLabel, - InputLabel, LinearProgress, - Radio, - RadioGroup, styled, - Switch, TextField, Typography, TypographyProps, @@ -26,8 +20,8 @@ import React, { useEffect, useState } from "react"; import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; import ArgumentNavigation from "./ArgumentNavigation"; +import ArgumentDialog from "./ArgumentDialog"; -// Styled component for argument type display const ArgTypeTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, fontFamily: "'Roboto Condensed', sans-serif", @@ -212,630 +206,6 @@ interface ArgIdx { displayKey: string; } -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; @@ -1214,15 +584,6 @@ interface CMDArg extends CMDArgBase { hasEnum?: boolean; } -interface CMDArgBaseT extends CMDArgBase { - blank?: CMDArgBlank; -} - -interface CMDArgT extends CMDArg { - default?: CMDArgDefault; - blank?: CMDArgBlank; -} - interface CMDClsArgBase extends CMDArgBase { clsName: string; } @@ -1231,17 +592,6 @@ interface CMDClsArg extends CMDClsArgBase, CMDArg { singularOptions?: string[]; } -interface CMDStringArgBase extends CMDArgBaseT { - enum?: CMDArgEnum; -} - -interface CMDStringArg extends CMDStringArgBase, CMDArgT {} - -interface CMDPasswordArgBase extends CMDStringArgBase {} -interface CMDPasswordArg extends CMDPasswordArgBase, CMDStringArg { - prompt?: CMDPasswordArgPromptInput; -} - interface CMDObjectArgBase extends CMDArgBase { args: CMDArg[]; } @@ -1650,86 +1000,6 @@ function decodeArg(response: any): { }; } -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()); - 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}`); - } -} - const DecodeArgs = (argGroups: any[]): { args: CMDArg[]; clsArgDefineMap: ClsArgDefinitionMap } => { let clsDefineMap: ClsArgDefinitionMap = {}; const args: CMDArg[] = []; From 48d7bef4ba905e9cdfb36835153b29ad5a266bad Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 14 Oct 2025 18:51:45 +1100 Subject: [PATCH 020/107] refactor: extract UnwrapClsDialog to own component --- .../src/views/workspace/UnwrapClsDialog.tsx | 118 ++++++++++++++++++ .../WSEditorCommandArgumentsContent.tsx | 87 +------------ 2 files changed, 119 insertions(+), 86 deletions(-) create mode 100644 src/web/src/views/workspace/UnwrapClsDialog.tsx diff --git a/src/web/src/views/workspace/UnwrapClsDialog.tsx b/src/web/src/views/workspace/UnwrapClsDialog.tsx new file mode 100644 index 00000000..3837ebe6 --- /dev/null +++ b/src/web/src/views/workspace/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/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index 43db9815..07795c9e 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -8,10 +8,7 @@ import { DialogContent, DialogTitle, LinearProgress, - styled, TextField, - Typography, - TypographyProps, } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../services"; @@ -21,13 +18,7 @@ import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from " import ArgumentNavigation from "./ArgumentNavigation"; import ArgumentDialog from "./ArgumentDialog"; - -const ArgTypeTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Roboto Condensed', sans-serif", - fontSize: 16, - fontWeight: 700, -})); +import UnwrapClsDialog from "./UnwrapClsDialog"; import { CardTitleTypography } from "./WSEditorTheme"; @@ -458,76 +449,6 @@ function FlattenDialog(props: { ); } -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 && ( - <> - - - - )} - - - ); -} - type CMDArgHelp = { short: string; lines?: string[]; @@ -602,16 +523,10 @@ interface CMDDictArgBase extends CMDArgBase { item?: CMDArgBase; anyType: boolean; } -interface CMDDictArg extends CMDDictArgBase, CMDArg {} - interface CMDArrayArgBase extends CMDArgBase { item: CMDArgBase; } -interface CMDArrayArg extends CMDArrayArgBase, CMDArg { - singularOptions?: string[]; -} - type ClsArgDefinitionMap = { [clsName: string]: CMDArgBase; }; From a6849cbde36e2c35de3fb1dbd0c0ce97d6b040da Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 11:52:21 +1100 Subject: [PATCH 021/107] feature: add tests for WSECArgumentSimilarPicker --- .../WSECArgumentSimilarPicker.test.tsx | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx 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..cda866d1 --- /dev/null +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -0,0 +1,425 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import WSECArgumentSimilarPicker, { + BuildArgSimilarTree, + type ArgSimilarTree, +} from "../../views/workspace/argument/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 = { + data: { + 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 = { + data: { + 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 = { + data: { + 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); + }); + }); +}); From ff7253e3b8f6c167419f00727609cc02bb64ccac Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 12:03:24 +1100 Subject: [PATCH 022/107] refactor: update WSECArgumentSimilarPicker to modern syntax --- .../argument/WSECArgumentSimilarPicker.tsx | 401 +++++++++--------- 1 file changed, 212 insertions(+), 189 deletions(-) diff --git a/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx index 677111ee..03f91a14 100644 --- a/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx +++ b/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx @@ -1,3 +1,4 @@ +import React, { useCallback } from "react"; import TreeView from "@mui/lab/TreeView"; import TreeItem from "@mui/lab/TreeItem"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; @@ -51,25 +52,27 @@ interface ResponseArgSimilarGroup { }; } -function decodeResponseArgSimilarCommand( +const decodeResponseArgSimilarCommand = ( responseCommand: ResponseArgSimilarCommand, commandName: string, -): ArgSimilarCommand { - let command: ArgSimilarCommand = { +): ArgSimilarCommand => { + const command: ArgSimilarCommand = { id: responseCommand.id, name: commandName, args: [], total: 0, selectedCount: 0, }; - for (const argVar in responseCommand.args) { + + Object.entries(responseCommand.args).forEach(([argVar, indexes]) => { const arg: ArgSimilarArg = { id: `${command.id}/Arguments/${argVar}`, var: argVar, - indexes: responseCommand.args[argVar], + indexes, display: "", isSelected: false, }; + if (arg.indexes.length > 1) { arg.display = `[${arg.var}] ${arg.indexes .map((idx) => { @@ -81,7 +84,7 @@ function decodeResponseArgSimilarCommand( }) .join(" ")}`; } else if (arg.indexes.length === 1) { - let idx = arg.indexes[0]; + const idx = arg.indexes[0]; if (idx[1] === "." || idx[1] === "[" || idx[1] === "{") { arg.display = `-${idx}`; } else { @@ -89,12 +92,13 @@ function decodeResponseArgSimilarCommand( } } command.args.push(arg); - } + }); + command.total = command.args.length; return command; -} +}; -function decodeResponseArgSimilarGroup(responseGroup: ResponseArgSimilarGroup, groupName: string): ArgSimilarGroup { +const decodeResponseArgSimilarGroup = (responseGroup: ResponseArgSimilarGroup, groupName: string): ArgSimilarGroup => { let group: ArgSimilarGroup = { id: responseGroup.id, name: groupName, @@ -102,46 +106,45 @@ function decodeResponseArgSimilarGroup(responseGroup: ResponseArgSimilarGroup, g 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 (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 (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 (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 === undefined && group.groups !== undefined && group.groups.length === 1) { + + 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)); + }); -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 BuildArgSimilarTree = (response: any): { tree: ArgSimilarTree; expandedIds: string[] } => { const tree = { root: decodeResponseArgSimilarGroup(response.data.aaz, "az"), selectedArgIds: [], @@ -150,39 +153,46 @@ function BuildArgSimilarTree(response: any): { tree: ArgSimilarTree; expandedIds const newTree = updateSelectionStateForArgSimilarTree(tree, new Set([tree.root.id])); return { tree: newTree, - expandedIds: expandedIds, + expandedIds, }; +}; + +interface WSECArgumentSimilarPickerProps { + tree: ArgSimilarTree; + expandedIds: string[]; + updatedIds: string[]; + onTreeUpdated: (tree: ArgSimilarTree) => void; + onToggle: (nodeIds: string[]) => void; } -function updateSelectionStateForArgSimilarCommand( +const updateSelectionStateForArgSimilarCommand = ( command: ArgSimilarCommand, selectedIds: Set, -): { command: ArgSimilarCommand; selectedArgIds: string[] } { - let newSelectedIds: string[] = []; - let newCommand = { +): { 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) { - let newId = idParts.slice(0, idx + 1).join("/"); + const newId = idParts.slice(0, idx + 1).join("/"); if (selectedIds.has(newId)) { isSelected = true; break; } } } - if (isSelected === true) { + if (isSelected) { newSelectedIds.push(arg.id); } - let newArg: ArgSimilarArg = { + return { ...arg, indexes: [...arg.indexes], - isSelected: isSelected, + isSelected, }; - return newArg; }), }; @@ -192,21 +202,21 @@ function updateSelectionStateForArgSimilarCommand( command: newCommand, selectedArgIds: newSelectedIds, }; -} +}; -function updateSelectionStateForArgSimilarGroup( +const updateSelectionStateForArgSimilarGroup = ( group: ArgSimilarGroup, selectedIds: Set, -): { group: ArgSimilarGroup; selectedArgIds: string[] } { +): { group: ArgSimilarGroup; selectedArgIds: string[] } => { let newSelectedIds: string[] = []; - let newGroup = { + const newGroup = { ...group, groups: group.groups?.map((subGroup) => { const { group: newSubGroup, selectedArgIds: subSelectedIds } = updateSelectionStateForArgSimilarGroup( subGroup, selectedIds, ); - newSelectedIds = [...newSelectedIds, ...subSelectedIds]; + newSelectedIds.push(...subSelectedIds); return newSubGroup; }), commands: group.commands?.map((command) => { @@ -214,7 +224,7 @@ function updateSelectionStateForArgSimilarGroup( command, selectedIds, ); - newSelectedIds = [...newSelectedIds, ...subSelectedIds]; + newSelectedIds.push(...subSelectedIds); return newCommand; }), }; @@ -225,153 +235,166 @@ function updateSelectionStateForArgSimilarGroup( group: newGroup, selectedArgIds: newSelectedIds, }; -} +}; -function updateSelectionStateForArgSimilarTree(tree: ArgSimilarTree, selectedIds: Set): ArgSimilarTree { +const updateSelectionStateForArgSimilarTree = (tree: ArgSimilarTree, selectedIds: Set): ArgSimilarTree => { const { group, selectedArgIds } = updateSelectionStateForArgSimilarGroup(tree.root, selectedIds); return { root: group, - selectedArgIds: 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 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 = (event: React.SyntheticEvent, nodeIds: string[]) => { - props.onToggle(nodeIds); - event.stopPropagation(); - event.preventDefault(); - }; + const onNodeToggle = useCallback( + (event: React.SyntheticEvent, nodeIds: string[]) => { + onToggle(nodeIds); + event.stopPropagation(); + event.preventDefault(); + }, + [onToggle], + ); - 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 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 = (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 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 = (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} - - ); - }; + 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={props.expandedIds} - > - {renderGroup(props.tree.root)} - - + } + defaultExpandIcon={} + onNodeToggle={onNodeToggle} + selected={[]} + expanded={expandedIds} + > + {renderGroup(tree.root)} + ); -} +}; export default WSECArgumentSimilarPicker; export { BuildArgSimilarTree }; From 2f57e0f6040e569cb1f937d43b13676b35b378ce Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 12:34:08 +1100 Subject: [PATCH 023/107] refactor: ArgNavBar to own file --- .../views/workspace/ArgumentNavigation.tsx | 79 +------------------ .../views/workspace/argument/ArgNavBar.tsx | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+), 76 deletions(-) create mode 100644 src/web/src/views/workspace/argument/ArgNavBar.tsx diff --git a/src/web/src/views/workspace/ArgumentNavigation.tsx b/src/web/src/views/workspace/ArgumentNavigation.tsx index 73bc77d1..22f36d34 100644 --- a/src/web/src/views/workspace/ArgumentNavigation.tsx +++ b/src/web/src/views/workspace/ArgumentNavigation.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Box, Button, ButtonBase, styled, Typography, TypographyProps } from "@mui/material"; -import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; +import { Box, Button, styled, Typography, TypographyProps } from "@mui/material"; import EditIcon from "@mui/icons-material/Edit"; import ImportExportIcon from "@mui/icons-material/ImportExport"; import { @@ -12,6 +11,7 @@ import { StableTypography, } from "./WSEditorTheme"; import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; +import ArgNavBar, { type ArgIdx } from "./argument/ArgNavBar"; import type { CMDArg, ClsArgDefinitionMap } from "./WSEditorCommandArgumentsContent"; interface CMDArgBase { @@ -51,11 +51,6 @@ interface CMDNumberArg extends CMDArg { }; } -interface ArgIdx { - var: string; - displayKey: string; -} - interface ArgumentNavigationProps { commandUrl: string; args: CMDArg[]; @@ -66,11 +61,6 @@ interface ArgumentNavigationProps { onAddSubcommand: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; } -interface ArgNavBarProps { - argIdxStack: ArgIdx[]; - onChangeArgIdStack: (end: number) => void; -} - interface ArgumentReviewerProps { arg: CMDArg; depth: number; @@ -78,17 +68,6 @@ interface ArgumentReviewerProps { onUnwrap: () => 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 ArgNameTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, fontFamily: "'Roboto Condensed', sans-serif", @@ -174,58 +153,6 @@ const spliceArgOptionsString = (arg: CMDArg, depth: number) => { return optionsString; }; -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} - - - - - ); -}; - const ArgumentReviewer: React.FC = ({ arg, depth, onEdit, onUnwrap }) => { const [choices, setChoices] = useState([]); @@ -612,4 +539,4 @@ const ArgumentNavigation: React.FC = ({ }; export default ArgumentNavigation; -export type { ArgumentNavigationProps, ArgIdx, ArgumentReviewerProps, ArgNavBarProps }; +export type { ArgumentNavigationProps, ArgumentReviewerProps }; diff --git a/src/web/src/views/workspace/argument/ArgNavBar.tsx b/src/web/src/views/workspace/argument/ArgNavBar.tsx new file mode 100644 index 00000000..7480a4c7 --- /dev/null +++ b/src/web/src/views/workspace/argument/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 }; From b188146f4a4d9ffccc5da62fac2ae4e8b8e7db76 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 13:04:09 +1100 Subject: [PATCH 024/107] refactor: ArReviewer to own component --- .../views/workspace/ArgumentNavigation.tsx | 283 +---------------- .../workspace/argument/ArgumentReviewer.tsx | 291 ++++++++++++++++++ 2 files changed, 295 insertions(+), 279 deletions(-) create mode 100644 src/web/src/views/workspace/argument/ArgumentReviewer.tsx diff --git a/src/web/src/views/workspace/ArgumentNavigation.tsx b/src/web/src/views/workspace/ArgumentNavigation.tsx index 22f36d34..588e63a7 100644 --- a/src/web/src/views/workspace/ArgumentNavigation.tsx +++ b/src/web/src/views/workspace/ArgumentNavigation.tsx @@ -1,17 +1,9 @@ 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 { - ExperimentalTypography, - LongHelpTypography, - PreviewTypography, - ShortHelpPlaceHolderTypography, - ShortHelpTypography, - StableTypography, -} from "./WSEditorTheme"; +import { Box } from "@mui/material"; +import { ExperimentalTypography, PreviewTypography, StableTypography } from "./WSEditorTheme"; import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; import ArgNavBar, { type ArgIdx } from "./argument/ArgNavBar"; +import ArgumentReviewer from "./argument/ArgumentReviewer"; import type { CMDArg, ClsArgDefinitionMap } from "./WSEditorCommandArgumentsContent"; interface CMDArgBase { @@ -39,18 +31,6 @@ interface CMDArrayArg extends CMDArg { singularOptions?: string[]; } -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 ArgumentNavigationProps { commandUrl: string; args: CMDArg[]; @@ -61,261 +41,6 @@ interface ArgumentNavigationProps { onAddSubcommand: (arg: CMDArg, argIdxStack: ArgIdx[]) => void; } -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 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: 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; -}; - -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} - ))} - - )} - - - ); -}; - const ArgumentNavigation: React.FC = ({ commandUrl, args, @@ -539,4 +264,4 @@ const ArgumentNavigation: React.FC = ({ }; export default ArgumentNavigation; -export type { ArgumentNavigationProps, ArgumentReviewerProps }; +export type { ArgumentNavigationProps }; diff --git a/src/web/src/views/workspace/argument/ArgumentReviewer.tsx b/src/web/src/views/workspace/argument/ArgumentReviewer.tsx new file mode 100644 index 00000000..16bef2b2 --- /dev/null +++ b/src/web/src/views/workspace/argument/ArgumentReviewer.tsx @@ -0,0 +1,291 @@ +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 "../WSEditorTheme"; +import type { CMDArg } from "../WSEditorCommandArgumentsContent"; + +interface CMDClsArg extends CMDArg { + clsName: string; + singularOptions?: string[]; +} + +interface CMDDictArg extends CMDArg { + item?: any; + anyType: boolean; +} + +interface CMDArrayArg extends CMDArg { + item: any; + singularOptions?: string[]; +} + +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 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: 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; +}; + +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 }; From bb6347542d5a917c5b90d927a940931140063584 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 13:54:28 +1100 Subject: [PATCH 025/107] refactor: ExampleDialog extracted to own file --- src/web/src/views/workspace/ExampleDialog.tsx | 441 ++++++++++++++++++ .../workspace/WSEditorCommandContent.tsx | 421 +---------------- 2 files changed, 442 insertions(+), 420 deletions(-) create mode 100644 src/web/src/views/workspace/ExampleDialog.tsx diff --git a/src/web/src/views/workspace/ExampleDialog.tsx b/src/web/src/views/workspace/ExampleDialog.tsx new file mode 100644 index 00000000..469d49b9 --- /dev/null +++ b/src/web/src/views/workspace/ExampleDialog.tsx @@ -0,0 +1,441 @@ +import { + styled, + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Input, + InputAdornment, + InputLabel, + LinearProgress, + TextField, + Typography, + TypographyProps, + Stack, +} from "@mui/material"; +import React 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 "./WSEditorExamplePicker"; +import { Command, Example, DecodeResponseCommand } from "./WSEditorCommandContent"; + +export 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] + + "/GenerateExamples"; + + 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={ + + {COMMAND_PREFIX} + + } + /> + + ); + }; + + 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 && } + + )} + + )} + + ); + } +} + +export default ExampleDialog; diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index 073d2888..b00fd287 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -20,9 +20,6 @@ import { Typography, TypographyProps, AccordionDetails, - IconButton, - Input, - InputAdornment, AccordionSummaryProps, FormLabel, Switch, @@ -42,8 +39,6 @@ import { 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"; @@ -55,9 +50,7 @@ import WSEditorCommandArgumentsContent, { 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"; +import ExampleDialog from "./ExampleDialog"; interface Plane { name: string; @@ -1069,418 +1062,6 @@ class CommandDialog extends React.Component 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] + - "/GenerateExamples"; - - 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={ - - {COMMAND_PREFIX} - - } - /> - - ); - }; - - 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; From 2e8e37fdd75230f702f047826e6f12489daddb8e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 14:39:32 +1100 Subject: [PATCH 026/107] refactor: extract AddSubcommandDialog --- .../views/workspace/AddSubcommandDialog.tsx | 201 +++++++ src/web/src/views/workspace/ExampleDialog.tsx | 529 ++++++++---------- .../workspace/WSEditorCommandContent.tsx | 177 +----- 3 files changed, 439 insertions(+), 468 deletions(-) create mode 100644 src/web/src/views/workspace/AddSubcommandDialog.tsx diff --git a/src/web/src/views/workspace/AddSubcommandDialog.tsx b/src/web/src/views/workspace/AddSubcommandDialog.tsx new file mode 100644 index 00000000..b3c89142 --- /dev/null +++ b/src/web/src/views/workspace/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 { Command } from "./WSEditorCommandContent"; + +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/ExampleDialog.tsx b/src/web/src/views/workspace/ExampleDialog.tsx index 709055de..30659445 100644 --- a/src/web/src/views/workspace/ExampleDialog.tsx +++ b/src/web/src/views/workspace/ExampleDialog.tsx @@ -17,7 +17,7 @@ import { TypographyProps, Stack, } from "@mui/material"; -import React from "react"; +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"; @@ -34,16 +34,6 @@ export interface ExampleDialogProps { 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", @@ -51,86 +41,80 @@ const ExampleCommandTypography = styled(Typography)(({ theme }) 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: [], - }; +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[this.props.idx]; - this.state = { - name: example.name, - exampleCommands: example.commands, - isAdd: false, - invalidText: undefined, - updating: false, - source: undefined, - exampleOptions: [], - }; + const example = examples[idx]; + setName(example.name); + setExampleCommands(example.commands); + setIsAdd(false); + setInvalidText(undefined); + setUpdating(false); + setSource(undefined); + setExampleOptions([]); } - } + }, [command.examples, idx]); - 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, - }); + 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]; - 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, - }); - } - }; + 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], + ); - handleDelete = () => { - const { command } = this.props; + const handleDelete = useCallback(() => { let examples: Example[] = command.examples ?? []; - const idx = this.props.idx!; - examples = [...examples.slice(0, idx), ...examples.slice(idx + 1)]; - this.onUpdateExamples(examples); - }; + const currentIdx = idx!; + examples = [...examples.slice(0, currentIdx), ...examples.slice(currentIdx + 1)]; + onUpdateExamples(examples); + }, [command.examples, idx, onUpdateExamples]); - handleModify = () => { - const { command } = this.props; - let { name, exampleCommands } = this.state; + const handleModify = useCallback(() => { + let trimmedName = name.trim(); let examples: Example[] = command.examples ?? []; - const idx = this.props.idx!; + const currentIdx = idx!; - name = name.trim(); - if (name.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); + if (trimmedName.length < 1) { + setInvalidText(`Field 'Name' is required.`); return; } - exampleCommands = exampleCommands + + const processedCommands = exampleCommands .map((cmd) => { return cmd .split("\n") @@ -141,36 +125,30 @@ class ExampleDialog extends React.Component cmd.length > 0); - if (exampleCommands.length < 1) { - this.setState({ - invalidText: `Field 'Commands' is required.`, - }); + if (processedCommands.length < 1) { + setInvalidText(`Field 'Commands' is required.`); return; } const newExample: Example = { - name: name, - commands: exampleCommands, + name: trimmedName, + commands: processedCommands, }; - examples = [...examples.slice(0, idx), newExample, ...examples.slice(idx + 1)]; + examples = [...examples.slice(0, currentIdx), newExample, ...examples.slice(currentIdx + 1)]; + onUpdateExamples(examples); + }, [name, exampleCommands, command.examples, idx, onUpdateExamples]); - this.onUpdateExamples(examples); - }; - - handleAdd = () => { - const { command } = this.props; - let { name, exampleCommands } = this.state; + const handleAdd = useCallback(() => { + let trimmedName = name.trim(); const examples: Example[] = command.examples ?? []; - name = name.trim(); - if (name.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); + if (trimmedName.length < 1) { + setInvalidText(`Field 'Name' is required.`); return; } - exampleCommands = exampleCommands + + const processedCommands = exampleCommands .map((cmd) => { return cmd .split("\n") @@ -181,118 +159,84 @@ class ExampleDialog extends React.Component cmd.length > 0); - if (exampleCommands.length < 1) { - this.setState({ - invalidText: `Field 'Commands' is required.`, - }); + if (processedCommands.length < 1) { + setInvalidText(`Field 'Commands' is required.`); return; } const newExample: Example = { - name: name, - commands: exampleCommands, + name: trimmedName, + commands: processedCommands, }; 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(""); + 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 { - ...preState, - exampleCommands: exampleCommands, - }; + return newCommands; }); - }; + }, []); - onAddExampleCommand = () => { - this.setState((preState) => { - return { - ...preState, - exampleCommands: [...preState.exampleCommands, ""], - }; - }); - }; + const onAddExampleCommand = useCallback(() => { + setExampleCommands((prevCommands) => [...prevCommands, ""]); + }, []); - loadSwaggerExamples = async () => { + const loadSwaggerExamples = useCallback(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, - }); + setSource("swagger"); + setUpdating(true); const examples = await commandApi.generateSwaggerExamples(leafUrl); - this.setState({ - exampleOptions: examples, - updating: false, - }); + setExampleOptions(examples); + setUpdating(false); if (examples.length > 0) { - this.onExampleSelectorUpdate(examples[0].name); + onExampleSelectorUpdate(examples[0].name); } } catch (err: any) { console.error(err.response); - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setUpdating(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); } - }; + }, [workspaceUrl, command.names]); - onExampleSelectorUpdate = (exampleDisplayName: string | null) => { - let example = this.state.exampleOptions.find((v) => v.name === exampleDisplayName) ?? undefined; + const onExampleSelectorUpdate = useCallback( + (exampleDisplayName: string | null) => { + const example = 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; + if (example === undefined) { + setName(exampleDisplayName ?? ""); + } else { + setName(example?.name ?? ""); + setExampleCommands(example?.commands ?? [""]); + } + }, + [exampleOptions], + ); - const buildExampleInput = (cmd: string, idx: number) => { + const buildExampleInput = useCallback( + (cmd: string, cmdIdx: number) => { return ( - this.onRemoveExampleCommand(idx)} aria-label="remove"> + onRemoveExampleCommand(cmdIdx)} aria-label="remove"> { - this.onModifyExampleCommand(event.target.value, idx); + onModifyExampleCommand(event.target.value, cmdIdx); }} sx={{ flexGrow: 1 }} placeholder="Input a command here." @@ -321,120 +265,119 @@ class ExampleDialog extends React.Component ); - }; - - return ( - - - {isAdd ? "Add Example" : "Modify Example"} - - - - - - {isAdd && source === undefined && ( - - - + }, + [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 && ( + + + )} - {(!isAdd || source != undefined) && ( + {!updating && ( - {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 && } )} - - {(!isAdd || source != undefined) && ( - - {updating && ( - - - - )} - {!updating && ( - - {!isAdd && ( - - - - - )} - {isAdd && } - - )} - - )} - - ); - } -} + + )} + + ); +}; export default ExampleDialog; diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index b00fd287..603a3ea5 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -26,7 +26,7 @@ import { ButtonBase, FormLabelProps, } from "@mui/material"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import MuiAccordionSummary from "@mui/material/AccordionSummary"; import { NameTypography, @@ -51,6 +51,7 @@ import WSEditorCommandArgumentsContent, { } from "./WSEditorCommandArgumentsContent"; import EditIcon from "@mui/icons-material/Edit"; import ExampleDialog from "./ExampleDialog"; +import AddSubcommandDialog from "./AddSubcommandDialog"; interface Plane { name: string; @@ -1062,180 +1063,6 @@ class CommandDialog extends React.Component 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!; From 3ed73a1538c440721db6813bd86f8c210c414368 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 14:52:51 +1100 Subject: [PATCH 027/107] refactor: extract CommandDeleteDialog --- .../views/workspace/CommandDeleteDialog.tsx | 116 ++++++++++++++++++ .../workspace/WSEditorCommandContent.tsx | 97 +-------------- 2 files changed, 117 insertions(+), 96 deletions(-) create mode 100644 src/web/src/views/workspace/CommandDeleteDialog.tsx diff --git a/src/web/src/views/workspace/CommandDeleteDialog.tsx b/src/web/src/views/workspace/CommandDeleteDialog.tsx new file mode 100644 index 00000000..d69a6382 --- /dev/null +++ b/src/web/src/views/workspace/CommandDeleteDialog.tsx @@ -0,0 +1,116 @@ +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + Typography, +} from "@mui/material"; +import React from "react"; +import { commandApi } from "../../services"; +import { COMMAND_PREFIX } from "../../constants"; +import { Command, ResponseCommand, DecodeResponseCommand } from "./WSEditorCommandContent"; + +export interface CommandDeleteDialogProps { + workspaceUrl: string; + open: boolean; + command: Command; + onClose: (deleted: boolean) => void; +} + +const CommandDeleteDialog: React.FC = (props) => { + 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); + 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) => ( + {`${COMMAND_PREFIX}${command}`} + ))} + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; + +export default CommandDeleteDialog; diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index 603a3ea5..2aaf66b1 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -52,6 +52,7 @@ import WSEditorCommandArgumentsContent, { import EditIcon from "@mui/icons-material/Edit"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; +import CommandDeleteDialog from "./CommandDeleteDialog"; interface Plane { name: string; @@ -728,102 +729,6 @@ class WSEditorCommandContent extends React.Component 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); - 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) => ( - {`${COMMAND_PREFIX}${command}`} - ))} - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); -} - interface CommandDialogProps { workspaceUrl: string; open: boolean; From 0c48a572962794aaf0affb1cbabff33fdb27cca1 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:12:06 +1100 Subject: [PATCH 028/107] refactor: extract Outputcard --- src/web/src/views/workspace/OutputCard.tsx | 257 ++++++++++++++++++ .../workspace/WSEditorCommandContent.tsx | 196 +------------ 2 files changed, 258 insertions(+), 195 deletions(-) create mode 100644 src/web/src/views/workspace/OutputCard.tsx diff --git a/src/web/src/views/workspace/OutputCard.tsx b/src/web/src/views/workspace/OutputCard.tsx new file mode 100644 index 00000000..8392221a --- /dev/null +++ b/src/web/src/views/workspace/OutputCard.tsx @@ -0,0 +1,257 @@ +import React 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 "./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, +})); + +function OutputCard(props: OutputCardProps) { + 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((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/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index 2aaf66b1..0f17c9a7 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -23,7 +23,6 @@ import { AccordionSummaryProps, FormLabel, Switch, - ButtonBase, FormLabelProps, } from "@mui/material"; import React, { useState } from "react"; @@ -40,7 +39,6 @@ import { CardTitleTypography, } from "./WSEditorTheme"; 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 { COMMAND_PREFIX } from "../../constants"; @@ -53,6 +51,7 @@ import EditIcon from "@mui/icons-material/Edit"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; import CommandDeleteDialog from "./CommandDeleteDialog"; +import OutputCard from "./OutputCard"; interface Plane { name: string; @@ -190,34 +189,6 @@ const ExampleAccordionSummary = styled((props: AccordionSummaryProps) => ( }, })); -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, })); @@ -968,171 +939,6 @@ class CommandDialog extends React.Component 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; From f02d38030d672e9c71c0136dc39ba007f75a6e00 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:27:11 +1100 Subject: [PATCH 029/107] refactor: address console error about App render --- src/web/src/index.tsx | 49 +++++++++++-------- src/web/src/views/cli/CLIPage.tsx | 3 +- src/web/src/views/commands/CommandsPage.tsx | 6 +-- src/web/src/views/home/HomePage.tsx | 3 +- src/web/src/views/workspace/WorkspacePage.tsx | 3 +- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index 8804c0cd..9c85f8bf 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -1,6 +1,8 @@ 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"; @@ -11,31 +13,36 @@ 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 theme from "./theme"; // import reportWebVitals from './reportWebVitals'; -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 diff --git a/src/web/src/views/cli/CLIPage.tsx b/src/web/src/views/cli/CLIPage.tsx index 419b62f1..4255873f 100644 --- a/src/web/src/views/cli/CLIPage.tsx +++ b/src/web/src/views/cli/CLIPage.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import withRoot from "../../withRoot"; import { Outlet } from "react-router"; class CLIPage extends React.Component { @@ -12,4 +11,4 @@ class CLIPage extends React.Component { } } -export default withRoot(CLIPage); +export default CLIPage; diff --git a/src/web/src/views/commands/CommandsPage.tsx b/src/web/src/views/commands/CommandsPage.tsx index b01987b8..5cd0b7b0 100644 --- a/src/web/src/views/commands/CommandsPage.tsx +++ b/src/web/src/views/commands/CommandsPage.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import withRoot from "../../withRoot"; import { AppAppBar } from "../../components/AppAppBar"; class CommandsPage extends React.Component { @@ -7,12 +6,9 @@ class CommandsPage extends React.Component { return ( - {/* - Commands Page - */} ); } } -export default withRoot(CommandsPage); +export default CommandsPage; diff --git a/src/web/src/views/home/HomePage.tsx b/src/web/src/views/home/HomePage.tsx index 18ea1ffa..fe6493fe 100644 --- a/src/web/src/views/home/HomePage.tsx +++ b/src/web/src/views/home/HomePage.tsx @@ -2,7 +2,6 @@ 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 PageLayout from "../../components/PageLayout"; @@ -179,4 +178,4 @@ function HomePage() { ); } -export default withRoot(HomePage); +export default HomePage; diff --git a/src/web/src/views/workspace/WorkspacePage.tsx b/src/web/src/views/workspace/WorkspacePage.tsx index 5f8fceef..a525e682 100644 --- a/src/web/src/views/workspace/WorkspacePage.tsx +++ b/src/web/src/views/workspace/WorkspacePage.tsx @@ -1,5 +1,4 @@ import React from "react"; -import withRoot from "../../withRoot"; import { Outlet } from "react-router"; const WorkspacePage: React.FC = () => { @@ -10,4 +9,4 @@ const WorkspacePage: React.FC = () => { ); }; -export default withRoot(WorkspacePage); +export default WorkspacePage; From 0ce767a7b5a8cc11cf17f2ee29c06ffdc4683cb0 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:28:26 +1100 Subject: [PATCH 030/107] refactor: remove reportWebVitals --- src/web/src/index.tsx | 6 ------ src/web/src/reportWebVitals.js | 13 ------------- 2 files changed, 19 deletions(-) delete mode 100644 src/web/src/reportWebVitals.js diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index 9c85f8bf..368c600b 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -14,7 +14,6 @@ import CLIPage from "./views/cli/CLIPage"; import CLIInstruction from "./views/cli/CLIInstruction"; import { CLIModuleGenerator } from "./views/cli/CLIModuleGenerator"; import theme from "./theme"; -// import reportWebVitals from './reportWebVitals'; const container = document.getElementById("root"); const root = createRoot(container!); @@ -44,8 +43,3 @@ root.render( , ); - -// 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/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; From 81da6e0c386fad488f6efcc510d5324028674d6f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:37:21 +1100 Subject: [PATCH 031/107] refactor: move out flattendialog --- src/web/src/views/workspace/FlattenDialog.tsx | 331 ++++++++++++++++++ .../WSEditorCommandArgumentsContent.tsx | 282 +-------------- 2 files changed, 340 insertions(+), 273 deletions(-) create mode 100644 src/web/src/views/workspace/FlattenDialog.tsx diff --git a/src/web/src/views/workspace/FlattenDialog.tsx b/src/web/src/views/workspace/FlattenDialog.tsx new file mode 100644 index 00000000..e56c8e4e --- /dev/null +++ b/src/web/src/views/workspace/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 "./argument/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/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx index 07795c9e..20b9cd95 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx @@ -1,24 +1,12 @@ -import { - Alert, - Box, - Button, - CardContent, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - LinearProgress, - TextField, -} from "@mui/material"; - -import { commandApi, errorHandlerApi } from "../../services"; +import { Box, CardContent } from "@mui/material"; + import pluralize from "pluralize"; -import React, { useEffect, useState } from "react"; -import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; +import React, { useState } from "react"; import ArgumentNavigation from "./ArgumentNavigation"; import ArgumentDialog from "./ArgumentDialog"; import UnwrapClsDialog from "./UnwrapClsDialog"; +import FlattenDialog from "./FlattenDialog"; import { CardTitleTypography } from "./WSEditorTheme"; @@ -30,6 +18,11 @@ interface WSEditorCommandArgumentsContentProps { onAddSubCommand: (argVar: string, subArgOptions: { var: string; options: string }[], argStackNames: string[]) => void; } +interface ArgIdx { + var: string; + displayKey: string; +} + const WSEditorCommandArgumentsContent: React.FC = ({ commandUrl, args, @@ -192,263 +185,6 @@ const WSEditorCommandArgumentsContent: React.FC 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 && ( - <> - - - - )} - - - ); -} - type CMDArgHelp = { short: string; lines?: string[]; From d02a4c9681bcc2977f419343acbe06cb2838211d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:45:32 +1100 Subject: [PATCH 032/107] refactor: move ArgumentsContent components to own sub-dir --- .../components/WSECArgumentSimilarPicker.test.tsx | 2 +- .../WSEditorCommandArgumentsContent.test.tsx | 7 +++++-- .../src/views/workspace/WSEditorCommandContent.tsx | 6 +----- .../ArgNavBar.tsx | 0 .../{ => commandArgumentsContent}/ArgumentDialog.tsx | 4 ++-- .../ArgumentNavigation.tsx | 6 +++--- .../ArgumentPropsReviewer.tsx | 2 +- .../ArgumentReviewer.tsx | 0 .../{ => commandArgumentsContent}/FlattenDialog.tsx | 4 ++-- .../{ => commandArgumentsContent}/UnwrapClsDialog.tsx | 2 +- .../WSECArgumentSimilarPicker.tsx | 0 .../WSEditorCommandArgumentsContent.tsx | 2 +- .../views/workspace/commandArgumentsContent/index.ts | 11 +++++++++++ 13 files changed, 28 insertions(+), 18 deletions(-) rename src/web/src/views/workspace/{argument => commandArgumentsContent}/ArgNavBar.tsx (100%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/ArgumentDialog.tsx (99%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/ArgumentNavigation.tsx (97%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/ArgumentPropsReviewer.tsx (99%) rename src/web/src/views/workspace/{argument => commandArgumentsContent}/ArgumentReviewer.tsx (100%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/FlattenDialog.tsx (98%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/UnwrapClsDialog.tsx (97%) rename src/web/src/views/workspace/{argument => commandArgumentsContent}/WSECArgumentSimilarPicker.tsx (100%) rename src/web/src/views/workspace/{ => commandArgumentsContent}/WSEditorCommandArgumentsContent.tsx (99%) create mode 100644 src/web/src/views/workspace/commandArgumentsContent/index.ts diff --git a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx index cda866d1..1e0f5f66 100644 --- a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen } from "@testing-library/react"; import WSECArgumentSimilarPicker, { BuildArgSimilarTree, type ArgSimilarTree, -} from "../../views/workspace/argument/WSECArgumentSimilarPicker"; +} from "../../views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker"; import { render } from "../test-utils"; describe("WSECArgumentSimilarPicker", () => { diff --git a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx index 4f0c10bb..8a68ecf1 100644 --- a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; -import WSEditorCommandArgumentsContent from "../../views/workspace/WSEditorCommandArgumentsContent"; -import type { CMDArg, ClsArgDefinitionMap } from "../../views/workspace/WSEditorCommandArgumentsContent"; +import WSEditorCommandArgumentsContent from "../../views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent"; +import type { + CMDArg, + ClsArgDefinitionMap, +} from "../../views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent"; import { render } from "../test-utils"; vi.mock("../../services/commandApi"); diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index 0f17c9a7..d8ae8ba8 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -42,11 +42,7 @@ import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArro import LabelIcon from "@mui/icons-material/Label"; import { commandApi, errorHandlerApi } from "../../services"; import { COMMAND_PREFIX } from "../../constants"; -import WSEditorCommandArgumentsContent, { - ClsArgDefinitionMap, - CMDArg, - DecodeArgs, -} from "./WSEditorCommandArgumentsContent"; +import WSEditorCommandArgumentsContent, { ClsArgDefinitionMap, CMDArg, DecodeArgs } from "./commandArgumentsContent"; import EditIcon from "@mui/icons-material/Edit"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; diff --git a/src/web/src/views/workspace/argument/ArgNavBar.tsx b/src/web/src/views/workspace/commandArgumentsContent/ArgNavBar.tsx similarity index 100% rename from src/web/src/views/workspace/argument/ArgNavBar.tsx rename to src/web/src/views/workspace/commandArgumentsContent/ArgNavBar.tsx diff --git a/src/web/src/views/workspace/ArgumentDialog.tsx b/src/web/src/views/workspace/commandArgumentsContent/ArgumentDialog.tsx similarity index 99% rename from src/web/src/views/workspace/ArgumentDialog.tsx rename to src/web/src/views/workspace/commandArgumentsContent/ArgumentDialog.tsx index d47a477d..f7c306aa 100644 --- a/src/web/src/views/workspace/ArgumentDialog.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/ArgumentDialog.tsx @@ -16,9 +16,9 @@ import { TextField, } from "@mui/material"; -import { commandApi, errorHandlerApi } from "../../services"; +import { commandApi, errorHandlerApi } from "../../../services"; import React, { useEffect, useState } from "react"; -import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; +import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; interface CMDClsArg { var: string; diff --git a/src/web/src/views/workspace/ArgumentNavigation.tsx b/src/web/src/views/workspace/commandArgumentsContent/ArgumentNavigation.tsx similarity index 97% rename from src/web/src/views/workspace/ArgumentNavigation.tsx rename to src/web/src/views/workspace/commandArgumentsContent/ArgumentNavigation.tsx index 588e63a7..21e29f33 100644 --- a/src/web/src/views/workspace/ArgumentNavigation.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/ArgumentNavigation.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; import { Box } from "@mui/material"; -import { ExperimentalTypography, PreviewTypography, StableTypography } from "./WSEditorTheme"; +import { ExperimentalTypography, PreviewTypography, StableTypography } from "../WSEditorTheme"; import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; -import ArgNavBar, { type ArgIdx } from "./argument/ArgNavBar"; -import ArgumentReviewer from "./argument/ArgumentReviewer"; +import ArgNavBar, { type ArgIdx } from "./ArgNavBar"; +import ArgumentReviewer from "./ArgumentReviewer"; import type { CMDArg, ClsArgDefinitionMap } from "./WSEditorCommandArgumentsContent"; interface CMDArgBase { diff --git a/src/web/src/views/workspace/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/commandArgumentsContent/ArgumentPropsReviewer.tsx similarity index 99% rename from src/web/src/views/workspace/ArgumentPropsReviewer.tsx rename to src/web/src/views/workspace/commandArgumentsContent/ArgumentPropsReviewer.tsx index 24021dcc..ab36207f 100644 --- a/src/web/src/views/workspace/ArgumentPropsReviewer.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/ArgumentPropsReviewer.tsx @@ -3,7 +3,7 @@ import { Box, Button, ButtonBase, styled, Typography, TypographyProps } from "@m 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 "./WSEditorTheme"; +import { SmallExperimentalTypography, SmallPreviewTypography, SubtitleTypography } from "../WSEditorTheme"; import type { CMDArg } from "./WSEditorCommandArgumentsContent"; interface CMDArrayArg extends CMDArg { diff --git a/src/web/src/views/workspace/argument/ArgumentReviewer.tsx b/src/web/src/views/workspace/commandArgumentsContent/ArgumentReviewer.tsx similarity index 100% rename from src/web/src/views/workspace/argument/ArgumentReviewer.tsx rename to src/web/src/views/workspace/commandArgumentsContent/ArgumentReviewer.tsx diff --git a/src/web/src/views/workspace/FlattenDialog.tsx b/src/web/src/views/workspace/commandArgumentsContent/FlattenDialog.tsx similarity index 98% rename from src/web/src/views/workspace/FlattenDialog.tsx rename to src/web/src/views/workspace/commandArgumentsContent/FlattenDialog.tsx index e56c8e4e..bc1d27f9 100644 --- a/src/web/src/views/workspace/FlattenDialog.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/FlattenDialog.tsx @@ -10,8 +10,8 @@ import { TextField, } from "@mui/material"; import React, { useEffect, useState } from "react"; -import { commandApi, errorHandlerApi } from "../../services"; -import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./argument/WSECArgumentSimilarPicker"; +import { commandApi, errorHandlerApi } from "../../../services"; +import WSECArgumentSimilarPicker, { ArgSimilarTree, BuildArgSimilarTree } from "./WSECArgumentSimilarPicker"; interface FlattenDialogProps { commandUrl: string; diff --git a/src/web/src/views/workspace/UnwrapClsDialog.tsx b/src/web/src/views/workspace/commandArgumentsContent/UnwrapClsDialog.tsx similarity index 97% rename from src/web/src/views/workspace/UnwrapClsDialog.tsx rename to src/web/src/views/workspace/commandArgumentsContent/UnwrapClsDialog.tsx index 3837ebe6..6bf16944 100644 --- a/src/web/src/views/workspace/UnwrapClsDialog.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/UnwrapClsDialog.tsx @@ -12,7 +12,7 @@ import { TypographyProps, } from "@mui/material"; -import { commandApi, errorHandlerApi } from "../../services"; +import { commandApi, errorHandlerApi } from "../../../services"; import React, { useState } from "react"; const ArgTypeTypography = styled(Typography)(({ theme }) => ({ diff --git a/src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker.tsx similarity index 100% rename from src/web/src/views/workspace/argument/WSECArgumentSimilarPicker.tsx rename to src/web/src/views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker.tsx diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent.tsx similarity index 99% rename from src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx rename to src/web/src/views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent.tsx index 20b9cd95..5bd0324d 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent.tsx @@ -8,7 +8,7 @@ import ArgumentDialog from "./ArgumentDialog"; import UnwrapClsDialog from "./UnwrapClsDialog"; import FlattenDialog from "./FlattenDialog"; -import { CardTitleTypography } from "./WSEditorTheme"; +import { CardTitleTypography } from "../WSEditorTheme"; interface WSEditorCommandArgumentsContentProps { commandUrl: string; diff --git a/src/web/src/views/workspace/commandArgumentsContent/index.ts b/src/web/src/views/workspace/commandArgumentsContent/index.ts new file mode 100644 index 00000000..c1063296 --- /dev/null +++ b/src/web/src/views/workspace/commandArgumentsContent/index.ts @@ -0,0 +1,11 @@ +export { default, DecodeArgs } from "./WSEditorCommandArgumentsContent"; +export type { ClsArgDefinitionMap, CMDArg } from "./WSEditorCommandArgumentsContent"; +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"; From bc0605f28c4667615ac2e52ea436a6326b6cc3c6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 15:56:42 +1100 Subject: [PATCH 033/107] refactor: move out Outputdialog to own file --- src/web/src/views/workspace/OutputDialog.tsx | 218 ++++++++++++++++++ .../workspace/WSEditorCommandContent.tsx | 161 +------------ 2 files changed, 220 insertions(+), 159 deletions(-) create mode 100644 src/web/src/views/workspace/OutputDialog.tsx diff --git a/src/web/src/views/workspace/OutputDialog.tsx b/src/web/src/views/workspace/OutputDialog.tsx new file mode 100644 index 00000000..bd6e2250 --- /dev/null +++ b/src/web/src/views/workspace/OutputDialog.tsx @@ -0,0 +1,218 @@ +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"; + +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[]; +} + +interface ResponseCommand { + 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 DecodeResponseCommand = (command: ResponseCommand): Command => { + let cmd: Command = { + id: "command:" + command.names.join("/"), + names: command.names, + help: command.help, + stage: command.stage ?? "Stable", + outputs: command.outputs, + resources: command.resources, + version: command.version, + }; + + return cmd; +}; + +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/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent.tsx index d8ae8ba8..49747810 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent.tsx @@ -21,11 +21,8 @@ import { TypographyProps, AccordionDetails, AccordionSummaryProps, - FormLabel, - Switch, - FormLabelProps, } from "@mui/material"; -import React, { useState } from "react"; +import React from "react"; import MuiAccordionSummary from "@mui/material/AccordionSummary"; import { NameTypography, @@ -48,6 +45,7 @@ import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; import CommandDeleteDialog from "./CommandDeleteDialog"; import OutputCard from "./OutputCard"; +import OutputDialog, { Output } from "./OutputDialog"; interface Plane { name: string; @@ -60,35 +58,6 @@ interface Example { 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"; -} - interface Resource { id: string; version: string; @@ -185,17 +154,6 @@ const ExampleAccordionSummary = styled((props: AccordionSummaryProps) => ( }, })); -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); @@ -935,121 +893,6 @@ class CommandDialog extends React.Component 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("/"), From e497b52d7b19ee725637aa7ec9e412f74ea962b1 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 16:58:07 +1100 Subject: [PATCH 034/107] refactor: move out WSEditorCommandContent to own dir --- src/web/src/__tests__/components/WSEditor.test.tsx | 2 +- .../components/WSEditorCommandContent.test.tsx | 4 ++-- src/web/src/views/workspace/WSEditor.tsx | 2 +- src/web/src/views/workspace/WSEditorClientConfig.tsx | 2 +- .../views/workspace/WSEditorCommandGroupContent.tsx | 2 +- src/web/src/views/workspace/WorkspaceCreateDialog.tsx | 2 +- .../{ => commandContent}/AddSubcommandDialog.tsx | 2 +- .../{ => commandContent}/CommandDeleteDialog.tsx | 4 ++-- .../workspace/{ => commandContent}/ExampleDialog.tsx | 6 +++--- .../workspace/{ => commandContent}/OutputCard.tsx | 2 +- .../workspace/{ => commandContent}/OutputDialog.tsx | 2 +- .../{ => commandContent}/WSEditorCommandContent.tsx | 10 ++++++---- src/web/src/views/workspace/commandContent/index.ts | 2 ++ 13 files changed, 23 insertions(+), 19 deletions(-) rename src/web/src/views/workspace/{ => commandContent}/AddSubcommandDialog.tsx (98%) rename src/web/src/views/workspace/{ => commandContent}/CommandDeleteDialog.tsx (97%) rename src/web/src/views/workspace/{ => commandContent}/ExampleDialog.tsx (98%) rename src/web/src/views/workspace/{ => commandContent}/OutputCard.tsx (98%) rename src/web/src/views/workspace/{ => commandContent}/OutputDialog.tsx (98%) rename src/web/src/views/workspace/{ => commandContent}/WSEditorCommandContent.tsx (98%) create mode 100644 src/web/src/views/workspace/commandContent/index.ts diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index e6fe97d6..4c7546a3 100644 --- a/src/web/src/__tests__/components/WSEditor.test.tsx +++ b/src/web/src/__tests__/components/WSEditor.test.tsx @@ -70,7 +70,7 @@ vi.mock("../../views/workspace/WSEditorCommandGroupContent", () => ({ ResponseCommandGroups: {}, })); -vi.mock("../../views/workspace/WSEditorCommandContent", () => ({ +vi.mock("../../views/workspace/commandContent/WSEditorCommandContent", () => ({ default: ({ previewCommand, onUpdateCommand }: any) => (
{previewCommand.id} diff --git a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx index e62bf91d..77dde44f 100644 --- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen, waitFor, within } from "@testing-library/react"; -import WSEditorCommandContent from "../../views/workspace/WSEditorCommandContent"; -import type { Command, Example, Resource } from "../../views/workspace/WSEditorCommandContent"; +import WSEditorCommandContent from "../../views/workspace/commandContent/WSEditorCommandContent"; +import type { Command, Example, Resource } from "../../views/workspace/commandContent/WSEditorCommandContent"; import { render } from "../test-utils"; import { commandApi } from "../../services/commandApi"; diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx index ac4bb02f..b39d9803 100644 --- a/src/web/src/views/workspace/WSEditor.tsx +++ b/src/web/src/views/workspace/WSEditor.tsx @@ -37,7 +37,7 @@ import WSEditorCommandContent, { Resource, DecodeResponseCommand, ResponseCommand, -} from "./WSEditorCommandContent"; +} from "./commandContent/WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; import { getTypespecRPResourcesOperations } from "../../typespec"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index 5e8b60b6..a2a54780 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -22,7 +22,7 @@ import { 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 { Plane, Resource } from "./commandContent/WSEditorCommandContent"; import SwaggerItemSelector from "./SwaggerItemSelector"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx index a24cda08..26affb51 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx @@ -1,6 +1,6 @@ import { Box, Button, Card, CardActions, CardContent, Typography } from "@mui/material"; import * as React from "react"; -import { ResponseCommands } from "./WSEditorCommandContent"; +import { ResponseCommands } from "./commandContent/WSEditorCommandContent"; import CommandGroupDialog from "./CommandGroupDialog"; import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; import { COMMAND_PREFIX } from "../../constants"; diff --git a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx index 28374c5c..f4b36eaf 100644 --- a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -12,7 +12,7 @@ import { import React, { useState, useEffect, useCallback } from "react"; import SwaggerItemSelector from "./SwaggerItemSelector"; import styled from "@emotion/styled"; -import { Plane } from "./WSEditorCommandContent"; +import { Plane } from "./commandContent/WSEditorCommandContent"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; interface WorkspaceCreateDialogProps { diff --git a/src/web/src/views/workspace/AddSubcommandDialog.tsx b/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx similarity index 98% rename from src/web/src/views/workspace/AddSubcommandDialog.tsx rename to src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx index b3c89142..f1f3a053 100644 --- a/src/web/src/views/workspace/AddSubcommandDialog.tsx +++ b/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx @@ -11,7 +11,7 @@ import { TextField, } from "@mui/material"; import React, { useState, useEffect } from "react"; -import { commandApi, errorHandlerApi } from "../../services"; +import { commandApi, errorHandlerApi } from "../../../services"; import { Command } from "./WSEditorCommandContent"; export interface AddSubcommandDialogProps { diff --git a/src/web/src/views/workspace/CommandDeleteDialog.tsx b/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx similarity index 97% rename from src/web/src/views/workspace/CommandDeleteDialog.tsx rename to src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx index d69a6382..382fddd5 100644 --- a/src/web/src/views/workspace/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx @@ -9,8 +9,8 @@ import { Typography, } from "@mui/material"; import React from "react"; -import { commandApi } from "../../services"; -import { COMMAND_PREFIX } from "../../constants"; +import { commandApi } from "../../../services"; +import { COMMAND_PREFIX } from "../../../constants"; import { Command, ResponseCommand, DecodeResponseCommand } from "./WSEditorCommandContent"; export interface CommandDeleteDialogProps { diff --git a/src/web/src/views/workspace/ExampleDialog.tsx b/src/web/src/views/workspace/commandContent/ExampleDialog.tsx similarity index 98% rename from src/web/src/views/workspace/ExampleDialog.tsx rename to src/web/src/views/workspace/commandContent/ExampleDialog.tsx index 30659445..bc0f9dd8 100644 --- a/src/web/src/views/workspace/ExampleDialog.tsx +++ b/src/web/src/views/workspace/commandContent/ExampleDialog.tsx @@ -21,9 +21,9 @@ 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 "./WSEditorExamplePicker"; +import { commandApi, errorHandlerApi } from "../../../services"; +import { COMMAND_PREFIX } from "../../../constants"; +import { ExampleItemSelector } from "../WSEditorExamplePicker"; import { Command, Example, DecodeResponseCommand } from "./WSEditorCommandContent"; export interface ExampleDialogProps { diff --git a/src/web/src/views/workspace/OutputCard.tsx b/src/web/src/views/workspace/commandContent/OutputCard.tsx similarity index 98% rename from src/web/src/views/workspace/OutputCard.tsx rename to src/web/src/views/workspace/commandContent/OutputCard.tsx index 8392221a..900e14f0 100644 --- a/src/web/src/views/workspace/OutputCard.tsx +++ b/src/web/src/views/workspace/commandContent/OutputCard.tsx @@ -2,7 +2,7 @@ import React 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 "./WSEditorTheme"; +import { SubtitleTypography, CardTitleTypography } from "../WSEditorTheme"; interface Example { name: string; diff --git a/src/web/src/views/workspace/OutputDialog.tsx b/src/web/src/views/workspace/commandContent/OutputDialog.tsx similarity index 98% rename from src/web/src/views/workspace/OutputDialog.tsx rename to src/web/src/views/workspace/commandContent/OutputDialog.tsx index bd6e2250..6caa8d70 100644 --- a/src/web/src/views/workspace/OutputDialog.tsx +++ b/src/web/src/views/workspace/commandContent/OutputDialog.tsx @@ -16,7 +16,7 @@ import { FormLabelProps, } from "@mui/material"; import { styled } from "@mui/material"; -import { commandApi, errorHandlerApi } from "../../services"; +import { commandApi, errorHandlerApi } from "../../../services"; interface ObjectOutput { type: "object"; diff --git a/src/web/src/views/workspace/WSEditorCommandContent.tsx b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx similarity index 98% rename from src/web/src/views/workspace/WSEditorCommandContent.tsx rename to src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx index 49747810..f31768d4 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx @@ -34,12 +34,12 @@ import { ExperimentalTypography, SubtitleTypography, CardTitleTypography, -} from "./WSEditorTheme"; +} from "../WSEditorTheme"; import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; import LabelIcon from "@mui/icons-material/Label"; -import { commandApi, errorHandlerApi } from "../../services"; -import { COMMAND_PREFIX } from "../../constants"; -import WSEditorCommandArgumentsContent, { ClsArgDefinitionMap, CMDArg, DecodeArgs } from "./commandArgumentsContent"; +import { commandApi, errorHandlerApi } from "../../../services"; +import { COMMAND_PREFIX } from "../../../constants"; +import WSEditorCommandArgumentsContent, { ClsArgDefinitionMap, CMDArg, DecodeArgs } from "../commandArgumentsContent"; import EditIcon from "@mui/icons-material/Edit"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; @@ -484,6 +484,7 @@ class WSEditorCommandContent extends React.Component - - - )} - - - ); - }; - - const buildArgumentsCard = () => { - return ( - - - - ); - }; - - const buildExampleCard = () => { - const examples = command!.examples ?? []; - return ( - + )} + + - + {loading && ( + + + + )} + {!loading && ( - [ EXAMPLE ] + + - {examples.length > 0 && {examples.map(buildExampleView)}} - + )} + + + ); + }, [command, previewCommand, name, onCommandDialogDisplay, loading, onCommandDeleteDialogDisplay]); + const buildArgumentsCard = useCallback(() => { + return ( + + + + ); + }, [commandUrl, command, loadCommand, onAddSubcommandDialogDisplay]); - { + 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 && ( - - )} - + + + ); - } -} + }, [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 && ( + + )} + + ); +}; interface CommandDialogProps { workspaceUrl: string; @@ -663,78 +627,49 @@ interface CommandDialogProps { onClose: (newCommand?: Command) => void; } -interface CommandDialogState { - name: string; - stage: string; - shortHelp: string; - longHelp: string; - invalidText?: string; - confirmation: string; - updating: boolean; -} +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); -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; + const handleModify = useCallback(async () => { + let trimmedName = name.trim(); + let trimmedShortHelp = shortHelp.trim(); + let trimmedLongHelp = longHelp.trim(); + let trimmedConfirmation = confirmation.trim(); - name = name.trim(); - shortHelp = shortHelp.trim(); - longHelp = longHelp.trim(); - confirmation = confirmation.trim(); + const names = trimmedName.split(" ").filter((n) => n.length > 0); - const names = name.split(" ").filter((n) => n.length > 0); - - this.setState({ - invalidText: undefined, - }); + setInvalidText(undefined); if (names.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); + setInvalidText(`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]+)* `, - }); + setInvalidText(`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.`, - }); + if (trimmedShortHelp.length < 1) { + setInvalidText(`Field 'Short Summary' is required.`); return; } let lines: string[] | null = null; - if (longHelp.length > 1) { - lines = longHelp.split("\n").filter((l) => l.length > 0); + if (trimmedLongHelp.length > 1) { + lines = trimmedLongHelp.split("\n").filter((l) => l.length > 0); } - this.setState({ - updating: true, - }); + setUpdating(true); const leafUrl = `${workspaceUrl}/CommandTree/Nodes/aaz/` + @@ -745,155 +680,134 @@ class CommandDialog extends React.Component { - 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 && ( - - - - - )} - - - ); - } -} + }, [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 && ( + + + + + )} + + + ); +}; const DecodeResponseCommand = (command: ResponseCommand): Command => { let cmd: Command = { From f947e10bdd1c292310bd9c453f1f8db9683731a9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 17:37:07 +1100 Subject: [PATCH 037/107] refactor: get WSEditorCommandCOntent tests passing --- .../views/workspace/commandContent/WSEditorCommandContent.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx index 28d9038d..7ecb2927 100644 --- a/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx @@ -182,10 +182,6 @@ const WSEditorCommandContent: React.FC = ({ } }, [workspaceUrl, previewCommand]); - useEffect(() => { - loadCommand(); - }, [loadCommand]); - useEffect(() => { if (command?.id !== previewCommand.id) { setCommand(undefined); From d06dd20be1fb284e7a912ed29fe489f34278f7c9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 17:49:53 +1100 Subject: [PATCH 038/107] refactor: start to export interfaces --- .../workspace/CommandGroupDeleteDialog.tsx | 2 +- .../views/workspace/CommandGroupDialog.tsx | 3 +- src/web/src/views/workspace/WSEditor.tsx | 12 ++-- .../views/workspace/WSEditorClientConfig.tsx | 2 +- .../workspace/WSEditorCommandGroupContent.tsx | 29 +-------- .../views/workspace/WorkspaceCreateDialog.tsx | 2 +- .../commandContent/AddSubcommandDialog.tsx | 2 +- .../commandContent/CommandDeleteDialog.tsx | 3 +- .../commandContent/ExampleDialog.tsx | 3 +- .../commandContent/WSEditorCommandContent.tsx | 62 +------------------ .../views/workspace/commandContent/index.ts | 2 +- .../workspace/interfaces/commandGroups.ts | 27 ++++++++ .../views/workspace/interfaces/commands.ts | 57 +++++++++++++++++ .../src/views/workspace/interfaces/index.ts | 3 + 14 files changed, 107 insertions(+), 102 deletions(-) create mode 100644 src/web/src/views/workspace/interfaces/commandGroups.ts create mode 100644 src/web/src/views/workspace/interfaces/commands.ts create mode 100644 src/web/src/views/workspace/interfaces/index.ts diff --git a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx index 7589e481..f38444b0 100644 --- a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx +++ b/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx @@ -10,8 +10,8 @@ import { } from "@mui/material"; import { commandApi } from "../../services"; import * as React from "react"; -import { CommandGroup } from "./WSEditorCommandGroupContent"; import { COMMAND_PREFIX } from "../../constants"; +import type { CommandGroup } from "./interfaces"; interface CommandGroupDeleteDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/CommandGroupDialog.tsx b/src/web/src/views/workspace/CommandGroupDialog.tsx index 35d5cca9..d00acba0 100644 --- a/src/web/src/views/workspace/CommandGroupDialog.tsx +++ b/src/web/src/views/workspace/CommandGroupDialog.tsx @@ -15,7 +15,8 @@ import { } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../services"; import * as React from "react"; -import { CommandGroup, DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import type { CommandGroup } from "./interfaces"; interface CommandGroupDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx index b39d9803..92506848 100644 --- a/src/web/src/views/workspace/WSEditor.tsx +++ b/src/web/src/views/workspace/WSEditor.tsx @@ -26,19 +26,17 @@ import { TransitionProps } from "@mui/material/transitions"; import WSEditorSwaggerPicker from "./WSEditorSwaggerPicker"; import WSEditorToolBar from "./WSEditorToolBar"; import WSEditorCommandTree, { CommandTreeLeaf, CommandTreeNode } from "./WSEditorCommandTree"; -import WSEditorCommandGroupContent, { +import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import WSEditorCommandContent, { DecodeResponseCommand } from "./commandContent/WSEditorCommandContent"; +import WSEditorClientConfigDialog from "./WSEditorClientConfig"; +import type { CommandGroup, - DecodeResponseCommandGroup, ResponseCommandGroup, ResponseCommandGroups, -} from "./WSEditorCommandGroupContent"; -import WSEditorCommandContent, { Command, Resource, - DecodeResponseCommand, ResponseCommand, -} from "./commandContent/WSEditorCommandContent"; -import WSEditorClientConfigDialog from "./WSEditorClientConfig"; +} from "./interfaces"; import { getTypespecRPResourcesOperations } from "../../typespec"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index a2a54780..62453996 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -22,9 +22,9 @@ import { import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; -import { Plane, Resource } from "./commandContent/WSEditorCommandContent"; import SwaggerItemSelector from "./SwaggerItemSelector"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import type { Plane, Resource } from "./interfaces"; interface WSEditorClientConfigDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx index 26affb51..31a0eb9f 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx @@ -1,6 +1,5 @@ import { Box, Button, Card, CardActions, CardContent, Typography } from "@mui/material"; import * as React from "react"; -import { ResponseCommands } from "./commandContent/WSEditorCommandContent"; import CommandGroupDialog from "./CommandGroupDialog"; import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; import { COMMAND_PREFIX } from "../../constants"; @@ -13,32 +12,7 @@ import { 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; -} +import type { CommandGroup, ResponseCommandGroup } from "./interfaces"; interface WSEditorCommandGroupContentProps { workspaceUrl: string; @@ -221,4 +195,3 @@ const DecodeResponseCommandGroup = (commandGroup: ResponseCommandGroup): Command export default WSEditorCommandGroupContent; export { DecodeResponseCommandGroup }; -export type { CommandGroup, ResponseCommandGroup, ResponseCommandGroups }; diff --git a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx index f4b36eaf..4e2ba6c6 100644 --- a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -12,8 +12,8 @@ import { import React, { useState, useEffect, useCallback } from "react"; import SwaggerItemSelector from "./SwaggerItemSelector"; import styled from "@emotion/styled"; -import { Plane } from "./commandContent/WSEditorCommandContent"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; +import type { Plane } from "./interfaces"; interface WorkspaceCreateDialogProps { openDialog: boolean; diff --git a/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx b/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx index f1f3a053..4f8a2836 100644 --- a/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx +++ b/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx @@ -12,7 +12,7 @@ import { } from "@mui/material"; import React, { useState, useEffect } from "react"; import { commandApi, errorHandlerApi } from "../../../services"; -import { Command } from "./WSEditorCommandContent"; +import type { Command } from "../interfaces"; export interface AddSubcommandDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx index 382fddd5..f3765bef 100644 --- a/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx @@ -11,7 +11,8 @@ import { import React from "react"; import { commandApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; -import { Command, ResponseCommand, DecodeResponseCommand } from "./WSEditorCommandContent"; +import { DecodeResponseCommand } from "./WSEditorCommandContent"; +import type { Command, ResponseCommand } from "../interfaces"; export interface CommandDeleteDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/commandContent/ExampleDialog.tsx b/src/web/src/views/workspace/commandContent/ExampleDialog.tsx index bc0f9dd8..265f63aa 100644 --- a/src/web/src/views/workspace/commandContent/ExampleDialog.tsx +++ b/src/web/src/views/workspace/commandContent/ExampleDialog.tsx @@ -24,7 +24,8 @@ import CloseIcon from "@mui/icons-material/Close"; import { commandApi, errorHandlerApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; import { ExampleItemSelector } from "../WSEditorExamplePicker"; -import { Command, Example, DecodeResponseCommand } from "./WSEditorCommandContent"; +import { DecodeResponseCommand } from "./WSEditorCommandContent"; +import type { Command, Example } from "../interfaces"; export interface ExampleDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx index 7ecb2927..a41cdd01 100644 --- a/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx @@ -40,67 +40,13 @@ import LabelIcon from "@mui/icons-material/Label"; import EditIcon from "@mui/icons-material/Edit"; import { commandApi, errorHandlerApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; -import WSEditorCommandArgumentsContent, { ClsArgDefinitionMap, CMDArg, DecodeArgs } from "../commandArgumentsContent"; +import WSEditorCommandArgumentsContent, { DecodeArgs } from "../commandArgumentsContent"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; import CommandDeleteDialog from "./CommandDeleteDialog"; import OutputCard from "./OutputCard"; -import OutputDialog, { Output } from "./OutputDialog"; - -interface Plane { - name: string; - displayName: string; - moduleOptions?: string[]; -} - -interface Example { - name: string; - commands: 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[]; - - confirmation?: string; - args?: CMDArg[]; - clsArgDefineMap?: ClsArgDefinitionMap; -} - -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; -} +import OutputDialog from "./OutputDialog"; +import type { Command, ResponseCommand, Example } from "../interfaces"; interface WSEditorCommandContentProps { workspaceUrl: string; @@ -834,5 +780,3 @@ const DecodeResponseCommand = (command: ResponseCommand): Command => { export default WSEditorCommandContent; export { DecodeResponseCommand }; - -export type { Plane, Command, Resource, ResponseCommand, ResponseCommands, Example }; diff --git a/src/web/src/views/workspace/commandContent/index.ts b/src/web/src/views/workspace/commandContent/index.ts index 77baa336..f078757b 100644 --- a/src/web/src/views/workspace/commandContent/index.ts +++ b/src/web/src/views/workspace/commandContent/index.ts @@ -1,2 +1,2 @@ export { default as WSEditorCommandContent, DecodeResponseCommand } from "./WSEditorCommandContent"; -export type { Command, Resource, ResponseCommand, ResponseCommands, Example, Plane } from "./WSEditorCommandContent"; +export type { Command, Resource, ResponseCommand, ResponseCommands, Example, Plane } from "../interfaces"; 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..fb144c01 --- /dev/null +++ b/src/web/src/views/workspace/interfaces/commands.ts @@ -0,0 +1,57 @@ +import type { Output } from "../commandContent/OutputDialog"; +import type { CMDArg, ClsArgDefinitionMap } from "../commandArgumentsContent"; + +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"; From a6e67609abe1d9398e33e7ebc712dd591a9e5b09 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 15 Oct 2025 18:07:44 +1100 Subject: [PATCH 039/107] refactor: move WSEditorCommandGroupContenet and sub-components to own dir --- src/web/src/__tests__/components/WSEditor.test.tsx | 2 +- .../components/WSEditorCommandGroupContent.test.tsx | 2 +- src/web/src/views/workspace/WSEditor.tsx | 2 +- .../{ => commandGroupContent}/CommandGroupDeleteDialog.tsx | 6 +++--- .../{ => commandGroupContent}/CommandGroupDialog.tsx | 4 ++-- .../WSEditorCommandGroupContent.tsx | 7 ++++--- src/web/src/views/workspace/commandGroupContent/index.tsx | 1 + 7 files changed, 13 insertions(+), 11 deletions(-) rename src/web/src/views/workspace/{ => commandGroupContent}/CommandGroupDeleteDialog.tsx (92%) rename src/web/src/views/workspace/{ => commandGroupContent}/CommandGroupDialog.tsx (98%) rename src/web/src/views/workspace/{ => commandGroupContent}/WSEditorCommandGroupContent.tsx (96%) create mode 100644 src/web/src/views/workspace/commandGroupContent/index.tsx diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index 4c7546a3..71fb48f7 100644 --- a/src/web/src/__tests__/components/WSEditor.test.tsx +++ b/src/web/src/__tests__/components/WSEditor.test.tsx @@ -55,7 +55,7 @@ vi.mock("../../views/workspace/WSEditorCommandTree", () => ({ CommandTreeNode: {}, })); -vi.mock("../../views/workspace/WSEditorCommandGroupContent", () => ({ +vi.mock("../../views/workspace/commandGroupContent", () => ({ default: ({ commandGroup, onUpdateCommandGroup }: any) => (
{commandGroup.id} diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx index 15c6058d..84947a97 100644 --- a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; -import WSEditorCommandGroupContent from "../../views/workspace/WSEditorCommandGroupContent"; +import WSEditorCommandGroupContent from "../../views/workspace/commandGroupContent/WSEditorCommandGroupContent"; import * as commandApi from "../../services/commandApi"; interface CommandGroup { diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx index 92506848..e6099b49 100644 --- a/src/web/src/views/workspace/WSEditor.tsx +++ b/src/web/src/views/workspace/WSEditor.tsx @@ -26,7 +26,7 @@ import { TransitionProps } from "@mui/material/transitions"; import WSEditorSwaggerPicker from "./WSEditorSwaggerPicker"; import WSEditorToolBar from "./WSEditorToolBar"; import WSEditorCommandTree, { CommandTreeLeaf, CommandTreeNode } from "./WSEditorCommandTree"; -import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "./commandGroupContent"; import WSEditorCommandContent, { DecodeResponseCommand } from "./commandContent/WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; import type { diff --git a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/commandGroupContent/CommandGroupDeleteDialog.tsx similarity index 92% rename from src/web/src/views/workspace/CommandGroupDeleteDialog.tsx rename to src/web/src/views/workspace/commandGroupContent/CommandGroupDeleteDialog.tsx index f38444b0..3e37fb75 100644 --- a/src/web/src/views/workspace/CommandGroupDeleteDialog.tsx +++ b/src/web/src/views/workspace/commandGroupContent/CommandGroupDeleteDialog.tsx @@ -8,10 +8,10 @@ import { LinearProgress, Typography, } from "@mui/material"; -import { commandApi } from "../../services"; +import { commandApi } from "../../../services"; import * as React from "react"; -import { COMMAND_PREFIX } from "../../constants"; -import type { CommandGroup } from "./interfaces"; +import { COMMAND_PREFIX } from "../../../constants"; +import type { CommandGroup } from "../interfaces"; interface CommandGroupDeleteDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/CommandGroupDialog.tsx b/src/web/src/views/workspace/commandGroupContent/CommandGroupDialog.tsx similarity index 98% rename from src/web/src/views/workspace/CommandGroupDialog.tsx rename to src/web/src/views/workspace/commandGroupContent/CommandGroupDialog.tsx index d00acba0..5c24d276 100644 --- a/src/web/src/views/workspace/CommandGroupDialog.tsx +++ b/src/web/src/views/workspace/commandGroupContent/CommandGroupDialog.tsx @@ -13,10 +13,10 @@ import { RadioGroup, TextField, } from "@mui/material"; -import { commandApi, errorHandlerApi } from "../../services"; +import { commandApi, errorHandlerApi } from "../../../services"; import * as React from "react"; import { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; -import type { CommandGroup } from "./interfaces"; +import type { CommandGroup } from "../interfaces"; interface CommandGroupDialogProps { workspaceUrl: string; diff --git a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/commandGroupContent/WSEditorCommandGroupContent.tsx similarity index 96% rename from src/web/src/views/workspace/WSEditorCommandGroupContent.tsx rename to src/web/src/views/workspace/commandGroupContent/WSEditorCommandGroupContent.tsx index 31a0eb9f..cabf00d0 100644 --- a/src/web/src/views/workspace/WSEditorCommandGroupContent.tsx +++ b/src/web/src/views/workspace/commandGroupContent/WSEditorCommandGroupContent.tsx @@ -2,7 +2,7 @@ import { Box, Button, Card, CardActions, CardContent, Typography } from "@mui/ma import * as React from "react"; import CommandGroupDialog from "./CommandGroupDialog"; import CommandGroupDeleteDialog from "./CommandGroupDeleteDialog"; -import { COMMAND_PREFIX } from "../../constants"; +import { COMMAND_PREFIX } from "../../../constants"; import { NameTypography, ShortHelpTypography, @@ -11,8 +11,8 @@ import { StableTypography, PreviewTypography, ExperimentalTypography, -} from "./WSEditorTheme"; -import type { CommandGroup, ResponseCommandGroup } from "./interfaces"; +} from "../WSEditorTheme"; +import type { CommandGroup, ResponseCommandGroup } from "../interfaces"; interface WSEditorCommandGroupContentProps { workspaceUrl: string; @@ -66,6 +66,7 @@ const WSEditorCommandGroupContent: React.FC = return ( Date: Thu, 16 Oct 2025 06:36:39 +1100 Subject: [PATCH 040/107] refactor: change dir names to match main component in dir --- src/web/src/__tests__/components/WSEditor.test.tsx | 4 ++-- .../src/__tests__/components/WSEditorCommandContent.test.tsx | 4 ++-- .../__tests__/components/WSEditorCommandGroupContent.test.tsx | 2 +- src/web/src/views/workspace/WSEditor.tsx | 4 ++-- .../AddSubcommandDialog.tsx | 0 .../CommandDeleteDialog.tsx | 0 .../ExampleDialog.tsx | 0 .../{commandContent => WSEditorCommandContent}/OutputCard.tsx | 0 .../OutputDialog.tsx | 0 .../WSEditorCommandContent.tsx | 0 .../{commandContent => WSEditorCommandContent}/index.ts | 2 +- .../CommandGroupDeleteDialog.tsx | 0 .../CommandGroupDialog.tsx | 0 .../WSEditorCommandGroupContent.tsx | 0 .../index.tsx | 0 src/web/src/views/workspace/interfaces/commands.ts | 2 +- 16 files changed, 9 insertions(+), 9 deletions(-) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/AddSubcommandDialog.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/CommandDeleteDialog.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/ExampleDialog.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/OutputCard.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/OutputDialog.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/WSEditorCommandContent.tsx (100%) rename src/web/src/views/workspace/{commandContent => WSEditorCommandContent}/index.ts (51%) rename src/web/src/views/workspace/{commandGroupContent => WSEditorCommandGroupContent}/CommandGroupDeleteDialog.tsx (100%) rename src/web/src/views/workspace/{commandGroupContent => WSEditorCommandGroupContent}/CommandGroupDialog.tsx (100%) rename src/web/src/views/workspace/{commandGroupContent => WSEditorCommandGroupContent}/WSEditorCommandGroupContent.tsx (100%) rename src/web/src/views/workspace/{commandGroupContent => WSEditorCommandGroupContent}/index.tsx (100%) diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index 71fb48f7..e6fe97d6 100644 --- a/src/web/src/__tests__/components/WSEditor.test.tsx +++ b/src/web/src/__tests__/components/WSEditor.test.tsx @@ -55,7 +55,7 @@ vi.mock("../../views/workspace/WSEditorCommandTree", () => ({ CommandTreeNode: {}, })); -vi.mock("../../views/workspace/commandGroupContent", () => ({ +vi.mock("../../views/workspace/WSEditorCommandGroupContent", () => ({ default: ({ commandGroup, onUpdateCommandGroup }: any) => (
{commandGroup.id} @@ -70,7 +70,7 @@ vi.mock("../../views/workspace/commandGroupContent", () => ({ ResponseCommandGroups: {}, })); -vi.mock("../../views/workspace/commandContent/WSEditorCommandContent", () => ({ +vi.mock("../../views/workspace/WSEditorCommandContent", () => ({ default: ({ previewCommand, onUpdateCommand }: any) => (
{previewCommand.id} diff --git a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx index 77dde44f..17900ca1 100644 --- a/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandContent.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen, waitFor, within } from "@testing-library/react"; -import WSEditorCommandContent from "../../views/workspace/commandContent/WSEditorCommandContent"; -import type { Command, Example, Resource } from "../../views/workspace/commandContent/WSEditorCommandContent"; +import WSEditorCommandContent from "../../views/workspace/WSEditorCommandContent"; +import type { Command, Example, Resource } from "../../views/workspace/interfaces"; import { render } from "../test-utils"; import { commandApi } from "../../services/commandApi"; diff --git a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx index 84947a97..7a1b8f78 100644 --- a/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandGroupContent.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; -import WSEditorCommandGroupContent from "../../views/workspace/commandGroupContent/WSEditorCommandGroupContent"; +import WSEditorCommandGroupContent from "../../views/workspace/WSEditorCommandGroupContent/WSEditorCommandGroupContent"; import * as commandApi from "../../services/commandApi"; interface CommandGroup { diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx index e6099b49..99f9b2af 100644 --- a/src/web/src/views/workspace/WSEditor.tsx +++ b/src/web/src/views/workspace/WSEditor.tsx @@ -26,8 +26,8 @@ import { TransitionProps } from "@mui/material/transitions"; import WSEditorSwaggerPicker from "./WSEditorSwaggerPicker"; import WSEditorToolBar from "./WSEditorToolBar"; import WSEditorCommandTree, { CommandTreeLeaf, CommandTreeNode } from "./WSEditorCommandTree"; -import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "./commandGroupContent"; -import WSEditorCommandContent, { DecodeResponseCommand } from "./commandContent/WSEditorCommandContent"; +import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; +import WSEditorCommandContent, { DecodeResponseCommand } from "./WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; import type { CommandGroup, diff --git a/src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/AddSubcommandDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/AddSubcommandDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/AddSubcommandDialog.tsx diff --git a/src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/CommandDeleteDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx diff --git a/src/web/src/views/workspace/commandContent/ExampleDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/ExampleDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/ExampleDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/ExampleDialog.tsx diff --git a/src/web/src/views/workspace/commandContent/OutputCard.tsx b/src/web/src/views/workspace/WSEditorCommandContent/OutputCard.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/OutputCard.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/OutputCard.tsx diff --git a/src/web/src/views/workspace/commandContent/OutputDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/OutputDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx diff --git a/src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx similarity index 100% rename from src/web/src/views/workspace/commandContent/WSEditorCommandContent.tsx rename to src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx diff --git a/src/web/src/views/workspace/commandContent/index.ts b/src/web/src/views/workspace/WSEditorCommandContent/index.ts similarity index 51% rename from src/web/src/views/workspace/commandContent/index.ts rename to src/web/src/views/workspace/WSEditorCommandContent/index.ts index f078757b..766bd15a 100644 --- a/src/web/src/views/workspace/commandContent/index.ts +++ b/src/web/src/views/workspace/WSEditorCommandContent/index.ts @@ -1,2 +1,2 @@ -export { default as WSEditorCommandContent, DecodeResponseCommand } from "./WSEditorCommandContent"; +export { default, DecodeResponseCommand } from "./WSEditorCommandContent"; export type { Command, Resource, ResponseCommand, ResponseCommands, Example, Plane } from "../interfaces"; diff --git a/src/web/src/views/workspace/commandGroupContent/CommandGroupDeleteDialog.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandGroupContent/CommandGroupDeleteDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx diff --git a/src/web/src/views/workspace/commandGroupContent/CommandGroupDialog.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent/CommandGroupDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandGroupContent/CommandGroupDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandGroupContent/CommandGroupDialog.tsx diff --git a/src/web/src/views/workspace/commandGroupContent/WSEditorCommandGroupContent.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx similarity index 100% rename from src/web/src/views/workspace/commandGroupContent/WSEditorCommandGroupContent.tsx rename to src/web/src/views/workspace/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx diff --git a/src/web/src/views/workspace/commandGroupContent/index.tsx b/src/web/src/views/workspace/WSEditorCommandGroupContent/index.tsx similarity index 100% rename from src/web/src/views/workspace/commandGroupContent/index.tsx rename to src/web/src/views/workspace/WSEditorCommandGroupContent/index.tsx diff --git a/src/web/src/views/workspace/interfaces/commands.ts b/src/web/src/views/workspace/interfaces/commands.ts index fb144c01..23bf70db 100644 --- a/src/web/src/views/workspace/interfaces/commands.ts +++ b/src/web/src/views/workspace/interfaces/commands.ts @@ -1,4 +1,4 @@ -import type { Output } from "../commandContent/OutputDialog"; +import type { Output } from "../WSEditorCommandContent/OutputDialog"; import type { CMDArg, ClsArgDefinitionMap } from "../commandArgumentsContent"; export interface Plane { From 25303153a5f309270dfef8e9a72b3c5e8eda6e6d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 06:43:46 +1100 Subject: [PATCH 041/107] refactor: change dir names to match main component in dir --- .../__tests__/components/WSECArgumentSimilarPicker.test.tsx | 2 +- .../components/WSEditorCommandArgumentsContent.test.tsx | 4 ++-- .../ArgNavBar.tsx | 0 .../ArgumentDialog.tsx | 0 .../ArgumentNavigation.tsx | 0 .../ArgumentPropsReviewer.tsx | 0 .../ArgumentReviewer.tsx | 0 .../FlattenDialog.tsx | 0 .../UnwrapClsDialog.tsx | 0 .../WSECArgumentSimilarPicker.tsx | 0 .../WSEditorCommandArgumentsContent.tsx | 0 .../index.ts | 3 ++- .../WSEditorCommandContent/WSEditorCommandContent.tsx | 2 +- src/web/src/views/workspace/interfaces/commands.ts | 2 +- 14 files changed, 7 insertions(+), 6 deletions(-) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/ArgNavBar.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/ArgumentDialog.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/ArgumentNavigation.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/ArgumentPropsReviewer.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/ArgumentReviewer.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/FlattenDialog.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/UnwrapClsDialog.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/WSECArgumentSimilarPicker.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/WSEditorCommandArgumentsContent.tsx (100%) rename src/web/src/views/workspace/{commandArgumentsContent => WSEditorCommandArgumentsContent}/index.ts (84%) diff --git a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx index 1e0f5f66..41fd86bd 100644 --- a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen } from "@testing-library/react"; import WSECArgumentSimilarPicker, { BuildArgSimilarTree, type ArgSimilarTree, -} from "../../views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker"; +} from "../../views/workspace/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker"; import { render } from "../test-utils"; describe("WSECArgumentSimilarPicker", () => { diff --git a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx index 8a68ecf1..21b83f4f 100644 --- a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; -import WSEditorCommandArgumentsContent from "../../views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent"; +import WSEditorCommandArgumentsContent from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; import type { CMDArg, ClsArgDefinitionMap, -} from "../../views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent"; +} from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; import { render } from "../test-utils"; vi.mock("../../services/commandApi"); diff --git a/src/web/src/views/workspace/commandArgumentsContent/ArgNavBar.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgNavBar.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/ArgNavBar.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgNavBar.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/ArgumentDialog.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/ArgumentDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentDialog.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/ArgumentNavigation.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/ArgumentNavigation.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/ArgumentPropsReviewer.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/ArgumentReviewer.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/ArgumentReviewer.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/FlattenDialog.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/FlattenDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/FlattenDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/FlattenDialog.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/UnwrapClsDialog.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/UnwrapClsDialog.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/WSECArgumentSimilarPicker.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx similarity index 100% rename from src/web/src/views/workspace/commandArgumentsContent/WSEditorCommandArgumentsContent.tsx rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx diff --git a/src/web/src/views/workspace/commandArgumentsContent/index.ts b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts similarity index 84% rename from src/web/src/views/workspace/commandArgumentsContent/index.ts rename to src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts index c1063296..44f7628e 100644 --- a/src/web/src/views/workspace/commandArgumentsContent/index.ts +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts @@ -1,4 +1,5 @@ -export { default, DecodeArgs } from "./WSEditorCommandArgumentsContent"; +export { default } from "./WSEditorCommandArgumentsContent"; +export { DecodeArgs } from "./WSEditorCommandArgumentsContent"; export type { ClsArgDefinitionMap, CMDArg } from "./WSEditorCommandArgumentsContent"; export { default as ArgumentDialog } from "./ArgumentDialog"; export { default as FlattenDialog } from "./FlattenDialog"; diff --git a/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx index a41cdd01..49d94c7b 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -40,7 +40,7 @@ import LabelIcon from "@mui/icons-material/Label"; import EditIcon from "@mui/icons-material/Edit"; import { commandApi, errorHandlerApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; -import WSEditorCommandArgumentsContent, { DecodeArgs } from "../commandArgumentsContent"; +import WSEditorCommandArgumentsContent, { DecodeArgs } from "../WSEditorCommandArgumentsContent"; import ExampleDialog from "./ExampleDialog"; import AddSubcommandDialog from "./AddSubcommandDialog"; import CommandDeleteDialog from "./CommandDeleteDialog"; diff --git a/src/web/src/views/workspace/interfaces/commands.ts b/src/web/src/views/workspace/interfaces/commands.ts index 23bf70db..59073afb 100644 --- a/src/web/src/views/workspace/interfaces/commands.ts +++ b/src/web/src/views/workspace/interfaces/commands.ts @@ -1,5 +1,5 @@ import type { Output } from "../WSEditorCommandContent/OutputDialog"; -import type { CMDArg, ClsArgDefinitionMap } from "../commandArgumentsContent"; +import type { CMDArg, ClsArgDefinitionMap } from "../WSEditorCommandArgumentsContent"; export interface Plane { name: string; From 181981920448ebed8eaaf907cacaedb1b85b6b75 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 06:59:16 +1100 Subject: [PATCH 042/107] feature: add unit tests for DecodeArgs --- .../__tests__/unit/argumentDecoding.test.ts | 825 ++++++++++++++++++ 1 file changed, 825 insertions(+) create mode 100644 src/web/src/__tests__/unit/argumentDecoding.test.ts diff --git a/src/web/src/__tests__/unit/argumentDecoding.test.ts b/src/web/src/__tests__/unit/argumentDecoding.test.ts new file mode 100644 index 00000000..d414512a --- /dev/null +++ b/src/web/src/__tests__/unit/argumentDecoding.test.ts @@ -0,0 +1,825 @@ +import { describe, it, expect } from "vitest"; +import { DecodeArgs } from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; + +describe("Argument Decoding Functions", () => { + describe("DecodeArgs", () => { + it("should decode empty argument groups", () => { + const result = DecodeArgs([]); + + expect(result.args).toEqual([]); + expect(result.clsArgDefineMap).toEqual({}); + }); + + it("should decode basic string arguments", () => { + const argGroups = [ + { + args: [ + { + var: "resource_group", + options: ["--resource-group", "-g"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Resource group name", + lines: ["The name of the resource group"], + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "resource_group", + options: ["--resource-group", "-g"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + }); + expect(result.args[0].help?.short).toBe("Resource group name"); + expect(result.args[0].help?.lines).toEqual(["The name of the resource group"]); + }); + + it("should decode integer arguments with defaults", () => { + const argGroups = [ + { + args: [ + { + var: "count", + options: ["--count", "-c"], + type: "integer", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + default: { + value: 1, + }, + help: { + short: "Number of instances", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "count", + type: "integer", + required: false, + default: { + value: 1, + }, + }); + }); + + it("should decode boolean arguments", () => { + const argGroups = [ + { + args: [ + { + var: "force", + options: ["--force"], + type: "boolean", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Force the operation", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "force", + type: "boolean", + required: false, + }); + }); + + it("should decode array arguments", () => { + const argGroups = [ + { + args: [ + { + var: "tags", + options: ["--tags"], + type: "array", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + item: { + type: "string", + nullable: false, + }, + singularOptions: ["--tag"], + help: { + short: "Tags for the resource", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "tags", + type: "array", + required: false, + singularOptions: ["--tag"], + }); + }); + + it("should decode object arguments with nested properties", () => { + const argGroups = [ + { + args: [ + { + var: "properties", + options: ["--properties"], + type: "object", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + args: [ + { + var: "name", + options: ["--name"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Property name", + }, + }, + { + var: "value", + options: ["--value"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Property value", + }, + }, + ], + help: { + short: "Object properties", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "properties", + type: "object", + required: false, + }); + // For object args with nested properties, they are stored directly on the arg + const objArg = result.args[0] as any; + expect(objArg.args).toHaveLength(2); + expect(objArg.args[0].var).toBe("name"); + expect(objArg.args[1].var).toBe("value"); + }); + + it("should decode dictionary arguments", () => { + const argGroups = [ + { + args: [ + { + var: "metadata", + options: ["--metadata"], + type: "object", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + additionalProps: { + item: { + type: "string", + nullable: false, + }, + }, + help: { + short: "Metadata dictionary", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "metadata", + type: "dict", + required: false, + }); + }); + + it("should decode enum arguments", () => { + const argGroups = [ + { + args: [ + { + var: "sku", + options: ["--sku"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + enum: { + items: [ + { name: "Standard", value: "Standard", hide: false }, + { name: "Premium", value: "Premium", hide: false }, + { name: "Basic", value: "Basic", hide: true }, + ], + }, + help: { + short: "SKU type", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "sku", + type: "string", + required: false, + hasEnum: true, + }); + // The enum information is encoded into the argument during decoding + // We can test that hasEnum is true, which indicates enum processing occurred + expect(result.args[0].hasEnum).toBe(true); + }); + + it("should decode class reference arguments", () => { + const argGroups = [ + { + args: [ + { + var: "config", + options: ["--config"], + type: "@ConfigurationClass", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + clsName: "ConfigurationClass", + help: { + short: "Configuration object", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "config", + type: "@ConfigurationClass", + required: false, + clsName: "ConfigurationClass", + }); + }); + + it("should decode password arguments with prompt", () => { + const argGroups = [ + { + args: [ + { + var: "password", + options: ["--password", "-p"], + type: "password", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + prompt: { + msg: "Enter password:", + confirm: true, + }, + help: { + short: "User password", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "password", + type: "password", + required: true, + }); + expect(result.args[0].prompt).toMatchObject({ + msg: "Enter password:", + confirm: true, + }); + }); + + it("should handle multiple argument groups", () => { + const argGroups = [ + { + args: [ + { + var: "name", + options: ["--name", "-n"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "Basic", + nullable: false, + help: { + short: "Resource name", + }, + }, + ], + }, + { + args: [ + { + var: "location", + options: ["--location", "-l"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "Advanced", + nullable: false, + help: { + short: "Resource location", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(2); + expect(result.args[0].var).toBe("name"); + expect(result.args[0].group).toBe("Basic"); + expect(result.args[1].var).toBe("location"); + expect(result.args[1].group).toBe("Advanced"); + }); + + it("should handle complex nested class definitions", () => { + const argGroups = [ + { + args: [ + { + var: "properties", + options: ["--properties"], + type: "object", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + cls: "PropertiesClass", + args: [ + { + var: "nested_config", + options: ["--nested-config"], + type: "object", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + cls: "NestedConfigClass", + args: [ + { + var: "deep_property", + options: ["--deep-property"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Deep nested property", + }, + }, + ], + help: { + short: "Nested configuration", + }, + }, + ], + help: { + short: "Properties object", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "properties", + type: "@PropertiesClass", + clsName: "PropertiesClass", + }); + expect(result.clsArgDefineMap).toHaveProperty("PropertiesClass"); + expect(result.clsArgDefineMap).toHaveProperty("NestedConfigClass"); + }); + + it("should sort options by length in descending order", () => { + const argGroups = [ + { + args: [ + { + var: "name", + options: ["-n", "--name", "--full-name"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Resource name", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args[0].options).toEqual(["--full-name", "--name", "-n"]); + }); + + it("should handle missing optional properties gracefully", () => { + const argGroups = [ + { + args: [ + { + var: "minimal", + options: ["--minimal"], + type: "string", + // Missing optional properties like required, stage, hide, etc. + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "minimal", + options: ["--minimal"], + type: "string", + required: false, // Default value + stage: "Stable", // Default value + hide: false, // Default value + group: "", // Default value + nullable: false, // Default value + }); + }); + + it("should handle blank values for different types", () => { + const argGroups = [ + { + args: [ + { + var: "optional_string", + options: ["--optional-string"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + blank: { + value: "", + }, + help: { + short: "Optional string with blank", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "optional_string", + type: "string", + blank: { + value: "", + }, + }); + }); + + it("should handle array of different primitive types", () => { + const testCases = [ + { type: "array", itemType: "integer" }, + { type: "array", itemType: "float" }, + { type: "array", itemType: "boolean" }, + ]; + + testCases.forEach(({ type, itemType }) => { + const argGroups = [ + { + args: [ + { + var: "test_array", + options: ["--test-array"], + type, + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + item: { + type: itemType, + nullable: false, + }, + help: { + short: `Array of ${itemType}`, + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "test_array", + type, + required: false, + }); + }); + }); + + it("should handle configuration keys and ID parts", () => { + const argGroups = [ + { + args: [ + { + var: "subscription_id", + options: ["--subscription"], + type: "SubscriptionId", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + idPart: "subscription", + configurationKey: "core.subscription_id", + help: { + short: "Subscription ID", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "subscription_id", + type: "SubscriptionId", + idPart: "subscription", + configurationKey: "core.subscription_id", + }); + }); + + it("should handle enum extension support", () => { + const argGroups = [ + { + args: [ + { + var: "extensible_enum", + options: ["--extensible-enum"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + enum: { + items: [ + { name: "Option1", value: "option1", hide: false }, + { name: "Option2", value: "option2", hide: false }, + ], + supportExtension: true, + }, + help: { + short: "Extensible enum", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "extensible_enum", + type: "string", + supportEnumExtension: true, + hasEnum: true, + }); + }); + }); + + describe("Edge Cases and Error Handling", () => { + it("should handle null and undefined responses gracefully", () => { + expect(() => DecodeArgs([])).not.toThrow(); + expect(() => DecodeArgs([{ args: [] }])).not.toThrow(); + }); + + it("should handle arguments with missing help", () => { + const argGroups = [ + { + args: [ + { + var: "no_help", + options: ["--no-help"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + // No help property + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0].help).toBeUndefined(); + }); + + it("should handle arguments with missing defaults", () => { + const argGroups = [ + { + args: [ + { + var: "no_default", + options: ["--no-default"], + type: "string", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + // No default property + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0].default).toBeUndefined(); + }); + + it("should handle array arguments without item definition", () => { + const argGroups = [ + { + args: [ + { + var: "invalid_array", + options: ["--invalid-array"], + type: "array", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + // Missing item property - this should cause an error + }, + ], + }, + ]; + + expect(() => DecodeArgs(argGroups)).toThrow("Invalid array object. Item is not defined"); + }); + + it("should handle unknown argument types", () => { + const argGroups = [ + { + args: [ + { + var: "unknown_type", + options: ["--unknown-type"], + type: "unknown_custom_type", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + }, + ], + }, + ]; + + expect(() => DecodeArgs(argGroups)).toThrow("Unknown type 'unknown_custom_type'"); + }); + + it("should handle dict with any type", () => { + const argGroups = [ + { + args: [ + { + var: "any_dict", + options: ["--any-dict"], + type: "object", + required: false, + stage: "Stable", + hide: false, + group: "", + nullable: false, + additionalProps: { + anyType: true, + }, + help: { + short: "Dictionary with any value type", + }, + }, + ], + }, + ]; + + const result = DecodeArgs(argGroups); + + expect(result.args).toHaveLength(1); + expect(result.args[0]).toMatchObject({ + var: "any_dict", + type: "dict", + anyType: true, + }); + }); + }); +}); From 2509bc9c0c4b1ecaace4dc0f78a167d4b80a7019 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 07:06:48 +1100 Subject: [PATCH 043/107] refactor: extract decodeArgs to utils --- .../__tests__/unit/argumentDecoding.test.ts | 2 +- .../WSEditorCommandArgumentsContent.tsx | 499 +---------------- .../WSEditorCommandArgumentsContent/index.ts | 4 +- .../src/views/workspace/utils/decodeArgs.ts | 505 ++++++++++++++++++ 4 files changed, 518 insertions(+), 492 deletions(-) create mode 100644 src/web/src/views/workspace/utils/decodeArgs.ts diff --git a/src/web/src/__tests__/unit/argumentDecoding.test.ts b/src/web/src/__tests__/unit/argumentDecoding.test.ts index d414512a..72640b51 100644 --- a/src/web/src/__tests__/unit/argumentDecoding.test.ts +++ b/src/web/src/__tests__/unit/argumentDecoding.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { DecodeArgs } from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; +import { DecodeArgs } from "../../views/workspace/utils/decodeArgs"; describe("Argument Decoding Functions", () => { describe("DecodeArgs", () => { diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx index 5bd0324d..46ed701b 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx @@ -9,6 +9,16 @@ import UnwrapClsDialog from "./UnwrapClsDialog"; import FlattenDialog from "./FlattenDialog"; import { CardTitleTypography } from "../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; @@ -185,493 +195,4 @@ const WSEditorCommandArgumentsContent: React.FC = { - 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 default WSEditorCommandArgumentsContent; -export { DecodeArgs }; -export type { ClsArgDefinitionMap, CMDArg }; diff --git a/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts index 44f7628e..6c47285b 100644 --- a/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts +++ b/src/web/src/views/workspace/WSEditorCommandArgumentsContent/index.ts @@ -1,6 +1,6 @@ export { default } from "./WSEditorCommandArgumentsContent"; -export { DecodeArgs } from "./WSEditorCommandArgumentsContent"; -export type { ClsArgDefinitionMap, CMDArg } 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"; 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, +}; From a82ea13afddd4002832b3ffabb10e281c13559fc Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 07:39:41 +1100 Subject: [PATCH 044/107] refactor: extract CommandDialog --- .../WSEditorCommandArgumentsContent.test.tsx | 5 +- .../unit/decodeResponseCommand.test.ts | 160 ++++++++++++ .../CommandDeleteDialog.tsx | 2 +- .../WSEditorCommandContent/CommandDialog.tsx | 210 ++++++++++++++++ .../WSEditorCommandContent/ExampleDialog.tsx | 2 +- .../WSEditorCommandContent/OutputDialog.tsx | 27 +- .../WSEditorCommandContent.tsx | 235 +----------------- .../workspace/WSEditorCommandContent/index.ts | 3 +- .../workspace/utils/decodeResponseCommand.ts | 30 +++ 9 files changed, 411 insertions(+), 263 deletions(-) create mode 100644 src/web/src/__tests__/unit/decodeResponseCommand.test.ts create mode 100644 src/web/src/views/workspace/WSEditorCommandContent/CommandDialog.tsx create mode 100644 src/web/src/views/workspace/utils/decodeResponseCommand.ts diff --git a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx index 21b83f4f..f704f7bf 100644 --- a/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx +++ b/src/web/src/__tests__/components/WSEditorCommandArgumentsContent.test.tsx @@ -1,11 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; import WSEditorCommandArgumentsContent from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; -import type { - CMDArg, - ClsArgDefinitionMap, -} from "../../views/workspace/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent"; import { render } from "../test-utils"; +import { ClsArgDefinitionMap, CMDArg } from "../../views/workspace/WSEditorCommandArgumentsContent"; vi.mock("../../services/commandApi"); vi.mock("../../services/errorHandlerApi"); diff --git a/src/web/src/__tests__/unit/decodeResponseCommand.test.ts b/src/web/src/__tests__/unit/decodeResponseCommand.test.ts new file mode 100644 index 00000000..f6a431b0 --- /dev/null +++ b/src/web/src/__tests__/unit/decodeResponseCommand.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { DecodeResponseCommand } from "../../views/workspace/utils/decodeResponseCommand"; +import type { ResponseCommand } from "../../views/workspace/interfaces"; + +describe("DecodeResponseCommand", () => { + it("should decode basic response command", () => { + const responseCommand: ResponseCommand = { + names: ["group", "command"], + help: { + short: "Test command", + lines: ["This is a test command", "With multiple lines"], + }, + stage: "Stable", + version: "1.0.0", + resources: [ + { + id: "test-resource", + version: "1.0.0", + swagger: "https://example.com/swagger", + }, + ], + }; + + const result = DecodeResponseCommand(responseCommand); + + expect(result).toEqual({ + id: "command:group/command", + names: ["group", "command"], + help: { + short: "Test command", + lines: ["This is a test command", "With multiple lines"], + }, + stage: "Stable", + version: "1.0.0", + resources: [ + { + id: "test-resource", + version: "1.0.0", + swagger: "https://example.com/swagger", + }, + ], + }); + }); + + it("should handle default stage when not provided", () => { + const responseCommand: ResponseCommand = { + names: ["test"], + version: "1.0.0", + resources: [], + }; + + const result = DecodeResponseCommand(responseCommand); + + expect(result.stage).toBe("Stable"); + expect(result.id).toBe("command:test"); + }); + + it("should handle confirmation message", () => { + const responseCommand: ResponseCommand = { + names: ["test"], + version: "1.0.0", + resources: [], + confirmation: "Are you sure?", + }; + + const result = DecodeResponseCommand(responseCommand); + + expect(result.confirmation).toBe("Are you sure?"); + }); + + it("should decode arguments when argGroups are provided", () => { + const responseCommand: ResponseCommand = { + names: ["test"], + version: "1.0.0", + resources: [], + argGroups: [ + { + args: [ + { + var: "name", + options: ["--name", "-n"], + type: "string", + required: true, + stage: "Stable", + hide: false, + group: "", + nullable: false, + help: { + short: "Resource name", + }, + }, + ], + }, + ], + }; + + const result = DecodeResponseCommand(responseCommand); + + expect(result.args).toBeDefined(); + expect(result.args).toHaveLength(1); + expect(result.args![0].var).toBe("name"); + expect(result.clsArgDefineMap).toBeDefined(); + }); + + it("should handle optional properties correctly", () => { + const responseCommand: ResponseCommand = { + names: ["test"], + version: "1.0.0", + resources: [], + examples: [ + { + name: "Example 1", + commands: ["az test --name myname"], + }, + ], + outputs: [ + { + type: "string", + ref: "#/definitions/TestOutput", + value: "result", + }, + ], + }; + + const result = DecodeResponseCommand(responseCommand); + + expect(result.examples).toEqual([ + { + name: "Example 1", + commands: ["az test --name myname"], + }, + ]); + expect(result.outputs).toEqual([ + { + type: "string", + ref: "#/definitions/TestOutput", + value: "result", + }, + ]); + }); + + it("should generate correct command ID from names", () => { + const testCases = [ + { names: ["single"], expected: "command:single" }, + { names: ["group", "command"], expected: "command:group/command" }, + { names: ["a", "b", "c", "d"], expected: "command:a/b/c/d" }, + ]; + + testCases.forEach(({ names, expected }) => { + const responseCommand: ResponseCommand = { + names, + version: "1.0.0", + resources: [], + }; + + const result = DecodeResponseCommand(responseCommand); + expect(result.id).toBe(expected); + }); + }); +}); diff --git a/src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx index f3765bef..a246538b 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent/CommandDeleteDialog.tsx @@ -11,7 +11,7 @@ import { import React from "react"; import { commandApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; -import { DecodeResponseCommand } from "./WSEditorCommandContent"; +import { DecodeResponseCommand } from "../utils/decodeResponseCommand"; import type { Command, ResponseCommand } from "../interfaces"; export interface CommandDeleteDialogProps { diff --git a/src/web/src/views/workspace/WSEditorCommandContent/CommandDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/CommandDialog.tsx new file mode 100644 index 00000000..322ef016 --- /dev/null +++ b/src/web/src/views/workspace/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/WSEditorCommandContent/ExampleDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/ExampleDialog.tsx index 265f63aa..54374805 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/ExampleDialog.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent/ExampleDialog.tsx @@ -24,7 +24,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { commandApi, errorHandlerApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; import { ExampleItemSelector } from "../WSEditorExamplePicker"; -import { DecodeResponseCommand } from "./WSEditorCommandContent"; +import { DecodeResponseCommand } from "../utils/decodeResponseCommand"; import type { Command, Example } from "../interfaces"; export interface ExampleDialogProps { diff --git a/src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx b/src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx index 6caa8d70..22eabdfb 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent/OutputDialog.tsx @@ -17,6 +17,7 @@ import { } from "@mui/material"; import { styled } from "@mui/material"; import { commandApi, errorHandlerApi } from "../../../services"; +import { DecodeResponseCommand } from "../utils/decodeResponseCommand"; interface ObjectOutput { type: "object"; @@ -60,18 +61,6 @@ interface Command { resources: any[]; } -interface ResponseCommand { - names: string[]; - help?: { - short: string; - lines?: string[]; - }; - stage?: "Stable" | "Preview" | "Experimental"; - version: string; - outputs?: Output[]; - resources: any[]; -} - const OutputDialogLabel = styled(FormLabel)(() => ({ fontSize: 12, })); @@ -91,20 +80,6 @@ interface OutputDialogProps { onClose: (newCommand?: Command) => void; } -const DecodeResponseCommand = (command: ResponseCommand): Command => { - let cmd: Command = { - id: "command:" + command.names.join("/"), - names: command.names, - help: command.help, - stage: command.stage ?? "Stable", - outputs: command.outputs, - resources: command.resources, - version: command.version, - }; - - return cmd; -}; - const OutputDialog: React.FC = (props) => { const [updating, setUpdating] = useState(false); const [invalidText, setInvalidText] = useState(undefined); diff --git a/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx index 49d94c7b..f52c8001 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -1,22 +1,12 @@ import { styled, - Alert, Box, Button, Card, CardActions, CardContent, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormControlLabel, Accordion, - InputLabel, LinearProgress, - Radio, - RadioGroup, - TextField, Typography, TypographyProps, AccordionDetails, @@ -38,15 +28,17 @@ import { import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; import LabelIcon from "@mui/icons-material/Label"; import EditIcon from "@mui/icons-material/Edit"; -import { commandApi, errorHandlerApi } from "../../../services"; +import { commandApi } from "../../../services"; import { COMMAND_PREFIX } from "../../../constants"; -import WSEditorCommandArgumentsContent, { DecodeArgs } from "../WSEditorCommandArgumentsContent"; +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, ResponseCommand, Example } from "../interfaces"; +import type { Command, Example } from "../interfaces"; interface WSEditorCommandContentProps { workspaceUrl: string; @@ -562,221 +554,4 @@ const WSEditorCommandContent: React.FC = ({ ); }; -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 && ( - - - - - )} - - - ); -}; - -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 }; diff --git a/src/web/src/views/workspace/WSEditorCommandContent/index.ts b/src/web/src/views/workspace/WSEditorCommandContent/index.ts index 766bd15a..f8c09433 100644 --- a/src/web/src/views/workspace/WSEditorCommandContent/index.ts +++ b/src/web/src/views/workspace/WSEditorCommandContent/index.ts @@ -1,2 +1,3 @@ -export { default, DecodeResponseCommand } from "./WSEditorCommandContent"; +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/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 }; From e4ea4b7a41595d6882de817fe104254dc6d664e0 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 08:08:24 +1100 Subject: [PATCH 045/107] refactor: swagger picker into common --- src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx | 2 +- src/web/src/views/workspace/WSEditorClientConfig.tsx | 2 +- src/web/src/views/workspace/WSEditorSwaggerPicker.tsx | 2 +- src/web/src/views/workspace/WorkspaceCreateDialog.tsx | 2 +- .../src/views/workspace/{ => common}/SwaggerItemSelector.tsx | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename src/web/src/views/workspace/{ => common}/SwaggerItemSelector.tsx (100%) diff --git a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx index 43c58a15..d0ede2d2 100644 --- a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx +++ b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import WSEditorSwaggerPicker from "../../views/workspace/WSEditorSwaggerPicker"; -import SwaggerItemSelector from "../../views/workspace/SwaggerItemSelector"; +import SwaggerItemSelector from "../../views/workspace/common/SwaggerItemSelector"; import { render } from "../test-utils"; import { workspaceApi, specsApi } from "../../services"; diff --git a/src/web/src/views/workspace/WSEditorClientConfig.tsx b/src/web/src/views/workspace/WSEditorClientConfig.tsx index 62453996..7bf285d6 100644 --- a/src/web/src/views/workspace/WSEditorClientConfig.tsx +++ b/src/web/src/views/workspace/WSEditorClientConfig.tsx @@ -22,7 +22,7 @@ import { import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; import DoDisturbOnRoundedIcon from "@mui/icons-material/DoDisturbOnRounded"; import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded"; -import SwaggerItemSelector from "./SwaggerItemSelector"; +import SwaggerItemSelector from "./common/SwaggerItemSelector"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import type { Plane, Resource } from "./interfaces"; diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx index cf3b127b..14c89dc9 100644 --- a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx @@ -29,7 +29,7 @@ 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 "./SwaggerItemSelector"; +import SwaggerItemSelector from "./common/SwaggerItemSelector"; interface WSEditorSwaggerPickerProps { workspaceName: string; diff --git a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx index 4e2ba6c6..ed95468b 100644 --- a/src/web/src/views/workspace/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/WorkspaceCreateDialog.tsx @@ -10,7 +10,7 @@ import { Alert, } from "@mui/material"; import React, { useState, useEffect, useCallback } from "react"; -import SwaggerItemSelector from "./SwaggerItemSelector"; +import SwaggerItemSelector from "./common/SwaggerItemSelector"; import styled from "@emotion/styled"; import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; import type { Plane } from "./interfaces"; diff --git a/src/web/src/views/workspace/SwaggerItemSelector.tsx b/src/web/src/views/workspace/common/SwaggerItemSelector.tsx similarity index 100% rename from src/web/src/views/workspace/SwaggerItemSelector.tsx rename to src/web/src/views/workspace/common/SwaggerItemSelector.tsx From eb56185d8d18b1b43d9131ded3c2488b6c375c09 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 08:18:06 +1100 Subject: [PATCH 046/107] refactor: delete old swagger picker --- .../{ => WSEditorSwaggerPicker}/WSEditorSwaggerPicker.tsx | 8 ++++---- .../src/views/workspace/WSEditorSwaggerPicker/index.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) rename src/web/src/views/workspace/{ => WSEditorSwaggerPicker}/WSEditorSwaggerPicker.tsx (99%) create mode 100644 src/web/src/views/workspace/WSEditorSwaggerPicker/index.ts diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx similarity index 99% rename from src/web/src/views/workspace/WSEditorSwaggerPicker.tsx rename to src/web/src/views/workspace/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index 14c89dc9..41ea739c 100644 --- a/src/web/src/views/workspace/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -25,11 +25,11 @@ import { FormHelperText, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import { workspaceApi, specsApi, errorHandlerApi } from "../../services"; -import EditorPageLayout from "../../components/EditorPageLayout"; +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 { getTypespecRPResources, getTypespecRPResourcesOperations } from "../../../typespec"; +import SwaggerItemSelector from "../common/SwaggerItemSelector"; interface WSEditorSwaggerPickerProps { workspaceName: string; diff --git a/src/web/src/views/workspace/WSEditorSwaggerPicker/index.ts b/src/web/src/views/workspace/WSEditorSwaggerPicker/index.ts new file mode 100644 index 00000000..abe3fadc --- /dev/null +++ b/src/web/src/views/workspace/WSEditorSwaggerPicker/index.ts @@ -0,0 +1,2 @@ +export { default } from "./WSEditorSwaggerPicker"; +export { default as WSEditorSwaggerPicker } from "./WSEditorSwaggerPicker"; From 82881e0c99a0f9e7afa7f5d54d1af9db21de11be Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 09:34:26 +1100 Subject: [PATCH 047/107] refactor: move WSEditor.tsx into own dir --- src/web/src/views/workspace/WSEditor.tsx | 1158 ----------------- .../src/views/workspace/WSEditor/WSEditor.tsx | 550 ++++++++ .../WSEditor/WSEditorDeleteDialog.tsx | 89 ++ .../WSEditor/WSEditorExportDialog.tsx | 125 ++ .../WSEditor/WSEditorSwaggerReloadDialog.tsx | 307 +++++ .../workspace/WSEditor/WSRenameDialog.tsx | 132 ++ src/web/src/views/workspace/WSEditor/index.ts | 5 + 7 files changed, 1208 insertions(+), 1158 deletions(-) delete mode 100644 src/web/src/views/workspace/WSEditor.tsx create mode 100644 src/web/src/views/workspace/WSEditor/WSEditor.tsx create mode 100644 src/web/src/views/workspace/WSEditor/WSEditorDeleteDialog.tsx create mode 100644 src/web/src/views/workspace/WSEditor/WSEditorExportDialog.tsx create mode 100644 src/web/src/views/workspace/WSEditor/WSEditorSwaggerReloadDialog.tsx create mode 100644 src/web/src/views/workspace/WSEditor/WSRenameDialog.tsx create mode 100644 src/web/src/views/workspace/WSEditor/index.ts diff --git a/src/web/src/views/workspace/WSEditor.tsx b/src/web/src/views/workspace/WSEditor.tsx deleted file mode 100644 index 99f9b2af..00000000 --- a/src/web/src/views/workspace/WSEditor.tsx +++ /dev/null @@ -1,1158 +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, { DecodeResponseCommandGroup } from "./WSEditorCommandGroupContent"; -import WSEditorCommandContent, { DecodeResponseCommand } from "./WSEditorCommandContent"; -import WSEditorClientConfigDialog from "./WSEditorClientConfig"; -import type { - CommandGroup, - ResponseCommandGroup, - ResponseCommandGroups, - Command, - Resource, - ResponseCommand, -} from "./interfaces"; -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/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/WSEditor/WSEditor.tsx new file mode 100644 index 00000000..52a76b01 --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/WSEditor.tsx @@ -0,0 +1,550 @@ +import * as React 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 "../WSEditorSwaggerPicker"; +import WSEditorToolBar from "../WSEditorToolBar"; +import WSEditorCommandTree, { CommandTreeLeaf, CommandTreeNode } from "../WSEditorCommandTree"; +import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "../WSEditorCommandGroupContent"; +import WSEditorCommandContent, { DecodeResponseCommand } from "../WSEditorCommandContent"; +import WSEditorClientConfigDialog from "../WSEditorClientConfig"; +import type { + CommandGroup, + ResponseCommandGroup, + ResponseCommandGroups, + Command, + ResponseCommand, +} from "../interfaces"; +import { workspaceApi, specsApi } from "../../../services"; +import WSEditorExportDialog from "./WSEditorExportDialog"; +import WSEditorDeleteDialog from "./WSEditorDeleteDialog"; +import WSEditorSwaggerReloadDialog from "./WSEditorSwaggerReloadDialog"; +import WSRenameDialog from "./WSRenameDialog"; + +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]; + } + + const clientConfigurable = !planeNames.includes(workspaceData.plane); + this.setState((preState) => { + const newExpanded = new Set(); + + preState.expanded.forEach((value) => { + if (value in commandGroupMap) { + newExpanded.add(value); + } + }); + + 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 && ( + + )} + + ); + } +} + +const WSEditorWrapper = (props: any) => { + const params = useParams(); + + return ; +}; + +export { WSEditorWrapper as WSEditor }; +export default WSEditor; diff --git a/src/web/src/views/workspace/WSEditor/WSEditorDeleteDialog.tsx b/src/web/src/views/workspace/WSEditor/WSEditorDeleteDialog.tsx new file mode 100644 index 00000000..d743456e --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/WSEditorDeleteDialog.tsx @@ -0,0 +1,89 @@ +import * as React 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; +} + +function WSEditorDeleteDialog(props: WSEditorDeleteDialogProps) { + 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 && ( + + + + + )} + + + ); +} + +export default WSEditorDeleteDialog; diff --git a/src/web/src/views/workspace/WSEditor/WSEditorExportDialog.tsx b/src/web/src/views/workspace/WSEditor/WSEditorExportDialog.tsx new file mode 100644 index 00000000..0ec951a8 --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/WSEditorExportDialog.tsx @@ -0,0 +1,125 @@ +import * as React 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; +} + +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 && } + + )} + + + ); + } +} + +export default WSEditorExportDialog; diff --git a/src/web/src/views/workspace/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/WSEditor/WSEditorSwaggerReloadDialog.tsx new file mode 100644 index 00000000..9117d329 --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -0,0 +1,307 @@ +import * as React 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; +} + +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 && ( + + + + + )} + + + ); + } +} + +export default WSEditorSwaggerReloadDialog; diff --git a/src/web/src/views/workspace/WSEditor/WSRenameDialog.tsx b/src/web/src/views/workspace/WSEditor/WSRenameDialog.tsx new file mode 100644 index 00000000..bb93b155 --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/WSRenameDialog.tsx @@ -0,0 +1,132 @@ +import * as React 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; +} + +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 && ( + + + + + )} + + + ); + } +} + +export default WSRenameDialog; diff --git a/src/web/src/views/workspace/WSEditor/index.ts b/src/web/src/views/workspace/WSEditor/index.ts new file mode 100644 index 00000000..7a33dc8d --- /dev/null +++ b/src/web/src/views/workspace/WSEditor/index.ts @@ -0,0 +1,5 @@ +export { WSEditor, WSEditor as default } from "./WSEditor"; +export { default as WSEditorExportDialog } from "./WSEditorExportDialog"; +export { default as WSEditorDeleteDialog } from "./WSEditorDeleteDialog"; +export { default as WSEditorSwaggerReloadDialog } from "./WSEditorSwaggerReloadDialog"; +export { default as WSRenameDialog } from "./WSRenameDialog"; From 22e7b7c6858b54790884daa92a74ffae2fa1c805 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 09:53:52 +1100 Subject: [PATCH 048/107] refactor: move WSEditorCommandTree into same dir --- src/web/src/__tests__/components/WSEditor.test.tsx | 2 +- .../src/__tests__/components/WSEditorCommandTree.test.tsx | 5 ++++- src/web/src/views/workspace/WSEditor/WSEditor.tsx | 2 +- .../views/workspace/{ => WSEditor}/WSEditorCommandTree.tsx | 0 src/web/src/views/workspace/WSEditor/index.ts | 2 ++ 5 files changed, 8 insertions(+), 3 deletions(-) rename src/web/src/views/workspace/{ => WSEditor}/WSEditorCommandTree.tsx (100%) diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index e6fe97d6..ac65286f 100644 --- a/src/web/src/__tests__/components/WSEditor.test.tsx +++ b/src/web/src/__tests__/components/WSEditor.test.tsx @@ -24,7 +24,7 @@ vi.mock("../../views/workspace/WSEditorToolBar", () => ({ ), })); -vi.mock("../../views/workspace/WSEditorCommandTree", () => ({ +vi.mock("../../views/workspace/WSEditor/WSEditorCommandTree", () => ({ default: ({ onSelected, onToggle, onAdd, onReload, selected, expanded, onEditClientConfig }: any) => (
+ + )} + {(!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; From 854e3434ba1654e34105764dc4e576fdd283472d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 10:42:46 +1100 Subject: [PATCH 052/107] refactor: remove dupe file --- .../commandContent/ExampleDialog.tsx | 384 ------------------ 1 file changed, 384 deletions(-) delete mode 100644 src/web/src/views/workspace/commandContent/ExampleDialog.tsx diff --git a/src/web/src/views/workspace/commandContent/ExampleDialog.tsx b/src/web/src/views/workspace/commandContent/ExampleDialog.tsx deleted file mode 100644 index 6cc85e6c..00000000 --- a/src/web/src/views/workspace/commandContent/ExampleDialog.tsx +++ /dev/null @@ -1,384 +0,0 @@ -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 "../WSEditorCommandContent/ExampleItemSelector"; -import { DecodeResponseCommand } from "./WSEditorCommandContent"; -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; From 4e85d6678bfe8f5bf4a0ceefa43dbbdac015d402 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 10:59:01 +1100 Subject: [PATCH 053/107] refactor: change dir name --- .../components/WSECArgumentSimilarPicker.test.tsx | 2 +- src/web/src/__tests__/components/WSEditor.test.tsx | 14 +++++++------- .../components/WSEditorClientConfig.test.tsx | 2 +- .../WSEditorCommandArgumentsContent.test.tsx | 4 ++-- .../components/WSEditorCommandContent.test.tsx | 2 +- .../WSEditorCommandGroupContent.test.tsx | 2 +- .../components/WSEditorCommandTree.test.tsx | 2 +- .../components/WSEditorSwaggerPicker.test.tsx | 2 +- .../components/WorkspaceSelector.test.tsx | 2 +- .../WSEditorClientConfig.integration.test.tsx | 2 +- .../WorkspaceSelector.integration.test.tsx | 2 +- src/web/src/index.tsx | 6 +++--- .../workspace/WSEditorCommandContent/index.ts | 3 --- .../{ => components}/WSEditor/WSEditor.tsx | 6 +++--- .../WSEditor/WSEditorClientConfig.tsx | 6 +++--- .../WSEditor/WSEditorCommandTree.tsx | 0 .../WSEditor/WSEditorDeleteDialog.tsx | 2 +- .../WSEditor/WSEditorExportDialog.tsx | 2 +- .../WSEditor/WSEditorSwaggerReloadDialog.tsx | 6 +++--- .../{ => components/WSEditor}/WSEditorTheme.tsx | 0 .../{ => components}/WSEditor/WSEditorToolBar.tsx | 0 .../{ => components}/WSEditor/WSRenameDialog.tsx | 2 +- .../workspace/{ => components}/WSEditor/index.ts | 0 .../WSEditorCommandArgumentsContent/ArgNavBar.tsx | 0 .../ArgumentDialog.tsx | 2 +- .../ArgumentNavigation.tsx | 2 +- .../ArgumentPropsReviewer.tsx | 2 +- .../ArgumentReviewer.tsx | 4 ++-- .../FlattenDialog.tsx | 2 +- .../UnwrapClsDialog.tsx | 2 +- .../WSECArgumentSimilarPicker.tsx | 0 .../WSEditorCommandArgumentsContent.tsx | 4 ++-- .../WSEditorCommandArgumentsContent/index.ts | 4 ++-- .../WSEditorCommandContent/AddSubcommandDialog.tsx | 4 ++-- .../WSEditorCommandContent/CommandDeleteDialog.tsx | 8 ++++---- .../WSEditorCommandContent/CommandDialog.tsx | 6 +++--- .../WSEditorCommandContent/ExampleDialog.tsx | 8 ++++---- .../WSEditorCommandContent/ExampleItemSelector.tsx | 0 .../WSEditorCommandContent/OutputCard.tsx | 2 +- .../WSEditorCommandContent/OutputDialog.tsx | 4 ++-- .../WSEditorCommandContent.tsx | 10 +++++----- .../components/WSEditorCommandContent/index.ts | 3 +++ .../CommandGroupDeleteDialog.tsx | 6 +++--- .../CommandGroupDialog.tsx | 4 ++-- .../WSEditorCommandGroupContent.tsx | 6 +++--- .../WSEditorCommandGroupContent/index.tsx | 0 .../WSEditorSwaggerPicker.tsx | 8 ++++---- .../WSEditorSwaggerPicker/index.ts | 0 .../WorkspaceInstruction/WorkspaceCreateDialog.tsx | 6 +++--- .../WorkspaceInstruction/WorkspaceInstruction.tsx | 6 +++--- .../WorkspaceInstruction/WorkspaceSelector.tsx | 4 ++-- .../{ => components}/WorkspaceInstruction/index.ts | 0 .../workspace/{ => components}/WorkspacePage.tsx | 0 src/web/src/views/workspace/interfaces/commands.ts | 4 ++-- 54 files changed, 90 insertions(+), 90 deletions(-) delete mode 100644 src/web/src/views/workspace/WSEditorCommandContent/index.ts rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditor.tsx (98%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorClientConfig.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorCommandTree.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorDeleteDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorExportDialog.tsx (98%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorSwaggerReloadDialog.tsx (97%) rename src/web/src/views/workspace/{ => components/WSEditor}/WSEditorTheme.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSEditorToolBar.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditor/WSRenameDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditor/index.ts (100%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/ArgNavBar.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/ArgumentDialog.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx (98%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/FlattenDialog.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/UnwrapClsDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/WSEditorCommandArgumentsContent.tsx (98%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandArgumentsContent/index.ts (83%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/AddSubcommandDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/CommandDeleteDialog.tsx (92%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/CommandDialog.tsx (96%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/ExampleDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/ExampleItemSelector.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/OutputCard.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/OutputDialog.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandContent/WSEditorCommandContent.tsx (98%) create mode 100644 src/web/src/views/workspace/components/WSEditorCommandContent/index.ts rename src/web/src/views/workspace/{ => components}/WSEditorCommandGroupContent/CommandGroupDeleteDialog.tsx (91%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandGroupContent/CommandGroupDialog.tsx (98%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandGroupContent/WSEditorCommandGroupContent.tsx (97%) rename src/web/src/views/workspace/{ => components}/WSEditorCommandGroupContent/index.tsx (100%) rename src/web/src/views/workspace/{ => components}/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx (99%) rename src/web/src/views/workspace/{ => components}/WSEditorSwaggerPicker/index.ts (100%) rename src/web/src/views/workspace/{ => components}/WorkspaceInstruction/WorkspaceCreateDialog.tsx (98%) rename src/web/src/views/workspace/{ => components}/WorkspaceInstruction/WorkspaceInstruction.tsx (88%) rename src/web/src/views/workspace/{ => components}/WorkspaceInstruction/WorkspaceSelector.tsx (98%) rename src/web/src/views/workspace/{ => components}/WorkspaceInstruction/index.ts (100%) rename src/web/src/views/workspace/{ => components}/WorkspacePage.tsx (100%) diff --git a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx index 41fd86bd..92343f31 100644 --- a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, screen } from "@testing-library/react"; import WSECArgumentSimilarPicker, { BuildArgSimilarTree, type ArgSimilarTree, -} from "../../views/workspace/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker"; +} from "../../views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker"; import { render } from "../test-utils"; describe("WSECArgumentSimilarPicker", () => { diff --git a/src/web/src/__tests__/components/WSEditor.test.tsx b/src/web/src/__tests__/components/WSEditor.test.tsx index 006d9043..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/WSEditor/WSEditorToolBar", () => ({ +vi.mock("../../views/workspace/components/WSEditor/WSEditorToolBar", () => ({ default: ({ workspaceName, onHomePage, onGenerate, onDelete, onModify }: any) => (
{workspaceName} @@ -24,7 +24,7 @@ vi.mock("../../views/workspace/WSEditor/WSEditorToolBar", () => ({ ), })); -vi.mock("../../views/workspace/WSEditor/WSEditorCommandTree", () => ({ +vi.mock("../../views/workspace/components/WSEditor/WSEditorCommandTree", () => ({ default: ({ onSelected, onToggle, onAdd, onReload, selected, expanded, onEditClientConfig }: any) => (
} - {!clientConfigOOD && } - {!clientConfigOOD && } - - )} - - - ); - } -} + return ( + + Export workspace command models to AAZ Repo + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + {updating && ( + + + + )} + {!updating && ( + + {clientConfigOOD && } + {!clientConfigOOD && } + {!clientConfigOOD && } + + )} + + + ); +}; export default WSEditorExportDialog; From c5d740d0b057efbb7319328d4c7c824e18d61107 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 13:09:49 +1100 Subject: [PATCH 056/107] refactor: WSEditorSwaggerReloadDialog to function-based --- .../WSEditor/WSEditorSwaggerReloadDialog.tsx | 339 ++++++++---------- 1 file changed, 151 insertions(+), 188 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx index 132b605d..d30493b8 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { useState, useEffect, Fragment } from "react"; import { Box, Dialog, @@ -29,58 +29,42 @@ interface WSEditorSwaggerReloadDialogProps { 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(), - }; - } +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()); - componentDidMount() { - this.loadResourceOptions(); - } + useEffect(() => { + loadResourceOptions(); + }, []); - loadResourceOptions = async () => { - this.setState({ - invalidText: undefined, - updating: true, - }); + const loadResourceOptions = async () => { + setInvalidText(undefined); + setUpdating(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)), - }); + 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); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); } }; - handleClose = () => { - this.props.onClose(false); + const handleClose = () => { + onClose(false); }; - handleReload = async () => { - const { selectedResources, resourceOptions } = this.state; + const handleReload = async () => { const data = { resources: resourceOptions .filter((option) => selectedResources.has(option.id)) @@ -93,24 +77,26 @@ class WSEditorSwaggerReloadDialog extends React.Component< }; if (data.resources.length === 0) { - this.props.onClose(false); + onClose(false); return; } - this.setState({ - invalidText: undefined, - updating: true, - }); + setInvalidText(undefined); + setUpdating(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, - }); + 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 = @@ -124,184 +110,161 @@ class WSEditorSwaggerReloadDialog extends React.Component< 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, - }); + setInvalidText("Invalid resource operation emitter info"); + setUpdating(false); return; } data.resources = emitterOptionRes; - await workspaceApi.reloadTypespecResources(this.props.workspaceUrl, data); + await workspaceApi.reloadTypespecResources(workspaceUrl, data); } else { - await workspaceApi.reloadSwaggerResources(this.props.workspaceUrl, data); + await workspaceApi.reloadSwaggerResources(workspaceUrl, data); } - this.setState({ - updating: false, - }); - this.props.onClose(true); + setUpdating(false); + onClose(true); } catch (err: any) { console.error(err); - this.setState({ - invalidText: errorHandlerApi.getErrorMessage(err), - updating: false, - }); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); } }; - onSelectedAllClick = () => { - this.setState((preState) => { - return { - ...preState, - selectedResources: - preState.selectedResources.size > 0 ? new Set() : new Set(preState.resourceOptions.map((op) => op.id)), - }; - }); + const onSelectedAllClick = () => { + setSelectedResources(selectedResources.size > 0 ? new Set() : new Set(resourceOptions.map((op) => op.id))); }; - onResourceItemClick = (resourceId: string) => { + const onResourceItemClick = (resourceId: string) => { return () => { - this.setState((preState) => { - const selectedResources = new Set(preState.selectedResources); - if (selectedResources.has(resourceId)) { - selectedResources.delete(resourceId); + setSelectedResources((prev) => { + const newSelectedResources = new Set(prev); + if (newSelectedResources.has(resourceId)) { + newSelectedResources.delete(resourceId); } else { - selectedResources.add(resourceId); + newSelectedResources.add(resourceId); } - return { - ...preState, - selectedResources: selectedResources, - }; + return newSelectedResources; }); }; }; - render() { - const { invalidText, selectedResources, updating, resourceOptions } = this.state; + return ( + + Reload {source.toLowerCase() === "typespec" ? "TypeSpec" : "Swagger"} Resources + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + + + {/* Resource Url */} - 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 ( + - + 0 && selectedResources.size === resourceOptions.length} - indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} + checked={selected} tabIndex={-1} disableRipple - inputProps={{ "aria-labelledby": "SelectAll" }} + inputProps={{ "aria-labelledby": labelId }} /> - - - - } - > - {resourceOptions.length > 0 && ( - - {resourceOptions.map((option) => { - const labelId = `resource-${option.id}`; - const selected = selectedResources.has(option.id); - return ( - - - - - - - - - ); - })} - - )} - - - - {updating && ( - - - + + ); + })} + )} - {!updating && ( - - - - - )} - - - ); - } -} + + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; export default WSEditorSwaggerReloadDialog; From 79a4621d2f839575dae2c360e15e34369fc66b93 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 13:14:28 +1100 Subject: [PATCH 057/107] refactor: WSEditorToolbar to function-based --- .../components/WSEditor/WSEditorToolBar.tsx | 111 +++++++++--------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx index baba8b8d..af7da4ac 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorToolBar.tsx @@ -4,7 +4,7 @@ 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"; +import React, { Fragment } from "react"; const ArgEditTypography = styled(Typography)(() => ({ color: "#ffffff", @@ -21,63 +21,66 @@ interface WSEditorToolBarProps { onModify: () => void; } -class WSEditorToolBar extends React.Component { - render() { - const { workspaceName, onHomePage, onGenerate, onDelete, onModify } = this.props; - return ( - - = ({ + workspaceName, + onHomePage, + onGenerate, + onDelete, + onModify, +}) => { + return ( + + theme.zIndex.drawer + 1, + }} + > + theme.zIndex.drawer + 1, + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + height: 64, }} > - - - - - WORKSPACE - - - - - {workspaceName} + + + + WORKSPACE - - + - - - - - - - - - - ); - } -} + + {workspaceName} + + + + + + + + + + + + + + ); +}; export default WSEditorToolBar; From 3a9c7606cecaa466feebd74cf32d77fd06e97bd7 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 13:22:42 +1100 Subject: [PATCH 058/107] refactor: WSRenameDialog to function-based --- .../components/WSEditor/WSRenameDialog.tsx | 148 +++++++----------- 1 file changed, 58 insertions(+), 90 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx index 74761f8b..2d5b7325 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { useState, Fragment } from "react"; import { Box, Dialog, @@ -19,114 +19,82 @@ interface WSRenameDialogProps { 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 WSRenameDialog: React.FC = ({ workspaceUrl, workspaceName, open, onClose }) => { + const [newWSName, setNewWSName] = useState(workspaceName); + const [invalidText, setInvalidText] = useState(undefined); + const [updating, setUpdating] = useState(false); + const handleModify = () => { const nName = newWSName.trim(); if (nName.length < 1) { - this.setState({ - invalidText: `Field 'Name' is required.`, - }); + setInvalidText(`Field 'Name' is required.`); return; } - this.setState({ - invalidText: undefined, - }); - this.setState({ - updating: true, - }); + setInvalidText(undefined); + setUpdating(true); if (workspaceName === nName) { - this.setState({ - updating: false, - }); - this.props.onClose(null); + setUpdating(false); + onClose(null); } else { workspaceApi .renameWorkspace(workspaceUrl, nName) .then((res: any) => { - this.setState({ - updating: false, - }); - this.props.onClose(res.name); + setUpdating(false); + onClose(res.name); }) .catch((err: any) => { - this.setState({ - updating: false, - invalidText: errorHandlerApi.getErrorMessage(err), - }); + setUpdating(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); }); } }; - handleClose = () => { - this.setState({ - invalidText: undefined, - }); - this.props.onClose(null); + const handleClose = () => { + setInvalidText(undefined); + onClose(null); }; - render() { - const { invalidText, updating } = this.state; - return ( - - Rename Workspace - - {invalidText && ( - - {" "} - {invalidText}{" "} - - )} - { - this.setState({ - newWSName: event.target.value, - }); - }} - margin="normal" - required - /> - - - {updating && ( - - - - )} - {!updating && ( - - - - - )} - - - ); - } -} + return ( + + Rename Workspace + + {invalidText && ( + + {" "} + {invalidText}{" "} + + )} + { + setNewWSName(event.target.value); + }} + margin="normal" + required + /> + + + {updating && ( + + + + )} + {!updating && ( + + + + + )} + + + ); +}; export default WSRenameDialog; From 53c008ea1e0fa3849480e679b3771d62308fc7bb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 13:29:13 +1100 Subject: [PATCH 059/107] refactor: WSrenameDialog to async/await pattern --- .../components/WSEditor/WSRenameDialog.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx index 2d5b7325..e701f569 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSRenameDialog.tsx @@ -24,7 +24,7 @@ const WSRenameDialog: React.FC = ({ workspaceUrl, workspace const [invalidText, setInvalidText] = useState(undefined); const [updating, setUpdating] = useState(false); - const handleModify = () => { + const handleModify = async () => { const nName = newWSName.trim(); if (nName.length < 1) { setInvalidText(`Field 'Name' is required.`); @@ -38,16 +38,14 @@ const WSRenameDialog: React.FC = ({ workspaceUrl, workspace setUpdating(false); onClose(null); } else { - workspaceApi - .renameWorkspace(workspaceUrl, nName) - .then((res: any) => { - setUpdating(false); - onClose(res.name); - }) - .catch((err: any) => { - setUpdating(false); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - }); + try { + const res = await workspaceApi.renameWorkspace(workspaceUrl, nName); + setUpdating(false); + onClose(res.name); + } catch (err: any) { + setUpdating(false); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + } } }; From 39fa2b31772f8eb7fee5b8fcc739d23e78e95e14 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 13:33:24 +1100 Subject: [PATCH 060/107] refactor: WSEditorDeleteDialog to modern syntax --- .../WSEditor/WSEditorDeleteDialog.tsx | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx index 96ec8750..fe05557a 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorDeleteDialog.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { useState, Fragment } from "react"; import { Box, Dialog, @@ -18,33 +18,31 @@ interface WSEditorDeleteDialogProps { onClose: (deleted: boolean) => void; } -function WSEditorDeleteDialog(props: WSEditorDeleteDialogProps) { - const [updating, setUpdating] = React.useState(false); - const [invalidText, setInvalidText] = React.useState(undefined); - const [confirmName, setConfirmName] = React.useState(undefined); +const WSEditorDeleteDialog: React.FC = ({ workspaceName, open, onClose }) => { + const [updating, setUpdating] = useState(false); + const [invalidText, setInvalidText] = useState(undefined); + const [confirmName, setConfirmName] = useState(undefined); const handleClose = () => { - props.onClose(false); + onClose(false); }; - const handleDelete = () => { + const handleDelete = async () => { setUpdating(true); - workspaceApi - .deleteWorkspace(props.workspaceName) - .then(() => { - setUpdating(false); - props.onClose(true); - }) - .catch((err: any) => { - console.error(err); - setInvalidText(errorHandlerApi.getErrorMessage(err)); - setUpdating(false); - }); + try { + await workspaceApi.deleteWorkspace(workspaceName); + setUpdating(false); + onClose(true); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); + setUpdating(false); + } }; return ( - - Delete '{props.workspaceName}' workspace? + + Delete '{workspaceName}' workspace? {invalidText && ( @@ -74,16 +72,16 @@ function WSEditorDeleteDialog(props: WSEditorDeleteDialogProps) { )} {!updating && ( - + - - + )} ); -} +}; export default WSEditorDeleteDialog; From 8d93131cae8f4bfbac4756a411c81131904e658d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:01:41 +1100 Subject: [PATCH 061/107] refactor: ArgumentDialog extract method and add unit tests --- .../unit/convertArgDefaultText.test.ts | 189 ++++++++++++++++++ .../ArgumentDialog.tsx | 81 +------- .../workspace/utils/convertArgDefaultText.ts | 112 +++++++++++ 3 files changed, 302 insertions(+), 80 deletions(-) create mode 100644 src/web/src/__tests__/unit/convertArgDefaultText.test.ts create mode 100644 src/web/src/views/workspace/utils/convertArgDefaultText.ts diff --git a/src/web/src/__tests__/unit/convertArgDefaultText.test.ts b/src/web/src/__tests__/unit/convertArgDefaultText.test.ts new file mode 100644 index 00000000..56deba5f --- /dev/null +++ b/src/web/src/__tests__/unit/convertArgDefaultText.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { convertArgDefaultText, type SupportedArgType } from "../../views/workspace/utils/convertArgDefaultText"; + +describe("convertArgDefaultText", () => { + describe("String types", () => { + const stringTypes: SupportedArgType[] = [ + "byte", + "binary", + "duration", + "date", + "dateTime", + "time", + "uuid", + "password", + "SubscriptionId", + "ResourceGroupName", + "ResourceId", + "ResourceLocation", + "string", + ]; + + stringTypes.forEach((type) => { + it(`should convert valid ${type} values`, () => { + expect(convertArgDefaultText(" hello world ", type)).toBe("hello world"); + expect(convertArgDefaultText("test-value", type)).toBe("test-value"); + }); + + it(`should throw error for empty ${type} values`, () => { + expect(() => convertArgDefaultText("", type)).toThrow(`Not supported empty value: ''`); + expect(() => convertArgDefaultText(" ", type)).toThrow(`Not supported empty value: ' '`); + }); + }); + }); + + describe("Integer types", () => { + const integerTypes: SupportedArgType[] = ["integer32", "integer64", "integer"]; + + integerTypes.forEach((type) => { + it(`should convert valid ${type} values`, () => { + expect(convertArgDefaultText("42", type)).toBe(42); + expect(convertArgDefaultText(" 123 ", type)).toBe(123); + expect(convertArgDefaultText("-456", type)).toBe(-456); + expect(convertArgDefaultText("0", type)).toBe(0); + }); + + it(`should throw error for invalid ${type} values`, () => { + expect(() => convertArgDefaultText("abc", type)).toThrow(`Not supported default value for integer type: 'abc'`); + expect(() => convertArgDefaultText("12.34", type)).toThrow( + `Not supported default value for integer type: '12.34'`, + ); + expect(() => convertArgDefaultText("", type)).toThrow(`Not supported default value for integer type: ''`); + }); + }); + }); + + describe("Float types", () => { + const floatTypes: SupportedArgType[] = ["float32", "float64", "float"]; + + floatTypes.forEach((type) => { + it(`should convert valid ${type} values`, () => { + expect(convertArgDefaultText("42.5", type)).toBe(42.5); + expect(convertArgDefaultText(" 123.456 ", type)).toBe(123.456); + expect(convertArgDefaultText("-456.789", type)).toBe(-456.789); + expect(convertArgDefaultText("0.0", type)).toBe(0.0); + expect(convertArgDefaultText("42", type)).toBe(42); + }); + + it(`should throw error for invalid ${type} values`, () => { + expect(() => convertArgDefaultText("abc", type)).toThrow(`Not supported default value for float type: 'abc'`); + expect(() => convertArgDefaultText("", type)).toThrow(`Not supported default value for float type: ''`); + }); + }); + }); + + describe("Boolean type", () => { + it("should convert true values", () => { + expect(convertArgDefaultText("true", "boolean")).toBe(true); + expect(convertArgDefaultText("TRUE", "boolean")).toBe(true); + expect(convertArgDefaultText(" True ", "boolean")).toBe(true); + expect(convertArgDefaultText("yes", "boolean")).toBe(true); + expect(convertArgDefaultText("YES", "boolean")).toBe(true); + }); + + it("should convert false values", () => { + expect(convertArgDefaultText("false", "boolean")).toBe(false); + expect(convertArgDefaultText("FALSE", "boolean")).toBe(false); + expect(convertArgDefaultText(" False ", "boolean")).toBe(false); + expect(convertArgDefaultText("no", "boolean")).toBe(false); + expect(convertArgDefaultText("NO", "boolean")).toBe(false); + }); + + it("should throw error for invalid boolean values", () => { + expect(() => convertArgDefaultText("maybe", "boolean")).toThrow( + `Not supported default value for boolean type: 'maybe'`, + ); + expect(() => convertArgDefaultText("1", "boolean")).toThrow(`Not supported default value for boolean type: '1'`); + expect(() => convertArgDefaultText("", "boolean")).toThrow(`Not supported default value for boolean type: ''`); + }); + }); + + describe("Any type", () => { + it("should convert integer values", () => { + expect(convertArgDefaultText("42", "any")).toBe(42); + expect(convertArgDefaultText("-123", "any")).toBe(-123); + }); + + it("should convert float values", () => { + expect(convertArgDefaultText("42.5", "any")).toBe(42.5); + expect(convertArgDefaultText("-123.456", "any")).toBe(-123.456); + }); + + it("should convert null values", () => { + expect(convertArgDefaultText("null", "any")).toBe(null); + expect(convertArgDefaultText("NULL", "any")).toBe(null); + }); + + it("should convert boolean values", () => { + expect(convertArgDefaultText("true", "any")).toBe(true); + expect(convertArgDefaultText("false", "any")).toBe(false); + expect(convertArgDefaultText("yes", "any")).toBe(true); + expect(convertArgDefaultText("no", "any")).toBe(false); + }); + + it("should convert string values as fallback", () => { + expect(convertArgDefaultText("hello", "any")).toBe("hello"); + expect(convertArgDefaultText("some text", "any")).toBe("some text"); + }); + }); + + describe("Object type", () => { + it("should parse valid JSON objects", () => { + expect(convertArgDefaultText('{"key": "value"}', "object")).toEqual({ key: "value" }); + expect(convertArgDefaultText('{"number": 42, "boolean": true}', "object")).toEqual({ number: 42, boolean: true }); + expect(convertArgDefaultText(' {"nested": {"key": "value"}} ', "object")).toEqual({ nested: { key: "value" } }); + }); + + it("should throw error for invalid JSON", () => { + expect(() => convertArgDefaultText("{invalid json}", "object")).toThrow(); + expect(() => convertArgDefaultText("not json at all", "object")).toThrow(); + }); + }); + + describe("Array types", () => { + it("should parse valid JSON arrays for array types", () => { + expect(convertArgDefaultText("[1, 2, 3]", "array")).toEqual([1, 2, 3]); + expect(convertArgDefaultText('["a", "b", "c"]', "array")).toEqual(["a", "b", "c"]); + expect(convertArgDefaultText(" [] ", "array")).toEqual([]); + }); + + it("should throw error for invalid JSON arrays", () => { + expect(() => convertArgDefaultText("[invalid, json]", "array")).toThrow(); + expect(() => convertArgDefaultText("not an array", "array")).toThrow(); + }); + }); + + describe("Dictionary types", () => { + it("should parse valid JSON objects for dict types", () => { + expect(convertArgDefaultText('{"key1": "value1"}', "dict")).toEqual({ key1: "value1" }); + expect(convertArgDefaultText('{"a": 1, "b": 2}', "dict")).toEqual({ a: 1, b: 2 }); + expect(convertArgDefaultText(" {} ", "dict")).toEqual({}); + }); + + it("should throw error for invalid JSON dictionaries", () => { + expect(() => convertArgDefaultText("{invalid: json}", "dict")).toThrow(); + expect(() => convertArgDefaultText("not a dict", "dict")).toThrow(); + }); + }); + + describe("Unsupported types", () => { + it("should throw error for unsupported types", () => { + expect(() => convertArgDefaultText("value", "unsupported")).toThrow("Not supported type: unsupported"); + expect(() => convertArgDefaultText("value", "custom-type")).toThrow("Not supported type: custom-type"); + }); + }); + + describe("Edge cases", () => { + it("should handle whitespace correctly", () => { + expect(convertArgDefaultText(" value ", "string")).toBe("value"); + expect(convertArgDefaultText(" 42 ", "integer")).toBe(42); + expect(convertArgDefaultText(" true ", "boolean")).toBe(true); + }); + + it("should handle case sensitivity for any type", () => { + expect(convertArgDefaultText("TRUE", "any")).toBe(true); + expect(convertArgDefaultText("FALSE", "any")).toBe(false); + expect(convertArgDefaultText("NULL", "any")).toBe(null); + }); + }); +}); diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx index 9f8c9de7..f9f647b0 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentDialog.tsx @@ -19,6 +19,7 @@ import { 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; @@ -129,86 +130,6 @@ interface ArgumentDialogProps { onClose: (updated: boolean) => Promise; } -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()); - 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}`); - } -} - const ArgumentDialog: React.FC = (props) => { const [updating, setUpdating] = useState(false); const [stage, setStage] = useState(""); 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}`); + } +} From 3f9ea93413f4f95cb6d94fac79e74c5a38da1ed7 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:09:08 +1100 Subject: [PATCH 062/107] refactor: address ArgumentNavigation typescript issues --- .../ArgumentNavigation.tsx | 39 ++++++------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx index 969d63e0..f8b84c03 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentNavigation.tsx @@ -4,32 +4,15 @@ import { ExperimentalTypography, PreviewTypography, StableTypography } from "../ import ArgumentPropsReviewer from "./ArgumentPropsReviewer"; import ArgNavBar, { type ArgIdx } from "./ArgNavBar"; import ArgumentReviewer from "./ArgumentReviewer"; -import type { CMDArg, ClsArgDefinitionMap } from "./WSEditorCommandArgumentsContent"; - -interface CMDArgBase { - type: string; - nullable: boolean; - blank?: any; -} - -interface CMDClsArg extends CMDArg { - clsName: string; - singularOptions?: string[]; -} - -interface CMDObjectArg extends CMDArg { - args: CMDArg[]; -} - -interface CMDDictArg extends CMDArg { - item?: any; - anyType: boolean; -} - -interface CMDArrayArg extends CMDArg { - item: any; - singularOptions?: string[]; -} +import type { + CMDArg, + CMDArgBase, + CMDClsArg, + CMDObjectArg, + CMDDictArgBase, + CMDArrayArgBase, + ClsArgDefinitionMap, +} from "../../utils/decodeArgs"; interface ArgumentNavigationProps { commandUrl: string; @@ -70,7 +53,7 @@ const ArgumentNavigation: React.FC = ({ flattenArgVar: (selectedArgBase as CMDObjectArg).var, }; } else if (selectedArgBase.type.startsWith("dict<")) { - const item = (selectedArgBase as CMDDictArg).item; + const item = (selectedArgBase as CMDDictArgBase).item; const itemProps = item ? getArgProps(item) : undefined; if (!itemProps) { return undefined; @@ -81,7 +64,7 @@ const ArgumentNavigation: React.FC = ({ flattenArgVar: undefined, }; } else if (selectedArgBase.type.startsWith("array<")) { - const itemProps = getArgProps((selectedArgBase as CMDArrayArg).item); + const itemProps = getArgProps((selectedArgBase as CMDArrayArgBase).item); if (!itemProps) { return undefined; } From e92c52689deda2a99c6051b45fc7207c8155f9ac Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:15:42 +1100 Subject: [PATCH 063/107] refactor: address ts interface issues in ArgumentPropsReviewer --- .../WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx index 08fe9cd5..9609e9c2 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx @@ -4,7 +4,7 @@ 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 "./WSEditorCommandArgumentsContent"; +import type { CMDArg } from "../../utils/decodeArgs"; interface CMDArrayArg extends CMDArg { singularOptions?: string[]; @@ -88,7 +88,7 @@ const ArgEditTypography = styled(Typography)(() => ({ const spliceArgOptionsString = (arg: CMDArg, depth: number) => { let optionsString = arg.options - .map((option) => { + .map((option: string) => { if (depth === 0) { if (option.length === 1) { return "-" + option; From f9848c75612c4552fb4a3c7ade1f21a78e54073e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:22:51 +1100 Subject: [PATCH 064/107] refactor: change named imports for CommandDeleteDialog --- .../WSEditorCommandContent/CommandDeleteDialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx index a053679f..01e3355a 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx @@ -8,7 +8,7 @@ import { LinearProgress, Typography, } from "@mui/material"; -import React from "react"; +import React, { useState, useEffect } from "react"; import { commandApi } from "../../../../services"; import { COMMAND_PREFIX } from "../../../../constants"; import { DecodeResponseCommand } from "../../utils/decodeResponseCommand"; @@ -22,8 +22,8 @@ export interface CommandDeleteDialogProps { } const CommandDeleteDialog: React.FC = (props) => { - const [updating, setUpdating] = React.useState(false); - const [relatedCommands, setRelatedCommands] = React.useState([]); + const [updating, setUpdating] = useState(false); + const [relatedCommands, setRelatedCommands] = useState([]); const getUrls = () => { const urls: string[] = []; @@ -41,7 +41,7 @@ const CommandDeleteDialog: React.FC = (props) => { return urls; }; - React.useEffect(() => { + useEffect(() => { setRelatedCommands([]); const urls = getUrls(); const promisesAll = urls.map(async (url) => { From 428777c7d8bcf2c1ffa128b1175a84a00170b8f2 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:28:20 +1100 Subject: [PATCH 065/107] refactor: CommandDeleteDialog to async/await --- .../CommandDeleteDialog.tsx | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx index 01e3355a..795909db 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/CommandDeleteDialog.tsx @@ -42,13 +42,13 @@ const CommandDeleteDialog: React.FC = (props) => { }; useEffect(() => { - setRelatedCommands([]); - const urls = getUrls(); - const promisesAll = urls.map(async (url) => { - return await commandApi.getCommandsForResource(url); - }); - Promise.all(promisesAll) - .then((responses) => { + 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 @@ -58,35 +58,32 @@ const CommandDeleteDialog: React.FC = (props) => { }); }); - const cmdNames: string[] = []; - commands.forEach((cmdName) => cmdNames.push(cmdName)); - cmdNames.sort((a, b) => a.localeCompare(b)); + const cmdNames = Array.from(commands).sort((a, b) => a.localeCompare(b)); setRelatedCommands(cmdNames); - }) - .catch((err) => { + } catch (err) { console.error(err); - }); + } + }; + + fetchRelatedCommands(); }, [props.command]); const handleClose = () => { props.onClose(false); }; - const handleDelete = () => { + const handleDelete = async () => { 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); - }); + + try { + await Promise.all(urls.map((url) => commandApi.deleteResource(url))); + setUpdating(false); + props.onClose(true); + } catch (err) { + setUpdating(false); + console.error(err); + } }; return ( @@ -104,10 +101,10 @@ const CommandDeleteDialog: React.FC = (props) => { )} {!updating && ( - + <> - + )} From 1e847f16a51d4b91881af5a79f911b113e4ba7cd Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 14:32:08 +1100 Subject: [PATCH 066/107] Refactor: index.tsx to index.ts --- .../WSEditorCommandGroupContent/{index.tsx => index.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/web/src/views/workspace/components/WSEditorCommandGroupContent/{index.tsx => index.ts} (100%) diff --git a/src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.tsx b/src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.ts similarity index 100% rename from src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.tsx rename to src/web/src/views/workspace/components/WSEditorCommandGroupContent/index.ts From a9954e731b76d62e9fa9615eb08a7a8dfea7745d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 15:42:29 +1100 Subject: [PATCH 067/107] refactor: extract spliceArgOptionsString --- .../unit/spliceArgOptionsString.test.ts | 200 ++++++++++++++++++ .../ArgumentPropsReviewer.tsx | 59 +----- .../ArgumentReviewer.tsx | 61 +----- .../workspace/utils/spliceArgOptionsString.ts | 68 ++++++ 4 files changed, 270 insertions(+), 118 deletions(-) create mode 100644 src/web/src/__tests__/unit/spliceArgOptionsString.test.ts create mode 100644 src/web/src/views/workspace/utils/spliceArgOptionsString.ts diff --git a/src/web/src/__tests__/unit/spliceArgOptionsString.test.ts b/src/web/src/__tests__/unit/spliceArgOptionsString.test.ts new file mode 100644 index 00000000..6734a708 --- /dev/null +++ b/src/web/src/__tests__/unit/spliceArgOptionsString.test.ts @@ -0,0 +1,200 @@ +import { spliceArgOptionsString } from "../../views/workspace/utils/spliceArgOptionsString"; + +const createMockArg = (overrides: any = {}): any => ({ + options: [], + type: "string", + var: "test", + group: "", + required: false, + hide: false, + stage: "Stable" as const, + nullable: false, + ...overrides, +}); + +describe("spliceArgOptionsString", () => { + describe("Basic option formatting", () => { + it("should format single character options with single dash at depth 0", () => { + const arg = createMockArg({ + options: ["v", "h"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("-v -h"); + }); + + it("should format multi-character options with double dash at depth 0", () => { + const arg = createMockArg({ + options: ["verbose", "help"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--verbose --help"); + }); + + it("should format mixed single and multi-character options at depth 0", () => { + const arg = createMockArg({ + options: ["v", "verbose", "h", "help"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("-v --verbose -h --help"); + }); + + it("should format options with dot prefix at depth > 0", () => { + const arg = createMockArg({ + options: ["property", "prop"], + }); + + const result = spliceArgOptionsString(arg, 1); + expect(result).toBe(".property .prop"); + }); + }); + + describe("Array arguments with singular options", () => { + it("should append singular options for array arguments at depth 0", () => { + const arg = createMockArg({ + options: ["items"], + type: "array", + item: { type: "string" }, + singularOptions: ["item", "i"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--items (--item -i)"); + }); + + it("should append singular options for array arguments at depth > 0", () => { + const arg = createMockArg({ + options: ["items"], + type: "array", + item: { type: "string" }, + singularOptions: ["item"], + }); + + const result = spliceArgOptionsString(arg, 1); + expect(result).toBe(".items (.item)"); + }); + + it("should handle array arguments without singular options", () => { + const arg = createMockArg({ + options: ["items"], + type: "array", + item: { type: "string" }, + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--items"); + }); + }); + + describe("Class arguments with singular options", () => { + it("should append singular options for class arguments at depth 0", () => { + const arg = createMockArg({ + options: ["configs"], + type: "@Config", + clsName: "Config", + singularOptions: ["config", "c"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--configs (--config -c)"); + }); + + it("should append singular options for class arguments at depth > 0", () => { + const arg = createMockArg({ + options: ["configs"], + type: "@Config", + clsName: "Config", + singularOptions: ["config"], + }); + + const result = spliceArgOptionsString(arg, 2); + expect(result).toBe(".configs (.config)"); + }); + + it("should handle class arguments without singular options", () => { + const arg = createMockArg({ + options: ["config"], + type: "@Config", + clsName: "Config", + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--config"); + }); + }); + + describe("Edge cases", () => { + it("should handle empty options array", () => { + const arg = createMockArg({ + options: [], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe(""); + }); + + it("should handle single option", () => { + const arg = createMockArg({ + options: ["single"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--single"); + }); + + it("should handle different depths", () => { + const arg = createMockArg({ + options: ["test"], + }); + + expect(spliceArgOptionsString(arg, 0)).toBe("--test"); + expect(spliceArgOptionsString(arg, 1)).toBe(".test"); + expect(spliceArgOptionsString(arg, 5)).toBe(".test"); + }); + + it("should handle both array and class singular options pattern correctly", () => { + const argWithBothPatterns = createMockArg({ + options: ["items"], + type: "array<@Config>", + item: { type: "@Config" }, + singularOptions: ["item"], + clsName: "Config", + }); + + const result = spliceArgOptionsString(argWithBothPatterns, 0); + expect(result).toBe("--items (--item)"); + }); + }); + + describe("Complex scenarios", () => { + it("should handle complex array argument with mixed option lengths", () => { + const arg = createMockArg({ + options: ["resource-groups", "rg"], + type: "array", + var: "resourceGroups", + required: true, + item: { type: "string" }, + singularOptions: ["resource-group", "g"], + }); + + const result = spliceArgOptionsString(arg, 0); + expect(result).toBe("--resource-groups --rg (--resource-group -g)"); + }); + + it("should handle nested class argument with singular options", () => { + const arg = createMockArg({ + options: ["configurations"], + type: "@Configuration", + var: "configs", + group: "advanced", + clsName: "Configuration", + singularOptions: ["configuration", "config", "c"], + }); + + const result = spliceArgOptionsString(arg, 1); + expect(result).toBe(".configurations (.configuration .config .c)"); + }); + }); +}); diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx index 9609e9c2..b6732986 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentPropsReviewer.tsx @@ -5,14 +5,7 @@ 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"; - -interface CMDArrayArg extends CMDArg { - singularOptions?: string[]; -} - -interface CMDClsArg extends CMDArg { - singularOptions?: string[]; -} +import { spliceArgOptionsString } from "../../utils/spliceArgOptionsString"; interface ArgGroup { name: string; @@ -86,56 +79,6 @@ const ArgEditTypography = styled(Typography)(() => ({ fontWeight: 400, })); -const spliceArgOptionsString = (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; -}; - const ArgumentPropsReviewer: React.FC = (props) => { const groupArgs: { [name: string]: CMDArg[] } = {}; if (props.args !== undefined) { diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx index 77d220c1..05739fd6 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/ArgumentReviewer.tsx @@ -4,22 +4,13 @@ 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 "."; - -interface CMDClsArg extends CMDArg { - clsName: string; - singularOptions?: string[]; -} +import { spliceArgOptionsString, type CMDArrayArg } from "../../utils/spliceArgOptionsString"; interface CMDDictArg extends CMDArg { item?: any; anyType: boolean; } -interface CMDArrayArg extends CMDArg { - item: any; - singularOptions?: string[]; -} - interface CMDStringArg extends CMDArg { enum?: { items: { name: string; hide: boolean; value: string }[]; @@ -74,56 +65,6 @@ const ArgChoicesTypography = styled(Typography)(({ theme }) => fontWeight: 700, })); -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: 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; -}; - const ArgumentReviewer: React.FC = ({ arg, depth, onEdit, onUnwrap }) => { const [choices, setChoices] = useState([]); 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 }; From 5439a2f1a8472c2a88fecc0cec3300379902f3f6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 19:23:18 +1100 Subject: [PATCH 068/107] refactor: WSEditorCommandContent hook bug fixes --- .../components/WSEditor/WSEditor.tsx | 741 ++++++------------ .../WSEditorCommandContent.tsx | 61 +- src/web/src/views/workspace/hooks/index.ts | 3 + .../views/workspace/hooks/useDialogManager.ts | 100 +++ .../src/views/workspace/hooks/useTreeState.ts | 131 ++++ .../views/workspace/hooks/useWorkspaceData.ts | 164 ++++ 6 files changed, 691 insertions(+), 509 deletions(-) create mode 100644 src/web/src/views/workspace/hooks/index.ts create mode 100644 src/web/src/views/workspace/hooks/useDialogManager.ts create mode 100644 src/web/src/views/workspace/hooks/useTreeState.ts create mode 100644 src/web/src/views/workspace/hooks/useWorkspaceData.ts diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx index c77aef91..255e30bd 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -1,33 +1,19 @@ -import * as React from "react"; +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, { CommandTreeLeaf, CommandTreeNode } from "./WSEditorCommandTree"; -import WSEditorCommandGroupContent, { DecodeResponseCommandGroup } from "../WSEditorCommandGroupContent"; -import WSEditorCommandContent, { DecodeResponseCommand } from "../WSEditorCommandContent"; +import WSEditorCommandTree from "./WSEditorCommandTree"; +import WSEditorCommandGroupContent from "../WSEditorCommandGroupContent"; +import WSEditorCommandContent from "../WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; -import type { - CommandGroup, - ResponseCommandGroup, - ResponseCommandGroups, - Command, - ResponseCommand, -} from "../../interfaces"; -import { workspaceApi, specsApi } from "../../../../services"; +import type { CommandGroup, Command } from "../../interfaces"; import WSEditorExportDialog from "./WSEditorExportDialog"; import WSEditorDeleteDialog from "./WSEditorDeleteDialog"; import WSEditorSwaggerReloadDialog from "./WSEditorSwaggerReloadDialog"; import WSRenameDialog from "./WSRenameDialog"; - -interface CommandGroupMap { - [id: string]: CommandGroup; -} - -interface CommandMap { - [id: string]: Command; -} +import { useDialogManager, useWorkspaceData, useTreeState } from "../../hooks"; interface WSEditorProps { params: { @@ -35,29 +21,6 @@ interface WSEditorProps { }; } -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, @@ -67,477 +30,267 @@ const swaggerResourcePickerTransition = React.forwardRef(function swaggerResourc 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[] = []; +function WSEditor({ params }: WSEditorProps) { + const { workspaceName } = params; - 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)); - } + const dialogManager = useDialogManager(); + const workspace = useWorkspaceData(workspaceName); + const treeState = useTreeState(workspace.commandMap, workspace.commandGroupMap, workspace.commandTree); - let selected: Command | CommandGroup | null = null; + useEffect(() => { + workspace.loadWorkspace(); + }, [workspace.loadWorkspace]); - 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]; - } + useEffect(() => { + if (!workspace.reloadTimestamp) return; - const clientConfigurable = !planeNames.includes(workspaceData.plane); - this.setState((preState) => { - const newExpanded = new Set(); - - preState.expanded.forEach((value) => { - if (value in commandGroupMap) { - newExpanded.add(value); - } - }); - - for (const groupId in commandGroupMap) { - if (!(groupId in preState.commandGroupMap)) { - newExpanded.add(groupId); + if (workspace.clientConfigurable) { + workspace + .getWorkspaceClientConfig(workspace.workspaceUrl) + .then((clientConfig) => { + if (!clientConfig) { + dialogManager.openClientConfigDialog(); } - } - - 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); + }) + .catch(console.error); } - }; - - 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(); + if (workspace.commandTree.length === 0) { + dialogManager.openSwaggerResourcePicker(); } - this.setState({ - showSwaggerReloadDialog: false, - }); - }; + }, [workspace.reloadTimestamp]); - handleSwaggerResourcePickerClose = (updated: boolean) => { - if (updated) { - this.loadWorkspace(); - } - this.setState({ - showSwaggerResourcePicker: false, - }); - }; + 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], + ); - handleBackToHomepage = (blank: boolean) => { + const handleBackToHomepage = useCallback((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, - }); - } - }; + const handleGenerate = useCallback(() => { + dialogManager.openExportDialog(); + }, [dialogManager]); - 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, - }; + 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(); }); - } - }; - - 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); + }, + [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} + /> + + + - - - - - {selected != null && ( - - )} - - - - - {selected != null && selected.id.startsWith("group:") && ( - - )} - {selected != null && selected.id.startsWith("command:") && ( - - )} - - - - - - - {showModifyDialog && ( - - )} - {showDeleteDialog && ( - - )} - {showExportDialog && ( - - )} - {showSwaggerReloadDialog && ( - - )} - {showClientConfigDialog && ( - - )} - - ); - } + + {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) => { diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx index b0a6bce8..52e6e177 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -12,7 +12,7 @@ import { AccordionDetails, AccordionSummaryProps, } from "@mui/material"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import MuiAccordionSummary from "@mui/material/AccordionSummary"; import { NameTypography, @@ -97,8 +97,46 @@ const WSEditorCommandContent: React.FC = ({ const [exampleIdx, setExampleIdx] = useState(undefined); const [outputIdx, setOutputIdx] = useState(undefined); const [loading, setLoading] = useState(false); + const lastLoadRef = useRef(""); - const loadCommand = useCallback(async () => { + useEffect(() => { + const loadCommand = async () => { + const requestKey = `${previewCommand.id}-${reloadTimestamp}`; + + if (lastLoadRef.current === requestKey || loading) { + 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 = @@ -111,21 +149,13 @@ const WSEditorCommandContent: React.FC = ({ const cmd = DecodeResponseCommand(commandData); if (cmd.id === previewCommand.id) { setCommand(cmd); - setLoading(false); } } catch (err: any) { - setLoading(false); console.error(err); - return; - } - }, [workspaceUrl, previewCommand]); - - useEffect(() => { - if (command?.id !== previewCommand.id) { - setCommand(undefined); + } finally { + setLoading(false); } - loadCommand(); - }, [workspaceUrl, previewCommand.id, reloadTimestamp, loadCommand, command?.id, previewCommand]); + }, [workspaceUrl, previewCommand.id, previewCommand.names]); const onCommandDialogDisplay = useCallback(() => { setDisplayCommandDialog(true); @@ -411,6 +441,7 @@ const WSEditorCommandContent: React.FC = ({ ); }, [command, previewCommand, name, onCommandDialogDisplay, loading, onCommandDeleteDialogDisplay]); + const buildArgumentsCard = useCallback(() => { return ( = ({ commandUrl={commandUrl} args={command!.args!} clsArgDefineMap={command!.clsArgDefineMap!} - onReloadArgs={loadCommand} + onReloadArgs={reloadCommand} onAddSubCommand={onAddSubcommandDialogDisplay} /> ); - }, [commandUrl, command, loadCommand, onAddSubcommandDialogDisplay]); + }, [commandUrl, command, reloadCommand, onAddSubcommandDialogDisplay]); const buildExampleCard = useCallback(() => { const examples = command!.examples ?? []; 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/useTreeState.ts b/src/web/src/views/workspace/hooks/useTreeState.ts new file mode 100644 index 00000000..ef1f36be --- /dev/null +++ b/src/web/src/views/workspace/hooks/useTreeState.ts @@ -0,0 +1,131 @@ +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) => 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) => { + setExpanded((prevExpanded) => { + const newExpanded = new Set(); + + prevExpanded.forEach((value) => { + if (value in newCommandGroupMap) { + newExpanded.add(value); + } + }); + + for (const groupId in newCommandGroupMap) { + if (!(groupId in commandGroupMap)) { + newExpanded.add(groupId); + } + } + + 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, + }; +} From 980f81999a9a5b1eddeb2a320b3ed9482df4a560 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 19:29:05 +1100 Subject: [PATCH 069/107] refactor: WSEditor add interface --- src/web/src/views/workspace/components/WSEditor/WSEditor.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx index 255e30bd..08d90909 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -9,11 +9,12 @@ import WSEditorCommandGroupContent from "../WSEditorCommandGroupContent"; import WSEditorCommandContent from "../WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; import type { CommandGroup, Command } from "../../interfaces"; +import type { ClientConfig } from "../../../../services"; import WSEditorExportDialog from "./WSEditorExportDialog"; import WSEditorDeleteDialog from "./WSEditorDeleteDialog"; import WSEditorSwaggerReloadDialog from "./WSEditorSwaggerReloadDialog"; import WSRenameDialog from "./WSRenameDialog"; -import { useDialogManager, useWorkspaceData, useTreeState } from "../../hooks"; +import { useDialogManager, useWorkspaceData, useTreeState } from "../../hooks/index"; interface WSEditorProps { params: { @@ -47,7 +48,7 @@ function WSEditor({ params }: WSEditorProps) { if (workspace.clientConfigurable) { workspace .getWorkspaceClientConfig(workspace.workspaceUrl) - .then((clientConfig) => { + .then((clientConfig: ClientConfig | null) => { if (!clientConfig) { dialogManager.openClientConfigDialog(); } From c6943bcc6c0725488856096bdeac59184e64bd20 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 20:08:48 +1100 Subject: [PATCH 070/107] refactor: WSEditor to async/await pattern --- .../components/WSEditor/WSEditor.tsx | 19 +++++++++++-------- .../WSEditorCommandContent.tsx | 4 ++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx index 08d90909..b9bb7497 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -9,7 +9,6 @@ import WSEditorCommandGroupContent from "../WSEditorCommandGroupContent"; import WSEditorCommandContent from "../WSEditorCommandContent"; import WSEditorClientConfigDialog from "./WSEditorClientConfig"; import type { CommandGroup, Command } from "../../interfaces"; -import type { ClientConfig } from "../../../../services"; import WSEditorExportDialog from "./WSEditorExportDialog"; import WSEditorDeleteDialog from "./WSEditorDeleteDialog"; import WSEditorSwaggerReloadDialog from "./WSEditorSwaggerReloadDialog"; @@ -45,16 +44,20 @@ function WSEditor({ params }: WSEditorProps) { useEffect(() => { if (!workspace.reloadTimestamp) return; - if (workspace.clientConfigurable) { - workspace - .getWorkspaceClientConfig(workspace.workspaceUrl) - .then((clientConfig: ClientConfig | null) => { + const checkClientConfig = async () => { + if (workspace.clientConfigurable) { + try { + const clientConfig = await workspace.getWorkspaceClientConfig(workspace.workspaceUrl); if (!clientConfig) { dialogManager.openClientConfigDialog(); } - }) - .catch(console.error); - } + } catch (error) { + console.error(error); + } + } + }; + + checkClientConfig(); if (workspace.commandTree.length === 0) { dialogManager.openSwaggerResourcePicker(); diff --git a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx index 52e6e177..69168afb 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandContent/WSEditorCommandContent.tsx @@ -101,9 +101,9 @@ const WSEditorCommandContent: React.FC = ({ useEffect(() => { const loadCommand = async () => { - const requestKey = `${previewCommand.id}-${reloadTimestamp}`; + const requestKey = `${workspaceUrl}-${previewCommand.id}-${reloadTimestamp}`; - if (lastLoadRef.current === requestKey || loading) { + if (lastLoadRef.current === requestKey) { return; } From 7d31245764f81c30e0ca3f2237195080cc30d4e5 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 20:18:13 +1100 Subject: [PATCH 071/107] refactor: WSEditor to function expresion --- src/web/src/views/workspace/components/WSEditor/WSEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx index b9bb7497..ae824430 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -30,7 +30,7 @@ const swaggerResourcePickerTransition = React.forwardRef(function swaggerResourc const drawerWidth = 300; -function WSEditor({ params }: WSEditorProps) { +const WSEditor = ({ params }: WSEditorProps) => { const { workspaceName } = params; const dialogManager = useDialogManager(); @@ -295,7 +295,7 @@ function WSEditor({ params }: WSEditorProps) { )} ); -} +}; const WSEditorWrapper = (props: any) => { const params = useParams(); From 3e5524df6defbe3f3367026e715654f8f6bab6d0 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 20:49:42 +1100 Subject: [PATCH 072/107] refactor: WSEditorSwaggerPicker to function-based --- .../WSEditorSwaggerPicker.tsx | 1187 ++++++++--------- 1 file changed, 536 insertions(+), 651 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index 277cd2c0..4f61f1f2 100644 --- a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useState, useEffect, useCallback } from "react"; import { Typography, Box, @@ -37,42 +38,6 @@ interface WSEditorSwaggerPickerProps { onClose: (updated: boolean) => void; } -interface WSEditorSwaggerPickerState { - loading: boolean; - - plane: string; - - defaultModule: string | null; - defaultResourceProvider: string | null; - defaultSource: 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; }; @@ -118,51 +83,52 @@ const MiddlePadding2 = styled(Box)(() => ({ 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, - 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: "", - }; - } +const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwaggerPickerProps) => { + 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]); + const [filterText, setFilterText] = useState(""); + const [realFilterText, setRealFilterText] = useState(""); + + useEffect(() => { + const initializeComponent = async () => { + await loadWorkspaceResources(); - componentDidMount() { - this.loadWorkspaceResources().then(async () => { - await this.loadSwaggerModules(this.props.plane); try { - const swaggerDefault = await workspaceApi.getSwaggerDefault(this.props.workspaceName); + 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/${this.props.plane}/` + swaggerDefault.modNames.join("/"); - if (this.state.moduleOptions.findIndex((v) => v === moduleValueUrl) == -1) { + + 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}`; @@ -170,195 +136,147 @@ class WSEditorSwaggerPicker extends React.Component { - this.props.onClose(false); - }; + initializeComponent(); + }, []); - handleSubmit = () => { - this.addSwagger(); - }; + const handleClose = useCallback(() => { + onClose(false); + }, [onClose]); - 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}`, - }); - } - }; + const handleSubmit = useCallback(() => { + addSwagger(); + }, []); - 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]; + const loadResourceProviders = useCallback( + async (moduleUrl: string | null, preferredRP: string | null) => { + if (moduleUrl != null) { + try { + let options = await specsApi.getResourceProvidersWithType(moduleUrl, defaultSource ?? undefined); + 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/`); + await onResourceProviderUpdate(selectedResourceProvider); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); } - 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 { + setResourceProviderOptions([]); + onResourceProviderUpdate(null); } - } else { - this.setState({ - resourceProviderOptions: [], - }); - this.onResourceProviderUpdate(null); - } - }; + }, + [defaultSource], + ); - loadWorkspaceResources = async () => { + const loadWorkspaceResources = useCallback(async () => { try { - const resources = await workspaceApi.getWorkspaceResourcesByName(this.props.workspaceName); - const existingResources = new Set(); + const resources = await workspaceApi.getWorkspaceResourcesByName(workspaceName); + const existingResourcesSet = new Set(); if (resources && Array.isArray(resources) && resources.length > 0) { resources.forEach((resource: any) => { - existingResources.add(resource.id); + existingResourcesSet.add(resource.id); }); } - this.setState({ - existingResources: existingResources, - }); + setExistingResources(existingResourcesSet); } catch (err: any) { console.error(err); const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, - }); + setInvalidText(`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); - } else { + }, [workspaceName]); + + const loadResources = useCallback( + async (resourceProviderUrl: string | null) => { + if (resourceProviderUrl != null) { + setInvalidText(undefined); + setLoading(true); + let data; + if (resourceProviderUrl.endsWith("/TypeSpec")) { + setLoading(false); + setVersionOptions([]); + data = await getTypespecRPResources(resourceProviderUrl); + } else { + try { + data = await specsApi.getProviderResources(resourceProviderUrl); + } catch (err: any) { + console.error(err); + const message = errorHandlerApi.getErrorMessage(err); + setInvalidText(`ResponseError: ${message}`); + } + } try { - data = await specsApi.getProviderResources(resourceProviderUrl); - } catch (err: any) { - console.error(err); - const message = errorHandlerApi.getErrorMessage(err); - this.setState({ - invalidText: `ResponseError: ${message}`, + 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); + }); }); - } - } - try { - const versionResourceIdMap: VersionResourceIdMap = {}; - const versionOptions: string[] = []; - const resourceMap: ResourceMap = {}; - const resourceIdList: string[] = []; - data.forEach((resource: Resource) => { - resourceIdList.push(resource.id); - resourceMap[resource.id] = resource; - resourceMap[resource.id].aazVersions = null; - - const resourceVersions = resource.versions.map((v) => v.version); - resourceVersions.forEach((v) => { - if (!(v in versionResourceIdMap)) { - versionResourceIdMap[v] = []; - versionOptions.push(v); + 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; } - versionResourceIdMap[v].push(resource); }); - }); - versionOptions.sort((a, b) => a.localeCompare(b)).reverse(); - let selectVersion = null; - if (versionOptions.length > 0) { - selectVersion = versionOptions[0]; + setLoading(false); + setVersionResourceIdMap(versionResourceIdMapLocal); + setResourceMap(resourceMapLocal); + setVersionOptions(versionOptionsLocal); + onVersionUpdate(selectVersion); + } catch (err: any) { + console.error(err); + setInvalidText(errorHandlerApi.getErrorMessage(err)); } - - 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 { + setVersionOptions([]); + onVersionUpdate(null); } - } else { - this.setState({ - versionOptions: [], - }); - this.onVersionUpdate(null); - } - }; - - addSwagger = async () => { - const { - selectedResources, - selectedVersion, - selectedModule, - moduleOptionsCommonPrefix, - updateOption, - resourceMap, - selectedResourceInheritanceAAZVersionMap, - defaultResourceProvider, - } = this.state; + }, + [plane], + ); + + const addSwagger = useCallback(async () => { 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 resourceOptionMap: { [key: string]: { update_by?: string; aaz_version: string | null } } = {}; + selectedResources.forEach((resourceId: string) => { const res: any = { id: resourceId, options: { @@ -367,7 +285,7 @@ class WSEditorSwaggerPicker extends React.Component v.version === selectedVersion)?.operations; + const operations = resource.versions.find((v: any) => v.version === selectedVersion)?.operations; if (operations) { let hasGet = false; let hasPut = false; @@ -384,7 +302,7 @@ class WSEditorSwaggerPicker extends React.Component v.version === selectedVersion)?.operations; + const operations = resource.versions.find((v: any) => v.version === selectedVersion)?.operations; if (operations) { for (const opName in operations) { if (operations[opName].toLowerCase() === "patch") { @@ -406,10 +324,8 @@ class WSEditorSwaggerPicker extends React.Component { - 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]] + }, [ + selectedResources, + selectedVersion, + selectedModule, + moduleOptionsCommonPrefix, + updateOption, + resourceMap, + selectedResourceInheritanceAAZVersionMap, + defaultResourceProvider, + workspaceName, + onClose, + ]); + + 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) => !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; + .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 { - selectedResourceInheritanceAAZVersionMap[r.id] = preState.selectedResourceInheritanceAAZVersionMap[r.id]; + newSelectedResourceInheritanceAAZVersionMap[r.id] = 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; + 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) => v === preState.preferredAAZVersion) >= 0) { - inheritanceAAZVersion = preState.preferredAAZVersion; + if (aazVersions.findIndex((v: string) => v === preferredAAZVersion) >= 0) { + inheritanceAAZVersion = preferredAAZVersion; } else { inheritanceAAZVersion = aazVersions[0]; } } - selectedResourceInheritanceAAZVersionMap[resourceId] = inheritanceAAZVersion; + newSelectedResourceInheritanceAAZVersionMap[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, + 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]); - onResourceInheritanceAAZVersionUpdate = (resourceId: string, aazVersion: string | null) => { - this.setState((preState) => { - const selectedResourceInheritanceAAZVersionMap = { ...preState.selectedResourceInheritanceAAZVersionMap }; - selectedResourceInheritanceAAZVersionMap[resourceId] = aazVersion; - let preferredAAZVersion = preState.preferredAAZVersion; + const onResourceInheritanceAAZVersionUpdate = useCallback( + (resourceId: string, aazVersion: string | null) => { + const newSelectedResourceInheritanceAAZVersionMap = { ...selectedResourceInheritanceAAZVersionMap }; + newSelectedResourceInheritanceAAZVersionMap[resourceId] = aazVersion; + let newPreferredAAZVersion = preferredAAZVersion; if (aazVersion !== null) { - preferredAAZVersion = aazVersion; + newPreferredAAZVersion = aazVersion; } - return { - ...preState, - selectedResourceInheritanceAAZVersionMap: selectedResourceInheritanceAAZVersionMap, - preferredAAZVersion: preferredAAZVersion, - }; - }); - }; - - render() { - const { - selectedResources, - existingResources, - resourceOptions, - resourceMap, - selectedVersion, - selectedModule, - selectedResourceInheritanceAAZVersionMap, - filterText, - realFilterText, - } = this.state; - return ( - - - - - - - - Add Resources - - - - - + + + + + + + Add Resources + + + + + + Swagger Filters + + + + + + + + + + + - - - + + + + Resource Url + + - Resource Url - - - - - 0 && selectedResources.size === resourceOptions.length} - indeterminate={selectedResources.size > 0 && selectedResources.size < resourceOptions.length} - tabIndex={-1} - disableRipple - inputProps={{ "aria-labelledby": "SelectAll" }} - /> - - + 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 ( - - - - - - + { + const reg = /\{.*?\}/g; + setFilterText(event.target.value); + setRealFilterText(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 - { + onResourceInheritanceAAZVersionUpdate( + option.id, + event.target.value === "_NULL_" ? null : event.target.value, + ); + }} + size="small" + > + + None + + {inheritanceOptions && + inheritanceOptions.map((inheritanceOption: string) => { + return ( + + {inheritanceOption} + ); - }} - size="small" - > - - None - - {inheritanceOptions && - inheritanceOptions.map((inheritanceOption) => { - return ( - - {inheritanceOption} - - ); - })} - - 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} - + })} + + Inherit modification from exported command models in aaz + + )} + + ); + })} + )} - {this.state.invalidText === undefined && } - - - ); - } -} + + + theme.zIndex.drawer + 1 }} open={loading}> + {invalidText !== undefined && ( + { + setInvalidText(undefined); + setLoading(false); + }} + > + {invalidText} + + )} + {invalidText === undefined && } + + + ); +}; export default WSEditorSwaggerPicker; From e3b4d289c47a529aff1fc243bac90504719a25ac Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 16 Oct 2025 21:28:14 +1100 Subject: [PATCH 073/107] refactor: WSEditorSwaggerPicker bug fixes and hook extraction --- .../WSEditorSwaggerPicker.tsx | 174 +++++++++--------- .../workspace/hooks/useResourceFilter.ts | 28 +++ 2 files changed, 115 insertions(+), 87 deletions(-) create mode 100644 src/web/src/views/workspace/hooks/useResourceFilter.ts diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index 4f61f1f2..586f5395 100644 --- a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -31,6 +31,7 @@ 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; @@ -84,6 +85,8 @@ const MiddlePadding2 = styled(Box)(() => ({ 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); @@ -107,8 +110,6 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge const [selectedVersion, setSelectedVersion] = useState(null); const [updateOptions] = useState(UpdateOptions); const [updateOption, setUpdateOption] = useState(UpdateOptions[0]); - const [filterText, setFilterText] = useState(""); - const [realFilterText, setRealFilterText] = useState(""); useEffect(() => { const initializeComponent = async () => { @@ -156,10 +157,6 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge onClose(false); }, [onClose]); - const handleSubmit = useCallback(() => { - addSwagger(); - }, []); - const loadResourceProviders = useCallback( async (moduleUrl: string | null, preferredRP: string | null) => { if (moduleUrl != null) { @@ -274,6 +271,15 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge ); 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) => { @@ -319,7 +325,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge }); const requestBody = { - module: selectedModule!.slice().replace(moduleOptionsCommonPrefix, ""), + module: selectedModule.replace(moduleOptionsCommonPrefix, ""), version: selectedVersion, resources: resources, }; @@ -386,6 +392,10 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge onClose, ]); + const handleSubmit = useCallback(() => { + addSwagger(); + }, [addSwagger]); + const onModuleSelectorUpdate = useCallback( async (moduleValueUrl: string | null) => { if (selectedModule !== moduleValueUrl) { @@ -653,9 +663,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge inputProps={{ "aria-label": "Filter by keywords" }} value={filterText} onChange={(event: any) => { - const reg = /\{.*?\}/g; - setFilterText(event.target.value); - setRealFilterText(event.target.value.toLocaleLowerCase().replace(reg, "{}")); + updateFilter(event.target.value); }} /> @@ -665,85 +673,77 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge > {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 ( - - - - - - { + 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 - { + onResourceInheritanceAAZVersionUpdate( + option.id, + event.target.value === "_NULL_" ? null : event.target.value, + ); + }} + size="small" + > + + None + + {inheritanceOptions && + inheritanceOptions.map((inheritanceOption: string) => { + return ( + + {inheritanceOption} + ); - }} - size="small" - > - - None - - {inheritanceOptions && - inheritanceOptions.map((inheritanceOption: string) => { - return ( - - {inheritanceOption} - - ); - })} - - Inherit modification from exported command models in aaz - - )} - - ); - })} + })} + + Inherit modification from exported command models in aaz + + )} + + ); + })} )} 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..768232f1 --- /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.indexOf(realFilterText) > -1); + } + return resources; + }, + [realFilterText], + ); + + return { + filterText, + updateFilter, + filterResources, + }; +}; From 78284b3b66e0ab54f061c396a16cb3be91b201be Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 13:28:44 +1100 Subject: [PATCH 074/107] refactor: remove unused CommandsPage and references --- src/web/src/components/AppAppBar.tsx | 14 -------------- src/web/src/index.tsx | 2 -- src/web/src/views/commands/CommandsPage.tsx | 14 -------------- 3 files changed, 30 deletions(-) delete mode 100644 src/web/src/views/commands/CommandsPage.tsx diff --git a/src/web/src/components/AppAppBar.tsx b/src/web/src/components/AppAppBar.tsx index 943156d0..e0bfbc0f 100644 --- a/src/web/src/components/AppAppBar.tsx +++ b/src/web/src/components/AppAppBar.tsx @@ -66,20 +66,6 @@ class AppAppBar extends React.Component { > {"Workspace"} - {/* - - {'Commands'} - */} } /> } /> - }> }> } /> }> diff --git a/src/web/src/views/commands/CommandsPage.tsx b/src/web/src/views/commands/CommandsPage.tsx deleted file mode 100644 index 5cd0b7b0..00000000 --- a/src/web/src/views/commands/CommandsPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as React from "react"; -import { AppAppBar } from "../../components/AppAppBar"; - -class CommandsPage extends React.Component { - render() { - return ( - - - - ); - } -} - -export default CommandsPage; From f37d703e74a882d2c7b7d2efca76cef51be8f1a8 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 13:39:53 +1100 Subject: [PATCH 075/107] refactor: rename AppAppBar to AppNavBar --- src/web/src/components/AppAppBar.tsx | 10 +- src/web/src/components/AppNavBar.tsx | 120 ++++++++++++++++++ src/web/src/views/cli/CLIInstruction.tsx | 4 +- src/web/src/views/home/HomePage.tsx | 4 +- .../WorkspaceInstruction.tsx | 4 +- 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 src/web/src/components/AppNavBar.tsx diff --git a/src/web/src/components/AppAppBar.tsx b/src/web/src/components/AppAppBar.tsx index e0bfbc0f..70483848 100644 --- a/src/web/src/components/AppAppBar.tsx +++ b/src/web/src/components/AppAppBar.tsx @@ -7,16 +7,16 @@ import Button from "@mui/material/Button"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; -type AppAppBarProps = { +type AppNavBarProps = { pageName: string | null; }; -type AppAppBarState = { +type AppNavBarState = { anchorEl: null | HTMLElement; }; -class AppAppBar extends React.Component { - constructor(props: AppAppBarProps) { +class AppNavBar extends React.Component { + constructor(props: AppNavBarProps) { super(props); this.state = { anchorEl: null, @@ -117,4 +117,4 @@ class AppAppBar extends React.Component { } } -export { AppAppBar }; +export { AppNavBar }; diff --git a/src/web/src/components/AppNavBar.tsx b/src/web/src/components/AppNavBar.tsx new file mode 100644 index 00000000..70483848 --- /dev/null +++ b/src/web/src/components/AppNavBar.tsx @@ -0,0 +1,120 @@ +import * as React 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; +}; + +type AppNavBarState = { + anchorEl: null | HTMLElement; +}; + +class AppNavBar extends React.Component { + constructor(props: AppNavBarProps) { + super(props); + this.state = { + anchorEl: null, + }; + } + + handleMenuOpen = (event: React.MouseEvent) => { + this.setState({ anchorEl: event.currentTarget }); + }; + + handleMenuClose = () => { + this.setState({ anchorEl: null }); + }; + + render() { + const { anchorEl } = this.state; + + return ( +
+ + + + + {"Home"} + + + + {"Workspace"} + + + + {"CLI"} + + + + + + + Document + + + Send a Feedback + + + + +
+ ); + } +} + +export { AppNavBar }; diff --git a/src/web/src/views/cli/CLIInstruction.tsx b/src/web/src/views/cli/CLIInstruction.tsx index 37954e73..988d052c 100644 --- a/src/web/src/views/cli/CLIInstruction.tsx +++ b/src/web/src/views/cli/CLIInstruction.tsx @@ -2,7 +2,7 @@ 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 { AppNavBar } from "../../components/AppNavBar"; import PageLayout from "../../components/PageLayout"; const MiddlePadding = styled(Box)(() => ({ @@ -17,7 +17,7 @@ class CLIInstruction extends React.Component { render() { return ( - + ({ @@ -32,7 +32,7 @@ function HomePage() { return ( - + ({ @@ -12,7 +12,7 @@ const MiddlePadding = styled(Box)(() => ({ const WorkspaceInstruction: React.FC = () => { return ( - + Date: Sat, 18 Oct 2025 14:05:38 +1100 Subject: [PATCH 076/107] refactor: AppNavBar to function-based --- src/web/src/components/AppNavBar.tsx | 179 ++++++++++++--------------- 1 file changed, 80 insertions(+), 99 deletions(-) diff --git a/src/web/src/components/AppNavBar.tsx b/src/web/src/components/AppNavBar.tsx index 70483848..db27a602 100644 --- a/src/web/src/components/AppNavBar.tsx +++ b/src/web/src/components/AppNavBar.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { useState } from "react"; import Box from "@mui/material/Box"; import Link from "@mui/material/Link"; import { AppBar, Toolbar } from "@mui/material"; @@ -11,110 +11,91 @@ type AppNavBarProps = { pageName: string | null; }; -type AppNavBarState = { - anchorEl: null | HTMLElement; -}; - -class AppNavBar extends React.Component { - constructor(props: AppNavBarProps) { - super(props); - this.state = { - anchorEl: null, - }; - } +const AppNavBar: React.FC = ({ pageName }) => { + const [anchorEl, setAnchorEl] = useState(null); - handleMenuOpen = (event: React.MouseEvent) => { - this.setState({ anchorEl: event.currentTarget }); + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); }; - handleMenuClose = () => { - this.setState({ anchorEl: null }); + const handleMenuClose = () => { + setAnchorEl(null); }; - render() { - const { anchorEl } = this.state; - - return ( -
- - - - - {"Home"} - - - - {"Workspace"} - - - - {"CLI"} - - - - - - - Document - - - Send a Feedback - - - - -
- ); - } -} + Workspace + + + + CLI + + + + + + + Document + + + Send a Feedback + + +
+ +
+ ); +}; export { AppNavBar }; From 4240325fe4cc28c5cfccf7c5d427c7d0fff10b94 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 14:23:36 +1100 Subject: [PATCH 077/107] Refactor: EditorPageLayout to modern syntax --- src/web/src/components/AppAppBar.tsx | 120 -------------------- src/web/src/components/EditorPageLayout.tsx | 14 +-- 2 files changed, 7 insertions(+), 127 deletions(-) delete mode 100644 src/web/src/components/AppAppBar.tsx diff --git a/src/web/src/components/AppAppBar.tsx b/src/web/src/components/AppAppBar.tsx deleted file mode 100644 index 70483848..00000000 --- a/src/web/src/components/AppAppBar.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import * as React 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; -}; - -type AppNavBarState = { - anchorEl: null | HTMLElement; -}; - -class AppNavBar extends React.Component { - constructor(props: AppNavBarProps) { - super(props); - this.state = { - anchorEl: null, - }; - } - - handleMenuOpen = (event: React.MouseEvent) => { - this.setState({ anchorEl: event.currentTarget }); - }; - - handleMenuClose = () => { - this.setState({ anchorEl: null }); - }; - - render() { - const { anchorEl } = this.state; - - return ( -
- - - - - {"Home"} - - - - {"Workspace"} - - - - {"CLI"} - - - - - - - Document - - - Send a 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; From 6242ecaface5450635c691394ada185d5e156d33 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 14:45:18 +1100 Subject: [PATCH 078/107] refactor: PageLayout to modern syntax --- src/web/src/components/PageLayout.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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; From d89cdc9657e7e0db81dff5b9de9fb63eeb254b8c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 14:52:57 +1100 Subject: [PATCH 079/107] refactor: CLIInstruction to function-based --- src/web/src/views/cli/CLIInstruction.tsx | 72 ++++++++++++------------ 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/src/web/src/views/cli/CLIInstruction.tsx b/src/web/src/views/cli/CLIInstruction.tsx index 988d052c..eafe9d58 100644 --- a/src/web/src/views/cli/CLIInstruction.tsx +++ b/src/web/src/views/cli/CLIInstruction.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import { Typography, Box } from "@mui/material"; import { styled } from "@mui/material/styles"; import CLIModuleSelector from "./CLIModuleSelector"; @@ -13,57 +13,55 @@ const SpacePadding = styled(Box)(() => ({ width: "3vh", })); -class CLIInstruction extends React.Component { - render() { - return ( - - - +const CLIInstruction: React.FC = () => { + return ( + <> + + + + - + + Please select a CLI Module + + - - Please select a CLI Module + + + + Or - - - - - - Or - - - - + + - - - - ); - } -} + + + + + ); +}; export default CLIInstruction; From 6274d172dee5703f3b0c560d812c396f8dcee645 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 15:04:04 +1100 Subject: [PATCH 080/107] refactor: CLIModGeneratorProfileTabs --- .../views/cli/CLIModGeneratorProfileTabs.tsx | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx index a1cd079a..49365ed6 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; @@ -8,34 +8,29 @@ interface CLIModGeneratorProfileTabsProps { 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 ( - - ); - })} - - ); - } -} +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; From e7bc5e68f824c5f2c2573d0b97e6cad1c9bf6042 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 15:10:12 +1100 Subject: [PATCH 081/107] Refactor: CLIModGeneratorToolBar to function based --- .../src/views/cli/CLIModGeneratorToolBar.tsx | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorToolBar.tsx b/src/web/src/views/cli/CLIModGeneratorToolBar.tsx index 809bdb27..d1d6b26d 100644 --- a/src/web/src/views/cli/CLIModGeneratorToolBar.tsx +++ b/src/web/src/views/cli/CLIModGeneratorToolBar.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; import { AppBar, Button, IconButton, Toolbar, Typography, Tooltip, Box } from "@mui/material"; import HomeIcon from "@mui/icons-material/Home"; @@ -8,44 +8,41 @@ interface CLIModGeneratorToolBarProps { onGenerate: () => void; } -class CLIModGeneratorToolBar extends React.Component { - render() { - const { moduleName, onHomePage, onGenerate } = this.props; - return ( - - theme.zIndex.drawer + 1 }}> - - - - - GENERATION - - - - - {moduleName} +const CLIModGeneratorToolBar: React.FC = ({ moduleName, onHomePage, onGenerate }) => { + return ( + <> + theme.zIndex.drawer + 1 }}> + + + + + GENERATION + - - - - - - - - - - ); - } -} + + {moduleName} + + + + + + + + + + + + ); +}; export default CLIModGeneratorToolBar; From 373abe7fcb134344abfae6125e2de51257ec6512 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 15:17:39 +1100 Subject: [PATCH 082/107] refactor: CLIPage to function-based --- src/web/src/views/cli/CLIPage.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/web/src/views/cli/CLIPage.tsx b/src/web/src/views/cli/CLIPage.tsx index 4255873f..c58ddf8f 100644 --- a/src/web/src/views/cli/CLIPage.tsx +++ b/src/web/src/views/cli/CLIPage.tsx @@ -1,14 +1,12 @@ -import * as React from "react"; +import React from "react"; import { Outlet } from "react-router"; -class CLIPage extends React.Component { - render() { - return ( - - - - ); - } -} +const CLIPage: React.FC = () => { + return ( + <> + + + ); +}; export default CLIPage; From 293adad0c1a3b89445ccc6a23996d53b9d5160a4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 15:48:28 +1100 Subject: [PATCH 083/107] refactor: interfaces for CLI view into /interfaces/ dir --- .../src/views/cli/CLIModGeneratorProfileCommandTree.tsx | 2 +- src/web/src/views/cli/CLIModuleGenerator.tsx | 2 +- .../cli/{CLIModuleCommon.tsx => interfaces/cliModule.ts} | 0 src/web/src/views/cli/interfaces/index.ts | 9 +++++++++ 4 files changed, 11 insertions(+), 2 deletions(-) rename src/web/src/views/cli/{CLIModuleCommon.tsx => interfaces/cliModule.ts} (100%) create mode 100644 src/web/src/views/cli/interfaces/index.ts diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 39f2f1b0..a95d932d 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -24,7 +24,7 @@ import { CLIModViewCommandGroups, CLIModViewCommands, CLIModViewProfile, -} from "./CLIModuleCommon"; +} from "./interfaces"; import { CLISpecsCommand, CLISpecsCommandGroup, diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 24d10a6f..c0b16a9f 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -22,7 +22,7 @@ import CLIModGeneratorProfileCommandTree, { ProfileCommandTree, } from "./CLIModGeneratorProfileCommandTree"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; -import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; +import { CLIModView, CLIModViewProfiles } from "./interfaces"; interface CLISpecsSimpleCommand { names: string[]; 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"; From 878f8ecd67f435fdfaafcc0aaceec034f9f972e4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 17:55:15 +1100 Subject: [PATCH 084/107] refactor: CLIModGeneratorProfileCommandTree remove unused method --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 45 +------------------ 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index a95d932d..aab184c2 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -609,49 +609,6 @@ function decodeProfileCTCommand( } } -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) => @@ -972,4 +929,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree }; -export { InitializeCommandTreeByModView, BuildProfileCommandTree, ExportModViewProfile }; +export { InitializeCommandTreeByModView, ExportModViewProfile }; From 8b30fba357f4a4daa7a3842ac5cbb14bc053c518 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 17:58:48 +1100 Subject: [PATCH 085/107] refactor: remove unused interfaces --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 1 - src/web/src/views/cli/CLIModuleGenerator.tsx | 23 +------------------ 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index aab184c2..977392b5 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -27,7 +27,6 @@ import { } from "./interfaces"; import { CLISpecsCommand, - CLISpecsCommandGroup, CLISpecsSimpleCommand, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommandTree, diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index c0b16a9f..630d786c 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -76,21 +76,6 @@ interface CLISpecsCommand { 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); } @@ -390,11 +375,5 @@ const CLIModuleGeneratorWrapper = (props: any) => { return ; }; -export type { - CLISpecsCommandGroup, - CLISpecsCommand, - CLISpecsSimpleCommandTree, - CLISpecsSimpleCommandGroup, - CLISpecsSimpleCommand, -}; +export type { CLISpecsCommand, CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; From 68332b6de7461eb655a96ea8bc2820777758a6d7 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 18:15:50 +1100 Subject: [PATCH 086/107] feature: add tests for CLIModGeneratorProfileCommandTree --- ...CLIModGeneratorProfileCommandTree.test.tsx | 399 ++++++++++++++++++ .../CLIModGeneratorProfileCommandTree.test.ts | 222 ++++++++++ .../cli/CLIModGeneratorProfileCommandTree.tsx | 5 - 3 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx create mode 100644 src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts diff --git a/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx new file mode 100644 index 00000000..dbf26112 --- /dev/null +++ b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx @@ -0,0 +1,399 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import CLIModGeneratorProfileCommandTree, { + ProfileCommandTree, +} from "../../../views/cli/CLIModGeneratorProfileCommandTree"; + +vi.mock("@mui/lab/TreeView", () => ({ + default: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("@mui/lab/TreeItem", () => ({ + default: ({ label, children, nodeId, ...props }: any) => ( +
+
{label}
+ {children &&
{children}
} +
+ ), +})); + +describe("CLIModGeneratorProfileCommandTree", () => { + const mockOnChange = vi.fn(); + const mockOnLoadCommands = vi.fn(); + + const mockProfileCommandTree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "test-command": { + id: "test-group/test-command", + names: ["test-group", "test-command"], + selected: false, + modified: false, + loading: false, + }, + "selected-command": { + id: "test-group/selected-command", + names: ["test-group", "selected-command"], + selected: true, + selectedVersion: "2023-01-01", + registered: true, + modified: false, + loading: false, + versions: [ + { name: "2023-01-01", stage: "stable" }, + { name: "2022-12-01", stage: "preview" }, + ], + }, + }, + loading: false, + selected: undefined, + }, + "empty-group": { + id: "empty-group", + names: ["empty-group"], + commands: {}, + loading: false, + selected: false, + }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnLoadCommands.mockResolvedValue([]); + }); + + it("should render the tree view with command groups", () => { + render( + , + ); + + expect(screen.getByTestId("tree-view")).toBeInTheDocument(); + expect(screen.getAllByTestId("tree-item")).toHaveLength(4); + }); + + it("should display command group names correctly", () => { + render( + , + ); + + expect(screen.getByText("test-group")).toBeInTheDocument(); + expect(screen.getByText("empty-group")).toBeInTheDocument(); + }); + + it("should display command names correctly", () => { + render( + , + ); + + expect(screen.getByText("test-command")).toBeInTheDocument(); + expect(screen.getByText("selected-command")).toBeInTheDocument(); + }); + + it("should show command group checkboxes with correct states", () => { + render( + , + ); + + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(4); + + const emptyGroupCheckbox = checkboxes.find((checkbox) => checkbox.closest('[data-node-id="empty-group"]')); + expect(emptyGroupCheckbox).toHaveProperty("checked", false); + }); + + it("should show version selector for selected commands", () => { + render( + , + ); + + expect(screen.getByDisplayValue("2023-01-01")).toBeInTheDocument(); + expect(screen.getByText("Version")).toBeInTheDocument(); + }); + + it("should show registered/unregistered selector for selected commands", () => { + render( + , + ); + + expect(screen.getByText("Command table")).toBeInTheDocument(); + expect(screen.getByText("Registered")).toBeInTheDocument(); + }); + + it("should call onChange when command is selected", async () => { + render( + , + ); + + const testCommandCheckbox = screen + .getAllByRole("checkbox") + .find((checkbox) => checkbox.closest('[data-node-id="test-group/test-command"]')); + + expect(testCommandCheckbox).toBeDefined(); + fireEvent.click(testCommandCheckbox!); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it("should call onChange when command group is selected", async () => { + render( + , + ); + + const groupCheckbox = screen + .getAllByRole("checkbox") + .find((checkbox) => checkbox.closest('[data-node-id="empty-group"]')); + + expect(groupCheckbox).toBeDefined(); + fireEvent.click(groupCheckbox!); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it("should call onChange when version is changed", async () => { + render( + , + ); + + const versionSelect = screen.getByDisplayValue("2023-01-01"); + fireEvent.change(versionSelect, { target: { value: "2022-12-01" } }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it("should call onChange when registration status is changed", async () => { + render( + , + ); + + const registrationSelects = screen.getAllByRole("combobox"); + expect(registrationSelects).toHaveLength(2); + + const commandTableSelect = registrationSelects[1]; + fireEvent.mouseDown(commandTableSelect); + + const unregisteredOption = screen.getByText("Unregistered"); + fireEvent.click(unregisteredOption); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it("should show loading state for commands that are loading", () => { + const loadingTree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "loading-command": { + id: "test-group/loading-command", + names: ["test-group", "loading-command"], + selected: true, + modified: false, + loading: true, + }, + }, + loading: false, + selected: true, + }, + }, + }; + + render( + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("should show edit icon correctly based on command state", () => { + const mixedTree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "modified-command": { + id: "test-group/modified-command", + names: ["test-group", "modified-command"], + selected: true, + selectedVersion: "2023-01-01", + modified: true, + loading: false, + }, + "unmodified-with-version": { + id: "test-group/unmodified-with-version", + names: ["test-group", "unmodified-with-version"], + selected: true, + selectedVersion: "2023-01-01", + modified: false, + loading: false, + }, + "unmodified-no-version": { + id: "test-group/unmodified-no-version", + names: ["test-group", "unmodified-no-version"], + selected: false, + modified: false, + loading: false, + // No selectedVersion + }, + }, + loading: false, + selected: undefined, + }, + }, + }; + + render( + , + ); + + // Should show 2 EditIcons total: + // 1. Secondary color icon for modified command + // 2. Disabled icon button for unmodified command with version + const editIcons = screen.getAllByTestId("EditIcon"); + expect(editIcons).toHaveLength(2); + + // The modified command should have a secondary color edit icon + expect(editIcons[0]).toHaveClass("MuiSvgIcon-colorSecondary"); + + // The unmodified command with version should have a disabled edit icon button + expect(editIcons[1]).toHaveClass("MuiSvgIcon-colorDisabled"); + }); + + it("should call onLoadCommands when selecting a command without versions", async () => { + const treeWithoutVersions: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "no-versions-command": { + id: "test-group/no-versions-command", + names: ["test-group", "no-versions-command"], + selected: false, + modified: false, + loading: false, + // No versions defined + }, + }, + loading: false, + selected: false, + }, + }, + }; + + render( + , + ); + + const commandCheckbox = screen + .getAllByRole("checkbox") + .find((checkbox) => checkbox.closest('[data-node-id="test-group/no-versions-command"]')); + + expect(commandCheckbox).toBeDefined(); + fireEvent.click(commandCheckbox!); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + + expect(commandCheckbox).toHaveProperty("checked", false); + }); + + it("should prevent event propagation on checkbox clicks", () => { + const mockStopPropagation = vi.fn(); + const mockPreventDefault = vi.fn(); + + render( + , + ); + + const checkbox = screen.getAllByRole("checkbox")[0]; + + const mockEvent = { + stopPropagation: mockStopPropagation, + preventDefault: mockPreventDefault, + target: { checked: true }, + } as any; + + fireEvent.click(checkbox, mockEvent); + + expect(mockOnChange).toHaveBeenCalled(); + }); +}); diff --git a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts new file mode 100644 index 00000000..664872bf --- /dev/null +++ b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect } from "vitest"; +import { + InitializeCommandTreeByModView, + ExportModViewProfile, + ProfileCommandTree, +} from "../../../views/cli/CLIModGeneratorProfileCommandTree"; +import { CLIModViewProfile } from "../../../views/cli/interfaces"; +import { CLISpecsSimpleCommandTree } from "../../../views/cli/CLIModuleGenerator"; + +describe("CLIModGeneratorProfileCommandTree", () => { + describe("InitializeCommandTreeByModView", () => { + it("should initialize command tree with empty profile", () => { + const profileName = "test-profile"; + const view: CLIModViewProfile | null = null; + const simpleTree: CLISpecsSimpleCommandTree = { + root: { + names: ["root"], + commands: {}, + commandGroups: { + "test-group": { + names: ["test-group"], + commands: { + "test-command": { + names: ["test-group", "test-command"], + }, + }, + commandGroups: {}, + }, + }, + }, + }; + + const result = InitializeCommandTreeByModView(profileName, view, simpleTree); + + expect(result.name).toBe(profileName); + expect(result.commandGroups).toBeDefined(); + expect(result.commandGroups["test-group"]).toBeDefined(); + expect(result.commandGroups["test-group"].names).toEqual(["test-group"]); + expect(result.commandGroups["test-group"].commands).toBeDefined(); + expect(result.commandGroups["test-group"].commands!["test-command"]).toBeDefined(); + expect(result.commandGroups["test-group"].commands!["test-command"].names).toEqual([ + "test-group", + "test-command", + ]); + expect(result.commandGroups["test-group"].commands!["test-command"].selected).toBe(false); + }); + + it("should initialize command tree with profile view", () => { + const profileName = "test-profile"; + const view: CLIModViewProfile = { + name: "test-profile", + commandGroups: { + "test-group": { + names: ["test-group"], + commands: { + "test-command": { + names: ["test-group", "test-command"], + version: "2023-01-01", + registered: true, + modified: false, + }, + }, + }, + }, + }; + const simpleTree: CLISpecsSimpleCommandTree = { + root: { + names: ["root"], + commands: {}, + commandGroups: { + "test-group": { + names: ["test-group"], + commands: { + "test-command": { + names: ["test-group", "test-command"], + }, + }, + commandGroups: {}, + }, + }, + }, + }; + + const result = InitializeCommandTreeByModView(profileName, view, simpleTree); + + expect(result.name).toBe(profileName); + expect(result.commandGroups["test-group"].commands!["test-command"].selected).toBe(true); + expect(result.commandGroups["test-group"].commands!["test-command"].selectedVersion).toBe("2023-01-01"); + expect(result.commandGroups["test-group"].commands!["test-command"].registered).toBe(true); + }); + + it("should throw error for missing command groups in aaz", () => { + const profileName = "test-profile"; + const view: CLIModViewProfile = { + name: "test-profile", + commandGroups: { + "missing-group": { + names: ["missing-group"], + commands: {}, + }, + }, + }; + const simpleTree: CLISpecsSimpleCommandTree = { + root: { + names: ["root"], + commands: {}, + commandGroups: {}, + }, + }; + + expect(() => { + InitializeCommandTreeByModView(profileName, view, simpleTree); + }).toThrow("Miss command groups in aaz: `az missing-group`"); + }); + }); + + describe("ExportModViewProfile", () => { + it("should export profile with selected commands", () => { + const tree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "test-command": { + id: "test-group/test-command", + names: ["test-group", "test-command"], + selected: true, + selectedVersion: "2023-01-01", + registered: true, + modified: false, + loading: false, + }, + }, + loading: false, + selected: true, + }, + }, + }; + + const result = ExportModViewProfile(tree); + + expect(result.name).toBe("test-profile"); + expect(result.commandGroups).toBeDefined(); + expect(result.commandGroups!["test-group"]).toBeDefined(); + expect(result.commandGroups!["test-group"].names).toEqual(["test-group"]); + expect(result.commandGroups!["test-group"].commands!["test-command"]).toBeDefined(); + expect(result.commandGroups!["test-group"].commands!["test-command"].names).toEqual([ + "test-group", + "test-command", + ]); + expect(result.commandGroups!["test-group"].commands!["test-command"].version).toBe("2023-01-01"); + expect(result.commandGroups!["test-group"].commands!["test-command"].registered).toBe(true); + expect(result.commandGroups!["test-group"].commands!["test-command"].modified).toBe(false); + }); + + it("should exclude unselected commands", () => { + const tree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "test-group": { + id: "test-group", + names: ["test-group"], + commands: { + "selected-command": { + id: "test-group/selected-command", + names: ["test-group", "selected-command"], + selected: true, + selectedVersion: "2023-01-01", + registered: true, + modified: false, + loading: false, + }, + "unselected-command": { + id: "test-group/unselected-command", + names: ["test-group", "unselected-command"], + selected: false, + modified: false, + loading: false, + }, + }, + loading: false, + selected: undefined, + }, + }, + }; + + const result = ExportModViewProfile(tree); + + expect(result.commandGroups!["test-group"].commands!["selected-command"]).toBeDefined(); + expect(result.commandGroups!["test-group"].commands!["unselected-command"]).toBeUndefined(); + }); + + it("should exclude command groups marked as false", () => { + const tree: ProfileCommandTree = { + name: "test-profile", + commandGroups: { + "selected-group": { + id: "selected-group", + names: ["selected-group"], + commands: {}, + loading: false, + selected: true, + }, + "unselected-group": { + id: "unselected-group", + names: ["unselected-group"], + commands: {}, + loading: false, + selected: false, + }, + }, + }; + + const result = ExportModViewProfile(tree); + + expect(result.commandGroups!["selected-group"]).toBeDefined(); + expect(result.commandGroups!["unselected-group"]).toBeUndefined(); + }); + }); +}); diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 977392b5..7cbe8180 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -534,9 +534,6 @@ interface ProfileCTCommands { interface ProfileCTCommandGroup { id: string; names: string[]; - // We use simple command tree now. - // `help` is not used. - // help: string; commandGroups?: ProfileCTCommandGroups; commands?: ProfileCTCommands; @@ -549,7 +546,6 @@ interface ProfileCTCommandGroup { interface ProfileCTCommand { id: string; names: string[]; - // help: string; versions?: ProfileCTCommandVersion[]; @@ -584,7 +580,6 @@ function decodeProfileCTCommand( const command = { id: response.names.join("/"), names: [...response.names], - // help: response.help.short, versions: versions, modified: modified, loading: false, From 8371c55a9b93a6c77d2920d63ccc684d09d8a660 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sat, 18 Oct 2025 18:35:58 +1100 Subject: [PATCH 087/107] refactor: extract CommandGroupItem --- ...CLIModGeneratorProfileCommandTree.test.tsx | 7 +- .../cli/CLIModGeneratorProfileCommandTree.tsx | 287 +----------------- src/web/src/views/cli/CommandGroupItem.tsx | 193 ++++++++++++ .../src/views/cli/utils/commandTreeUtils.ts | 107 +++++++ 4 files changed, 316 insertions(+), 278 deletions(-) create mode 100644 src/web/src/views/cli/CommandGroupItem.tsx create mode 100644 src/web/src/views/cli/utils/commandTreeUtils.ts diff --git a/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx index dbf26112..afb8d177 100644 --- a/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx +++ b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx @@ -80,8 +80,11 @@ describe("CLIModGeneratorProfileCommandTree", () => { />, ); - expect(screen.getByTestId("tree-view")).toBeInTheDocument(); - expect(screen.getAllByTestId("tree-item")).toHaveLength(4); + expect(screen.getByTestId("cli-command-tree")).toBeInTheDocument(); + // Should have 1 command group + 2 command items + 1 nested command group = 4 total + const commandGroups = screen.getAllByTestId(/^command-group-/); + const commandItems = screen.getAllByTestId("tree-item"); + expect(commandGroups.length + commandItems.length).toBe(4); }); it("should display command group names correctly", () => { diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 7cbe8180..98f9e3ac 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -4,7 +4,6 @@ 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, @@ -31,13 +30,15 @@ import { CLISpecsSimpleCommandGroup, CLISpecsSimpleCommandTree, } from "./CLIModuleGenerator"; - -const CommandGroupTypography = styled(Typography)(({ theme }) => ({ - color: theme.palette.primary.main, - fontFamily: "'Work Sans', sans-serif", - fontSize: 17, - fontWeight: 600, -})); +import CommandGroupItem from "./CommandGroupItem"; +import { + calculateSelected, + prepareLoadCommandsOfCommandGroup, + type ProfileCTCommandGroup, + type ProfileCTCommand, + type ProfileCTCommandGroups, + type ProfileCTCommandVersion, +} from "./utils/commandTreeUtils"; const CommandTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -263,171 +264,6 @@ const CommandItem: React.FC = React.memo(({ command, onUpdateC ); }); -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; @@ -504,6 +340,7 @@ const CLIModGeneratorProfileCommandTree: React.FC} defaultExpandIcon={} + data-testid="cli-command-tree" > {Object.values(profileCommandTree.commandGroups).map((commandGroup) => ( ))} @@ -523,45 +361,6 @@ interface ProfileCommandTree { commandGroups: ProfileCTCommandGroups; } -interface ProfileCTCommandGroups { - [name: string]: ProfileCTCommandGroup; -} - -interface ProfileCTCommands { - [name: string]: ProfileCTCommand; -} - -interface ProfileCTCommandGroup { - id: string; - names: string[]; - - commandGroups?: ProfileCTCommandGroups; - commands?: ProfileCTCommands; - waitCommand?: CLIModViewCommand; - - 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 decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { return { name: response.name, @@ -622,48 +421,6 @@ function GetDefaultExpanded(tree: ProfileCommandTree): string[] { }); } -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( @@ -735,28 +492,6 @@ function genericUpdateCommand( }; } -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, diff --git a/src/web/src/views/cli/CommandGroupItem.tsx b/src/web/src/views/cli/CommandGroupItem.tsx new file mode 100644 index 00000000..ce4c6718 --- /dev/null +++ b/src/web/src/views/cli/CommandGroupItem.tsx @@ -0,0 +1,193 @@ +import * as React 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 { + 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; + CommandItem: React.ComponentType<{ + command: ProfileCTCommand; + onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void; + onLoadCommand: (names: string[]) => Promise; + }>; +} + +const CommandGroupItem: React.FC = React.memo( + ({ commandGroup, onUpdateCommandGroup, onLoadCommands, CommandItem }) => { + 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, nodeName], + ); + + 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, nodeName], + ); + + 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, 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) => ( + + ))} + + ); + }, +); + +export default CommandGroupItem; + +export type { CommandGroupItemProps }; 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, +}; From b52bad38937fa0149c2a73452dc225e5ad330157 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 09:32:43 +1100 Subject: [PATCH 088/107] refactor: add displayName for CommandGroupItem to find in DOM --- src/web/src/views/cli/CommandGroupItem.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web/src/views/cli/CommandGroupItem.tsx b/src/web/src/views/cli/CommandGroupItem.tsx index ce4c6718..3ad76d53 100644 --- a/src/web/src/views/cli/CommandGroupItem.tsx +++ b/src/web/src/views/cli/CommandGroupItem.tsx @@ -188,6 +188,8 @@ const CommandGroupItem: React.FC = React.memo( }, ); +CommandGroupItem.displayName = "CommandGroupItem"; + export default CommandGroupItem; export type { CommandGroupItemProps }; From 2d91a75153da5a549a1f2817787a1b59d5f7fc00 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 10:23:35 +1100 Subject: [PATCH 089/107] refactor: extract COmmandItem to own file --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 239 ----------------- src/web/src/views/cli/CommandGroupItem.tsx | 9 +- src/web/src/views/cli/CommandItem.tsx | 246 ++++++++++++++++++ 3 files changed, 248 insertions(+), 246 deletions(-) create mode 100644 src/web/src/views/cli/CommandItem.tsx diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 98f9e3ac..8382d37e 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -1,22 +1,8 @@ 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 EditIcon from "@mui/icons-material/Edit"; -import { - Box, - Checkbox, - FormControl, - Typography, - Select, - MenuItem, - styled, - TypographyProps, - InputLabel, - IconButton, -} from "@mui/material"; import { CLIModViewCommand, CLIModViewCommandGroup, @@ -40,230 +26,6 @@ import { type ProfileCTCommandVersion, } 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 = 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 CLIModGeneratorProfileCommandTreeProps { profile?: string; profileCommandTree: ProfileCommandTree; @@ -348,7 +110,6 @@ const CLIModGeneratorProfileCommandTree: React.FC ))} diff --git a/src/web/src/views/cli/CommandGroupItem.tsx b/src/web/src/views/cli/CommandGroupItem.tsx index 3ad76d53..d4a2e923 100644 --- a/src/web/src/views/cli/CommandGroupItem.tsx +++ b/src/web/src/views/cli/CommandGroupItem.tsx @@ -2,6 +2,7 @@ import * as React 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, @@ -23,15 +24,10 @@ interface CommandGroupItemProps { updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup, ) => void; onLoadCommands: (names: string[][]) => Promise; - CommandItem: React.ComponentType<{ - command: ProfileCTCommand; - onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void; - onLoadCommand: (names: string[]) => Promise; - }>; } const CommandGroupItem: React.FC = React.memo( - ({ commandGroup, onUpdateCommandGroup, onLoadCommands, CommandItem }) => { + ({ commandGroup, onUpdateCommandGroup, onLoadCommands }) => { const nodeName = commandGroup.names[commandGroup.names.length - 1]; const selected = commandGroup.selected ?? false; @@ -180,7 +176,6 @@ const CommandGroupItem: React.FC = React.memo( commandGroup={group} onUpdateCommandGroup={onUpdateSubCommandGroup} onLoadCommands={onLoadCommands} - CommandItem={CommandItem} /> ))} diff --git a/src/web/src/views/cli/CommandItem.tsx b/src/web/src/views/cli/CommandItem.tsx new file mode 100644 index 00000000..121d9c7b --- /dev/null +++ b/src/web/src/views/cli/CommandItem.tsx @@ -0,0 +1,246 @@ +import * as React 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 = 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, leafName], + ); + + const selectVersion = React.useCallback( + (version: string) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + selectedVersion: version, + modified: true, + }; + }); + }, + [onUpdateCommand, leafName], + ); + + const selectRegistered = React.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 }; From 20100eff895be819d47bc2fba0f8e715403a18ae Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 10:56:39 +1100 Subject: [PATCH 090/107] Refactor: CommandItem components to named immport and function expressions --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 70 +++++++++---------- src/web/src/views/cli/CommandGroupItem.tsx | 12 ++-- src/web/src/views/cli/CommandItem.tsx | 10 +-- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 8382d37e..60f00557 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { useState, useCallback, useEffect } from "react"; import TreeView from "@mui/lab/TreeView"; import ArrowRightIcon from "@mui/icons-material/ArrowRight"; @@ -38,9 +38,9 @@ const CLIModGeneratorProfileCommandTree: React.FC { - const [defaultExpanded, _] = React.useState(GetDefaultExpanded(profileCommandTree)); + const [defaultExpanded, _] = useState(GetDefaultExpanded(profileCommandTree)); - const onUpdateCommandGroup = React.useCallback( + const onUpdateCommandGroup = useCallback( (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onChange((profileCommandTree) => { return { @@ -55,7 +55,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { onChange((profileCommandTree) => { const newTree = commands.reduce((tree, command) => { @@ -77,7 +77,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { const commands = await onLoadCommands(names); handleBatchedLoadedCommands(commands); @@ -85,7 +85,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { + useEffect(() => { const [loadingNamesList, newTree] = PrepareLoadCommands(profileCommandTree); if (loadingNamesList.length > 0) { onChange(newTree); @@ -122,20 +122,20 @@ interface ProfileCommandTree { commandGroups: ProfileCTCommandGroups; } -function decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { +const decodeProfileCTCommandVersion = (response: any): ProfileCTCommandVersion => { return { name: response.name, stage: response.stage, }; -} +}; -function decodeProfileCTCommand( +const decodeProfileCTCommand = ( response: CLISpecsCommand, selected: boolean = false, modified: boolean = false, registered: boolean | undefined = undefined, selectedVersion: string | undefined = undefined, -): ProfileCTCommand { +): ProfileCTCommand => { const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); const command = { id: response.names.join("/"), @@ -161,18 +161,18 @@ function decodeProfileCTCommand( } else { return command; } -} +}; -function getDefaultExpandedOfCommandGroup(commandGroup: ProfileCTCommandGroup): string[] { +const 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[] { +const GetDefaultExpanded = (tree: ProfileCommandTree): string[] => { return Object.values(tree.commandGroups).flatMap((value) => { const ids = getDefaultExpandedOfCommandGroup(value); if (value.selected !== false) { @@ -180,9 +180,9 @@ function GetDefaultExpanded(tree: ProfileCommandTree): string[] { } return ids; }); -} +}; -function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileCommandTree] { +const PrepareLoadCommands = (tree: ProfileCommandTree): [string[][], ProfileCommandTree] => { const namesList: string[][] = []; const commandGroups = Object.fromEntries( Object.entries(tree.commandGroups).map(([key, value]) => { @@ -202,13 +202,13 @@ function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileComm } else { return [[], tree]; } -} +}; -function genericUpdateCommand( +const genericUpdateCommand = ( tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined, -): ProfileCommandTree | undefined { +): ProfileCommandTree | undefined => { const nodes: ProfileCTCommandGroup[] = []; for (const name of names.slice(0, -1)) { const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; @@ -251,12 +251,12 @@ function genericUpdateCommand( [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, }, }; -} +}; -function initializeCommandByModView( +const initializeCommandByModView = ( view: CLIModViewCommand | undefined, simpleCommand: CLISpecsSimpleCommand, -): ProfileCTCommand { +): ProfileCTCommand => { return { id: simpleCommand.names.join("/"), names: simpleCommand.names, @@ -266,12 +266,12 @@ function initializeCommandByModView( selectedVersion: view !== undefined ? view.version : undefined, registered: view !== undefined ? view.registered : true, }; -} +}; -function initializeCommandGroupByModView( +const initializeCommandGroupByModView = ( view: CLIModViewCommandGroup | undefined, simpleCommandGroup: CLISpecsSimpleCommandGroup, -): ProfileCTCommandGroup { +): ProfileCTCommandGroup => { const commands = simpleCommandGroup.commands !== undefined ? Object.fromEntries( @@ -322,13 +322,13 @@ function initializeCommandGroupByModView( loading: false, selected: selected, }; -} +}; -function InitializeCommandTreeByModView( +const InitializeCommandTreeByModView = ( profileName: string, view: CLIModViewProfile | null, simpleTree: CLISpecsSimpleCommandTree, -): ProfileCommandTree { +): ProfileCommandTree => { const commandGroups = Object.fromEntries( Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [ key, @@ -348,9 +348,9 @@ function InitializeCommandTreeByModView( name: profileName, commandGroups: commandGroups, }; -} +}; -function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | undefined { +const ExportModViewCommand = (command: ProfileCTCommand): CLIModViewCommand | undefined => { if (command.selectedVersion === undefined) { return undefined; } @@ -361,9 +361,9 @@ function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | un version: command.selectedVersion!, modified: command.modified, }; -} +}; -function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined { +const ExportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined => { if (commandGroup.selected === false) { return undefined; } @@ -397,9 +397,9 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV commands: commands, waitCommand: commandGroup.waitCommand, }; -} +}; -function ExportModViewProfile(tree: ProfileCommandTree): CLIModViewProfile { +const ExportModViewProfile = (tree: ProfileCommandTree): CLIModViewProfile => { const commandGroups: CLIModViewCommandGroups = {}; Object.values(tree.commandGroups).forEach((value) => { @@ -413,7 +413,7 @@ function ExportModViewProfile(tree: ProfileCommandTree): CLIModViewProfile { name: tree.name, commandGroups: commandGroups, }; -} +}; export default CLIModGeneratorProfileCommandTree; diff --git a/src/web/src/views/cli/CommandGroupItem.tsx b/src/web/src/views/cli/CommandGroupItem.tsx index d4a2e923..6c50668c 100644 --- a/src/web/src/views/cli/CommandGroupItem.tsx +++ b/src/web/src/views/cli/CommandGroupItem.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +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"; @@ -26,12 +26,12 @@ interface CommandGroupItemProps { onLoadCommands: (names: string[][]) => Promise; } -const CommandGroupItem: React.FC = React.memo( +const CommandGroupItem: React.FC = memo( ({ commandGroup, onUpdateCommandGroup, onLoadCommands }) => { const nodeName = commandGroup.names[commandGroup.names.length - 1]; const selected = commandGroup.selected ?? false; - const onUpdateCommand = React.useCallback( + const onUpdateCommand = useCallback( (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { const commands = { @@ -49,7 +49,7 @@ const CommandGroupItem: React.FC = React.memo( [onUpdateCommandGroup, nodeName], ); - const onUpdateSubCommandGroup = React.useCallback( + const onUpdateSubCommandGroup = useCallback( (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { const commandGroups = { @@ -68,7 +68,7 @@ const CommandGroupItem: React.FC = React.memo( [onUpdateCommandGroup, nodeName], ); - const onLoadCommand = React.useCallback( + const onLoadCommand = useCallback( async (names: string[]) => { await onLoadCommands([names]); }, @@ -115,7 +115,7 @@ const CommandGroupItem: React.FC = React.memo( }; }; - const selectCommandGroup = React.useCallback( + const selectCommandGroup = useCallback( (selected: boolean) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { const selectedGroup = updateGroupSelected(oldCommandGroup, selected); diff --git a/src/web/src/views/cli/CommandItem.tsx b/src/web/src/views/cli/CommandItem.tsx index 121d9c7b..393b35b6 100644 --- a/src/web/src/views/cli/CommandItem.tsx +++ b/src/web/src/views/cli/CommandItem.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React, { memo, useCallback } from "react"; import TreeItem from "@mui/lab/TreeItem"; import EditIcon from "@mui/icons-material/Edit"; import { @@ -41,10 +41,10 @@ interface CommandItemProps { onLoadCommand(names: string[]): Promise; } -const CommandItem: React.FC = React.memo(({ command, onUpdateCommand, onLoadCommand }) => { +const CommandItem: React.FC = memo(({ command, onUpdateCommand, onLoadCommand }) => { const leafName = command.names[command.names.length - 1]; - const selectCommand = React.useCallback( + const selectCommand = useCallback( (selected: boolean) => { onUpdateCommand(leafName, (oldCommand) => { if (oldCommand.versions === undefined && selected === true) { @@ -68,7 +68,7 @@ const CommandItem: React.FC = React.memo(({ command, onUpdateC [onUpdateCommand, onLoadCommand, leafName], ); - const selectVersion = React.useCallback( + const selectVersion = useCallback( (version: string) => { onUpdateCommand(leafName, (oldCommand) => { return { @@ -81,7 +81,7 @@ const CommandItem: React.FC = React.memo(({ command, onUpdateC [onUpdateCommand, leafName], ); - const selectRegistered = React.useCallback( + const selectRegistered = useCallback( (registered: boolean) => { onUpdateCommand(leafName, (oldCommand) => { return { From 7426a99a46df218301c38a96657443de7b4b8b50 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:11:21 +1100 Subject: [PATCH 091/107] refactor: methods to camelCase --- .../CLIModGeneratorProfileCommandTree.test.ts | 20 ++++++++-------- .../cli/CLIModGeneratorProfileCommandTree.tsx | 24 +++++++++---------- src/web/src/views/cli/CLIModuleGenerator.tsx | 10 ++++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts index 664872bf..3cf7b62d 100644 --- a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts +++ b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect } from "vitest"; import { - InitializeCommandTreeByModView, - ExportModViewProfile, ProfileCommandTree, + initializeCommandTreeByModView, + exportModViewProfile, } from "../../../views/cli/CLIModGeneratorProfileCommandTree"; import { CLIModViewProfile } from "../../../views/cli/interfaces"; import { CLISpecsSimpleCommandTree } from "../../../views/cli/CLIModuleGenerator"; describe("CLIModGeneratorProfileCommandTree", () => { - describe("InitializeCommandTreeByModView", () => { + describe("initializeCommandTreeByModView", () => { it("should initialize command tree with empty profile", () => { const profileName = "test-profile"; const view: CLIModViewProfile | null = null; @@ -30,7 +30,7 @@ describe("CLIModGeneratorProfileCommandTree", () => { }, }; - const result = InitializeCommandTreeByModView(profileName, view, simpleTree); + const result = initializeCommandTreeByModView(profileName, view, simpleTree); expect(result.name).toBe(profileName); expect(result.commandGroups).toBeDefined(); @@ -81,7 +81,7 @@ describe("CLIModGeneratorProfileCommandTree", () => { }, }; - const result = InitializeCommandTreeByModView(profileName, view, simpleTree); + const result = initializeCommandTreeByModView(profileName, view, simpleTree); expect(result.name).toBe(profileName); expect(result.commandGroups["test-group"].commands!["test-command"].selected).toBe(true); @@ -109,12 +109,12 @@ describe("CLIModGeneratorProfileCommandTree", () => { }; expect(() => { - InitializeCommandTreeByModView(profileName, view, simpleTree); + initializeCommandTreeByModView(profileName, view, simpleTree); }).toThrow("Miss command groups in aaz: `az missing-group`"); }); }); - describe("ExportModViewProfile", () => { + describe("exportModViewProfile", () => { it("should export profile with selected commands", () => { const tree: ProfileCommandTree = { name: "test-profile", @@ -139,7 +139,7 @@ describe("CLIModGeneratorProfileCommandTree", () => { }, }; - const result = ExportModViewProfile(tree); + const result = exportModViewProfile(tree); expect(result.name).toBe("test-profile"); expect(result.commandGroups).toBeDefined(); @@ -186,7 +186,7 @@ describe("CLIModGeneratorProfileCommandTree", () => { }, }; - const result = ExportModViewProfile(tree); + const result = exportModViewProfile(tree); expect(result.commandGroups!["test-group"].commands!["selected-command"]).toBeDefined(); expect(result.commandGroups!["test-group"].commands!["unselected-command"]).toBeUndefined(); @@ -213,7 +213,7 @@ describe("CLIModGeneratorProfileCommandTree", () => { }, }; - const result = ExportModViewProfile(tree); + const result = exportModViewProfile(tree); expect(result.commandGroups!["selected-group"]).toBeDefined(); expect(result.commandGroups!["unselected-group"]).toBeUndefined(); diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 60f00557..f3efe5f2 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -38,7 +38,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { - const [defaultExpanded, _] = useState(GetDefaultExpanded(profileCommandTree)); + const [defaultExpanded, _] = useState(getDefaultExpanded(profileCommandTree)); const onUpdateCommandGroup = useCallback( (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { @@ -86,7 +86,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { - const [loadingNamesList, newTree] = PrepareLoadCommands(profileCommandTree); + const [loadingNamesList, newTree] = prepareLoadCommands(profileCommandTree); if (loadingNamesList.length > 0) { onChange(newTree); onLoadCommands(loadingNamesList).then((commands) => { @@ -172,7 +172,7 @@ const getDefaultExpandedOfCommandGroup = (commandGroup: ProfileCTCommandGroup): return expandedIds; }; -const GetDefaultExpanded = (tree: ProfileCommandTree): string[] => { +const getDefaultExpanded = (tree: ProfileCommandTree): string[] => { return Object.values(tree.commandGroups).flatMap((value) => { const ids = getDefaultExpandedOfCommandGroup(value); if (value.selected !== false) { @@ -182,7 +182,7 @@ const GetDefaultExpanded = (tree: ProfileCommandTree): string[] => { }); }; -const PrepareLoadCommands = (tree: ProfileCommandTree): [string[][], ProfileCommandTree] => { +const prepareLoadCommands = (tree: ProfileCommandTree): [string[][], ProfileCommandTree] => { const namesList: string[][] = []; const commandGroups = Object.fromEntries( Object.entries(tree.commandGroups).map(([key, value]) => { @@ -324,7 +324,7 @@ const initializeCommandGroupByModView = ( }; }; -const InitializeCommandTreeByModView = ( +const initializeCommandTreeByModView = ( profileName: string, view: CLIModViewProfile | null, simpleTree: CLISpecsSimpleCommandTree, @@ -350,7 +350,7 @@ const InitializeCommandTreeByModView = ( }; }; -const ExportModViewCommand = (command: ProfileCTCommand): CLIModViewCommand | undefined => { +const exportModViewCommand = (command: ProfileCTCommand): CLIModViewCommand | undefined => { if (command.selectedVersion === undefined) { return undefined; } @@ -363,7 +363,7 @@ const ExportModViewCommand = (command: ProfileCTCommand): CLIModViewCommand | un }; }; -const ExportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined => { +const exportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined => { if (commandGroup.selected === false) { return undefined; } @@ -373,7 +373,7 @@ const ExportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModV commands = {}; Object.values(commandGroup.commands!).forEach((value) => { - const view = ExportModViewCommand(value); + const view = exportModViewCommand(value); if (view !== undefined) { commands![value.names[value.names.length - 1]] = view; } @@ -385,7 +385,7 @@ const ExportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModV commandGroups = {}; Object.values(commandGroup.commandGroups!).forEach((value) => { - const view = ExportModViewCommandGroup(value); + const view = exportModViewCommandGroup(value); if (view !== undefined) { commandGroups![value.names[value.names.length - 1]] = view; } @@ -399,11 +399,11 @@ const ExportModViewCommandGroup = (commandGroup: ProfileCTCommandGroup): CLIModV }; }; -const ExportModViewProfile = (tree: ProfileCommandTree): CLIModViewProfile => { +const exportModViewProfile = (tree: ProfileCommandTree): CLIModViewProfile => { const commandGroups: CLIModViewCommandGroups = {}; Object.values(tree.commandGroups).forEach((value) => { - const view = ExportModViewCommandGroup(value); + const view = exportModViewCommandGroup(value); if (view !== undefined) { commandGroups[value.names[value.names.length - 1]] = view; } @@ -419,4 +419,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree }; -export { InitializeCommandTreeByModView, ExportModViewProfile }; +export { initializeCommandTreeByModView, exportModViewProfile }; diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 630d786c..e3d6caa0 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -17,8 +17,8 @@ import { useParams } from "react-router"; import { cliApi, errorHandlerApi } from "../../services"; import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; import CLIModGeneratorProfileCommandTree, { - ExportModViewProfile, - InitializeCommandTreeByModView, + exportModViewProfile, + initializeCommandTreeByModView, ProfileCommandTree, } from "./CLIModGeneratorProfileCommandTree"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; @@ -163,7 +163,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const commandTrees = Object.fromEntries( profiles.map((profile) => { - return [profile, InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; + return [profile, initializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; }), ); @@ -300,7 +300,7 @@ function GenerateDialog(props: { const handleGenerateAll = async () => { const profiles: CLIModViewProfiles = {}; Object.values(props.profileCommandTrees).forEach((tree) => { - profiles[tree.name] = ExportModViewProfile(tree); + profiles[tree.name] = exportModViewProfile(tree); }); const data = { name: props.moduleName, @@ -322,7 +322,7 @@ function GenerateDialog(props: { const handleGenerateModified = async () => { const profiles: CLIModViewProfiles = {}; Object.values(props.profileCommandTrees).forEach((tree) => { - profiles[tree.name] = ExportModViewProfile(tree); + profiles[tree.name] = exportModViewProfile(tree); }); const data = { name: props.moduleName, From 324ad0dd2b4dd8beb73e64ec537527268e60effb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:18:21 +1100 Subject: [PATCH 092/107] refactor: extract GenerateDialog --- src/web/src/views/cli/CLIModuleGenerator.tsx | 110 +------------------ src/web/src/views/cli/GenerateDialog.tsx | 101 +++++++++++++++++ 2 files changed, 104 insertions(+), 107 deletions(-) create mode 100644 src/web/src/views/cli/GenerateDialog.tsx diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index e3d6caa0..33d59913 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -1,28 +1,15 @@ import * as React from "react"; -import { - Backdrop, - Box, - Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Drawer, - LinearProgress, - Toolbar, - Alert, -} from "@mui/material"; +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, { - exportModViewProfile, initializeCommandTreeByModView, ProfileCommandTree, } from "./CLIModGeneratorProfileCommandTree"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; -import { CLIModView, CLIModViewProfiles } from "./interfaces"; +import { CLIModView } from "./interfaces"; +import GenerateDialog, { type ProfileCommandTrees } from "./GenerateDialog"; interface CLISpecsSimpleCommand { names: string[]; @@ -121,10 +108,6 @@ const useSpecsCommandTree: () => (namesList: string[][]) => Promise = ({ params }) => { ); }; -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 ; diff --git a/src/web/src/views/cli/GenerateDialog.tsx b/src/web/src/views/cli/GenerateDialog.tsx new file mode 100644 index 00000000..a073dcb1 --- /dev/null +++ b/src/web/src/views/cli/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 "./CLIModGeneratorProfileCommandTree"; +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 }; From 7b0ad7decfe5b5139e942d29542de424638c0ced Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:43:37 +1100 Subject: [PATCH 093/107] refactor: extract commandTreeUtils --- .../CLIModGeneratorProfileCommandTree.test.ts | 2 +- .../cli/CLIModGeneratorProfileCommandTree.tsx | 230 +----------------- src/web/src/views/cli/CLIModuleGenerator.tsx | 6 +- src/web/src/views/cli/GenerateDialog.tsx | 2 +- .../cli/utils/commandTreeInitialization.ts | 228 +++++++++++++++++ 5 files changed, 239 insertions(+), 229 deletions(-) create mode 100644 src/web/src/views/cli/utils/commandTreeInitialization.ts diff --git a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts index 3cf7b62d..129db58d 100644 --- a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts +++ b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts @@ -3,7 +3,7 @@ import { ProfileCommandTree, initializeCommandTreeByModView, exportModViewProfile, -} from "../../../views/cli/CLIModGeneratorProfileCommandTree"; +} from "../../../views/cli/utils/commandTreeInitialization"; import { CLIModViewProfile } from "../../../views/cli/interfaces"; import { CLISpecsSimpleCommandTree } from "../../../views/cli/CLIModuleGenerator"; diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index f3efe5f2..37b81281 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -3,28 +3,20 @@ import TreeView from "@mui/lab/TreeView"; import ArrowRightIcon from "@mui/icons-material/ArrowRight"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; -import { - CLIModViewCommand, - CLIModViewCommandGroup, - CLIModViewCommandGroups, - CLIModViewCommands, - CLIModViewProfile, -} from "./interfaces"; -import { - CLISpecsCommand, - CLISpecsSimpleCommand, - CLISpecsSimpleCommandGroup, - CLISpecsSimpleCommandTree, -} from "./CLIModuleGenerator"; +import { CLISpecsCommand } from "./CLIModuleGenerator"; import CommandGroupItem from "./CommandGroupItem"; import { calculateSelected, prepareLoadCommandsOfCommandGroup, type ProfileCTCommandGroup, type ProfileCTCommand, - type ProfileCTCommandGroups, - type ProfileCTCommandVersion, } from "./utils/commandTreeUtils"; +import { + ProfileCommandTree, + initializeCommandTreeByModView, + exportModViewProfile, + decodeProfileCTCommand, +} from "./utils/commandTreeInitialization"; interface CLIModGeneratorProfileCommandTreeProps { profile?: string; @@ -117,52 +109,6 @@ const CLIModGeneratorProfileCommandTree: React.FC { - return { - name: response.name, - stage: response.stage, - }; -}; - -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 getDefaultExpandedOfCommandGroup = (commandGroup: ProfileCTCommandGroup): string[] => { const expandedIds = commandGroup.commandGroups ? Object.values(commandGroup.commandGroups).flatMap((value) => @@ -253,168 +199,6 @@ const genericUpdateCommand = ( }; }; -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, - }; -}; - -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, - }; -}; - -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, - }; -}; - export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree }; diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 33d59913..a40b9d89 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -3,10 +3,8 @@ import { Backdrop, Box, CircularProgress, Drawer, Toolbar, Alert } from "@mui/ma import { useParams } from "react-router"; import { cliApi, errorHandlerApi } from "../../services"; import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; -import CLIModGeneratorProfileCommandTree, { - initializeCommandTreeByModView, - ProfileCommandTree, -} from "./CLIModGeneratorProfileCommandTree"; +import CLIModGeneratorProfileCommandTree from "./CLIModGeneratorProfileCommandTree"; +import { initializeCommandTreeByModView, ProfileCommandTree } from "./utils/commandTreeInitialization"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView } from "./interfaces"; import GenerateDialog, { type ProfileCommandTrees } from "./GenerateDialog"; diff --git a/src/web/src/views/cli/GenerateDialog.tsx b/src/web/src/views/cli/GenerateDialog.tsx index a073dcb1..9c55d003 100644 --- a/src/web/src/views/cli/GenerateDialog.tsx +++ b/src/web/src/views/cli/GenerateDialog.tsx @@ -1,7 +1,7 @@ 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 "./CLIModGeneratorProfileCommandTree"; +import { exportModViewProfile, type ProfileCommandTree } from "./utils/commandTreeInitialization"; import { type CLIModViewProfiles } from "./interfaces"; interface ProfileCommandTrees { 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..310b54a9 --- /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 "../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, + }; +}; From d59f016d0e91825a7f4bea5e816574670413a7b4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:47:41 +1100 Subject: [PATCH 094/107] refactor: CLIModuleGenerator named imports --- src/web/src/views/cli/CLIModuleGenerator.tsx | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index a40b9d89..a85f74f8 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import { useState, useEffect, useCallback, useRef, Fragment, FC } from "react"; import { Backdrop, Box, CircularProgress, Drawer, Toolbar, Alert } from "@mui/material"; import { useParams } from "react-router"; import { cliApi, errorHandlerApi } from "../../services"; @@ -70,9 +70,9 @@ async function retrieveCommands(namesList: string[][]): Promise (namesList: string[][]) => Promise = () => { - const commandCache = React.useRef(new Map>()); + const commandCache = useRef(new Map>()); - const fetchCommands = React.useCallback( + const fetchCommands = useCallback( async (namesList: string[][]) => { const promiseResults = []; const uncachedNamesList = []; @@ -113,17 +113,17 @@ interface CLIModuleGeneratorProps { }; } -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 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(); - React.useEffect(() => { + useEffect(() => { loadModule(); }, []); @@ -173,11 +173,11 @@ const CLIModuleGenerator: React.FC = ({ params }) => { setShowGenerateDialog(false); }; - const onProfileChange = React.useCallback((selectedProfile: string) => { + const onProfileChange = useCallback((selectedProfile: string) => { setSelectedProfile(selectedProfile); }, []); - const onSelectedProfileTreeUpdate = React.useCallback( + const onSelectedProfileTreeUpdate = useCallback( (updater: ((oldTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { setCommandTrees((commandTrees) => { const selectedCommandTree = commandTrees[selectedProfile!]; @@ -189,7 +189,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { ); return ( - + = ({ params }) => { )} - + ); }; From eb61cb670e2b056b36df7c11169eb0c9a17b377b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:53:18 +1100 Subject: [PATCH 095/107] refactor: extract useSpecsCommandTree --- src/web/src/views/cli/CLIModuleGenerator.tsx | 81 +------------------ src/web/src/views/cli/hooks/index.ts | 8 ++ .../views/cli/hooks/useSpecsCommandTree.ts | 77 ++++++++++++++++++ 3 files changed, 89 insertions(+), 77 deletions(-) create mode 100644 src/web/src/views/cli/hooks/index.ts create mode 100644 src/web/src/views/cli/hooks/useSpecsCommandTree.ts diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index a85f74f8..d58f98ae 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, Fragment, FC } from "react"; +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"; @@ -8,6 +8,7 @@ import { initializeCommandTreeByModView, ProfileCommandTree } from "./utils/comm import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView } from "./interfaces"; import GenerateDialog, { type ProfileCommandTrees } from "./GenerateDialog"; +import { useSpecsCommandTree } from "./hooks"; interface CLISpecsSimpleCommand { names: string[]; @@ -31,81 +32,6 @@ 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[]; -} - -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 = 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; -}; - interface CLIModuleGeneratorProps { params: { repoName: string; @@ -269,5 +195,6 @@ const CLIModuleGeneratorWrapper = (props: any) => { return ; }; -export type { CLISpecsCommand, CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; +export type { CLISpecsCommand } from "./hooks"; +export type { CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; 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; +}; From 327f835acf8611eb49b0f58cd5f86eb792c51bde Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 11:57:16 +1100 Subject: [PATCH 096/107] refactor: remove redundant import/exports --- .../src/views/cli/CLIModGeneratorProfileCommandTree.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 37b81281..61e5c9d1 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -11,12 +11,7 @@ import { type ProfileCTCommandGroup, type ProfileCTCommand, } from "./utils/commandTreeUtils"; -import { - ProfileCommandTree, - initializeCommandTreeByModView, - exportModViewProfile, - decodeProfileCTCommand, -} from "./utils/commandTreeInitialization"; +import { ProfileCommandTree, decodeProfileCTCommand } from "./utils/commandTreeInitialization"; interface CLIModGeneratorProfileCommandTreeProps { profile?: string; @@ -202,5 +197,3 @@ const genericUpdateCommand = ( export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree }; - -export { initializeCommandTreeByModView, exportModViewProfile }; From 58e1f57c9d8a44b87cb9f2b7614828ca64fee038 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 12:18:33 +1100 Subject: [PATCH 097/107] refactor: move react component files to /components --- .../cli/CLIModGeneratorProfileCommandTree.test.tsx | 5 ++--- .../unit/cli/CLIModGeneratorProfileCommandTree.test.ts | 2 +- src/web/src/index.tsx | 6 +++--- .../src/views/cli/{ => components}/CLIInstruction.tsx | 4 ++-- .../CLIModGeneratorProfileCommandTree.tsx | 6 ++---- .../{ => components}/CLIModGeneratorProfileTabs.tsx | 0 .../cli/{ => components}/CLIModGeneratorToolBar.tsx | 0 .../views/cli/{ => components}/CLIModuleGenerator.tsx | 10 +++++----- .../views/cli/{ => components}/CLIModuleSelector.tsx | 2 +- src/web/src/views/cli/{ => components}/CLIPage.tsx | 4 ++-- .../views/cli/{ => components}/CommandGroupItem.tsx | 2 +- src/web/src/views/cli/{ => components}/CommandItem.tsx | 2 +- .../src/views/cli/{ => components}/GenerateDialog.tsx | 6 +++--- .../src/views/cli/utils/commandTreeInitialization.ts | 2 +- 14 files changed, 24 insertions(+), 27 deletions(-) rename src/web/src/views/cli/{ => components}/CLIInstruction.tsx (93%) rename src/web/src/views/cli/{ => components}/CLIModGeneratorProfileCommandTree.tsx (97%) rename src/web/src/views/cli/{ => components}/CLIModGeneratorProfileTabs.tsx (100%) rename src/web/src/views/cli/{ => components}/CLIModGeneratorToolBar.tsx (100%) rename src/web/src/views/cli/{ => components}/CLIModuleGenerator.tsx (96%) rename src/web/src/views/cli/{ => components}/CLIModuleSelector.tsx (99%) rename src/web/src/views/cli/{ => components}/CLIPage.tsx (65%) rename src/web/src/views/cli/{ => components}/CommandGroupItem.tsx (99%) rename src/web/src/views/cli/{ => components}/CommandItem.tsx (99%) rename src/web/src/views/cli/{ => components}/GenerateDialog.tsx (94%) diff --git a/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx index afb8d177..88091068 100644 --- a/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx +++ b/src/web/src/__tests__/components/cli/CLIModGeneratorProfileCommandTree.test.tsx @@ -1,8 +1,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import CLIModGeneratorProfileCommandTree, { - ProfileCommandTree, -} from "../../../views/cli/CLIModGeneratorProfileCommandTree"; +import CLIModGeneratorProfileCommandTree from "../../../views/cli/components/CLIModGeneratorProfileCommandTree"; +import { ProfileCommandTree } from "../../../views/cli/utils/commandTreeInitialization"; vi.mock("@mui/lab/TreeView", () => ({ default: ({ children, ...props }: any) => ( diff --git a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts index 129db58d..8166a7fe 100644 --- a/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts +++ b/src/web/src/__tests__/unit/cli/CLIModGeneratorProfileCommandTree.test.ts @@ -5,7 +5,7 @@ import { exportModViewProfile, } from "../../../views/cli/utils/commandTreeInitialization"; import { CLIModViewProfile } from "../../../views/cli/interfaces"; -import { CLISpecsSimpleCommandTree } from "../../../views/cli/CLIModuleGenerator"; +import { CLISpecsSimpleCommandTree } from "../../../views/cli/components/CLIModuleGenerator"; describe("CLIModGeneratorProfileCommandTree", () => { describe("initializeCommandTreeByModView", () => { diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index fe5ff98e..9cd4fb99 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -9,9 +9,9 @@ import HomePage from "./views/home/HomePage"; 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/CLIPage"; -import CLIInstruction from "./views/cli/CLIInstruction"; -import { CLIModuleGenerator } from "./views/cli/CLIModuleGenerator"; +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"; const container = document.getElementById("root"); diff --git a/src/web/src/views/cli/CLIInstruction.tsx b/src/web/src/views/cli/components/CLIInstruction.tsx similarity index 93% rename from src/web/src/views/cli/CLIInstruction.tsx rename to src/web/src/views/cli/components/CLIInstruction.tsx index eafe9d58..bac59756 100644 --- a/src/web/src/views/cli/CLIInstruction.tsx +++ b/src/web/src/views/cli/components/CLIInstruction.tsx @@ -2,8 +2,8 @@ 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"; +import { AppNavBar } from "../../../components/AppNavBar"; +import PageLayout from "../../../components/PageLayout"; const MiddlePadding = styled(Box)(() => ({ height: "6vh", diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx similarity index 97% rename from src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx rename to src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx index 61e5c9d1..dcbbc7d6 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/components/CLIModGeneratorProfileCommandTree.tsx @@ -10,8 +10,8 @@ import { prepareLoadCommandsOfCommandGroup, type ProfileCTCommandGroup, type ProfileCTCommand, -} from "./utils/commandTreeUtils"; -import { ProfileCommandTree, decodeProfileCTCommand } from "./utils/commandTreeInitialization"; +} from "../utils/commandTreeUtils"; +import { ProfileCommandTree, decodeProfileCTCommand } from "../utils/commandTreeInitialization"; interface CLIModGeneratorProfileCommandTreeProps { profile?: string; @@ -195,5 +195,3 @@ const genericUpdateCommand = ( }; export default CLIModGeneratorProfileCommandTree; - -export type { ProfileCommandTree }; diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/components/CLIModGeneratorProfileTabs.tsx similarity index 100% rename from src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx rename to src/web/src/views/cli/components/CLIModGeneratorProfileTabs.tsx diff --git a/src/web/src/views/cli/CLIModGeneratorToolBar.tsx b/src/web/src/views/cli/components/CLIModGeneratorToolBar.tsx similarity index 100% rename from src/web/src/views/cli/CLIModGeneratorToolBar.tsx rename to src/web/src/views/cli/components/CLIModGeneratorToolBar.tsx diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/components/CLIModuleGenerator.tsx similarity index 96% rename from src/web/src/views/cli/CLIModuleGenerator.tsx rename to src/web/src/views/cli/components/CLIModuleGenerator.tsx index d58f98ae..e05d3a84 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/components/CLIModuleGenerator.tsx @@ -1,14 +1,14 @@ 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 { cliApi, errorHandlerApi } from "../../../services"; import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; import CLIModGeneratorProfileCommandTree from "./CLIModGeneratorProfileCommandTree"; -import { initializeCommandTreeByModView, ProfileCommandTree } from "./utils/commandTreeInitialization"; +import { initializeCommandTreeByModView, ProfileCommandTree } from "../utils/commandTreeInitialization"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; -import { CLIModView } from "./interfaces"; +import { CLIModView } from "../interfaces"; import GenerateDialog, { type ProfileCommandTrees } from "./GenerateDialog"; -import { useSpecsCommandTree } from "./hooks"; +import { useSpecsCommandTree } from "../hooks"; interface CLISpecsSimpleCommand { names: string[]; @@ -195,6 +195,6 @@ const CLIModuleGeneratorWrapper = (props: any) => { return ; }; -export type { CLISpecsCommand } from "./hooks"; +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/CLIPage.tsx b/src/web/src/views/cli/components/CLIPage.tsx similarity index 65% rename from src/web/src/views/cli/CLIPage.tsx rename to src/web/src/views/cli/components/CLIPage.tsx index c58ddf8f..c7cbba4a 100644 --- a/src/web/src/views/cli/CLIPage.tsx +++ b/src/web/src/views/cli/components/CLIPage.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import { FC } from "react"; import { Outlet } from "react-router"; -const CLIPage: React.FC = () => { +const CLIPage: FC = () => { return ( <> diff --git a/src/web/src/views/cli/CommandGroupItem.tsx b/src/web/src/views/cli/components/CommandGroupItem.tsx similarity index 99% rename from src/web/src/views/cli/CommandGroupItem.tsx rename to src/web/src/views/cli/components/CommandGroupItem.tsx index 6c50668c..c92aaa9e 100644 --- a/src/web/src/views/cli/CommandGroupItem.tsx +++ b/src/web/src/views/cli/components/CommandGroupItem.tsx @@ -8,7 +8,7 @@ import { prepareLoadCommandsOfCommandGroup, type ProfileCTCommandGroup, type ProfileCTCommand, -} from "./utils/commandTreeUtils"; +} from "../utils/commandTreeUtils"; const CommandGroupTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, diff --git a/src/web/src/views/cli/CommandItem.tsx b/src/web/src/views/cli/components/CommandItem.tsx similarity index 99% rename from src/web/src/views/cli/CommandItem.tsx rename to src/web/src/views/cli/components/CommandItem.tsx index 393b35b6..615230ef 100644 --- a/src/web/src/views/cli/CommandItem.tsx +++ b/src/web/src/views/cli/components/CommandItem.tsx @@ -13,7 +13,7 @@ import { InputLabel, IconButton, } from "@mui/material"; -import { type ProfileCTCommand } from "./utils/commandTreeUtils"; +import { type ProfileCTCommand } from "../utils/commandTreeUtils"; const CommandTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, diff --git a/src/web/src/views/cli/GenerateDialog.tsx b/src/web/src/views/cli/components/GenerateDialog.tsx similarity index 94% rename from src/web/src/views/cli/GenerateDialog.tsx rename to src/web/src/views/cli/components/GenerateDialog.tsx index 9c55d003..4f95560a 100644 --- a/src/web/src/views/cli/GenerateDialog.tsx +++ b/src/web/src/views/cli/components/GenerateDialog.tsx @@ -1,8 +1,8 @@ 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"; +import { cliApi, errorHandlerApi } from "../../../services"; +import { exportModViewProfile, type ProfileCommandTree } from "../utils/commandTreeInitialization"; +import { type CLIModViewProfiles } from "../interfaces"; interface ProfileCommandTrees { [name: string]: ProfileCommandTree; diff --git a/src/web/src/views/cli/utils/commandTreeInitialization.ts b/src/web/src/views/cli/utils/commandTreeInitialization.ts index 310b54a9..f481d795 100644 --- a/src/web/src/views/cli/utils/commandTreeInitialization.ts +++ b/src/web/src/views/cli/utils/commandTreeInitialization.ts @@ -10,7 +10,7 @@ import { CLISpecsSimpleCommand, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommandTree, -} from "../CLIModuleGenerator"; +} from "../components/CLIModuleGenerator"; import { calculateSelected, type ProfileCTCommandGroup, From 0546acc52e103a99715d50e0512bec204569a81b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 12:25:32 +1100 Subject: [PATCH 098/107] refactor: delete unused legacy files from CRA --- src/web/src/logo.svg | 1 - src/web/src/react-app-env.d.ts | 1 - src/web/src/setupTests.js | 5 ----- src/web/src/withRoot.tsx | 18 ------------------ 4 files changed, 25 deletions(-) delete mode 100644 src/web/src/logo.svg delete mode 100644 src/web/src/react-app-env.d.ts delete mode 100644 src/web/src/setupTests.js delete mode 100644 src/web/src/withRoot.tsx 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/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/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; -} From 4962ba37985f5c58922a52558ad203b0121e0433 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Sun, 19 Oct 2025 12:31:39 +1100 Subject: [PATCH 099/107] refactor: fix some typos/grammar --- src/web/src/components/AppNavBar.tsx | 4 ++-- src/web/src/views/home/HomePage.tsx | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/web/src/components/AppNavBar.tsx b/src/web/src/components/AppNavBar.tsx index db27a602..699692ee 100644 --- a/src/web/src/components/AppNavBar.tsx +++ b/src/web/src/components/AppNavBar.tsx @@ -80,7 +80,7 @@ const AppNavBar: React.FC = ({ pageName }) => { target="_blank" rel="noopener noreferrer" > - Document + Documentation = ({ pageName }) => { target="_blank" rel="noopener noreferrer" > - Send a Feedback + Send Feedback diff --git a/src/web/src/views/home/HomePage.tsx b/src/web/src/views/home/HomePage.tsx index 5ff26b49..1b291230 100644 --- a/src/web/src/views/home/HomePage.tsx +++ b/src/web/src/views/home/HomePage.tsx @@ -84,7 +84,7 @@ function HomePage() { - {"Introduce"} + {"Introduction"} @@ -93,11 +93,11 @@ function HomePage() { } - {"Go to "} + {"Go to the "} - Introduction + introduction - {" for more details."} + {" page in our docs for more details."} @@ -107,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 @@ -159,7 +159,7 @@ function HomePage() { {"To convert command models to CLI code,"} - {"please use "} + {"please use the "} CLI From 6fac297044b018401a3e6d9598f5feb69ed05c21 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 20 Oct 2025 12:32:06 +1100 Subject: [PATCH 100/107] fix: testing if this fixes pipeline issues --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1c8936ae..e88e63c2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packageManager": "pnpm@9.5.0", "scripts": { "run-all": "pnpm -r --filter=\"!./src/typespec/core/\" ", - "build:typespec": "pnpm install -C src/typespec/ && pnpm -r --filter=\"!./src/web/\" --filter=\"!./src/typespec/core/\" --workspace-concurrency=Infinity --aggregate-output --reporter=append-only build ", + "build:typespec": "pnpm install -C src/typespec/ && pnpm -r --filter=\"!./src/web/\" --filter=\"!./src/typespec/core/\" --filter=\"!./src/typespec/core/packages/typespec-vs\" --workspace-concurrency=Infinity --aggregate-output --reporter=append-only build ", "build:web": "pnpm -r --filter=\"./src/web/\" --workspace-concurrency=Infinity --aggregate-output --reporter=append-only build ", "bundle": "node ./eng/scripts/bundle_dists.js", "test-aaz-emitter": "cd ./src/typespec-aaz && pnpm test-aaz", From 686f0108963872ebf25a2d7f0c845ac7fe702ba4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 20 Oct 2025 13:56:41 +1100 Subject: [PATCH 101/107] fix: typespec resource loading bug --- .../WSEditorSwaggerPicker.tsx | 54 ++++++++++++++++--- .../workspace/hooks/useResourceFilter.ts | 2 +- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index 586f5395..65b7da3e 100644 --- a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -172,7 +172,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge setDefaultResourceProvider(defaultResourceProviderVal); setResourceProviderOptions(options); setResourceProviderOptionsCommonPrefix(`${moduleUrl}/ResourceProviders/`); - await onResourceProviderUpdate(selectedResourceProvider); + setSelectedResourceProvider(selectedResourceProvider); } catch (err: any) { console.error(err); const message = errorHandlerApi.getErrorMessage(err); @@ -180,7 +180,10 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge } } else { setResourceProviderOptions([]); - onResourceProviderUpdate(null); + setSelectedResourceProvider(null); + setVersionOptions([]); + setResourceOptions([]); + setSelectedVersion(null); } }, [defaultSource], @@ -210,9 +213,15 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge setLoading(true); let data; if (resourceProviderUrl.endsWith("/TypeSpec")) { - setLoading(false); - setVersionOptions([]); - data = await getTypespecRPResources(resourceProviderUrl); + 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); @@ -220,9 +229,16 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge 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 = {}; @@ -257,10 +273,25 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge setVersionResourceIdMap(versionResourceIdMapLocal); setResourceMap(resourceMapLocal); setVersionOptions(versionOptionsLocal); - onVersionUpdate(selectVersion); + + 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([]); @@ -270,6 +301,17 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge [plane], ); + // 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", { diff --git a/src/web/src/views/workspace/hooks/useResourceFilter.ts b/src/web/src/views/workspace/hooks/useResourceFilter.ts index 768232f1..a1acf021 100644 --- a/src/web/src/views/workspace/hooks/useResourceFilter.ts +++ b/src/web/src/views/workspace/hooks/useResourceFilter.ts @@ -13,7 +13,7 @@ export const useResourceFilter = () => { const filterResources = useCallback( (resources: any[]) => { if (realFilterText.trim().length > 0) { - return resources.filter((resource) => resource.id.indexOf(realFilterText) > -1); + return resources.filter((resource) => resource.id.toLowerCase().indexOf(realFilterText) > -1); } return resources; }, From 18d3651c990f5fdcb14fed6d9ed17b64dd58ff0c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 21 Oct 2025 15:34:05 +1100 Subject: [PATCH 102/107] feature: add test for pre-pop bug --- .../components/WorkspaceSelector.test.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx index 6e6a6932..3f5ab013 100644 --- a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx +++ b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx @@ -298,4 +298,44 @@ describe("Workspace Management", () => { expect(result).toEqual(expectedResult); }); }); + + describe("WorkspaceCreateDialog Component", () => { + it("should pre-select the first plane after planes are loaded in the create dialog", async () => { + const user = userEvent.setup(); + const mockPlanes = [ + { name: "MgmtClient", displayName: "Control plane", moduleOptions: ["mod1", "mod2"] }, + { name: "DataPlaneClient", displayName: "Data plane", moduleOptions: ["mod3"] }, + ]; + const { specsApi } = await import("../../services"); + (specsApi.getPlanes as any).mockResolvedValue(mockPlanes); + + render(); + + // Wait for workspaces to load + await waitFor(() => { + expect(workspaceApi.getWorkspaces).toHaveBeenCalled(); + }); + + // Type a new workspace name to trigger create option + const autocomplete = screen.getByLabelText("Select Workspace"); + await user.click(autocomplete); + await user.type(autocomplete, "new-test-workspace"); + + // Click on the create option + const createOption = await screen.findByText('Create "new-test-workspace"'); + await user.click(createOption); + + // Wait for the dialog to appear + await screen.findByText("Create a new workspace"); + + // Wait for the getPlanes API to be called + await waitFor(() => { + expect(specsApi.getPlanes).toHaveBeenCalled(); + }); + + // The plane dropdown should be populated with the first plane + const planeDropdown = await screen.findByLabelText(/Plane/i); + expect(planeDropdown).toHaveValue("Control plane"); + }); + }); }); From b80125ff79c3b775287ff00552ba07075646ef06 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 21 Oct 2025 15:47:45 +1100 Subject: [PATCH 103/107] fix: default plane workspace create bug fix --- .../WorkspaceCreateDialog.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx index 2fae9ce4..b72525e5 100644 --- a/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx +++ b/src/web/src/views/workspace/components/WorkspaceInstruction/WorkspaceCreateDialog.tsx @@ -39,11 +39,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo const [selectedResourceProvider, setSelectedResourceProvider] = useState(null); useEffect(() => { - loadPlanes().then(async () => { - if (planes.length > 0) { - await onPlaneSelectorUpdate(planes[0].name); - } - }); + loadPlanes(); }, []); const loadPlanes = useCallback(async () => { @@ -56,7 +52,7 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo setPlaneOptions(planeOptionsData); setLoading(false); if (planeOptionsData.length > 0) { - await onPlaneSelectorUpdate(planeOptionsData[0]); + await onPlaneSelectorUpdateWithData(planeOptionsData[0], planesData); } } catch (err: any) { console.error(err); @@ -65,9 +61,27 @@ const WorkspaceCreateDialog: React.FC = ({ openDialo } }, []); + 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; From f7f217a7d63d86f86c83dcb3da4a0303a53c615b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 21 Oct 2025 17:43:26 +1100 Subject: [PATCH 104/107] fix: auto expand bug --- .../components/WorkspaceSelector.test.tsx | 6 - .../src/__tests__/hooks/useTreeState.test.ts | 237 ++++++++++++++++++ .../components/WSEditor/WSEditor.tsx | 6 + .../src/views/workspace/hooks/useTreeState.ts | 30 ++- 4 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 src/web/src/__tests__/hooks/useTreeState.test.ts diff --git a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx index 3f5ab013..5efbcdba 100644 --- a/src/web/src/__tests__/components/WorkspaceSelector.test.tsx +++ b/src/web/src/__tests__/components/WorkspaceSelector.test.tsx @@ -311,29 +311,23 @@ describe("Workspace Management", () => { render(); - // Wait for workspaces to load await waitFor(() => { expect(workspaceApi.getWorkspaces).toHaveBeenCalled(); }); - // Type a new workspace name to trigger create option const autocomplete = screen.getByLabelText("Select Workspace"); await user.click(autocomplete); await user.type(autocomplete, "new-test-workspace"); - // Click on the create option const createOption = await screen.findByText('Create "new-test-workspace"'); await user.click(createOption); - // Wait for the dialog to appear await screen.findByText("Create a new workspace"); - // Wait for the getPlanes API to be called await waitFor(() => { expect(specsApi.getPlanes).toHaveBeenCalled(); }); - // The plane dropdown should be populated with the first plane const planeDropdown = await screen.findByLabelText(/Plane/i); expect(planeDropdown).toHaveValue("Control plane"); }); diff --git a/src/web/src/__tests__/hooks/useTreeState.test.ts b/src/web/src/__tests__/hooks/useTreeState.test.ts new file mode 100644 index 00000000..6e29fc50 --- /dev/null +++ b/src/web/src/__tests__/hooks/useTreeState.test.ts @@ -0,0 +1,237 @@ +import { renderHook, act } from "@testing-library/react"; +import { useTreeState } from "../../views/workspace/hooks/useTreeState"; +import type { Command, CommandGroup } from "../../views/workspace/interfaces"; + +describe("useTreeState", () => { + const mockCommandGroupMap = { + "group:automanage": { + id: "group:automanage", + names: ["automanage"], + } as CommandGroup, + "group:automanage/configuration-profile": { + id: "group:automanage/configuration-profile", + names: ["automanage", "configuration-profile"], + } as CommandGroup, + "group:automanage/configuration-profile/assignment": { + id: "group:automanage/configuration-profile/assignment", + names: ["automanage", "configuration-profile", "assignment"], + } as CommandGroup, + "group:storage": { + id: "group:storage", + names: ["storage"], + } as CommandGroup, + "group:storage/account": { + id: "group:storage/account", + names: ["storage", "account"], + } as CommandGroup, + }; + + const mockCommandMap = { + "command:automanage/configuration-profile/assignment/create": { + id: "command:automanage/configuration-profile/assignment/create", + names: ["automanage", "configuration-profile", "assignment", "create"], + } as Command, + }; + + const mockCommandTree = [ + { + id: "group:automanage", + names: ["automanage"], + canDelete: true, + nodes: [ + { + id: "group:automanage/configuration-profile", + names: ["automanage", "configuration-profile"], + canDelete: true, + nodes: [ + { + id: "group:automanage/configuration-profile/assignment", + names: ["automanage", "configuration-profile", "assignment"], + canDelete: true, + leaves: [ + { + id: "command:automanage/configuration-profile/assignment/create", + names: ["automanage", "configuration-profile", "assignment", "create"], + }, + ], + }, + ], + }, + ], + }, + { + id: "group:storage", + names: ["storage"], + canDelete: true, + nodes: [ + { + id: "group:storage/account", + names: ["storage", "account"], + canDelete: true, + }, + ], + }, + ]; + + describe("updateExpanded with autoExpandAll", () => { + it("should expand all command groups when autoExpandAll is true", () => { + const { result } = renderHook(() => useTreeState(mockCommandMap, {}, mockCommandTree)); + + act(() => { + result.current.updateExpanded(mockCommandGroupMap, undefined, true); + }); + + const expandedArray = Array.from(result.current.expanded); + + // Should include all command groups + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toContain("group:automanage/configuration-profile"); + expect(expandedArray).toContain("group:automanage/configuration-profile/assignment"); + expect(expandedArray).toContain("group:storage"); + expect(expandedArray).toContain("group:storage/account"); + + // Should include all parent paths for hierarchy + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toContain("group:automanage/configuration-profile"); + expect(expandedArray).toContain("group:storage"); + + // Total count should be all unique paths + expect(expandedArray).toHaveLength(5); + }); + + it("should expand all groups regardless of existing expanded state when autoExpandAll is true", () => { + const { result } = renderHook(() => useTreeState(mockCommandMap, {}, mockCommandTree)); + + // First, manually expand only one group + act(() => { + result.current.updateExpanded({ "group:storage": mockCommandGroupMap["group:storage"] }, undefined, false); + }); + + // Should only have storage expanded initially + expect(Array.from(result.current.expanded)).toEqual(["group:storage"]); + + // Now call with autoExpandAll=true + act(() => { + result.current.updateExpanded(mockCommandGroupMap, undefined, true); + }); + + const expandedArray = Array.from(result.current.expanded); + + // Should now include ALL groups, not just storage + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toContain("group:automanage/configuration-profile"); + expect(expandedArray).toContain("group:automanage/configuration-profile/assignment"); + expect(expandedArray).toContain("group:storage"); + expect(expandedArray).toContain("group:storage/account"); + }); + + it("should only expand new groups when autoExpandAll is false", () => { + const initialCommandGroupMap = { + "group:storage": mockCommandGroupMap["group:storage"], + }; + + const { result } = renderHook(() => useTreeState(mockCommandMap, initialCommandGroupMap, mockCommandTree)); + + // First, expand storage (which already exists in initial map) + act(() => { + result.current.updateExpanded(initialCommandGroupMap, undefined, false); + }); + + // Should be empty since storage already existed in the initial map + expect(Array.from(result.current.expanded)).toHaveLength(0); + + // Now add new groups with autoExpandAll=false + act(() => { + result.current.updateExpanded(mockCommandGroupMap, undefined, false); + }); + + const expandedArray = Array.from(result.current.expanded); + + // Should only include new groups (not storage since it existed before) + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toContain("group:automanage/configuration-profile"); + expect(expandedArray).toContain("group:automanage/configuration-profile/assignment"); + expect(expandedArray).toContain("group:storage/account"); // This is new + + // Should NOT include storage since it existed in the original commandGroupMap + expect(expandedArray).not.toContain("group:storage"); + }); + + it("should include parent paths for deeply nested groups", () => { + const deeplyNestedMap = { + "group:level1/level2/level3/level4": { + id: "group:level1/level2/level3/level4", + names: ["level1", "level2", "level3", "level4"], + } as CommandGroup, + }; + + const { result } = renderHook(() => useTreeState(mockCommandMap, {}, mockCommandTree)); + + act(() => { + result.current.updateExpanded(deeplyNestedMap, undefined, true); + }); + + const expandedArray = Array.from(result.current.expanded); + + // Should include the group itself + expect(expandedArray).toContain("group:level1/level2/level3/level4"); + + // Should include all parent paths for proper hierarchy + expect(expandedArray).toContain("group:level1/level2"); + expect(expandedArray).toContain("group:level1/level2/level3"); + }); + + it("should preserve existing expanded state when adding new groups with autoExpandAll=true", () => { + const { result } = renderHook(() => useTreeState(mockCommandMap, {}, mockCommandTree)); + + // Start with some manual expansion + act(() => { + result.current.handleCommandTreeToggle(["group:storage"]); + }); + + expect(Array.from(result.current.expanded)).toEqual(["group:storage"]); + + // Now call updateExpanded with autoExpandAll=true + act(() => { + result.current.updateExpanded(mockCommandGroupMap, undefined, true); + }); + + const expandedArray = Array.from(result.current.expanded); + + // Should still contain the manually expanded group + expect(expandedArray).toContain("group:storage"); + + // Plus all the auto-expanded groups + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toContain("group:automanage/configuration-profile"); + expect(expandedArray).toContain("group:automanage/configuration-profile/assignment"); + expect(expandedArray).toContain("group:storage/account"); + }); + }); + + describe("basic functionality", () => { + it("should initialize and auto-select first group with its path expanded", () => { + const { result } = renderHook(() => useTreeState(mockCommandMap, mockCommandGroupMap, mockCommandTree)); + + // Should auto-select the first group from commandTree + expect(result.current.selected?.id).toBe("group:automanage"); + + // Should auto-expand the path to the selected group + expect(result.current.expanded.size).toBe(1); + expect(Array.from(result.current.expanded)).toContain("group:automanage"); + }); + + it("should handle command tree toggle", () => { + const { result } = renderHook(() => useTreeState(mockCommandMap, mockCommandGroupMap, mockCommandTree)); + + act(() => { + result.current.handleCommandTreeToggle(["group:storage", "group:automanage"]); + }); + + const expandedArray = Array.from(result.current.expanded); + expect(expandedArray).toContain("group:storage"); + expect(expandedArray).toContain("group:automanage"); + expect(expandedArray).toHaveLength(2); + }); + }); +}); diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx index ae824430..316bddd7 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditor.tsx @@ -64,6 +64,12 @@ const WSEditor = ({ params }: WSEditorProps) => { } }, [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) { diff --git a/src/web/src/views/workspace/hooks/useTreeState.ts b/src/web/src/views/workspace/hooks/useTreeState.ts index ef1f36be..4e1a39b8 100644 --- a/src/web/src/views/workspace/hooks/useTreeState.ts +++ b/src/web/src/views/workspace/hooks/useTreeState.ts @@ -14,7 +14,11 @@ interface UseTreeStateReturn { expanded: Set; handleCommandTreeSelect: (nodeId: string) => void; handleCommandTreeToggle: (nodeIds: string[]) => void; - updateExpanded: (commandGroupMap: CommandGroupMap, selected?: Command | CommandGroup | null) => void; + updateExpanded: ( + commandGroupMap: CommandGroupMap, + selected?: Command | CommandGroup | null, + autoExpandAll?: boolean, + ) => void; setSelected: (selected: Command | CommandGroup | null) => void; } @@ -58,7 +62,7 @@ export function useTreeState( }, []); const updateExpanded = useCallback( - (newCommandGroupMap: CommandGroupMap, newSelected?: Command | CommandGroup | null) => { + (newCommandGroupMap: CommandGroupMap, newSelected?: Command | CommandGroup | null, autoExpandAll?: boolean) => { setExpanded((prevExpanded) => { const newExpanded = new Set(); @@ -68,9 +72,27 @@ export function useTreeState( } }); - for (const groupId in newCommandGroupMap) { - if (!(groupId in commandGroupMap)) { + 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); + } + } } } From ff951c9eb8d3fa8493f431625fd2e6af7ede0442 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 22 Oct 2025 11:46:10 +1100 Subject: [PATCH 105/107] fix: type param included --- .../components/WSEditor/WSEditorSwaggerReloadDialog.tsx | 2 -- .../WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx index d30493b8..fab095eb 100644 --- a/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx +++ b/src/web/src/views/workspace/components/WSEditor/WSEditorSwaggerReloadDialog.tsx @@ -174,8 +174,6 @@ const WSEditorSwaggerReloadDialog: React.FC = }} color="inherit" > - {/* Resource Url */} - { + async (moduleUrl: string | null, preferredRP: string | null, sourceOverride?: string) => { if (moduleUrl != null) { try { - let options = await specsApi.getResourceProvidersWithType(moduleUrl, defaultSource ?? undefined); + 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) { From 4bc0c4f73fc09d4ff05f83fcb7db313bad51a552 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 22 Oct 2025 12:22:59 +1100 Subject: [PATCH 106/107] fix: hide already loaded resources from sawgger picker" --- .../components/WSEditorSwaggerPicker.test.tsx | 184 ++++++++++++++++++ .../WSEditorSwaggerPicker.tsx | 2 +- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx index 3e9783f5..e2cd9f85 100644 --- a/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx +++ b/src/web/src/__tests__/components/WSEditorSwaggerPicker.test.tsx @@ -211,6 +211,71 @@ describe("WSEditorSwaggerPicker", () => { expect(vi.mocked(workspaceApi).getWorkspaceResourcesByName).toHaveBeenCalledWith("test-workspace"); }); }); + + it("calls getResourceProvidersWithType with type=OpenAPI parameter", async () => { + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "Swagger", + ); + }); + }); + + it("passes sourceOverride parameter when loading resource providers", async () => { + vi.mocked(workspaceApi).getSwaggerDefault.mockResolvedValue({ + ...mockSwaggerDefault, + source: "TypeSpec", + }); + + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "TypeSpec", + ); + }); + }); + + it("reloads resources when existingResources change", async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledTimes(1); + }); + + const newMockWorkspaceResources = [ + ...mockWorkspaceResources, + { + id: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{accountName}/blobServices/default", + }, + ]; + vi.mocked(workspaceApi).getWorkspaceResourcesByName.mockResolvedValue(newMockWorkspaceResources); + + rerender(); + + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "Swagger", + ); + }); + + it("correctly filters existing resources when displaying available options", async () => { + const availableResources = [mockResources[0], mockResources[1]]; + vi.mocked(specsApi).getProviderResources.mockResolvedValue(availableResources); + + render(); + + await waitFor(() => { + expect(vi.mocked(workspaceApi).getWorkspaceResourcesByName).toHaveBeenCalledWith("test-workspace"); + }); + + await waitFor(() => { + expect(vi.mocked(specsApi).getProviderResources).toHaveBeenCalled(); + }); + }); }); describe("Resource Selection", () => { @@ -491,6 +556,125 @@ describe("WSEditorSwaggerPicker", () => { }); }); + describe("API Type Parameter Handling", () => { + it("includes type=OpenAPI parameter for Swagger sources", async () => { + vi.mocked(workspaceApi).getSwaggerDefault.mockResolvedValue({ + ...mockSwaggerDefault, + source: "Swagger", + }); + + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "Swagger", + ); + }); + }); + + it("includes type=TypeSpec parameter for TypeSpec sources", async () => { + vi.mocked(workspaceApi).getSwaggerDefault.mockResolvedValue({ + ...mockSwaggerDefault, + source: "TypeSpec", + }); + + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "TypeSpec", + ); + }); + }); + + it("uses default source when no source override is available", async () => { + vi.mocked(workspaceApi).getSwaggerDefault.mockResolvedValue({ + ...mockSwaggerDefault, + source: undefined, + }); + + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + undefined, + ); + }); + }); + }); + + describe("Existing Resources State Management", () => { + it("reloads resources when existing workspace resources change", async () => { + render(); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledTimes(1); + }); + + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "Swagger", + ); + }); + + it("prevents duplicate resources from appearing in selector", async () => { + const duplicateResource = { + id: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{accountName}", + }; + + vi.mocked(workspaceApi).getWorkspaceResourcesByName.mockResolvedValue([duplicateResource]); + vi.mocked(specsApi).getProviderResources.mockResolvedValue([ + { + ...mockResources[0], + id: duplicateResource.id, + }, + mockResources[1], + ]); + + render(); + + await waitFor(() => { + expect(vi.mocked(workspaceApi).getWorkspaceResourcesByName).toHaveBeenCalledWith("test-workspace"); + }); + + await waitFor(() => { + expect(vi.mocked(specsApi).getProviderResources).toHaveBeenCalled(); + }); + }); + + it("handles empty workspace resources correctly", async () => { + vi.mocked(workspaceApi).getWorkspaceResourcesByName.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(vi.mocked(workspaceApi).getWorkspaceResourcesByName).toHaveBeenCalledWith("test-workspace"); + }); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledWith( + "/Swagger/Specs/ResourceManagement/microsoft.storage", + "Swagger", + ); + }); + }); + + it("validates existingResources dependency in useCallback", async () => { + render(); + + await waitFor(() => { + expect(vi.mocked(workspaceApi).getWorkspaceResourcesByName).toHaveBeenCalledWith("test-workspace"); + }); + + await waitFor(() => { + expect(vi.mocked(specsApi).getResourceProvidersWithType).toHaveBeenCalledTimes(1); + }); + }); + }); + describe("Close Functionality", () => { it("calls onClose when close button is clicked", async () => { const onCloseMock = vi.fn(); diff --git a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx index ff3f1e76..c83406d8 100644 --- a/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorSwaggerPicker/WSEditorSwaggerPicker.tsx @@ -299,7 +299,7 @@ const WSEditorSwaggerPicker = ({ workspaceName, plane, onClose }: WSEditorSwagge onVersionUpdate(null); } }, - [plane], + [plane, existingResources], ); // Effect to load resources when selectedResourceProvider changes From a23d22a070f99a1735f8560cd3d2061ea73baa5d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 22 Oct 2025 13:14:31 +1100 Subject: [PATCH 107/107] fix: update all fix --- .../WSECArgumentSimilarPicker.test.tsx | 60 +++++++++---------- .../WSECArgumentSimilarPicker.tsx | 6 +- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx index 92343f31..5dbf5aa5 100644 --- a/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx +++ b/src/web/src/__tests__/components/WSECArgumentSimilarPicker.test.tsx @@ -271,19 +271,17 @@ describe("WSECArgumentSimilarPicker", () => { describe("BuildArgSimilarTree Utility", () => { it("transforms API response into correct tree structure", () => { const mockApiResponse = { - data: { - aaz: { - id: "aaz", - commandGroups: { - storage: { - id: "storage", - commands: { - "account create": { - id: "account-create", - args: { - name: ["name"], - resource_group: ["resource-group", "g"], - }, + aaz: { + id: "aaz", + commandGroups: { + storage: { + id: "storage", + commands: { + "account create": { + id: "account-create", + args: { + name: ["name"], + resource_group: ["resource-group", "g"], }, }, }, @@ -303,17 +301,15 @@ describe("WSECArgumentSimilarPicker", () => { it("handles single character options correctly", () => { const mockApiResponse = { - data: { - aaz: { - id: "aaz", - commands: { - test: { - id: "test", - args: { - short_option: ["g"], - long_option: ["resource-group"], - both_options: ["resource-group", "g"], - }, + aaz: { + id: "aaz", + commands: { + test: { + id: "test", + args: { + short_option: ["g"], + long_option: ["resource-group"], + both_options: ["resource-group", "g"], }, }, }, @@ -330,15 +326,13 @@ describe("WSECArgumentSimilarPicker", () => { it("handles nested special characters in options", () => { const mockApiResponse = { - data: { - aaz: { - id: "aaz", - commands: { - test: { - id: "test", - args: { - nested_option: [".property", "[index]", "{key}"], - }, + aaz: { + id: "aaz", + commands: { + test: { + id: "test", + args: { + nested_option: [".property", "[index]", "{key}"], }, }, }, diff --git a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx index 03f91a14..e3df1723 100644 --- a/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx +++ b/src/web/src/views/workspace/components/WSEditorCommandArgumentsContent/WSECArgumentSimilarPicker.tsx @@ -145,8 +145,12 @@ const gatherNodeIds = (group: ArgSimilarGroup): string[] => { }; 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.data.aaz, "az"), + root: decodeResponseArgSimilarGroup(response.aaz, "az"), selectedArgIds: [], }; const expandedIds = gatherNodeIds(tree.root);