diff --git a/core/src/main/java/com/vml/es/aem/acm/core/code/ArgumentType.java b/core/src/main/java/com/vml/es/aem/acm/core/code/ArgumentType.java index eeb3badf..ff56ed66 100644 --- a/core/src/main/java/com/vml/es/aem/acm/core/code/ArgumentType.java +++ b/core/src/main/java/com/vml/es/aem/acm/core/code/ArgumentType.java @@ -7,5 +7,6 @@ public enum ArgumentType { STRING, TEXT, SELECT, - MULTISELECT + MULTISELECT, + PATH } diff --git a/core/src/main/java/com/vml/es/aem/acm/core/code/Arguments.java b/core/src/main/java/com/vml/es/aem/acm/core/code/Arguments.java index f2d23b20..967c33e6 100644 --- a/core/src/main/java/com/vml/es/aem/acm/core/code/Arguments.java +++ b/core/src/main/java/com/vml/es/aem/acm/core/code/Arguments.java @@ -106,6 +106,16 @@ public void text(String name, Closure options) { add(argument); } + public void path(String name) { + path(name, null); + } + + public void path(String name, Closure options) { + PathArgument argument = new PathArgument(name); + GroovyUtils.with(argument, options); + add(argument); + } + public void select(String name) { select(name, null); } diff --git a/core/src/main/java/com/vml/es/aem/acm/core/code/arg/PathArgument.java b/core/src/main/java/com/vml/es/aem/acm/core/code/arg/PathArgument.java new file mode 100644 index 00000000..c15c5d2c --- /dev/null +++ b/core/src/main/java/com/vml/es/aem/acm/core/code/arg/PathArgument.java @@ -0,0 +1,21 @@ +package com.vml.es.aem.acm.core.code.arg; + +import com.vml.es.aem.acm.core.code.Argument; +import com.vml.es.aem.acm.core.code.ArgumentType; + +public class PathArgument extends Argument { + + private String rootPath; + + public PathArgument(String name) { + super(name, ArgumentType.PATH); + } + + public String getRootPath() { + return rootPath; + } + + public void setRootPath(String rootPath) { + this.rootPath = rootPath; + } +} diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/argument/path.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/argument/path.yml new file mode 100644 index 00000000..1858c178 --- /dev/null +++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/argument/path.yml @@ -0,0 +1,14 @@ +group: Argument +name: argument_path +content: | + args.path("${1:name}") { label = "${2:label}"; value = ${3:value}; rootPath = "${4:rootPath}" } +documentation: | + An argument that allows the user to select a path.
+ The path can be relative to the current resource or absolute. +

+ Example: +
+        args.path("path") { label = "Path"; value = "/content"; rootPath = "/content" }
+    
+
+ Note the fact that both rootpath and value can't end with '/' diff --git a/ui.frontend/src/components/CodeArgumentInput.module.css b/ui.frontend/src/components/CodeArgumentInput.module.css index 15dfe7e1..4e1556a1 100644 --- a/ui.frontend/src/components/CodeArgumentInput.module.css +++ b/ui.frontend/src/components/CodeArgumentInput.module.css @@ -1,9 +1,9 @@ /*Custom error state since react spectrum doesn't provide one for switch and checkbox component*/ .error { - color: var(--spectrum-red-900); - font-size: var(--spectrum-global-dimension-font-size-75); - line-height: var(--spectrum-global-font-line-height-small); - letter-spacing: var(--spectrum-global-font-letter-spacing-none); - margin-inline-end: var(--spectrum-global-dimension-size-100); - font-family: var(--spectrum-global-font-family-base), sans-serif; -} \ No newline at end of file + color: var(--spectrum-red-900); + font-size: var(--spectrum-global-dimension-font-size-75); + line-height: var(--spectrum-global-font-line-height-small); + letter-spacing: var(--spectrum-global-font-letter-spacing-none); + margin-inline-end: var(--spectrum-global-dimension-size-100); + font-family: var(--spectrum-global-font-family-base), sans-serif; +} diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 226d8a4f..57859b2e 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -4,9 +4,10 @@ import { Field } from '@react-spectrum/label'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import useFormCrossFieldValidation from '../hooks/form.ts'; -import { Argument, ArgumentValue, isBoolArgument, isMultiSelectArgument, isNumberArgument, isSelectArgument, isStringArgument, isTextArgument } from '../utils/api.types.ts'; +import { Argument, ArgumentValue, isBoolArgument, isMultiSelectArgument, isNumberArgument, isPathArgument, isSelectArgument, isStringArgument, isTextArgument } from '../utils/api.types.ts'; import { Strings } from '../utils/strings.ts'; -import styles from "./CodeArgumentInput.module.css" +import styles from './CodeArgumentInput.module.css'; +import { PathInput } from './PathInput.tsx'; interface CodeArgumentInputProps { arg: Argument; @@ -211,6 +212,31 @@ const CodeArgumentInput: React.FC = ({ arg }) => { )} /> ); + } else if (isPathArgument(arg)) { + return ( + ( + + + + {fieldState.error &&

{fieldState.error.message}

} +
+
+ )} + /> + ); } else { return null; } diff --git a/ui.frontend/src/components/PathInput.module.css b/ui.frontend/src/components/PathInput.module.css new file mode 100644 index 00000000..32bf807d --- /dev/null +++ b/ui.frontend/src/components/PathInput.module.css @@ -0,0 +1,9 @@ +.label { + color: var(--spectrum-gray-700); + padding-top: var(--spectrum-global-dimension-size-50); + padding-bottom: var(--spectrum-global-dimension-size-65); + font-size: var(--spectrum-global-dimension-font-size-75); + font-weight: var(--spectrum-global-font-weight-regular); + line-height: var(--spectrum-global-font-line-height-small); + font-family: var(--spectrum-global-font-family-base), sans-serif; +} diff --git a/ui.frontend/src/components/PathInput.tsx b/ui.frontend/src/components/PathInput.tsx new file mode 100644 index 00000000..f0d93471 --- /dev/null +++ b/ui.frontend/src/components/PathInput.tsx @@ -0,0 +1,182 @@ +import { SpectrumTreeViewProps, Text, TreeView, TreeViewItem, TreeViewItemContent } from '@adobe/react-spectrum'; +import { Selection } from '@react-types/shared/src/selection'; +import React, {forwardRef, Ref, useCallback, useEffect, useState} from 'react'; +import { apiRequest } from '../utils/api.ts'; +import { AssistCodeOutput, AssistCodeOutputSuggestion } from '../utils/api.types.ts'; +import styles from './PathInput.module.css'; + +interface Node { + name: string; + children: Node[] | null; +} + +interface IPathInput extends SpectrumTreeViewProps { + rootPath: string; + onChange: (paths: string) => void; + errorMessage: string | undefined; + isInvalid: boolean; + label: string; + value: string; +} + +export const PathInput = forwardRef(function PathInput({ rootPath = "", onChange, label, value, ...props }: IPathInput, ref: Ref) { + const { getPathCompletion } = useRequestPathCompletion(); + const [loadedPaths, setLoadedPaths] = useState>(new Set()); + const [selected, setSelected] = useState>(new Set()); + const [expanded, setExpanded] = useState>(new Set()); + const [items, setItems] = useState({ + name: rootPath == "/" ? "" : rootPath, + children: null, + }); + + const onSelectionChange = (keys: Selection) => { + const newSelection = new Set([...keys].map((key) => key.toString())); + const selectedValue = [...newSelection][0]; + if (selectedValue != undefined) { + setSelected(new Set([selectedValue])); + // Offload the onChange call to avoid blocking the UI + setTimeout(() => { + onChange(selectedValue.length > 0 ? selectedValue : '/'); + }, 0) + } + }; + + const translateSuggestion = (suggestion: AssistCodeOutputSuggestion): Node => { + const name = suggestion.it.split('/').at(-1); + return { name: name ? name : suggestion.it, children: null }; + }; + + const findAndUpdateNodeFromPath = (path: string, newChildren: Node[]) => { + const updateTree = (node: Node, pathParts: string[]): Node => { + if (pathParts.length === 0) { + return { ...node, children: newChildren.length > 0 ? newChildren : [] }; + } + const [currentPart, ...remainingParts] = pathParts; + const children = node.children || []; + let child = children.find((child) => child.name === currentPart); + if (!child) { + child = { name: currentPart, children: [] }; + children.push(child); + } + + return { + ...node, + children: children.map((c) => (c.name === currentPart ? updateTree(c, remainingParts) : c)), + }; + }; + const rootName = items.name; + const normalizedPath = path.startsWith(rootName) ? path.slice(rootName.length) : path; + const parts = normalizedPath.split('/').filter(Boolean); + setItems((prevItems) => updateTree(prevItems, parts)); + }; + + const loadSuggestions = useCallback(async (path: string) => { + await getPathCompletion(path) + .then((data) => data.suggestions.map(translateSuggestion)) + .then((nodes) => { + findAndUpdateNodeFromPath(path, nodes); + }); + }, []) + + const onExpandedChange = (keys: Selection) => { + const newKeys = new Set([...keys].map((key) => key.toString())); + const differenceToAdd = [...newKeys].filter((key) => !expanded.has(key)); + const differenceToRemove = [...expanded].filter((key) => !newKeys.has(key)); + + const updatedExpanded = new Set(expanded); + differenceToAdd.forEach(async (key) => { + if (!loadedPaths.has(key)) { + setLoadedPaths(prevPaths => new Set(prevPaths).add(key)); + await loadSuggestions(key + '/'); + } + updatedExpanded.add(key); + }); + + differenceToRemove.forEach((key) => { + updatedExpanded.delete(key); + }); + + setExpanded(updatedExpanded); + }; + + useEffect(() => { + if (rootPath == '/') { + rootPath = ""; + } + // Expand the root path when the component mounts + const initializePathInput = async () => { + if (!loadedPaths.has(rootPath)) { + setLoadedPaths(prevPaths => new Set(prevPaths).add(rootPath)); + await loadSuggestions(rootPath); + } + const expandPathToValue = async (path: string) => { + const pathParts = path.split("/").filter(Boolean); + let currentPath = ""; + const tempExpanded = new Set([rootPath]); + const tempLoadedPaths = new Set(loadedPaths); + // Expand each part of the path except for the last oen + for (const part of pathParts.slice(0, -1)) { + currentPath = `${currentPath}/${part}`.replace(/\/+/g, '/'); + if (!tempLoadedPaths.has(currentPath)) { + await loadSuggestions(currentPath); + tempLoadedPaths.add(currentPath); + tempExpanded.add(currentPath); + } + } + + setLoadedPaths(new Set(tempLoadedPaths)); + setExpanded(new Set(tempExpanded)); + }; + await expandPathToValue(value); + setSelected(new Set([value])); + }; + if (value != null && value.length > 0) { + // Offload the initialization to avoid blocking the UI + setTimeout(async () => { + await initializePathInput() + }, 0) + } + }, [rootPath, loadSuggestions]); + + return ( + <> +

{label}

+
+ + {items && } + +
+ + ); +}) + +const TreeItem = ({ node, path }: { node: Node; path: string }) => { + return ( + 0 ? node.name : "/"}> + + {node.name.length > 0 ? node.name.replace("/", "") : '/'} + + {node.children && node.children.length > 0 ? ( + node.children.map((child: Node) => ) + ) : node.children ? null : ( + + )} + + ); +}; + +const useRequestPathCompletion = () => { + async function getPathCompletion(path: string) { + if (!path.endsWith('/')) { + path += '/'; + } + const response = await apiRequest({ + method: 'GET', + url: `/apps/acm/api/assist-code.json?type=resource&word=${encodeURIComponent(path)}`, + operation: 'Code assistance', + }); + return response.data.data; + } + + return { getPathCompletion }; +}; diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index fcf03f9f..e9a41792 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -31,7 +31,6 @@ const ExecutionView = () => { const [autoscrollOutput, setAutoscrollOutput] = useState(true); const { execution, setExecution, loading } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); const [selectedTab, handleTabChange] = useNavigationTab('details'); - if (loading) { return ( @@ -87,7 +86,7 @@ const ExecutionView = () => { - +
diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index cac5f825..80fa991e 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -23,7 +23,7 @@ export type Description = { }; }; -export type ArgumentType = 'BOOL' | 'STRING' | 'TEXT' | 'SELECT' | 'MULTISELECT' | 'INTEGER' | 'DECIMAL'; +export type ArgumentType = 'BOOL' | 'STRING' | 'TEXT' | 'SELECT' | 'MULTISELECT' | 'INTEGER' | 'DECIMAL' | 'PATH'; export type ArgumentValue = string | string[] | number | number[] | boolean | null | undefined; export type ArgumentValues = Record; @@ -62,6 +62,10 @@ export type MultiSelectArgument = Argument & { display: 'AUTO' | 'CHECKBOX' | 'DROPDOWN'; }; +export type PathArgument = Argument & { + rootPath: string; +}; + export function isStringArgument(arg: Argument): arg is Argument { return arg.type === 'STRING'; } @@ -86,6 +90,10 @@ export function isMultiSelectArgument(arg: Argument): arg is Mult return arg.type === 'MULTISELECT'; } +export function isPathArgument(arg: Argument): arg is PathArgument { + return arg.type === 'PATH'; +} + export type Execution = { id: string; userId: string; @@ -145,14 +153,16 @@ export type ExecutionOutput = { list: E[]; }; +export type AssistCodeOutputSuggestion = { + k: string; // kind + l: string; // label + it: string; // insert text + i: string; +}; + export type AssistCodeOutput = { code: string; - suggestions: { - k: string; // kind - l: string; // label - it: string; // insert text - i: string; // info - }[]; + suggestions: AssistCodeOutputSuggestion[]; }; export type SnippetOutput = {