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 (19)
Showing
with 1431 additions and 463 deletions
import { DropdownContainer, DropdownItem, DropdownItemContainer, DropdownTrigger, Icon } from '@/lib/components';
import { getDataTypeIcon } from '@/lib/components/DataTypeIcon';
import { Input } from '@/lib/components/inputs';
import React, { useState } from 'react';
import { SchemaAttributeTypes } from 'ts-common';
interface DropdownOption {
name: string;
type: string;
}
interface DropdownSelectorProps {
label: string;
selectedValue: string;
options: DropdownOption[];
filter: string;
setFilter: (value: string) => void;
onChange: (selected: DropdownOption) => void;
}
export const DropdownSelector: React.FC<DropdownSelectorProps> = ({ label, selectedValue, filter, setFilter, options, onChange }) => {
const [open, setOpen] = useState(false);
const filteredOptions = options.filter(option => option.name.toLowerCase().includes(filter.toLowerCase()));
return (
<DropdownContainer open={open} onOpenChange={setOpen}>
<span>{label}</span>
<DropdownTrigger title={selectedValue} size="md" variant="outline" onClick={() => setOpen(v => !v)} />
<DropdownItemContainer className="max-h-none">
<Input
label=""
type="text"
placeholder="Filter"
size="xs"
className="mb-1 rounded-sm w-full"
value={filter}
onClick={e => e.stopPropagation()}
onChange={v => setFilter(v)}
/>
{filteredOptions.map(attr => (
<DropdownItem
key={attr.name + attr.type}
value={attr.name}
className="w-full"
onClick={() => {
onChange(attr);
setOpen(false);
}}
>
<div className="flex items-center gap-1">
<Icon component={getDataTypeIcon(attr.type as SchemaAttributeTypes)} size={16} />
</div>
</DropdownItem>
))}
</DropdownItemContainer>
</DropdownContainer>
);
};
......@@ -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;
}
......@@ -68,7 +68,7 @@ export function PreviewPlugin({ contentEditable }: { contentEditable: RefObject<
if (!activeVisualization) {
throw new Error('Tried to render non-existing visualization');
}
const xAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.xAxisLabel!);
const xAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.xAxisLabel.name!);
let yAxisData: (string | number)[] = [];
let zAxisData: (string | number)[] = [];
......@@ -77,11 +77,11 @@ export function PreviewPlugin({ contentEditable }: { contentEditable: RefObject<
yAxisData = getAttributeValues(
result,
activeVisualization.selectedEntity,
activeVisualization.xAxisLabel, // Note: x not y!
activeVisualization.xAxisLabel.name, // Note: x not y!
activeVisualization.groupData,
);
} else {
yAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.yAxisLabel);
yAxisData = getAttributeValues(result, activeVisualization.selectedEntity, activeVisualization.yAxisLabel.name);
}
}
......
......@@ -243,7 +243,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 +305,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 }));
}
}),
......
......@@ -11,7 +11,6 @@ const userURI = getEnvVariable('BACKEND_USER');
export const fetchSettings: RequestInit = {
method: 'GET',
credentials: 'include',
redirect: 'follow',
};
export const useAuthentication = () => {
......
......@@ -353,37 +353,39 @@ 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 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,
......
......@@ -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 { Button, ControlContainer, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/lib/components';
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from '@/lib/components/menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/components/popover';
import { Tab, Tabs } from '@/lib/components/tabs';
import { addError } from '@/lib/data-access/store/configSlice';
......@@ -162,74 +163,113 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => {
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();
<Menu atCursor>
<MenuTrigger className="-mb-px" rightClick>
<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 && (
<Button
variantType="secondary"
variant="ghost"
disabled={!ss.authorization.database?.W}
rounded
size="3xs"
iconComponent="icon-[ic--baseline-close]"
onClick={e => {
e.stopPropagation();
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
)}
</>
</Tab>
</MenuTrigger>
<MenuContent>
{/* <Menu>
<MenuTrigger label="Change mode" />
<MenuContent>
<MenuRadioGroup value={'visual'} onValueChange={() => {}}>
<MenuRadioItem value="visual" label="Visual" />
<MenuRadioItem value="cypher" label="Cypher" />
</MenuRadioGroup>
</MenuContent>
</Menu> */}
<MenuItem
label="Rename"
closeOnClick={true}
onClick={e => {
e.stopPropagation();
setTimeout(() => {
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 && (
<Button
variantType="secondary"
variant="ghost"
disabled={!ss?.authorization.database?.W}
rounded
size="3xs"
iconComponent="icon-[ic--baseline-close]"
onClick={e => {
e.stopPropagation();
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
)}
</>
</Tab>
}, 1);
}}
/>
<MenuSeparator />
<MenuItem
label="Remove"
className="text-danger"
onClick={() => {
if (query.id !== undefined) {
wsDeleteQuery({ saveStateID: ss.id, queryID: query.id });
dispatch(removeQueryByID(query.id));
}
}}
/>
</MenuContent>
</Menu>
))}
</Tabs>
<div className="sticky right-0 px-0.5 ml-auto items-center flex truncate">
<ControlContainer>
<TooltipProvider>
......
import { Button } from '@/lib/components/buttons';
import { DropdownSelector } from '@/lib/components/DropdownSelector/DropdownSelector';
import { Input } from '@/lib/components/inputs';
import { EntityPill } from '@/lib/components/pills/Pill';
import { SettingsContainer } from '@/lib/vis/components/config';
import html2canvas from 'html2canvas';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { VISComponentType, VisualizationPropTypes, VisualizationSettingsPropTypes } from '../../common';
import { CustomChartPlotly } from './components/CustomChartPlotly';
import { plotTypeOptions, preparePlotData } from './components/MakePlot';
import { Vis1DProps, Vis1DVisHandle, getAttributeValues, vis1DHistogramAggregationOptions } from './model';
import { exportImageInternal } from './subPlots/utils';
import { getAllAxisData } from './prepareData/utils';
import { vis1DHistogramAggregationOptions, Vis1DVisHandle, Vis1DProps } from './subPlots/types';
import { Vis1DSettingsLine, Vis1DLine } from './subPlots/Vis1DLine';
import { Vis1DSettingsScatter, Vis1DScatter } from './subPlots/Vis1DScatter';
import { Vis1DPie } from './subPlots/Vis1DPie';
import { Vis1DSettingsBar, Vis1DBar } from './subPlots/Vis1DBar';
import { Vis1DSettingsHistogramCount, Vis1DHistogramCount } from './subPlots/Vis1DHistogramCount';
import { Vis1DSettingsHistogramDegree, Vis1DHistogramDegree } from './subPlots/Vis1DHistogramDegree';
import { plotTypeOptions } from './subPlots/types';
const defaultSettings: Vis1DProps = {
plotType: 'bar',
plotType: 'line',
title: '',
selectedEntity: '',
xAxisLabel: '',
yAxisLabel: '',
zAxisLabel: '',
xAxisLabel: { name: '', type: '' },
yAxisLabel: { name: '', type: '' },
zAxisLabel: { name: '', type: '' },
showAxis: true,
groupData: undefined,
groupDataByTime: undefined,
stack: false,
groupAggregation: 'count',
};
const Vis1D = forwardRef<Vis1DVisHandle, VisualizationPropTypes<Vis1DProps>>(({ data, settings }, refExternal) => {
const internalRef = useRef<HTMLDivElement>(null);
useImperativeHandle(refExternal, () => ({
exportImageInternal() {
const captureImage = () => {
const element = internalRef.current;
if (element) {
html2canvas(element, {
backgroundColor: '#FFFFFF',
})
.then(canvas => {
const finalImage = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = finalImage;
link.download = 'Vis1D.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(error => {
console.error('Error capturing image:', error);
});
} else {
console.error('Container element not found');
}
};
const renderCanvas = () => {
requestAnimationFrame(() => {
captureImage();
});
};
renderCanvas();
exportImageInternal: () => {
if (internalRef.current) {
exportImageInternal(internalRef as RefObject<HTMLDivElement>);
} else {
console.error('internalRef is null');
}
},
}));
const xAxisData = useMemo(
() => getAttributeValues(data, settings.selectedEntity, settings.xAxisLabel),
[data, settings.selectedEntity, settings.xAxisLabel],
);
const yAxisData = useMemo(() => {
if (settings.plotType === 'histogram') {
return getAttributeValues(
data,
settings.selectedEntity,
settings.xAxisLabel, // Note: x not y!
settings.groupData,
);
}
return getAttributeValues(data, settings.selectedEntity, settings.yAxisLabel);
}, [data, settings.selectedEntity, settings.yAxisLabel, settings.plotType, settings.groupData]);
const zAxisData = useMemo(
() => getAttributeValues(data, settings.selectedEntity, settings.zAxisLabel),
[data, settings.selectedEntity, settings.zAxisLabel],
const { xAxisData, yAxisData, zAxisData } = useMemo(
() => getAllAxisData(data, settings),
[data, settings.selectedEntity, settings.xAxisLabel, settings.yAxisLabel, settings.zAxisLabel, settings.groupAggregation],
);
return (
<div className="h-full w-full flex items-center justify-center overflow-hidden" ref={internalRef}>
<CustomChartPlotly
plot={preparePlotData({
xAxisData,
plotType: settings.plotType,
yAxisData,
zAxisData,
xAxisLabel: settings.xAxisLabel,
yAxisLabel: settings.yAxisLabel,
zAxisLabel: settings.zAxisLabel,
showAxis: settings.showAxis,
groupBy: settings.groupData,
stack: settings.stack,
})}
/>
{settings.plotType === 'line' && (
<Vis1DLine xData={xAxisData} yData={yAxisData} showAxis={settings.showAxis} groupDataByTime={settings.groupDataByTime} />
)}
{settings.plotType === 'scatter' && (
<Vis1DScatter xData={xAxisData} yData={yAxisData} zData={zAxisData} showAxis={settings.showAxis} />
)}
{settings.plotType === 'pie' && <Vis1DPie xData={xAxisData} showAxis={settings.showAxis} />}
{settings.plotType === 'bar' && (
<Vis1DBar
xData={xAxisData}
yData={yAxisData}
zData={zAxisData}
showAxis={settings.showAxis}
groupDataByTime={settings.groupDataByTime}
/>
)}
{settings.plotType === 'histogram' && (
<>
{settings.groupAggregation === 'count' && (
<Vis1DHistogramCount
xData={xAxisData}
yData={yAxisData}
zData={zAxisData}
showAxis={settings.showAxis}
stackVariable={settings.stack}
/>
)}
{settings.groupAggregation === 'degree' && (
<Vis1DHistogramDegree xData={xAxisData} yData={yAxisData} showAxis={settings.showAxis} />
)}
</>
)}
</div>
);
});
const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: VisualizationSettingsPropTypes<Vis1DProps>) => {
// !TODO: include relationship
const mutablePlotTypes = [...plotTypeOptions];
const [attributeOptions, setAttributeOptions] = useState<string[]>([]);
const [attributeOptions, setAttributeOptions] = useState<{ name: string; type: string }[]>([]);
const [filterXLabel, setFilterXLabel] = useState<string>('');
const [filterYLabel, setFilterYLabel] = useState<string>('');
const [filterZLabel, setFilterZLabel] = useState<string>('');
useEffect(() => {
if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0) {
......@@ -121,22 +110,30 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio
if (graphMetadata && graphMetadata.nodes && graphMetadata.nodes.labels.length > 0 && settings.selectedEntity != '') {
const labelNodes = graphMetadata.nodes.labels;
if (labelNodes.includes(settings.selectedEntity)) {
const newAttributeOptions = Object.keys(graphMetadata.nodes.types[settings.selectedEntity].attributes);
if (settings.xAxisLabel === '') {
updateSettings({ xAxisLabel: newAttributeOptions[0] });
const newAttributeOptions: { name: string; type: string }[] = [
{ name: 'No selection', type: 'cross' },
...Object.entries(graphMetadata.nodes.types[settings.selectedEntity].attributes)
.filter(([key]) => key.toLowerCase().includes(filterXLabel.toLowerCase()))
.map(([key, value]) => ({
name: key,
type: value.attributeType,
})),
];
if (settings.xAxisLabel.name === '') {
updateSettings({ xAxisLabel: { name: newAttributeOptions[1].name, type: newAttributeOptions[1].type } });
// !TODO: instead of contain "datum" chekc type: if it is date
if (newAttributeOptions[0].includes('Datum')) {
updateSettings({ groupData: 'yearly' });
if (newAttributeOptions[0].name.includes('Datum')) {
updateSettings({ groupDataByTime: 'yearly' });
} else {
updateSettings({ groupData: undefined });
updateSettings({ groupDataByTime: undefined });
}
}
newAttributeOptions.unshift(' ');
setAttributeOptions(newAttributeOptions);
}
}
}, [graphMetadata, settings.selectedEntity]);
}, [graphMetadata, settings.selectedEntity, filterXLabel]);
return (
<SettingsContainer>
......@@ -170,88 +167,99 @@ const Vis1DSettings = ({ settings, graphMetadata, updateSettings }: Visualizatio
options={mutablePlotTypes}
onChange={value => {
updateSettings({ plotType: value as (typeof plotTypeOptions)[number] });
if (value === 'histogram') {
updateSettings({ yAxisLabel: '', groupData: 'count' });
} else if (value === 'bar' || value === 'pie') {
updateSettings({ yAxisLabel: '', groupData: undefined });
}
}}
/>
</div>
<div className="mb-2">
<Input
type="dropdown"
label="X-axis:"
value={settings.xAxisLabel}
options={attributeOptions}
onChange={value => {
const valueString = value as string;
updateSettings({ xAxisLabel: valueString });
if (!valueString.includes('Datum')) {
updateSettings({ groupData: undefined });
} else {
updateSettings({ groupData: 'monthly' });
{
if (value === 'histogram') {
updateSettings({ yAxisLabel: { name: '', type: '' }, groupDataByTime: 'count' });
} else if (value === 'bar' || value === 'pie') {
updateSettings({ yAxisLabel: { name: '', type: '' }, groupDataByTime: undefined });
}
}
}}
/>
</div>
{(settings.plotType === 'scatter' || settings.plotType === 'line') && (
<div className="mb-2">
<Input
type="dropdown"
label="Y-axis:"
value={settings.yAxisLabel}
options={attributeOptions}
onChange={value => {
updateSettings({ yAxisLabel: value as string });
}}
/>
</div>
)}
{settings.plotType === 'histogram' && (
<div className="mb-2">
<Input
type="dropdown"
label="Aggregation:"
value={settings.groupData}
value={settings.groupAggregation}
options={vis1DHistogramAggregationOptions}
onChange={value => {
updateSettings({ groupData: value as string });
updateSettings({ groupAggregation: value as string });
}}
/>
</div>
)}
{(settings.plotType === 'bar' || settings.plotType === 'scatter' || settings.plotType === 'histogram') && (
{!(settings.groupAggregation === 'degree' && settings.plotType === 'histogram') && (
<div className="mb-2">
<Input
type="dropdown"
label="Color:"
value={settings.zAxisLabel}
<DropdownSelector
label="X Axis"
selectedValue={settings.xAxisLabel.name}
options={attributeOptions}
onChange={value => {
updateSettings({ zAxisLabel: value as string });
filter={filterXLabel}
setFilter={setFilterXLabel}
onChange={selected => {
updateSettings({ xAxisLabel: { name: selected.name, type: selected.type } });
if (!selected.name.includes('Datum') || selected.type !== 'Date') {
updateSettings({ groupDataByTime: undefined });
} else {
updateSettings({ groupDataByTime: 'monthly' });
}
}}
/>
</div>
)}
{settings.plotType === 'histogram' && (
<div className="mb-2">
<Input type="boolean" label="Normalize: " value={settings.stack} onChange={val => updateSettings({ stack: val })} />
</div>
{settings.plotType === 'line' && (
<Vis1DSettingsLine
settings={settings}
updateSettings={updateSettings}
attributeOptions={attributeOptions}
filterYLabel={filterYLabel}
setFilterYLabel={setFilterYLabel}
/>
)}
{settings.xAxisLabel?.includes('Datum') && (
<div className="mb-2">
<Input
type="dropdown"
label="Group Time:"
value={settings.groupData}
options={['', 'monthly', 'quarterly', 'yearly']}
onChange={value => {
updateSettings({ groupData: value as string });
}}
/>
</div>
{settings.plotType === 'scatter' && (
<Vis1DSettingsScatter
settings={settings}
updateSettings={updateSettings}
attributeOptions={attributeOptions}
filterYLabel={filterYLabel}
setFilterYLabel={setFilterYLabel}
filterZLabel={filterZLabel}
setFilterZLabel={setFilterZLabel}
/>
)}
{settings.plotType === 'bar' && (
<Vis1DSettingsBar
settings={settings}
updateSettings={updateSettings}
attributeOptions={attributeOptions}
filterYLabel={filterYLabel}
setFilterYLabel={setFilterYLabel}
filterZLabel={filterZLabel}
setFilterZLabel={setFilterZLabel}
/>
)}
{settings.plotType === 'histogram' && (
<>
{settings.groupAggregation === 'count' && (
<Vis1DSettingsHistogramCount
settings={settings}
updateSettings={updateSettings}
attributeOptions={attributeOptions}
filterYLabel={filterYLabel}
setFilterYLabel={setFilterYLabel}
filterZLabel={filterZLabel}
setFilterZLabel={setFilterZLabel}
/>
)}
{settings.groupAggregation === 'degree' && (
<Vis1DSettingsHistogramDegree settings={settings} updateSettings={updateSettings} attributeOptions={attributeOptions} />
)}
</>
)}
<div className="mb-2">
<Input type="boolean" label="Show axis" value={settings.showAxis} onChange={val => updateSettings({ showAxis: val })} />
......
import React, { useEffect, useRef, useState } from 'react';
import Plot from 'react-plotly.js';
import { PreparePlot } from './MakePlot';
export const CustomChartPlotly: React.FC<{ plot: PreparePlot }> = ({ plot }) => {
const internalRef = useRef<HTMLDivElement>(null);
const [divSize, setDivSize] = useState({ width: 0, height: 0 });
const [hoveredPoint, setHoveredPoint] = useState<{
left: number;
top: number;
xValue: number;
yValue: number;
} | null>(null);
useEffect(() => {
const handleResize = () => {
if (internalRef.current) {
const { width, height } = internalRef.current.getBoundingClientRect();
setDivSize({ width, height });
}
};
handleResize();
window.addEventListener('resize', handleResize);
if (internalRef.current) {
new ResizeObserver(handleResize).observe(internalRef.current);
}
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
const handleHover = (event: any) => {
const { points } = event;
if (points.length) {
const point = points[0];
const plotRect = internalRef.current?.getBoundingClientRect();
if (plotRect) {
const xIndex = point.xaxis.d2p(point.x);
const yIndex = point.yaxis.d2p(point.y);
setHoveredPoint({
left: plotRect.left + xIndex,
top: plotRect.top + yIndex,
xValue: point.x,
yValue: point.y,
});
}
}
};
const handleUnHover = () => {
setHoveredPoint(null);
};
// !TODO: implement pattern fill for nonData
/*
useEffect(() => {
const svg = document.querySelector('svg');
if (svg) {
// Create or find the `defs` section
let defs = svg.querySelector('defs');
if (!defs) {
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
svg.insertBefore(defs, svg.firstChild);
}
// Check if the pattern already exists
let pattern = defs.querySelector('#diagonalHatch');
if (!pattern) {
// Create the diagonal hatch pattern
pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
pattern.setAttribute('id', 'diagonalHatch');
pattern.setAttribute('width', '6');
pattern.setAttribute('height', '6');
pattern.setAttribute('patternTransform', 'rotate(45)');
pattern.setAttribute('patternUnits', 'userSpaceOnUse');
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', '2');
rect.setAttribute('height', '6');
rect.setAttribute('fill', '#cccccc');
pattern.appendChild(rect);
defs.appendChild(pattern);
}
//const bars = select('.points').selectAll('path').nodes();
const bars = document.querySelectorAll('.points path');
//console.log(bars);
if (plotType === 'histogram') {
bars.forEach((bar, index) => {
const customData = (plotData[0] as any).customdata[index];
//console.log(select(bar), customData, customData == 'nonData');
select(bar).style('fill', 'rgb(250, 0, 0)');
if (customData == 'nonData') {
//select(bar).style('fill', 'url(#diagonalHatch)');
}
//console.log(bar);
});
}
}
}, [plotData]);
*/
return (
<div className="h-full w-full flex items-center justify-center overflow-hidden relative" ref={internalRef}>
<Plot
data={plot.plotData}
config={{
responsive: true,
scrollZoom: false,
displayModeBar: false,
displaylogo: false,
}}
layout={{
...plot.layout,
width: divSize.width,
height: divSize.height,
dragmode: false,
}}
onHover={handleHover}
onUnhover={handleUnHover}
/>
{/*
{hoveredPoint && (
<div>
<Tooltip open={true} showArrow={true}>
<TooltipTrigger />
<TooltipContent
style={{
position: 'absolute',
left: hoveredPoint.left,
top: hoveredPoint.top,
transform: 'translate(-50%, 0%)',
transform: 'translate(-50%, 0%)',
}}
>
<div>
<strong>{hoveredPoint.xValue}</strong>: {hoveredPoint.yValue}
</div>
</TooltipContent>
</Tooltip>
</div>
)}
*/}
</div>
);
};
......@@ -12,8 +12,17 @@ const getCSSVariableHSL = (varName: string) => {
return `hsl(${hslValue})`;
};
const getWeekNumber = (date: Date): string => {
const startDate = new Date(date.getFullYear(), 0, 1); // Start of the year
const days = Math.floor((date.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
const weekNumber = Math.ceil((days + 1) / 7); // Week number is 1-based
return `${date.getFullYear()}-W${weekNumber.toString().padStart(2, '0')}`; // Format as "YYYY-WXX"
};
const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableData?: (string | number)[]) => {
// Function to parse the date-time string into a JavaScript Date object
//console.log('xAxisData ', xAxisData);
//console.log('additionalVariableData ', additionalVariableData);
const parseDate = (dateStr: string) => {
// Remove nanoseconds part and use just the standard "YYYY-MM-DD HH:MM:SS" part
const cleanedDateStr = dateStr.split('.')[0];
......@@ -35,11 +44,24 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat
} else if (groupBy === 'monthly') {
// Group by month, e.g., "2012-07"
groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
} else if (groupBy === 'weekly') {
// Group by week, e.g., "2012-W15"
groupKey = getWeekNumber(date);
} else if (groupBy === 'daily') {
// Group by day, e.g., "2012-07-15"
groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
} else if (groupBy === 'hourly') {
// Group by hour, e.g., "2012-07-15 14"
groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}`;
} else if (groupBy === 'minutely') {
// Group by minute, e.g., "2012-07-15 14:30"
groupKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} else {
// Default case: group by year (or some other grouping logic)
groupKey = date.getFullYear().toString();
}
//console.log('additionalVariableData ', additionalVariableData);
// Initialize the group if it doesn't exist
if (!acc[groupKey]) {
acc[groupKey] = additionalVariableData
......@@ -66,9 +88,56 @@ const groupByTime = (xAxisData: string[], groupBy: string, additionalVariableDat
{} as Record<string, number | string[]>,
);
// Extract grouped data into arrays for Plotly
const xValuesGrouped = Object.keys(groupedData);
const yValuesGrouped = Object.values(groupedData);
// Sorting logic outside the reduce block
/*
const sortedAcc = Object.keys(groupedData)
.sort((a, b) => {
const parseKey = (key: string) => {
if (key.includes('Q')) {
// For quarterly (e.g., "2024-Q1")
return new Date(key.split('-')[0], (parseInt(key.split('-')[1].replace('Q', ''), 10) - 1) * 3);
} else if (key.includes('-')) {
// For monthly (e.g., "2024-01")
return new Date(key + '-01');
} else if (key.includes('W')) {
// For weekly (e.g., "2024-W04")
const [year, week] = key.split('-W').map(Number);
const jan1 = new Date(year, 0, 1);
return new Date(year, 0, jan1.getDate() + (week - 1) * 7);
}
return new Date(key); // Fallback for other date formats
};
return parseKey(a).getTime() - parseKey(b).getTime();
})
.reduce(
(sortedAcc, key) => {
sortedAcc[key] = groupedData[key];
return sortedAcc;
},
{} as Record<string, number | string[]>,
);
*/
const sortedArray = Object.entries(groupedData)
.map(([key, value]) => ({
x: key,
y: value,
dateObj: (() => {
if (key.includes('Q')) {
return new Date(parseInt(key.split('-')[0], 10), (parseInt(key.split('-')[1].replace('Q', ''), 10) - 1) * 3);
} else if (key.includes('-W')) {
const [year, week] = key.split('-W').map(Number);
const jan1 = new Date(year, 0, 1);
return new Date(year, 0, jan1.getDate() + (week - 1) * 7);
} else {
return new Date(key);
}
})(),
}))
.sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime());
const xValuesGrouped = sortedArray.map(({ x }) => x);
const yValuesGrouped = sortedArray.map(({ y }) => y);
return { xValuesGrouped, yValuesGrouped };
};
......@@ -91,8 +160,9 @@ export const preparePlotData = (params: {
showAxis: boolean;
groupBy?: string;
stack?: boolean;
xAxisType: string;
}): PreparePlot => {
const { xAxisData, plotType, yAxisData, zAxisData, xAxisLabel, yAxisLabel, zAxisLabel, showAxis, groupBy, stack } = params;
const { xAxisData, plotType, yAxisData, zAxisData, xAxisLabel, yAxisLabel, zAxisLabel, showAxis, groupBy, stack, xAxisType } = params;
const primaryColor = getCSSVariableHSL('--clr-sec--400');
const lengthLabelsX = 7; // !TODO computed number of elements based
const lengthLabelsY = 8; // !TODO computed number of elements based
......@@ -159,10 +229,12 @@ export const preparePlotData = (params: {
if (groupBy) {
if (yAxisData && yAxisData.length !== 0) {
const { xValuesGrouped, yValuesGrouped } = groupByTime(xAxisData as string[], groupBy, yAxisData);
//console.log('1', xValuesGrouped);
xValues = xValuesGrouped;
yValues = yValuesGrouped.flat();
} else {
const { xValuesGrouped, yValuesGrouped } = groupByTime(xAxisData as string[], groupBy);
//console.log('2', xValuesGrouped);
xValues = xValuesGrouped;
yValues = yValuesGrouped.flat();
}
......@@ -186,10 +258,11 @@ export const preparePlotData = (params: {
if (typeof yValues[0] === 'string' && (plotType === 'scatter' || plotType === 'line')) {
truncatedYLabels = computeStringTickValues(yValues, 2, lengthLabelsY);
}
const plotData = (() => {
switch (plotType) {
case 'bar':
if (typeof xAxisData[0] === 'string' && groupBy == undefined) {
if (xAxisType === 'string' && groupBy == undefined) {
const frequencyMap = xAxisData.reduce(
(acc, item) => {
acc[item] = (acc[item] || 0) + 1;
......@@ -221,7 +294,22 @@ export const preparePlotData = (params: {
// hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>',
},
];
} else if (xAxisType === 'date') {
return [
{
type: 'bar' as PlotType,
x: xValues,
y: yValues,
marker: {
color: colorDataZ?.length != 0 ? colorDataZ : primaryColor,
},
customdata: xValues,
hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>',
},
];
} else {
//console.log(xValues);
//console.log(yValues);
return [
{
type: 'bar' as PlotType,
......@@ -229,7 +317,7 @@ export const preparePlotData = (params: {
y: yValues,
marker: { color: primaryColor },
customdata: xValues,
// hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>',
hovertemplate: '<b>%{customdata}</b>: %{y}<extra></extra>',
},
];
}
......@@ -526,7 +614,6 @@ export const preparePlotData = (params: {
},
hoverlabel: {
bgcolor: 'rgba(255, 255, 255, 0.8)',
// className: 'text-dark',
bordercolor: 'rgba(0, 0, 0, 0.2)',
font: {
family: 'Inter',
......