Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • graphpolaris/frontend-v2
  • rijkheere/frontend-v-2-reordering-paoh
2 results
Show changes
Commits on Source (11)
Showing
with 1628 additions and 1032 deletions
......@@ -20,6 +20,7 @@ type DropdownTriggerProps = {
onClick?: () => void;
children?: ReactNode;
noDropdownArrow?: boolean;
validateFlag?: boolean;
};
export function DropdownTrigger({
......@@ -32,6 +33,7 @@ export function DropdownTrigger({
noDropdownArrow = false,
popover = true,
children = undefined,
validateFlag = true,
}: DropdownTriggerProps) {
const paddingClass = size === 'xs' ? 'py-0' : size === 'sm' ? 'px-1 py-1' : size === 'md' ? 'px-2 py-1' : 'px-4 py-2';
const textSizeClass = size === 'xs' ? 'text-xs' : size === 'sm' ? 'text-sm' : size === 'md' ? 'text-base' : 'text-lg';
......@@ -51,7 +53,7 @@ export function DropdownTrigger({
<div
className={`inline-flex w-full truncate justify-between items-center gap-x-1.5 ${variantClass} ${textSizeClass} ${paddingClass} text-secondary-900 shadow-sm ${
noDropdownArrow ? `pointer-events-none cursor-default` : ''
} ${disabled ? ` cursor-not-allowed text-secondary-400 bg-secondary-100` : 'cursor-pointer'} pl-1 truncate`}
} ${disabled ? ` cursor-not-allowed text-secondary-400 bg-secondary-100` : 'cursor-pointer'} pl-1 truncate ${!validateFlag ? 'border border-danger-600' : ' border border-secondary-200'} `}
>
<span className={`text-${size}`}>{title}</span>
{!noDropdownArrow && <Icon component="icon-[ic--baseline-arrow-drop-down]" size={16} />}
......
import React, { useEffect, useRef } from 'react';
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { DropdownContainer, DropdownItem, DropdownItemContainer, DropdownTrigger } from '../dropdowns';
import Info from '../info';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
......@@ -31,7 +31,7 @@ const AutocompleteRenderer = ({
}: {
onChange: (e: string) => void;
value: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}) => {
const ref = useRef<HTMLInputElement>(null);
......@@ -67,10 +67,11 @@ export const DropdownInput = ({
autocomplete = false,
onKeyDown,
info,
validateFlag = true,
}: DropdownProps) => {
const [isDropdownOpen, setIsDropdownOpen] = React.useState<boolean>(false);
const [filterInput, setFilterInput] = React.useState<string | number | undefined>(value);
const [filteredOptions, setFilteredOptions] = React.useState<(string | number | { [key: string]: string })[]>(options);
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [filterInput, setFilterInput] = useState<string | number | undefined>(value);
const [filteredOptions, setFilteredOptions] = useState<(string | number | { [key: string]: string })[]>(options);
useEffect(() => {
setFilteredOptions(options);
......@@ -93,7 +94,7 @@ export const DropdownInput = ({
setFilteredOptions(newFilteredOptions);
};
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
if (onChange) {
onChange(filterInput as string);
......@@ -131,6 +132,7 @@ export const DropdownInput = ({
size={size}
className="cursor-pointer w-full"
disabled={disabled}
validateFlag={validateFlag}
onClick={() => {
setIsDropdownOpen(!isDropdownOpen);
}}
......
......@@ -11,6 +11,7 @@ export const EmailInput = ({
onEmailsChange,
className,
label,
validateFlag = true,
}: EmailProps) => {
const [emails, setEmails] = useState<string[]>(value);
const [currentInput, setCurrentInput] = useState<string>('');
......@@ -89,6 +90,7 @@ export const EmailInput = ({
placeholder={placeholder}
className="w-full outline-none text-sm text-gray-700"
label=""
validateFlag={validateFlag}
errorText={errorText}
/>
</div>
......
import { useState } from 'react';
import { useEffect, useState } from 'react';
import Info from '../info';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import styles from './inputs.module.scss';
......@@ -21,10 +21,15 @@ export const TextInput = ({
tooltip,
info,
className,
validateFlag = true,
...props
}: TextProps) => {
const [isValid, setIsValid] = useState<boolean>(true);
useEffect(() => {
setIsValid(validateFlag);
}, [validateFlag]);
const handleInputChange = (inputValue: string) => {
if (required && validate) {
setIsValid(validate(inputValue));
......@@ -55,8 +60,8 @@ export const TextInput = ({
type={visible ? 'text' : 'password'}
placeholder={placeholder}
className={
`${size} bg-light border border-secondary-200 placeholder-secondary-400 w-auto min-w-6 shrink-0 grow-1 focus:outline-none block focus:ring-1 ${
isValid ? '' : 'input-error'
`${size} bg-light placeholder-secondary-400 w-auto min-w-6 shrink-0 grow-1 focus:outline-none block focus:ring-1 ${!validateFlag ? 'border border-danger-600' : ' border border-secondary-200'} ${
!isValid || !validateFlag ? 'input-error' : ''
}` + (className ? ` ${className}` : '')
}
value={value.toString()}
......
......@@ -37,6 +37,7 @@ export type TextProps = CommonInputProps<'text', string> & {
onKeyUp?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
validateFlag?: boolean;
};
export type NumberProps = CommonInputProps<'number', number> & {
......@@ -90,6 +91,7 @@ export type DropdownProps = CommonInputProps<'dropdown', number | string | undef
options: number[] | string[] | { [key: string]: string }[];
autocomplete?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
validateFlag?: boolean;
};
export type EmailProps = {
......@@ -103,6 +105,7 @@ export type EmailProps = {
visible?: boolean;
disabled?: boolean;
className?: string;
validateFlag?: boolean;
onEmailsChange?: (emails: string[]) => void;
};
......
import { Button } from '@/lib';
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Menu, MenuCheckboxItem, MenuContent, MenuItem, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from './';
const meta = {
title: 'Components/Menu',
component: Menu,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: `
A flexible menu component that supports nested submenus, checkboxes, radio groups, section labels, and custom triggers.
Features include:
- Click or hover activation
- Keyboard navigation
- Custom placement options
- Nested submenus
- Section labels for better grouping
- Checkbox and radio items
- Custom triggers
- Disabled states
`,
},
},
},
argTypes: {
trigger: {
control: 'radio',
options: ['click', 'hover'],
description: 'The interaction method to open the menu',
table: {
defaultValue: { summary: 'click' },
},
},
placement: {
control: 'select',
options: [
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'right',
'right-start',
'right-end',
'left',
'left-start',
'left-end',
],
description: 'The preferred placement of the menu',
table: {
defaultValue: { summary: 'bottom-start' },
},
},
isOpen: {
control: 'boolean',
description: 'Controls the open state of the menu',
},
onOpenChange: {
action: 'onOpenChange',
description: 'Callback fired when the open state changes',
},
},
args: {
trigger: 'click',
placement: 'bottom-start',
},
} satisfies Meta<typeof Menu>;
export default meta;
type Story = StoryObj<typeof Menu>;
export const Basic: Story = {
name: 'Basic Usage',
render: args => (
<Menu {...args}>
<MenuTrigger label="Open Menu" />
<MenuContent>
<MenuLabel label="File Options" />
<MenuItem label="New File" onClick={() => console.log('New File')} />
<MenuItem label="Open..." onClick={() => console.log('Open')} />
<MenuItem label="Save" onClick={() => console.log('Save')} />
<MenuSeparator />
<MenuItem label="Exit" onClick={() => console.log('Exit')} />
</MenuContent>
</Menu>
),
};
export const WithDisabledItems: Story = {
name: 'With Disabled Items',
render: args => (
<Menu {...args}>
<MenuTrigger label="Edit" />
<MenuContent>
<MenuLabel label="Editing" />
<MenuItem label="Undo" onClick={() => console.log('Undo')} />
<MenuItem label="Redo" disabled onClick={() => console.log('Redo')} />
<MenuSeparator />
<MenuItem label="Cut" disabled onClick={() => console.log('Cut')} />
<MenuItem label="Copy" onClick={() => console.log('Copy')} />
<MenuItem label="Paste" onClick={() => console.log('Paste')} />
</MenuContent>
</Menu>
),
};
export const WithCheckboxItems: Story = {
name: 'With Checkbox Items',
render: function CheckboxStory(args) {
const [items, setItems] = useState({
spellcheck: true,
readonly: false,
hidden: false,
disabled: true,
});
return (
<Menu {...args}>
<MenuTrigger label="Options" />
<MenuContent>
<MenuLabel label="Preferences" />
<MenuCheckboxItem
checked={items.spellcheck}
onCheckedChange={checked => setItems(prev => ({ ...prev, spellcheck: checked }))}
label="Spellcheck"
/>
<MenuCheckboxItem
checked={items.readonly}
onCheckedChange={checked => setItems(prev => ({ ...prev, readonly: checked }))}
label="Read-only"
/>
<MenuCheckboxItem
checked={items.hidden}
onCheckedChange={checked => setItems(prev => ({ ...prev, hidden: checked }))}
label="Hidden"
/>
<MenuCheckboxItem
disabled
checked={items.disabled}
onCheckedChange={checked => setItems(prev => ({ ...prev, disabled: checked }))}
label="Disabled"
/>
</MenuContent>
</Menu>
);
},
};
export const WithRadioItems: Story = {
name: 'With Radio Items',
render: function RadioStory(args) {
const [size, setSize] = useState('medium');
return (
<Menu {...args}>
<MenuTrigger label="Select Size" />
<MenuContent>
<MenuLabel label="Size Options" />
<MenuRadioGroup value={size} onValueChange={setSize}>
<MenuRadioItem value="small" label="Small" />
<MenuRadioItem value="medium" label="Medium" />
<MenuRadioItem value="large" label="Large" />
</MenuRadioGroup>
</MenuContent>
</Menu>
);
},
};
export const NestedSubmenus: Story = {
name: 'Nested Submenus',
render: args => (
<Menu {...args}>
<MenuTrigger label="File" />
<MenuContent>
<MenuLabel label="Actions" />
<MenuItem closeOnClick={true} iconRight="icon-[ic--outline-new-releases]" label="New" onClick={() => alert('New clicked')} />
<MenuItem closeOnClick={true} iconRight="icon-[ic--outline-new-releases]" label="New 2" />
<MenuItem closeOnClick={false} iconLeft="icon-[ic--baseline-content-copy]" rightSlot={<div>Ctrl + C</div>}>
<div>Copy</div>
</MenuItem>
<Menu>
<MenuTrigger label="Open Recent" />
<MenuContent>
<MenuItem label="Document 1.txt" />
<MenuItem label="Document 2.txt" />
<MenuSeparator />
<MenuItem label="Document 1.txt" />
<MenuItem label="Document 2.txt" />
<MenuItem label="Document 1.txt" />
<MenuItem label="Document 2.txt" />
<Menu placement="right-start">
<MenuTrigger label="More Options" />
<MenuContent>
<MenuItem label="Clear List" />
<MenuItem label="Show Folder" />
<MenuItem label="Show Folder1" />
<MenuItem label="Show Folder2" />
<MenuItem label="Show Folder3" />
<MenuItem label="Show Folder4" />
<MenuItem label="Show Folder5" />
<MenuItem label="Show Folder6" onClick={() => alert('New clicked')} />
</MenuContent>
</Menu>
</MenuContent>
</Menu>
<MenuSeparator />
<MenuItem label="Save" onClick={() => console.log('Save')} />
<MenuItem label="Save As..." onClick={() => console.log('Save As')} />
</MenuContent>
</Menu>
),
};
export const CustomTrigger: Story = {
name: 'Custom Trigger',
render: args => (
<Menu {...args}>
<MenuTrigger as="div">
<Button size="xl" rounded variantType="primary" iconComponent="icon-[ic--baseline-more-vert]" />
</MenuTrigger>
<MenuContent>
<MenuLabel label="Custom Actions" />
<MenuItem label="Option 1" />
<MenuItem label="Option 2" />
<MenuItem label="Option 3" />
</MenuContent>
</Menu>
),
};
export const HoverActivation: Story = {
name: 'Hover Activation',
args: {
trigger: 'hover',
},
render: args => (
<Menu {...args}>
<MenuTrigger label="Hover Me" />
<MenuContent>
<MenuLabel label="Quick Actions" />
<MenuItem label="Option 1" />
<MenuItem label="Option 2" />
<Menu trigger="hover">
<MenuTrigger label="More Options" />
<MenuContent>
<MenuItem label="Sub Option 1" />
<MenuItem label="Sub Option 2" />
</MenuContent>
</Menu>
</MenuContent>
</Menu>
),
};
export const RightClickAtCursor: Story = {
name: 'Right click at cursor',
render: args => (
<Menu atCursor {...args}>
<MenuTrigger rightClick>
<div className="flex justify-center items-center rounded bg-blue-300 w-52 h-52">Right click me</div>
</MenuTrigger>
<MenuContent>
<MenuLabel label="Quick Actions" />
<MenuItem label="Option 1" />
<MenuItem label="Option 2" />
<Menu trigger="hover">
<MenuTrigger label="More Options" />
<MenuContent>
<MenuItem label="Sub Option 1" />
<MenuItem label="Sub Option 2" />
</MenuContent>
</Menu>
</MenuContent>
</Menu>
),
};
import { Icon } from '@/lib';
import type { OpenChangeReason, Placement } from '@floating-ui/react';
import {
arrow,
autoUpdate,
flip,
FloatingFocusManager,
FloatingList,
FloatingPortal,
FloatingTree,
offset,
safePolygon,
shift,
useClick,
useClientPoint,
useDismiss,
useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
useTypeahead,
} from '@floating-ui/react';
import * as React from 'react';
import { useControllableState } from '../primitives';
import styles from './menu.module.scss';
// --- Menu Context and Provider ---
export interface MenuContextProps {
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
isOpen: boolean;
setIsOpen: (value: boolean, event?: Event, reason?: OpenChangeReason) => void;
trigger: 'rightClick' | 'click' | 'hover';
floating: {
x: number | null;
y: number | null;
strategy: 'absolute' | 'fixed';
refs: ReturnType<typeof useFloating>['refs'];
context: ReturnType<typeof useFloating>['context'];
interactions: ReturnType<typeof useInteractions>;
};
elementsRef: React.RefObject<Array<HTMLButtonElement | null>>;
labelsRef: React.RefObject<Array<string | null>>;
isNested: boolean;
parent: MenuContextProps | null;
menuNodeId?: string;
setIsChildOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const MenuContext = React.createContext<MenuContextProps | null>(null);
export interface MenuProviderProps extends React.HTMLAttributes<HTMLDivElement> {
trigger?: 'rightClick' | 'click' | 'hover';
placement?: string;
isOpen?: boolean;
defaultIsOpen?: boolean;
onOpenChange?: (open: boolean) => void;
showArrow?: boolean;
children: React.ReactNode;
atCursor?: boolean;
}
export const MenuComponent = React.forwardRef<HTMLDivElement, MenuProviderProps>((props, ref) => {
const {
trigger = 'click',
placement = '',
isOpen: controlledIsOpen,
defaultIsOpen,
onOpenChange: onOpenChangeProp,
showArrow = false,
children,
atCursor = false,
...rest
} = props;
const prevContext = React.useContext(MenuContext); // in case there is a parent Menu
const [isChildOpen, setIsChildOpen] = React.useState(false);
const [isOpen, setIsOpen] = useControllableState<boolean>({
prop: controlledIsOpen,
defaultProp: defaultIsOpen ?? false,
onChange: onOpenChangeProp,
}) as [boolean, React.Dispatch<React.SetStateAction<boolean>>];
const menuIsOpen = isOpen ?? false;
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
const elementsRef = React.useRef<Array<HTMLButtonElement | null>>([]);
const labelsRef = React.useRef<Array<string | null>>([]);
const arrowRef = React.useRef(null);
const nodeId = useFloatingNodeId();
const parentId = prevContext?.menuNodeId;
const tree = useFloatingTree();
const isNested = parentId != null;
const onOpenChange = (value: boolean, event?: Event, reason?: OpenChangeReason) => {
if (isChildOpen && !value && reason === 'hover') {
return;
}
setIsOpen(value);
};
const middleware = [offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }), flip(), shift()];
if (showArrow) {
middleware.push(arrow({ element: arrowRef }));
}
const { x, y, strategy, refs, context } = useFloating<HTMLDivElement>({
nodeId,
open: menuIsOpen,
onOpenChange: onOpenChange,
placement: placement !== '' ? (placement as Placement) : isNested ? 'right-start' : 'bottom-start',
middleware,
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, {
enabled: trigger === 'hover' || isNested,
delay: { open: 75 },
handleClose: safePolygon({ requireIntent: false, blockPointerEvents: true }),
});
const click = useClick(context, {
event: 'mousedown',
enabled: trigger === 'click',
toggle: true,
});
const role = useRole(context, { role: 'menu' });
const dismiss = useDismiss(context, { bubbles: true });
const listNavigation = useListNavigation(context, {
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
nested: isNested,
orientation: 'vertical',
});
const typeahead = useTypeahead(context, {
listRef: labelsRef,
onMatch: menuIsOpen ? setActiveIndex : undefined,
activeIndex,
});
const clientPoint = useClientPoint(context, { enabled: atCursor && !isOpen });
const interactions = useInteractions([hover, click, role, dismiss, listNavigation, typeahead, clientPoint]);
const contextValue: MenuContextProps = {
activeIndex,
setActiveIndex,
getItemProps: interactions.getItemProps,
isOpen: menuIsOpen,
setIsOpen: onOpenChange,
trigger,
floating: { x, y, strategy, refs, context, interactions },
elementsRef,
labelsRef,
isNested,
parent: prevContext,
menuNodeId: nodeId,
setIsChildOpen,
};
return <MenuContext.Provider value={contextValue}>{children}</MenuContext.Provider>;
});
export interface MenuProps extends MenuProviderProps {
label?: string;
showArrow?: boolean;
}
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>((props, ref) => {
const parentId = useFloatingParentNodeId();
const isRoot = parentId === null;
if (isRoot) {
return (
<FloatingTree>
<MenuComponent {...props} ref={ref}>
{props.children ? (
props.children
) : props.label ? (
<>
<MenuTrigger label={props.label} />
<MenuContent>{/* Menu-items */}</MenuContent>
</>
) : null}
</MenuComponent>
</FloatingTree>
);
}
return (
<MenuComponent {...props} ref={ref}>
{props.children ? (
props.children
) : props.label ? (
<>
<MenuTrigger label={props.label} />
<MenuContent>{/* Menu-items */}</MenuContent>
</>
) : null}
</MenuComponent>
);
});
export type MenuTriggerProps = Omit<BaseMenuItemProps, 'role' | 'checked' | 'onCheckedChange' | 'closeOnClick'> & {
x?: number;
y?: number;
rightClick?: boolean;
};
export const MenuTrigger = React.forwardRef<HTMLElement, MenuTriggerProps>(
({ label, as, disabled, leftSlot, rightSlot, x = null, y = null, children, ...props }, ref) => {
const context = React.useContext(MenuContext);
if (!context) throw new Error('MenuTrigger must be used within a Menu');
const { ref: itemRef, isActive, index } = useMenuItem();
const isNested = context.isNested;
if (x != null && y != null) {
const rect = context.floating.refs.reference.current?.getBoundingClientRect() ?? { x: 0, y: 0 };
context.floating.x = x + rect.x;
context.floating.y = y + rect.y;
}
const mergedRef = useMergeRefs([context.floating.refs.setReference, ref, itemRef]);
if (children != null) {
const referenceProps = {
...context.floating.interactions.getReferenceProps({}),
};
if (props.rightClick) {
referenceProps.onContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
context.setIsOpen(!context.isOpen);
};
delete referenceProps.onMouseDown;
delete referenceProps.onPointerDown;
referenceProps.onClick = (event: React.MouseEvent) => {
event.preventDefault();
console.log('Click event!!!', context.isOpen);
context.setIsOpen(false);
};
}
const element = React.cloneElement(children as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>, {
...props,
...referenceProps,
ref: mergedRef,
});
return element;
}
return (
<BaseMenuItem
ref={mergedRef}
as={as ?? 'button'}
role="menuitem"
label={label}
leftSlot={leftSlot}
rightSlot={
rightSlot ??
(isNested ? <Icon size={12} component="icon-[ic--baseline-arrow-forward-ios]" className="text-secondary-500" /> : undefined)
}
isTrigger
disabled={disabled}
{...props}
{...context.floating.interactions.getReferenceProps({
onMouseEnter: () => {
context.setIsChildOpen(true);
},
})}
/>
);
},
);
export type MenuContentProps = React.HTMLProps<HTMLDivElement>;
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>((props, ref) => {
const context = React.useContext(MenuContext);
if (!context) throw new Error('MenuContent must be used within a Menu');
const { floating } = context;
const { x, y, strategy, interactions } = floating;
const isNested = context.isNested;
return (
<FloatingList elementsRef={context.elementsRef} labelsRef={context.labelsRef}>
{context.isOpen && (
<FloatingPortal>
<FloatingFocusManager context={floating.context} modal={false} initialFocus={isNested ? -1 : 0} returnFocus={!isNested}>
<div
ref={node => {
floating.refs.setFloating(node);
if (ref) {
if (typeof ref === 'function') ref(node);
else (ref as React.RefObject<HTMLDivElement | null>).current = node;
}
}}
{...interactions.getFloatingProps(props)}
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
className={`${styles.menu} ${props.className}`}
>
{props.children}
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</FloatingList>
);
});
interface BaseMenuItemProps extends React.HTMLAttributes<HTMLElement> {
as?: React.ElementType;
role?: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
label?: string;
disabled?: boolean;
leftSlot?: React.ReactNode;
iconLeft?: string;
rightSlot?: React.ReactNode;
iconRight?: string;
closeOnClick?: boolean;
isTrigger?: boolean;
}
const BaseMenuItem = React.forwardRef<HTMLElement, BaseMenuItemProps>(
(
{
as: Component = 'button',
role = 'menuitem',
checked,
onCheckedChange,
label,
disabled,
children,
leftSlot,
rightSlot,
iconLeft,
iconRight,
closeOnClick = false,
className,
isTrigger = false,
...props
},
forwardedRef,
) => {
const context = React.useContext(MenuContext);
if (!context) throw new Error('BaseMenuItem must be used within a Menu');
const { ref, index } = useMenuItem();
const tree = useFloatingTree();
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
event.preventDefault();
props.onClick?.(event);
if (closeOnClick) {
tree?.events.emit('click');
context?.setIsOpen(false);
context?.parent?.setIsOpen(false);
context?.parent?.parent?.setIsOpen(false);
context?.parent?.parent?.parent?.setIsOpen(false);
context?.parent?.parent?.parent?.parent?.setIsOpen(false);
}
};
const isActive = index === context.activeIndex;
return (
<Component
ref={useMergeRefs([ref, forwardedRef])}
type={Component === 'button' ? 'button' : undefined}
role={role as 'menuitem' | 'menuitemcheckbox' | 'menuitemradio'}
tabIndex={isActive ? 0 : -1}
data-open={context?.isOpen && isTrigger ? '' : null}
disabled={Component === 'button' ? disabled : undefined}
{...props}
{...context.getItemProps({
onClick: handleClick,
onFocus: event => {
props.onFocus?.(event);
},
})}
className={`${styles.menuItem} ${className ?? ''}`}
>
{/* Left Slot */}
{iconLeft ? <Icon className="text-secondary-500" size={16} component={iconLeft} /> : leftSlot}
{/* Main Content */}
<span className={styles.mainSlot}>{label ? label : children}</span>
{/* Right Slot */}
{iconRight ? <Icon className="text-secondary-500" size={16} component={iconRight} /> : rightSlot}
</Component>
);
},
);
export interface MenuItemProps extends Omit<BaseMenuItemProps, 'role' | 'onCheckedChange' | 'checked'> {
closeOnClick?: boolean;
}
export const MenuItem = React.forwardRef<HTMLElement, MenuItemProps>(({ closeOnClick = true, ...props }, ref) => (
<BaseMenuItem {...props} ref={ref} closeOnClick={closeOnClick} />
));
export interface MenuCheckboxItemProps extends Omit<BaseMenuItemProps, 'role' | 'onCheckedChange' | 'checked'> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
closeOnClick?: boolean;
}
export const MenuCheckboxItem = React.forwardRef<HTMLButtonElement, MenuCheckboxItemProps>(
({ checked, onCheckedChange, leftSlot, closeOnClick = false, ...props }, ref) => (
<BaseMenuItem
{...props}
ref={ref}
role="menuitemcheckbox"
leftSlot={
leftSlot ?? (
<Icon
size={16}
className={checked ? 'text-primary' : 'opacity-50'}
component={checked ? 'icon-[ic--baseline-check-box]' : 'icon-[ic--baseline-check-box-outline-blank]'}
/>
)
}
onClick={event => {
event.stopPropagation();
onCheckedChange(!checked);
props.onClick?.(event);
}}
closeOnClick={closeOnClick}
/>
),
);
export interface MenuRadioGroupProps {
value: string;
onValueChange: (value: string) => void;
children: React.ReactNode;
}
const MenuRadioGroupContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
} | null>(null);
export const MenuRadioGroup: React.FC<MenuRadioGroupProps> = ({ value, onValueChange, children }) => {
return <MenuRadioGroupContext.Provider value={{ value, onValueChange }}>{children}</MenuRadioGroupContext.Provider>;
};
export interface MenuRadioItemProps extends Omit<BaseMenuItemProps, 'role' | 'onCheckedChange' | 'checked'> {
value: string;
}
export const MenuRadioItem = React.forwardRef<HTMLButtonElement, MenuRadioItemProps>(
({ value, leftSlot, closeOnClick = false, ...props }, ref) => {
const radioContext = React.useContext(MenuRadioGroupContext);
const selected = radioContext?.value === value;
return (
<BaseMenuItem
{...props}
ref={ref}
role="menuitemradio"
leftSlot={
leftSlot ?? (
<Icon
size={16}
className={selected ? 'text-primary' : 'opacity-50'}
component={selected ? 'icon-[ic--baseline-radio-button-checked]' : 'icon-[ic--baseline-radio-button-unchecked]'}
/>
)
}
onClick={event => {
event.stopPropagation();
radioContext?.onValueChange(value);
props.onClick?.(event);
}}
closeOnClick={closeOnClick}
/>
);
},
);
export function useMenuItem() {
const context = React.useContext(MenuContext);
if (!context) throw new Error('useMenuItem must be used within a Menu');
const item = useListItem();
return {
...item,
isActive: () => item.index === context.activeIndex,
};
}
export const MenuSeparator = () => <div className={styles.menuSeparator} />;
export interface MenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
}
export const MenuLabel: React.FC<MenuLabelProps> = ({ label, className, ...props }) => {
return (
<div className={`${styles.menuLabel} ${className || ''}`} {...props}>
{label}
</div>
);
};
.menu {
@apply p-1 min-w-20 flex flex-col rounded outline-0 shadow-lg bg-light;
}
.menuSeparator {
@apply border-t border-secondary-200 my-1 -mx-1;
}
.menuItem {
@apply flex flex-row items-center gap-0.5 text-sm px-1 rounded min-h-6 outline-0;
}
.menuItem:disabled {
opacity: 0.5;
pointer-events: none;
}
.menuItem:focus {
@apply bg-secondary-100;
}
.menuItem[data-open] {
@apply bg-secondary-300;
}
.menuLabel {
@apply font-semibold uppercase text-2xs pointer-events-none text-secondary-600 px-1 py-1;
}
.leftSlot {
@apply flex shrink-0 min-w-5 min-h-5;
}
.mainSlot {
@apply grow text-left px-0.5;
}
.rightSlot {
@apply min-h-5 shrink-0;
}
.menu::-webkit-scrollbar {
background-color: #fff;
width: 16px;
}
.menu::-webkit-scrollbar-track {
background-color: #fff;
}
.menu::-webkit-scrollbar-thumb {
background-color: #babac0;
border-radius: 16px;
border: 5px solid #fff;
}
/* set button(top and bottom of the scrollbar) */
.scrollbar::-webkit-scrollbar-button {
display: none;
}
......@@ -14,7 +14,14 @@ import {
wsSchemaStatsRequest,
wsSchemaSubscription,
} from '@/lib/data-access';
import { Broker, wsQueryCountSubscription, wsQueryErrorSubscription, wsQueryRequest, wsQuerySubscription } from '@/lib/data-access/broker';
import {
Broker,
wsQueryCountSubscription,
wsQueryErrorSubscription,
wsQueryRequest,
wsQuerySubscription,
wsQueryTranslationSubscription,
} from '@/lib/data-access/broker';
import { addError, addInfo } from '@/lib/data-access/store/configSlice';
import { setMLResult } from '@/lib/data-access/store/mlSlice';
import { isEqual } from 'lodash-es';
......@@ -51,6 +58,7 @@ import {
setFetchingSaveStates,
setQueryState,
setQuerybuilderNodes,
setQuerybuilderSettings,
testedSaveState,
updateSaveStateList,
updateSelectedSaveState,
......@@ -140,6 +148,16 @@ export const EventBus = (props: { onRunQuery: (useCached: boolean) => void; onAu
}),
);
unsubs.push(
wsQueryTranslationSubscription(({ data, status }) => {
if (!data || status !== 'success') {
dispatch(addError('Failed to translate query'));
return;
}
dispatch(setQuerybuilderSettings({ cypher: data.result }));
}),
);
unsubs.push(
wsQuerySubscription(({ data, status, callID }) => {
if (status === 'aborted') {
......@@ -243,7 +261,6 @@ export const EventBus = (props: { onRunQuery: (useCached: boolean) => void; onAu
console.log('State unchanged, not updating');
return;
}
console.log('State fetched', data, status);
dispatch(addSaveState({ ss: data, select: true }));
}
});
......@@ -306,7 +323,6 @@ export const EventBus = (props: { onRunQuery: (useCached: boolean) => void; onAu
console.log('State unchanged, not updating');
return;
}
console.log('State fetched', data, status);
dispatch(addSaveState({ ss: data, select: false }));
}
}),
......
......@@ -9,6 +9,7 @@ export const wsQueryRequest: WsFrontendCall<{
queryID: number;
useCached: boolean;
callID: string;
manualQuery?: string;
}> = params => {
const mlEnabled = Object.entries(params.ml)
.filter(([_, value]) => value.enabled)
......@@ -21,6 +22,7 @@ export const wsQueryRequest: WsFrontendCall<{
queryID: params.queryID,
ml: mlEnabled,
useCached: params.useCached,
manualQuery: params.manualQuery,
},
callID: params.callID,
});
......@@ -103,14 +105,6 @@ export const wsDeleteQuerySubscription = (callback: ResponseCallback<wsReturnKey
};
};
export const wsManualQueryRequest: WsFrontendCall<{ query: string }> = params => {
Broker.instance().sendMessage({
key: wsKeys.query,
subKey: wsSubKeys.manual,
body: JSON.stringify({ query: params.query }),
});
};
export function wsQueryTranslationSubscription(callback: ResponseCallback<wsReturnKey.queryStatusTranslationResult>) {
const id = Broker.instance().subscribe(callback, wsReturnKey.queryStatusTranslationResult);
return () => {
......
......@@ -11,7 +11,6 @@ const userURI = getEnvVariable('BACKEND_USER');
export const fetchSettings: RequestInit = {
method: 'GET',
credentials: 'include',
redirect: 'follow',
};
export const useAuthentication = () => {
......
......@@ -11,8 +11,9 @@ import {
QueryMultiGraph,
QueryState,
QueryUnionType,
QueryViewMode,
SaveStateAuthorizationHeaders,
SaveStateWithAuthorization,
SaveStateWithAuthorization
} from 'ts-common';
import { getParam, setParam, URLParams } from '../api/url';
import { wsUpdateQueryOrder } from '../broker/wsQuery';
......@@ -130,10 +131,10 @@ export const sessionSlice = createSlice({
activeQuery.graphCounts = { nodeCounts: { updatedAt: 0 } };
}
},
setQuerybuilderSettings: (state: SessionCacheI, action: PayloadAction<QueryBuilderSettings>) => {
setQuerybuilderSettings: (state: SessionCacheI, action: PayloadAction<Partial<QueryBuilderSettings>>) => {
const activeQuery = getActiveQuery(state);
if (activeQuery) {
activeQuery.settings = action.payload;
activeQuery.settings = { ...activeQuery.settings, ...action.payload };
}
},
attributeShownToggle: (state: SessionCacheI, action: PayloadAction<QueryGraphEdgeHandle>) => {
......@@ -162,6 +163,12 @@ export const sessionSlice = createSlice({
activeQuery.name = action.payload;
}
},
setQueryViewMode: (state: SessionCacheI, action: PayloadAction<QueryViewMode>) => {
const activeQuery = getActiveQuery(state);
if (activeQuery) {
activeQuery.settings.mode = action.payload;
}
},
addNewQuery: (state: SessionCacheI, payload: PayloadAction<Query>) => {
if (!payload.payload.id) {
console.error('No query id found');
......@@ -268,6 +275,7 @@ export const sessionSlice = createSlice({
layout: 'manual',
autocompleteRelation: true,
unionTypes: {},
mode: QueryViewMode.Visual,
};
activeQuery.attributesBeingShown = [];
} else {
......@@ -291,6 +299,7 @@ export const {
setActiveQueryID,
addNewQuery,
setQueryName,
setQueryViewMode,
clearQB,
removeQueryByID,
setQueryState,
......@@ -353,44 +362,48 @@ export const selectQuerybuilderHash = (state: RootState): string => {
return ''; // Return an empty string if no active graph exists
}
function isEmptyLogicPill(node: any) {
if (node == null || node.attributes.inputs == null) return false;
const input = Object.values(node.attributes.inputs)[0];
return node.attributes.type == 'logic' && input != null && input == node.attributes.logic.input.default;
function isNotEmptyLogicPill(node: any) {
if (node == null) {
return false; // is empty
} else if (node.attributes.type !== 'logic') {
return true; // is not a logic
} else if (node.attributes.logic.numExtraInputs === 0) {
return true; // is a logic with no extra inputs required
} else if (node.attributes.inputs == null || Object.values(node.attributes.inputs).some(i => i == null || i === '')) {
return false; // there is a missing input to be filled in, so assume empty
}
return true; // default
}
const hashedNodes = activeQuery.graph.nodes
.filter(n => {
if (isEmptyLogicPill(n)) return false;
return true;
})
.map(n => {
const node = { ...n };
if (n?.attributes) {
const newAttributes = { ...n?.attributes };
newAttributes.x = 0;
newAttributes.y = 0;
newAttributes.height = 0;
newAttributes.width = 0;
node.attributes = newAttributes;
}
return node;
});
const hashedNodes = activeQuery.graph.nodes.filter(isNotEmptyLogicPill).map(n => {
const node = { ...n };
if (n?.attributes) {
const newAttributes = { ...n?.attributes };
newAttributes.x = 0;
newAttributes.y = 0;
newAttributes.height = 0;
newAttributes.width = 0;
node.attributes = newAttributes;
}
return node;
});
const { cypher, ...settings } = activeQuery.settings;
const ret = {
nodes: hashedNodes,
edges: activeQuery.graph.edges
.filter(e => {
const targetNode = activeQuery.graph.nodes.find(n => n.key == e.target);
if (isEmptyLogicPill(targetNode)) return false;
return true;
return isNotEmptyLogicPill(targetNode);
})
.map(n => ({
key: n.key,
source: n.source,
target: n.target,
})),
settings: activeQuery.settings,
settings: settings,
};
return objectHash(ret);
};
......@@ -28,13 +28,22 @@ export function FormInsight(props: Props) {
hour: number;
dayOfWeek?: (typeof FrequencyWeekDays)[number];
}>({
frequency: 'Specific Day of the Week',
frequency: '',
minute: 0,
hour: 0,
dayOfWeek: 'SUN',
dayOfWeek: '',
});
const [inputErrors, setInputErrors] = useState<{ [key: string]: boolean }>({
name: false,
recipients: false,
frequency: false,
minutes: false,
hours: false,
frequencyDayOfWeek: false,
nodeLabel: false,
});
const [valid, setValid] = useState(true);
const [selectedQueryID, setSelectedQueryID] = useState<{ label: string; queryId: number; entities: string[] }>({
label: '',
queryId: 0,
......@@ -62,6 +71,7 @@ export function FormInsight(props: Props) {
}
if (props.insight.frequency && props.insight.frequency !== '') {
const [minute, hour, , , dayOfWeek] = props.insight.frequency.split(' ');
setFrequency({
frequency: dayOfWeek === '*' ? (hour === '*' ? 'Hourly' : 'Daily') : 'Specific Day of the Week',
minute: parseInt(minute),
......@@ -70,10 +80,10 @@ export function FormInsight(props: Props) {
});
} else {
setFrequency({
frequency: 'Specific Day of the Week',
frequency: '',
minute: 0,
hour: 0,
dayOfWeek: 'SUN',
dayOfWeek: '',
});
}
......@@ -97,24 +107,56 @@ export function FormInsight(props: Props) {
};
}, [props.insight]);
useEffect(() => {
if (!localInsight.name || localInsight.name.trim() === '') {
setValid(false);
} else if (frequency.hour < 0 || frequency.hour > 23) {
setValid(false);
} else if (frequency.minute < 0 || frequency.minute > 59) {
setValid(false);
} else {
setValid(true);
}
}, [localInsight, frequency]);
const handleSave = async (element: SerializedEditorState, generateEmail: boolean) => {
if (!localInsight.name || localInsight.name.trim() === '') {
dispatch(addError('Name is required'));
setInputErrors(prev => ({ ...prev, name: true }));
return;
}
if (localInsight.recipients.length == 0) {
dispatch(addError('Recipients is required'));
setInputErrors(prev => ({ ...prev, recipients: true }));
return;
}
if (frequency.frequency == '') {
dispatch(addError('Frequency to trigger is required'));
setInputErrors(prev => ({ ...prev, frequency: true }));
return;
}
if (frequency.minute < 0 || frequency.minute >= 59) {
dispatch(addError('Minute range is not correct'));
return;
}
if (frequency.frequency == 'Daily' || frequency.frequency == 'Specific Day of the Week') {
if (frequency.hour < 0 || frequency.hour >= 24) {
dispatch(addError('Hour range is not correct'));
return;
}
}
if (frequency.frequency == 'Specific Day of the Week') {
if (frequency.dayOfWeek == '') {
dispatch(addError('Day of Week is required'));
setInputErrors(prev => ({ ...prev, frequencyDayOfWeek: true }));
return;
}
}
if (localInsight.alarmMode == 'conditional') {
if (localInsight.conditionsCheck?.[0]?.nodeLabel == '') {
setInputErrors(prev => ({ ...prev, nodeLabel: true }));
dispatch(addError('Node Label is required'));
return;
}
}
if (!session.currentSaveState) {
dispatch(addError('No save state selected'));
return;
......@@ -158,18 +200,34 @@ export function FormInsight(props: Props) {
{capitalizeFirstLetter(props.insight.type)} ID: {props.insight.id}
</h2>
<div className="p-2 flex flex-col gap-2">
<Input label="Name" type="text" value={localInsight.name} onChange={e => setLocalInsight({ ...localInsight, name: e })} />
<Input
label="Name"
type="text"
value={localInsight.name}
required
validateFlag={!inputErrors.name}
errorText={inputErrors.name ? 'Name is required' : undefined}
onChange={e => {
setLocalInsight({ ...localInsight, name: e });
setInputErrors(prev => ({ ...prev, name: false }));
}}
/>
<Input
label="Description"
type="text"
value={localInsight.description}
onChange={e => setLocalInsight({ ...localInsight, description: e })}
/>
<Input
type="email"
label="Recipient(s)"
value={localInsight.recipients}
validateFlag={!inputErrors.recipients}
errorText={inputErrors.re ? 'Email is required' : undefined}
onEmailsChange={emails => {
setInputErrors(prev => ({ ...prev, recipients: false }));
setLocalInsight(prev => ({
...prev,
recipients: emails,
......@@ -281,12 +339,15 @@ export function FormInsight(props: Props) {
type="dropdown"
value={localInsight.conditionsCheck[0].nodeLabel}
onChange={value => {
setInputErrors(prev => ({ ...prev, nodeLabel: false }));
const updatedCondition = { ...localInsight.conditionsCheck[0], nodeLabel: String(value) };
setLocalInsight({ ...localInsight, conditionsCheck: [updatedCondition] });
}}
options={selectedQueryID.entities}
inline={false}
size="md"
validateFlag={!inputErrors.nodeLabel}
required
info="Select the node label to check"
/>
<Input
......@@ -309,7 +370,7 @@ export function FormInsight(props: Props) {
onChange={value => {
const updatedCondition = {
...localInsight.conditionsCheck[0],
condition: InsightConditionMap[String(value)],
operator: InsightConditionMap[String(value)],
};
setLocalInsight({ ...localInsight, conditionsCheck: [updatedCondition] });
}}
......@@ -339,10 +400,15 @@ export function FormInsight(props: Props) {
label={'Frequency to trigger the ' + props.insight.type}
type="dropdown"
value={frequency.frequency}
onChange={value => generateCronExpression(value as string, frequency.minute, frequency.hour, frequency.dayOfWeek)}
validateFlag={!inputErrors.frequency}
onChange={value => {
setInputErrors(prev => ({ ...prev, frequency: false }));
generateCronExpression(value as string, frequency.minute || 0, frequency.hour || 0, frequency.dayOfWeek);
}}
options={FrequencyOptions}
inline={false}
size="md"
required
info={'Select the frequency to trigger the ' + props.insight.type}
/>
<Input
......@@ -352,8 +418,8 @@ export function FormInsight(props: Props) {
max={59}
validate={value => value >= 0 && value <= 59}
required
value={frequency.minute}
onChange={value => generateCronExpression(frequency.frequency, value, frequency.hour, frequency.dayOfWeek)}
value={frequency.minute ?? ''}
onChange={value => generateCronExpression(frequency.frequency, value, frequency.hour || 0, frequency.dayOfWeek)}
info={'Enter the minute of the hour to trigger the ' + props.insight.type}
/>
{frequency.frequency !== 'Hourly' && (
......@@ -364,8 +430,8 @@ export function FormInsight(props: Props) {
max={23}
validate={value => value >= 0 && value <= 23}
required
value={frequency.hour}
onChange={value => generateCronExpression(frequency.frequency, frequency.minute, value, frequency.dayOfWeek)}
value={frequency.hour ?? ''}
onChange={value => generateCronExpression(frequency.frequency, frequency.minute || 0, value, frequency.dayOfWeek)}
info={'Enter the hour of the day to trigger the ' + props.insight.type}
/>
)}
......@@ -374,9 +440,14 @@ export function FormInsight(props: Props) {
label="Day of Week"
type="dropdown"
value={frequency.dayOfWeek}
onChange={value => generateCronExpression(frequency.frequency, frequency.minute, frequency.hour, value as string)}
onChange={value => {
setInputErrors(prev => ({ ...prev, FrequencyWeekDays: false }));
generateCronExpression(frequency.frequency, frequency.minute || 0, frequency.hour || 0, value as string);
}}
options={FrequencyWeekDays}
inline={false}
validateFlag={!inputErrors.frequencyDayOfWeek}
required
size="md"
info={'Select the day of the week to trigger the ' + props.insight.type}
/>
......@@ -398,7 +469,7 @@ export function FormInsight(props: Props) {
showToolbar={true}
placeholder={`Start typing your ${props.insight.type} template...`}
handleSave={handleSave}
saveDisabled={!valid}
saveDisabled={false}
variableOptions={selectedQueryID.entities}
>
<Button
......
......@@ -15,8 +15,8 @@ import { IndicatorGraph } from '@/lib/components/charts/indicatorGraph';
import { useActiveQuery, useAppDispatch, useGraphQueryCounts, useSessionCache, wsDeleteState } from '@/lib/data-access';
import { clearSchema } from '@/lib/data-access/store/schemaSlice';
import { clearQB, deleteSaveState, selectSaveState } from '@/lib/data-access/store/sessionSlice';
import { useState } from 'react';
import { CountQueryResultFromBackend, GraphQueryCountResultFromBackend, Query, SaveState } from 'ts-common';
// Update props to include missing functions and optional sharing flag.
interface DatabaseLineProps {
saveState: SaveState;
......@@ -83,6 +83,12 @@ export const DatabaseLine = (props: DatabaseLineProps) => {
const activeQuery = useActiveQuery();
const graphCounts = useGraphQueryCounts();
const sharing = props.sharing ?? false;
const [isOpen, setIsOpen] = useState(false);
const handleItemClick = (callback: () => void) => {
callback();
setIsOpen(false);
};
return (
<>
......@@ -134,43 +140,20 @@ export const DatabaseLine = (props: DatabaseLineProps) => {
dispatch(deleteSaveState(props.saveState.id));
}}
/>
<Popover>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger>
<Button iconComponent="icon-[mi--options-vertical]" variant="ghost" />
</PopoverTrigger>
<PopoverContent className="p-1 min-w-24">
<DropdownItem
value="Open"
onClick={() => {
props.onSelect();
}}
/>
<DropdownItem
value="Update"
onClick={() => {
props.onUpdate();
}}
/>
<DropdownItem
value="Clone"
onClick={() => {
props.onClone();
}}
/>
<DropdownItem value="Open" onClick={() => handleItemClick(props.onSelect)} />
<DropdownItem value="Update" onClick={() => handleItemClick(props.onUpdate)} />
<DropdownItem value="Clone" onClick={() => handleItemClick(props.onClone)} />
<DropdownItem
value={sharing ? 'Creating Share Link' : 'Share'}
disabled={sharing}
onClick={async () => {
props.onShare();
}}
/>
<DropdownItem
value="Delete"
onClick={() => {
props.onDelete();
}}
className="text-danger"
onClick={() => handleItemClick(props.onShare)}
/>
<DropdownItem value="Delete" onClick={() => handleItemClick(props.onDelete)} className="text-danger" />
</PopoverContent>
</Popover>
</td>
......
......@@ -126,7 +126,7 @@ export class QueryMultiGraphology extends MultiGraph<QueryGraphNodes, QueryGraph
attributes.logic.inputs.forEach((input, i) => {
// Setup default non-linked inputs as regular values matching the input expected type
if (!attributes.inputs) attributes.inputs = {};
attributes.inputs[input.name] = inputValues?.[input.name] || input.default;
attributes.inputs[input.name] = inputValues?.[input.name] || input.default || '';
});
// (attributes as LogicNodeAttributes).leftEntityHandleId = getHandleId(attributes.id, name, type, Handles.RelationLeft, '');
// (attributes as LogicNodeAttributes).rightEntityHandleId = getHandleId(attributes.id, name, type, Handles.RelationRight, '');
......
import { useState } from 'react';
import { Button } from '../../components';
type ManualQueryDialogProps = {
onSubmit: (query: string) => void;
};
export const ManualQueryDialog = ({ onSubmit }: ManualQueryDialogProps) => {
const [query, setQuery] = useState('');
const handleSubmit = () => {
if (query.trim() !== '') {
onSubmit(query);
setQuery('');
}
};
return (
<div className="flex flex-col w-full gap-2 p-2">
<label className="text-xs font-bold">Manual Query</label>
<textarea
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Enter your Cypher query"
className="w-full h-32 border rounded p-2"
/>
<Button variantType="primary" onClick={handleSubmit}>
Run Query
</Button>
</div>
);
};
import { Button, Input, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger } from '@/lib/components';
// import { wsAddQuery, wsManualQueryRequest } from '@/lib/data-access/broker';
import { addError } from '@/lib/data-access/store/configSlice';
import { addNewQuery, clearQB, setQuerybuilderSettings } from '@/lib/data-access/store/sessionSlice';
import { useAppDispatch } from '../../data-access';
// import { ManualQueryDialog } from './ManualQueryDialog';
import { wsAddQuery } from '@/lib/data-access/broker';
import { QueryMLDialog } from './querysidepanel/QueryMLDialog';
import { QuerySettings } from './querysidepanel/QuerySettings';
// ----- Begin Button Components -----
interface TooltipButtonProps {
tooltip: string;
iconComponent: string;
onClick: (e: React.MouseEvent) => void;
variantType?: 'primary' | 'secondary' | 'danger';
variant?: 'solid' | 'outline' | 'ghost';
size?: 'xs' | '3xs';
disabled?: boolean;
className?: string;
disableTooltip?: boolean;
}
export const TooltipButton = ({
tooltip,
iconComponent,
onClick,
variantType = 'secondary' as 'primary' | 'secondary' | 'danger',
variant = 'ghost',
size = 'xs',
disabled = false,
className = '',
disableTooltip = false,
}: TooltipButtonProps) => (
<Tooltip>
<TooltipTrigger>
<Button
variantType={variantType}
variant={variant}
size={size}
iconComponent={iconComponent}
onClick={onClick}
disabled={disabled}
className={className}
/>
</TooltipTrigger>
<TooltipContent disabled={disableTooltip}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
interface PopoverButtonProps extends TooltipButtonProps {
popoverContent: React.ReactNode;
}
export const PopoverButton = ({
tooltip,
iconComponent,
onClick,
popoverContent,
variantType = 'secondary',
variant = 'ghost',
size = 'xs',
disabled = false,
className = '',
disableTooltip = false,
}: PopoverButtonProps) => (
<Popover>
<PopoverTrigger disabled={disabled}>
<Tooltip>
<TooltipTrigger>
<Button
variantType={variantType}
variant={variant}
size={size}
iconComponent={iconComponent}
onClick={onClick}
disabled={disabled}
className={className}
/>
</TooltipTrigger>
<TooltipContent disabled={disableTooltip}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>{popoverContent}</PopoverContent>
</Popover>
);
interface AddQueryButtonProps {
disabled: boolean;
saveStateId: string;
}
export const AddQueryButton = ({ disabled, saveStateId }: AddQueryButtonProps) => {
const dispatch = useAppDispatch();
const handleAddQuery = async () => {
wsAddQuery({ saveStateID: saveStateId }, ({ data, status }) => {
if (status !== 'success' || data == null || !data.id || data.id < 0) {
console.error('Failed to add query');
addError('Failed to add query');
return;
}
console.log('Query added', data);
dispatch(addNewQuery(data));
});
};
return <TooltipButton tooltip="Add query" iconComponent="icon-[ic--baseline-add]" onClick={handleAddQuery} disabled={disabled} />;
};
interface FitViewButtonProps {
onFitView: () => void;
}
export const FitViewButton = ({ onFitView }: FitViewButtonProps) => (
<TooltipButton tooltip="Fit to screen" iconComponent="icon-[ic--baseline-fullscreen]" onClick={onFitView} />
);
interface ClearNodesButtonProps {
disabled: boolean;
}
export const ClearNodesButton = ({ disabled }: ClearNodesButtonProps) => {
const dispatch = useAppDispatch();
const handleClearNodes = () => {
dispatch(clearQB());
};
return (
<TooltipButton tooltip="Clear query panel" iconComponent="icon-[ic--baseline-delete]" onClick={handleClearNodes} disabled={disabled} />
);
};
interface ScreenshotButtonProps {
onScreenshot: () => void;
}
export const ScreenshotButton = ({ onScreenshot }: ScreenshotButtonProps) => (
<TooltipButton tooltip="Capture screen" iconComponent="icon-[ic--baseline-camera-alt]" onClick={onScreenshot} />
);
interface SettingsButtonProps {
disabled: boolean;
}
export const SettingsButton = ({ disabled }: SettingsButtonProps) => (
<PopoverButton
tooltip="Query builder settings"
iconComponent="icon-[ic--baseline-settings]"
onClick={() => {}}
disabled={disabled}
className="query-settings"
popoverContent={<QuerySettings />}
/>
);
interface RunQueryButtonProps {
onRunQuery: (useCached: boolean) => void;
}
export const RunQueryButton = ({ onRunQuery }: RunQueryButtonProps) => (
<TooltipButton
tooltip="Rerun query"
iconComponent="icon-[ic--baseline-cached]"
onClick={() => onRunQuery(true)}
// Note: The original had onDoubleClick which we need to handle differently
/>
);
interface LogicButtonProps {
disabled: boolean;
onLogic: () => void;
isActive: boolean;
}
export const LogicButton = ({ disabled, onLogic, isActive }: LogicButtonProps) => (
<TooltipButton
tooltip="Logic settings"
iconComponent="icon-[ic--baseline-difference]"
onClick={onLogic}
disabled={disabled}
disableTooltip={isActive}
/>
);
interface MLButtonProps {
disabled: boolean;
mlEnabled: boolean;
isActive: boolean;
mlData: any;
}
export const MLButton = ({ disabled, mlEnabled, isActive, mlData }: MLButtonProps) => {
const ml = mlData;
const renderMLTooltipContent = () =>
mlEnabled ? (
<>
<p className="font-bold text-base">Machine learning</p>
<p className="mb-2">Algorithms detected the following results:</p>
{ml.linkPrediction.enabled && ml.linkPrediction.result && (
<>
<p className="mt-2 font-semibold">Link prediction</p>
<p>{ml.linkPrediction.result.length} links</p>{' '}
</>
)}
{ml.centrality.enabled && Object.values(ml.centrality.result).length > 0 && (
<>
<p className="mt-2 font-semibold">Centrality</p>
<p>{(Object.values(ml.centrality.result) as number[]).reduce((a, b) => b + a).toFixed(2)} sum of centers</p>
</>
)}
{ml.communityDetection.enabled && ml.communityDetection.result && (
<>
<p className="mt-2 font-semibold">Community detection</p>
<p>{ml.communityDetection.result.length} communities</p>
</>
)}
{ml.shortestPath.enabled && (
<>
<p className="mt-2 font-semibold">Shortest path</p>
{ml.shortestPath.result?.length > 0 && <p># of hops: {ml.shortestPath.result.length}</p>}
{!ml.shortestPath.srcNode ? <p>Please select source node</p> : !ml.shortestPath.trtNode && <p>Please select target node</p>}
</>
)}
</>
) : (
<p>Machine learning</p>
);
return (
<PopoverButton
tooltip="Machine learning"
iconComponent="icon-[ic--baseline-lightbulb]"
onClick={() => {}}
variantType={mlEnabled ? 'primary' : 'secondary'}
variant={mlEnabled ? 'outline' : 'ghost'}
disabled={disabled}
disableTooltip={isActive}
popoverContent={<QueryMLDialog />}
/>
);
};
interface LimitButtonProps {
disabled: boolean;
activeQuery: any;
resultSize: number;
totalSize: number;
}
export const LimitButton = ({ disabled, activeQuery, resultSize, totalSize }: LimitButtonProps) => {
const dispatch = useAppDispatch();
const limitReached = activeQuery.settings.limit <= resultSize;
const renderLimitTooltipContent = () => (
<>
<p className="font-bold text-base">Limit</p>
<p>Limits the number of edges retrieved from the database.</p>
<p>Required to manage performance.</p>
<p className={`font-semibold${limitReached ? ' text-primary-200' : ''}`}>
Fetched {resultSize} out of {totalSize} nodes
</p>
</>
);
const handleLimitChange = (value: any) => {
dispatch(setQuerybuilderSettings({ ...activeQuery.settings, limit: Number(value) }));
};
return (
<Popover placement="bottom">
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={limitReached ? 'primary' : 'secondary'}
variant={limitReached ? 'outline' : 'ghost'}
size="xs"
disabled={disabled}
iconComponent="icon-[ic--baseline-filter-alt]"
/>
</TooltipTrigger>
<TooltipContent>{renderLimitTooltipContent()}</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<Input
type="number"
size="sm"
label="Limit"
value={activeQuery.settings.limit}
lazy
onChange={handleLimitChange}
className={`w-24${limitReached ? ' border-danger-600' : ''}`}
containerClassName="p-2"
/>
</PopoverContent>
</Popover>
);
};
interface ManualQueryButtonProps {
disabled: boolean;
}
// export const ManualQueryButton = ({ disabled }: ManualQueryButtonProps) => {
// const handleManualQuery = (query: string): void => {
// wsManualQueryRequest({ query });
// };
// return (
// <PopoverButton
// tooltip="Manual Query"
// iconComponent="icon-[ic--baseline-search]"
// onClick={() => {}}
// disabled={disabled}
// popoverContent={<ManualQueryDialog onSubmit={handleManualQuery} />}
// />
// );
// };
// ----- End Button Components -----
// Also export all interfaces for external use
export type {
AddQueryButtonProps,
ClearNodesButtonProps,
FitViewButtonProps,
LimitButtonProps,
LogicButtonProps,
// ManualQueryButtonProps,
MLButtonProps,
PopoverButtonProps,
RunQueryButtonProps,
ScreenshotButtonProps,
SettingsButtonProps,
TooltipButtonProps,
};
import { Button, ControlContainer, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/lib/components';
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/components/popover';
import { Tab, Tabs } from '@/lib/components/tabs';
import { addError } from '@/lib/data-access/store/configSlice';
import objectHash from 'object-hash';
import { useEffect, useMemo, useRef, useState } from 'react';
import Sortable from 'sortablejs';
import { useActiveQuery, useActiveSaveState, useAppDispatch, useGraphQuery, useML } from '../../data-access';
import { wsAddQuery, wsDeleteQuery, wsManualQueryRequest, wsUpdateQuery } from '../../data-access/broker';
import {
addNewQuery,
clearQB,
removeQueryByID,
reorderQueryState,
setActiveQueryID,
setQuerybuilderSettings,
setQueryName,
} from '../../data-access/store/sessionSlice';
import { ManualQueryDialog } from './ManualQueryDialog';
import { QueryMLDialog } from './querysidepanel/QueryMLDialog';
import { QuerySettings } from './querysidepanel/QuerySettings';
export type QueryBuilderToggleSettings = 'settings' | 'ml' | 'logic' | 'relatedNodes' | undefined;
export type QueryBuilderNavProps = {
toggleSettings: QueryBuilderToggleSettings;
onFitView: () => void;
onRunQuery: (useCached: boolean) => void;
onScreenshot: () => void;
onLogic: () => void;
};
export const QueryBuilderNav = (props: QueryBuilderNavProps) => {
const dispatch = useAppDispatch();
const activeQuery = useActiveQuery();
const ss = useActiveSaveState();
const graphQuery = useGraphQuery();
const resultSize = useMemo(() => {
if (!graphQuery) return 0;
return graphQuery.graph.nodes.length;
}, [graphQuery]);
const totalSize = useMemo(() => {
if (!activeQuery || !graphQuery || !ss) return 0;
const nodeCounts = activeQuery.graph.nodes
.filter(x => x.attributes.type == 'entity')
.map(x => graphQuery.graphCounts?.nodeCounts[`${x.key}_count`] ?? 0);
return nodeCounts.reduce((a, b) => a + b, 0);
}, [graphQuery]);
const ml = useML();
const [editingIdx, setEditingIdx] = useState<{ idx: number; text: string } | null>(null);
/**
* Clears all nodes in the graph.
*/
function clearAllNodes() {
dispatch(clearQB());
}
const tabsRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ss || !tabsRef.current) return;
const sortable = new Sortable(tabsRef.current, {
animation: 150,
draggable: '[data-type="tab"]',
ghostClass: 'bg-secondary-300',
dragClass: 'bg-secondary-100',
onEnd: evt => {
if (evt.oldIndex != null && evt.newIndex != null && evt.oldIndex !== evt.newIndex) {
dispatch(
reorderQueryState({
oldIndex: evt.oldIndex,
newIndex: evt.newIndex,
}),
);
}
},
});
const sortedQueries = ss.queryStates.openQueryArray
.filter(query => query.id != null)
.sort((a, b) => {
return a.order < b.order ? -1 : 1;
});
sortable.sort(sortedQueries.map(x => String(x.id)));
return () => {
sortable.destroy();
};
}, [ss ? objectHash(Object.fromEntries(ss?.queryStates.openQueryArray.map(x => [x.id, x.order]))) : null]);
const mlEnabled = ml.linkPrediction.enabled || ml.centrality.enabled || ml.communityDetection.enabled || ml.shortestPath.enabled;
const handleManualQuery = (query: string) => {
wsManualQueryRequest({ query: query });
};
if (!ss || !activeQuery) {
console.debug('No active query found in query nav');
return null;
}
function updateQueryName(text: string) {
if (!ss || !activeQuery) return;
wsUpdateQuery(
{
saveStateID: ss.id,
query: { ...activeQuery, name: text },
},
({ status }) => {
if (status !== 'success') {
addError('Failed to update query');
}
dispatch(setQueryName(text));
setEditingIdx(null);
},
);
}
return (
<div className="sticky shrink-0 top-0 left-0 right-0 flex items-stretch justify-start h-7 bg-secondary-100 border-b border-secondary-200 max-w-full">
<div className="flex items-center px-2">
<h1 className="text-xs font-semibold text-secondary-600 truncate">Query builder</h1>
</div>
<div className="flex items-center px-0.5 gap-1 border-l border-secondary-200">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
as="a"
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-add]"
disabled={!ss?.authorization.database?.W}
onClick={async () => {
wsAddQuery({ saveStateID: ss.id }, ({ data, status }) => {
if (status !== 'success' || data == null || !data.id || data.id < 0) {
console.error('Failed to add query');
addError('Failed to add query');
return;
}
console.log('Query added', data);
dispatch(addNewQuery(data));
});
}}
/>
</TooltipTrigger>
<TooltipContent>
<p>Add query</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Tabs ref={tabsRef} className={`-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x`}>
{ss.queryStates.openQueryArray
.filter(query => query.id != null)
.sort((a, b) => {
return a.order < b.order ? -1 : 1;
})
.map((query, i) => (
<Tab
text=""
activeTab={query.id === activeQuery?.id}
key={i}
data-id={query.id}
onClick={() => {
if (query.id == null) return;
dispatch(setActiveQueryID(query.id));
}}
>
<>
{editingIdx?.idx === i ? (
<Input
type="text"
size="xs"
value={editingIdx.text}
label=""
onChange={e => {
setEditingIdx({ idx: i, text: e });
}}
onBlur={() => {
updateQueryName(editingIdx.text);
}}
onKeyDown={e => {
if (e.key === 'Enter') {
updateQueryName(editingIdx.text);
}
}}
className="w-20"
style={{
border: 'none',
boxShadow: 'none',
background: 'none',
}}
autoFocus
/>
) : (
<div
onDoubleClick={e => {
e.stopPropagation();
dispatch(setActiveQueryID(query.id || -1));
setEditingIdx({ idx: i, text: query.name ?? '' });
}}
>
{query.name ?? 'Query'}
</div>
)}
{ss.queryStates.openQueryArray.filter(query => query.id != null).length > 1 && (
<Popover placement="bottom">
<PopoverTrigger>
<Button
variantType="secondary"
variant="ghost"
disabled={!ss?.authorization.database?.W}
className={
query.id == ss.queryStates.activeQueryId
? ''
: 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100'
}
rounded
size="3xs"
iconComponent="icon-[ic--baseline-close]"
/>
</PopoverTrigger>
<PopoverContent className="p-2 text-sm">
<p className="pb-1">Are you sure?</p>
<Button
variantType="danger"
variant="solid"
size="xs"
onClick={e => {
e.stopPropagation();
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
>
Yes, remove
</Button>
</PopoverContent>
</Popover>
)}
</>
</Tab>
))}
</Tabs>
<div className="sticky right-0 px-0.5 ml-auto items-center flex truncate">
<ControlContainer>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-fullscreen]"
onClick={props.onFitView}
/>
</TooltipTrigger>
<TooltipContent>
<p>Fit to screen</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-delete]"
onClick={() => {
if (ss?.authorization.query.W) clearAllNodes();
}}
/>
</TooltipTrigger>
<TooltipContent>
<p>Clear query panel</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-camera-alt]"
onClick={props.onScreenshot}
/>
</TooltipTrigger>
<TooltipContent>
<p>Capture screen</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-settings]"
className="query-settings"
/>
</TooltipTrigger>
<TooltipContent>
<p>Query builder settings</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<QuerySettings />
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger>
<Button
variantType="secondary"
variant="ghost"
size="xs"
iconComponent="icon-[ic--baseline-cached]"
onClick={() => props.onRunQuery(true)}
onDoubleClick={() => props.onRunQuery(false)}
/>
</TooltipTrigger>
<TooltipContent>
<p>Rerun query</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variantType="secondary"
variant="ghost"
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-difference]"
onClick={props.onLogic}
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'logic'}>
<p>Logic settings</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={mlEnabled ? 'primary' : 'secondary'}
variant={mlEnabled ? 'outline' : 'ghost'}
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-lightbulb]"
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'ml'}>
{mlEnabled ? (
<>
<p className="font-bold text-base">Machine learning</p>
<p className="mb-2">Algorithms detected the following results:</p>
{ml.linkPrediction.enabled && ml.linkPrediction.result && (
<>
<p className="mt-2 font-semibold">Link prediction</p>
<p>{ml.linkPrediction.result.length} links</p>{' '}
</>
)}
{ml.centrality.enabled && Object.values(ml.centrality.result).length > 0 && (
<>
<p className="mt-2 font-semibold">Centrality</p>
<p>
{Object.values(ml.centrality.result)
.reduce((a, b) => b + a)
.toFixed(2)}{' '}
sum of centers
</p>
</>
)}
{ml.communityDetection.enabled && ml.communityDetection.result && (
<>
<p className="mt-2 font-semibold">Community detection</p>
<p>{ml.communityDetection.result.length} communities</p>
</>
)}
{ml.shortestPath.enabled && (
<>
<p className="mt-2 font-semibold">Shortest path</p>
{ml.shortestPath.result?.length > 0 && <p># of hops: {ml.shortestPath.result.length}</p>}
{!ml.shortestPath.srcNode ? (
<p>Please select source node</p>
) : (
!ml.shortestPath.trtNode && <p>Please select target node</p>
)}
</>
)}
</>
) : (
<p>Machine learning</p>
)}
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<QueryMLDialog />
</PopoverContent>
</Popover>
<Popover placement="bottom">
<PopoverTrigger>
<Tooltip>
<TooltipTrigger>
<Button
variantType={activeQuery.settings.limit <= resultSize ? 'primary' : 'secondary'}
variant={activeQuery.settings.limit <= resultSize ? 'outline' : 'ghost'}
size="xs"
disabled={!ss?.authorization.query.W}
iconComponent="icon-[ic--baseline-filter-alt]"
/>
</TooltipTrigger>
<TooltipContent disabled={props.toggleSettings === 'ml'}>
<p className="font-bold text-base">Limit</p>
<p>Limits the number of edges retrieved from the database.</p>
<p>Required to manage performance.</p>
<p className={`font-semibold${activeQuery.settings.limit <= resultSize ? ' text-primary-200' : ''}`}>
Fetched {resultSize} out of {totalSize} nodes
</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<Input
type="number"
size="sm"
label="Limit"
value={activeQuery.settings.limit}
lazy
onChange={e => {
dispatch(setQuerybuilderSettings({ ...activeQuery.settings, limit: Number(e) }));
}}
className={`w-24${activeQuery.settings.limit <= resultSize ? ' border-danger-600' : ''}`}
containerClassName="p-2"
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger disabled={!ss?.authorization.query.W}>
<Tooltip>
<TooltipTrigger>
<Button variantType="secondary" variant="ghost" size="xs" iconComponent="icon-[ic--baseline-search]" />
</TooltipTrigger>
<TooltipContent>
<p>Manual Query</p>
</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent>
<ManualQueryDialog onSubmit={handleManualQuery} />
</PopoverContent>
</Popover>
</TooltipProvider>
</ControlContainer>
</div>
</div>
);
};
import { Button } from '@/lib/components';
import React, { useState } from 'react';
type QueryInputCypherProps = {
cypher: string | undefined;
onCypherChange: (value: string) => void;
onRunQuery: () => void;
};
type ViewMode = 'raw' | 'readable';
export const QueryInputCypher: React.FC<QueryInputCypherProps> = ({ cypher, onCypherChange, onRunQuery }) => {
const [viewMode, setViewMode] = useState<ViewMode>('raw');
console.log('QueryInputCypher', cypher);
const formatCypherQuery = (query: string): React.ReactNode => {
if (!query) return null;
// Split the query by common Cypher keywords
const keywords = [
'MATCH',
'WHERE',
'RETURN',
'ORDER BY',
'LIMIT',
'WITH',
'CREATE',
'MERGE',
'DELETE',
'SET',
'REMOVE',
'OPTIONAL MATCH',
];
// Create a regex pattern that matches any of the keywords
const keywordPattern = new RegExp(`(${keywords.join('|')})`, 'gi');
// Split the query by keywords and preserve the keywords
const parts = query.split(keywordPattern);
return (
<div className="font-mono text-sm py-2">
{parts.map((part, index) => {
// Check if this part is a keyword
const isKeyword = keywords.some(keyword => part.trim().toUpperCase() === keyword);
return (
<div key={index} className={`${isKeyword ? 'text-blue-600 font-bold' : ''} ${isKeyword ? 'ml-0' : 'ml-8'} mb-1`}>
{part}
</div>
);
})}
</div>
);
};
return (
<div className="bg-neutral-50 h-full p-2 flex flex-col">
<div className="flex justify-between items-center mb-3">
<div>
<h3 className="font-semibold">Cypher query</h3>
<p className="text-neutral-500 text-sm">
Below you find the Cypher query as executed by the backend.
{viewMode === 'raw' && ' You can edit directly, but changes to the query are not persisted.'}
</p>
</div>
<div className="flex space-x-2">
<Button variantType="secondary" variant={viewMode === 'raw' ? 'solid' : 'outline'} onClick={() => setViewMode('raw')} size="sm">
Raw Edit
</Button>
<Button
variantType="secondary"
variant={viewMode === 'readable' ? 'solid' : 'outline'}
onClick={() => setViewMode('readable')}
size="sm"
>
Readable
</Button>
</div>
</div>
<div className="flex-grow overflow-auto border rounded">
{viewMode === 'raw' ? (
<textarea className="w-full h-full p-2 font-mono text-sm" value={cypher} onChange={e => onCypherChange(e.target.value)} />
) : (
<div className="p-2 h-full overflow-auto">{formatCypherQuery(cypher || '')}</div>
)}
</div>
<div className="mt-3">
<Button variantType="primary" variant="solid" onClick={onRunQuery}>
Run
</Button>
</div>
</div>
);
};