Skip to content

custom component for path input #77

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public enum ArgumentType {
STRING,
TEXT,
SELECT,
MULTISELECT
MULTISELECT,
PATH
}
10 changes: 10 additions & 0 deletions core/src/main/java/com/vml/es/aem/acm/core/code/Arguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ public void text(String name, Closure<TextArgument> options) {
add(argument);
}

public void path(String name) {
path(name, null);
}

public void path(String name, Closure<PathArgument> options) {
PathArgument argument = new PathArgument(name);
GroovyUtils.with(argument, options);
add(argument);
}

public void select(String name) {
select(name, null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {

private String rootPath;

public PathArgument(String name) {
super(name, ArgumentType.PATH);
}

public String getRootPath() {
return rootPath;
}

public void setRootPath(String rootPath) {
this.rootPath = rootPath;
}
}
Original file line number Diff line number Diff line change
@@ -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.<br>
The path can be relative to the current resource or absolute.
<br><br>
<b>Example:</b>
<pre>
args.path("path") { label = "Path"; value = "/content"; rootPath = "/content" }
</pre>
<br>
<b>Note the fact that both rootpath and value can't end with '/'</b>
14 changes: 7 additions & 7 deletions ui.frontend/src/components/CodeArgumentInput.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
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;
}
30 changes: 28 additions & 2 deletions ui.frontend/src/components/CodeArgumentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentValue>;
Expand Down Expand Up @@ -211,6 +212,31 @@ const CodeArgumentInput: React.FC<CodeArgumentInputProps> = ({ arg }) => {
)}
/>
);
} else if (isPathArgument(arg)) {
return (
<Controller
name={arg.name}
control={control}
rules={controllerRules(arg)}
render={({ field, fieldState }) => (
<View key={arg.name} marginBottom={'size-200'}>
<Flex alignItems={'start'} justifyContent={'start'} direction={'column'}>
<PathInput
{...field}
selectionMode={'single'}
maxHeight={"size-3000"}
label={argLabel(arg)}
rootPath={arg.rootPath ?? ""}
errorMessage={fieldState.error ? fieldState.error.message : undefined}
isInvalid={!!fieldState.error}
aria-label={`Argument ${arg.name}`}
/>
{fieldState.error && <p className={styles.error}>{fieldState.error.message}</p>}
</Flex>
</View>
)}
/>
);
} else {
return null;
}
Expand Down
9 changes: 9 additions & 0 deletions ui.frontend/src/components/PathInput.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
182 changes: 182 additions & 0 deletions ui.frontend/src/components/PathInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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<HTMLDivElement>) {
const { getPathCompletion } = useRequestPathCompletion();
const [loadedPaths, setLoadedPaths] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [items, setItems] = useState<Node>({
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<string>([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 (
<>
<p className={styles.label}>{label}</p>
<div ref={ref} tabIndex={-1} style={{outline: "none", width: "100%", height: "100%"}}>
<TreeView {...props} selectionMode={"single"} width={'100%'} onSelectionChange={onSelectionChange} onExpandedChange={onExpandedChange} defaultSelectedKeys={new Set(value)} selectedKeys={selected} expandedKeys={expanded}>
{items && <TreeItem node={items} path={''} />}
</TreeView>
</div>
</>
);
})

const TreeItem = ({ node, path }: { node: Node; path: string }) => {
return (
<TreeViewItem id={path + node.name} textValue={node.name.length > 0 ? node.name : "/"}>
<TreeViewItemContent>
<Text>{node.name.length > 0 ? node.name.replace("/", "") : '/'}</Text>
</TreeViewItemContent>
{node.children && node.children.length > 0 ? (
node.children.map((child: Node) => <TreeItem node={child} path={path + node.name + '/'} key={path + node.name + child.name} />)
) : node.children ? null : (
<TreeViewItem children={[]} textValue={'empty'} />
)}
</TreeViewItem>
);
};

const useRequestPathCompletion = () => {
async function getPathCompletion(path: string) {
if (!path.endsWith('/')) {
path += '/';
}
const response = await apiRequest<AssistCodeOutput>({
method: 'GET',
url: `/apps/acm/api/assist-code.json?type=resource&word=${encodeURIComponent(path)}`,
operation: 'Code assistance',
});
return response.data.data;
}

return { getPathCompletion };
};
3 changes: 1 addition & 2 deletions ui.frontend/src/pages/ExecutionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const ExecutionView = () => {
const [autoscrollOutput, setAutoscrollOutput] = useState<boolean>(true);
const { execution, setExecution, loading } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval);
const [selectedTab, handleTabChange] = useNavigationTab('details');

if (loading) {
return (
<Flex flex="1" justifyContent="center" alignItems="center">
Expand Down Expand Up @@ -87,7 +86,7 @@ const ExecutionView = () => {
<View backgroundColor="gray-50" padding="size-200" borderRadius="medium" borderColor="dark" borderWidth="thin">
<Flex direction="row" justifyContent="space-between" gap="size-200">
<LabeledValue label="ID" value={execution.id} flex="1" />
<LabeledValue label="User" value={execution.userId} flex="1" />
<LabeledValue label="User" value={execution.userId ? execution.userId : "unknown"} flex="1" />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was throwing errors during development, so I added default version. Do we want to keep it?

<Flex justifyContent="end">
<Field label="Status" flex="1">
<div>
Expand Down
24 changes: 17 additions & 7 deletions ui.frontend/src/utils/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ArgumentValue>;

Expand Down Expand Up @@ -62,6 +62,10 @@ export type MultiSelectArgument = Argument<ArgumentValue> & {
display: 'AUTO' | 'CHECKBOX' | 'DROPDOWN';
};

export type PathArgument = Argument<ArgumentValue> & {
rootPath: string;
};

export function isStringArgument(arg: Argument<ArgumentValue>): arg is Argument<string> {
return arg.type === 'STRING';
}
Expand All @@ -86,6 +90,10 @@ export function isMultiSelectArgument(arg: Argument<ArgumentValue>): arg is Mult
return arg.type === 'MULTISELECT';
}

export function isPathArgument(arg: Argument<ArgumentValue>): arg is PathArgument {
return arg.type === 'PATH';
}

export type Execution = {
id: string;
userId: string;
Expand Down Expand Up @@ -145,14 +153,16 @@ export type ExecutionOutput<E> = {
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 = {
Expand Down