Skip to content
Merged
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
29 changes: 27 additions & 2 deletions ui.frontend/src/components/CodeExecuteButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Button, ButtonGroup, Content, Dialog, DialogContainer, Divider, Form, Heading, Item, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum';
import { Button, ButtonGroup, Content, Dialog, DialogContainer, Divider, Footer, Form, Heading, Item, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum';
import Checkmark from '@spectrum-icons/workflow/Checkmark';
import Close from '@spectrum-icons/workflow/Close';
import Copy from '@spectrum-icons/workflow/Copy';
import Gears from '@spectrum-icons/workflow/Gears';
import React, { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
Expand All @@ -10,6 +11,7 @@ import { Objects } from '../utils/objects';
import { ToastTimeoutLong } from '../utils/spectrum.ts';
import { Strings } from '../utils/strings';
import CodeArgumentInput from './CodeArgumentInput';
import PathPicker from './PathPicker.tsx';

interface CodeExecuteButtonProps {
code: string;
Expand All @@ -23,7 +25,7 @@ const CodeExecuteButton: React.FC<CodeExecuteButtonProps> = ({ code, onDescribeF
const [description, setDescription] = useState<Description | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [described, setDescribed] = useState(false);

const [pathPickerOpened, setPathPickerOpened] = useState(false);
const methods = useForm<ArgumentValues>({
mode: 'onChange',
reValidateMode: 'onChange',
Expand Down Expand Up @@ -89,13 +91,30 @@ const CodeExecuteButton: React.FC<CodeExecuteButtonProps> = ({ code, onDescribeF
onExecute(description!, data);
};

const handlePathSelect = (path: string) => {
setPathPickerOpened(false);
navigator.clipboard.writeText(path);
};

const descriptionArguments: Argument<ArgumentValue>[] = Object.values(description?.arguments || []);
const groups = Array.from(new Set(descriptionArguments.map((arg) => arg.group)));
const shouldRenderTabs = groups.length > 1 || (groups.length === 1 && groups[0] !== ArgumentGroupDefault);
const validationFailed = Object.keys(formState.errors).length > 0;

return (
<>
<PathPicker
onSelect={handlePathSelect}
onCancel={() => setPathPickerOpened(false)}
basePath="/"
confirmButtonLabel={
<>
<Copy size="XS" marginEnd="size-100" />
Copy to clipboard
</>
}
open={pathPickerOpened}
/>
<Button aria-label="Execute" variant="accent" onPress={handleExecute} isPending={isPending || described} isDisabled={isDisabled}>
<Gears />
<Text>Execute</Text>
Expand Down Expand Up @@ -134,6 +153,12 @@ const CodeExecuteButton: React.FC<CodeExecuteButtonProps> = ({ code, onDescribeF
)}
</Form>
</Content>
<Footer>
<Button aria-label="Pick Path" variant="secondary" onPress={() => setPathPickerOpened(true)}>
<Gears size="XS" />
<Text>Pick Path</Text>
</Button>
</Footer>
<ButtonGroup>
<Button aria-label="Cancel" variant="secondary" onPress={handleCloseDialog}>
<Close size="XS" />
Expand Down
204 changes: 204 additions & 0 deletions ui.frontend/src/components/PathPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Breadcrumbs, Button, ButtonGroup, Content, Dialog, DialogContainer, Flex, Heading, Item, ListView, Selection, Text } from '@adobe/react-spectrum';
import Document from '@spectrum-icons/workflow/Document';
import FileCode from '@spectrum-icons/workflow/FileCode';
import Folder from '@spectrum-icons/workflow/Folder';
import Home from '@spectrum-icons/workflow/Home';
import Project from '@spectrum-icons/workflow/Project';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { apiRequest } from '../utils/api';
import { AssistCodeOutput } from '../utils/api.types';

enum NodeType {
Copy link
Collaborator

Choose a reason for hiding this comment

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

move it to api.types.ts / I am putting there anything related to backend

FOLDER = 'nt:folder',
ORDERED_FOLDER = 'sling:OrderedFolder',
SLING_FOLDER = 'sling:Folder',
CQ_PROJECTS = 'cq/projects',
Copy link
Collaborator

Choose a reason for hiding this comment

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

why cq/projects is needed here... looks unexpected ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have it in my repo, so I guess it's real use case :)

REDIRECT = 'sling:redirect',
ACL = 'rep:ACL',
PAGE = 'cq:Page',
}

const FOLDER_NODE_TYPES = [NodeType.FOLDER, NodeType.ORDERED_FOLDER, NodeType.SLING_FOLDER, NodeType.CQ_PROJECTS, NodeType.REDIRECT, NodeType.ACL] as const;

interface PathItem {
id: string;
name: string;
path: string;
type: string;
hasChildren?: boolean;
}

interface PathPickerProps {
open?: boolean;
onSelect: (path: string) => void;
onCancel: () => void;
label?: ReactNode;
basePath?: string;
confirmButtonLabel?: ReactNode;
}

const getIconForType = (type: string) => {
switch (type) {
case 'sling:Folder':
return <Folder gridRow="1 / span 2" marginEnd="size-100" size="M" />;
case 'cq:Page':
return <FileCode gridRow="1 / span 2" marginEnd="size-100" size="M" />;
case 'nt:file':
return <Document gridRow="1 / span 2" marginEnd="size-100" size="M" />;
case 'sling:OrderedFolder':
return <Project gridRow="1 / span 2" marginEnd="size-100" size="M" />;
default:
return <Document gridRow="1 / span 2" marginEnd="size-100" size="M" />;
}
};

const PathPicker = ({ onSelect, onCancel, label = 'Select Path', basePath = '/', confirmButtonLabel = 'Confirm', open }: PathPickerProps) => {
const [selectedItemData, setSelectedItemData] = useState<PathItem | null>(null);
const [path, setPath] = useState<string>(basePath);
const [isLoading, setIsLoading] = useState(false);
const [loadedPaths, setLoadedPaths] = useState<Record<string, PathItem[]>>({});

useEffect(() => {
const fetchItems = async () => {
setSelectedItemData(null);
if (loadedPaths[path]) {
return;
}

setIsLoading(true);

try {
const response = await apiRequest<AssistCodeOutput>({
operation: 'Path search',
url: `/apps/acm/api/assist-code.json?type=resource&word=${encodeURIComponent(path)}/`,
method: 'get',
});

const responseData = response.data;
const responseItems = responseData?.data.suggestions;

if (responseItems && Array.isArray(responseItems)) {
const newItems = responseItems.map((item) => {
const resourceTypeMatch = item.i.match(/Resource Type: (.+)/);
const resourceType = resourceTypeMatch ? resourceTypeMatch[1] : 'unknown';
const isFolderLike = FOLDER_NODE_TYPES.some((t) => resourceType.includes(t));
const isPage = resourceType === NodeType.PAGE;
const name = item.it.split('/').pop() || item.it;

return {
id: item.it,
name: name,
path: item.it,
type: resourceType,
hasChildren: isFolderLike || isPage,
};
});

setLoadedPaths((prev) => ({ ...prev, [path]: newItems }));
setPath(path);
}
} catch (error) {
console.error('Error fetching paths:', error);
} finally {
setIsLoading(false);
}
};

fetchItems();

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);

const handleItemClick = useCallback((item: PathItem) => {
if (item.hasChildren) {
setPath(item.path);
}
}, []);

const handleSelectionChange = (keys: Selection) => {
if (keys === 'all') {
return;
}

if (keys.size !== 1) {
return;
}

const selectedKeys = Array.from(keys);
const key = String(selectedKeys[0]);
const item = loadedPaths[path].find((item) => item.id === key);

if (item) {
setSelectedItemData(item);
}
};

const handleConfirm = useCallback(() => {
if (selectedItemData) {
onSelect(selectedItemData.path);
}
}, [selectedItemData, onSelect]);

const handleAction = (key: React.Key) => {
const item = loadedPaths[path].find((item) => item.id === key);

if (item?.hasChildren) {
handleItemClick(item);
}
};

return (
<DialogContainer onDismiss={onCancel}>
{open && (
<Dialog>
<Heading>
<Flex direction="column" gap="size-100">
<Text>{label}</Text>
<Breadcrumbs marginTop="size-100" showRoot size="M" isDisabled={isLoading} onAction={(p) => setPath(p.toString())}>
{path.split('/').map((p, index) => {
const fullPath = path
.split('/')
.slice(0, index + 1)
.join('/');
const label = index === 0 ? <Home size="S" /> : p;

return <Item key={fullPath}>{label}</Item>;
})}
</Breadcrumbs>
</Flex>
</Heading>
<Content>
<ListView
aria-label="Path items"
density="compact"
selectionMode="single"
selectionStyle="highlight"
selectedKeys={selectedItemData ? [selectedItemData.id] : undefined}
onSelectionChange={handleSelectionChange}
items={loadedPaths[path] ?? []}
onAction={handleAction}
>
{(item) => (
<Item key={item.id} textValue={item.name} hasChildItems={item.hasChildren}>
<Text>{item.name}</Text>
{getIconForType(item.type)}
<Text slot="description">{item.type}</Text>
</Item>
)}
</ListView>
</Content>
<ButtonGroup>
<Button variant="secondary" onPress={onCancel} isDisabled={isLoading}>
Cancel
</Button>
<Button variant="accent" onPress={handleConfirm} isDisabled={!selectedItemData || isLoading}>
{confirmButtonLabel}
</Button>
</ButtonGroup>
</Dialog>
)}
</DialogContainer>
);
};

export default PathPicker;