feat(ui): field context menu, add/remove from linear ui

This commit is contained in:
psychedelicious 2023-08-17 17:44:34 +10:00
parent 64a6aa0293
commit 84cf8bdc08
12 changed files with 400 additions and 160 deletions

View File

@ -18,6 +18,7 @@ const SelectionOverlay = ({ isSelected, isHovered }: Props) => {
opacity: isSelected ? 1 : 0.7, opacity: isSelected ? 1 : 0.7,
transitionProperty: 'common', transitionProperty: 'common',
transitionDuration: '0.1s', transitionDuration: '0.1s',
pointerEvents: 'none',
shadow: isSelected shadow: isSelected
? isHovered ? isHovered
? 'hoverSelected.light' ? 'hoverSelected.light'

View File

@ -79,6 +79,7 @@ const NodeTitle = ({ nodeId, title }: Props) => {
fontSize="sm" fontSize="sm"
sx={{ sx={{
p: 0, p: 0,
fontWeight: 'initial',
_focusVisible: { _focusVisible: {
p: 0, p: 0,
boxShadow: 'none', boxShadow: 'none',

View File

@ -1,26 +1,111 @@
import { MenuItem, MenuList } from '@chakra-ui/react'; import { MenuGroup, MenuItem, MenuList } from '@chakra-ui/react';
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { import {
InputFieldTemplate, IAIContextMenu,
InputFieldValue, IAIContextMenuProps,
} from 'features/nodes/types/types'; } from 'common/components/IAIContextMenu';
import { MouseEvent, useCallback } from 'react'; import {
useFieldInputKind,
useFieldLabel,
useFieldTemplateTitle,
} from 'features/nodes/hooks/useNodeData';
import {
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
} from 'features/nodes/store/nodesSlice';
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
import { FaMinus, FaPlus } from 'react-icons/fa';
import { menuListMotionProps } from 'theme/components/menu'; import { menuListMotionProps } from 'theme/components/menu';
type Props = { type Props = {
nodeId: string; nodeId: string;
field: InputFieldValue; fieldName: string;
fieldTemplate: InputFieldTemplate; kind: 'input' | 'output';
children: ContextMenuProps<HTMLDivElement>['children']; children: IAIContextMenuProps<HTMLDivElement>['children'];
}; };
const FieldContextMenu = (props: Props) => { const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
const dispatch = useAppDispatch();
const label = useFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => { const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
}, []); }, []);
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const isExposed = Boolean(
nodes.workflow.exposedFields.find(
(f) => f.nodeId === nodeId && f.fieldName === fieldName
)
);
return { isExposed };
},
defaultSelectorOptions
),
[fieldName, nodeId]
);
const input = useFieldInputKind(nodeId, fieldName);
const mayExpose = useMemo(
() => ['any', 'direct'].includes(input ?? '__UNKNOWN_INPUT__'),
[input]
);
const { isExposed } = useAppSelector(selector);
const handleExposeField = useCallback(() => {
dispatch(workflowExposedFieldAdded({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
const handleUnexposeField = useCallback(() => {
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
const menuItems = useMemo(() => {
const menuItems: ReactNode[] = [];
if (mayExpose && !isExposed) {
menuItems.push(
<MenuItem
key={`${nodeId}.${fieldName}.expose-field`}
icon={<FaPlus />}
onClick={handleExposeField}
>
Add to Linear View
</MenuItem>
);
}
if (mayExpose && isExposed) {
menuItems.push(
<MenuItem
key={`${nodeId}.${fieldName}.unexpose-field`}
icon={<FaMinus />}
onClick={handleUnexposeField}
>
Remove from Linear View
</MenuItem>
);
}
return menuItems;
}, [
fieldName,
handleExposeField,
handleUnexposeField,
isExposed,
mayExpose,
nodeId,
]);
return ( return (
<ContextMenu<HTMLDivElement> <IAIContextMenu<HTMLDivElement>
menuProps={{ menuProps={{
size: 'sm', size: 'sm',
isLazy: true, isLazy: true,
@ -29,19 +114,23 @@ const FieldContextMenu = (props: Props) => {
bg: 'transparent', bg: 'transparent',
_hover: { bg: 'transparent' }, _hover: { bg: 'transparent' },
}} }}
renderMenu={() => ( renderMenu={() =>
!menuItems.length ? null : (
<MenuList <MenuList
sx={{ visibility: 'visible !important' }} sx={{ visibility: 'visible !important' }}
motionProps={menuListMotionProps} motionProps={menuListMotionProps}
onContextMenu={skipEvent} onContextMenu={skipEvent}
> >
<MenuItem>Test</MenuItem> <MenuGroup title={label || fieldTemplateTitle || 'Unknown Field'}>
{menuItems}
</MenuGroup>
</MenuList> </MenuList>
)} )
}
> >
{props.children} {children}
</ContextMenu> </IAIContextMenu>
); );
}; };
export default FieldContextMenu; export default memo(FieldContextMenu);

View File

@ -3,63 +3,42 @@ import {
EditableInput, EditableInput,
EditablePreview, EditablePreview,
Flex, Flex,
forwardRef,
useEditableControls, useEditableControls,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import IAIDraggable from 'common/components/IAIDraggable';
import { NodeFieldDraggableData } from 'features/dnd/types';
import { import {
useFieldData, useFieldLabel,
useFieldTemplate, useFieldTemplateTitle,
} from 'features/nodes/hooks/useNodeData'; } from 'features/nodes/hooks/useNodeData';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice'; import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { import { MouseEvent, memo, useCallback, useEffect, useState } from 'react';
MouseEvent,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
interface Props { interface Props {
nodeId: string; nodeId: string;
fieldName: string; fieldName: string;
isDraggable?: boolean;
kind: 'input' | 'output'; kind: 'input' | 'output';
} }
const FieldTitle = (props: Props) => { const FieldTitle = forwardRef((props: Props, ref) => {
const { nodeId, fieldName, isDraggable = false, kind } = props; const { nodeId, fieldName, kind } = props;
const fieldTemplate = useFieldTemplate(nodeId, fieldName, kind); const label = useFieldLabel(nodeId, fieldName);
const field = useFieldData(nodeId, fieldName); const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [localTitle, setLocalTitle] = useState( const [localTitle, setLocalTitle] = useState(
field?.label || fieldTemplate?.title || 'Unknown Field' label || fieldTemplateTitle || 'Unknown Field'
);
const draggableData: NodeFieldDraggableData | undefined = useMemo(
() =>
field &&
fieldTemplate?.fieldKind === 'input' &&
fieldTemplate?.input !== 'connection' &&
isDraggable
? {
id: `${nodeId}-${fieldName}`,
payloadType: 'NODE_FIELD',
payload: { nodeId, field, fieldTemplate },
}
: undefined,
[field, fieldName, fieldTemplate, isDraggable, nodeId]
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (newTitle: string) => { async (newTitle: string) => {
if (newTitle === label || newTitle === fieldTemplateTitle) {
return;
}
setLocalTitle(newTitle || fieldTemplateTitle || 'Unknown Field');
dispatch(fieldLabelChanged({ nodeId, fieldName, label: newTitle })); dispatch(fieldLabelChanged({ nodeId, fieldName, label: newTitle }));
setLocalTitle(newTitle || fieldTemplate?.title || 'Unknown Field');
}, },
[dispatch, nodeId, fieldName, fieldTemplate?.title] [label, fieldTemplateTitle, dispatch, nodeId, fieldName]
); );
const handleChange = useCallback((newTitle: string) => { const handleChange = useCallback((newTitle: string) => {
@ -68,27 +47,33 @@ const FieldTitle = (props: Props) => {
useEffect(() => { useEffect(() => {
// Another component may change the title; sync local title with global state // Another component may change the title; sync local title with global state
setLocalTitle(field?.label || fieldTemplate?.title || 'Unknown Field'); setLocalTitle(label || fieldTemplateTitle || 'Unknown Field');
}, [field?.label, fieldTemplate?.title]); }, [label, fieldTemplateTitle]);
return ( return (
<Flex <Flex
ref={ref}
className="nopan" className="nopan"
sx={{ sx={{
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
h: 'full', alignItems: 'center',
alignItems: 'flex-start',
justifyContent: 'flex-start', justifyContent: 'flex-start',
gap: 1, gap: 1,
h: 'full',
w: 'full',
}} }}
> >
<Editable <Editable
value={localTitle} value={localTitle}
onChange={handleChange} onChange={handleChange}
onSubmit={handleSubmit} onSubmit={handleSubmit}
as={Flex}
sx={{ sx={{
position: 'relative', position: 'relative',
alignItems: 'center',
h: 'full',
w: 'full',
}} }}
> >
<EditablePreview <EditablePreview
@ -101,6 +86,11 @@ const FieldTitle = (props: Props) => {
<EditableInput <EditableInput
sx={{ sx={{
p: 0, p: 0,
fontWeight: 600,
color: 'base.900',
_dark: {
color: 'base.100',
},
_focusVisible: { _focusVisible: {
p: 0, p: 0,
textAlign: 'left', textAlign: 'left',
@ -108,27 +98,24 @@ const FieldTitle = (props: Props) => {
}, },
}} }}
/> />
<EditableControls draggableData={draggableData} /> <EditableControls />
</Editable> </Editable>
</Flex> </Flex>
); );
}; });
export default memo(FieldTitle); export default memo(FieldTitle);
type EditableControlsProps = { const EditableControls = memo(() => {
draggableData?: NodeFieldDraggableData;
};
const EditableControls = memo((props: EditableControlsProps) => {
const { isEditing, getEditButtonProps } = useEditableControls(); const { isEditing, getEditButtonProps } = useEditableControls();
const handleDoubleClick = useCallback( const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => { (e: MouseEvent<HTMLDivElement>) => {
const { onClick } = getEditButtonProps(); const { onClick } = getEditButtonProps();
if (!onClick) { if (!onClick) {
return; return;
} }
onClick(e); onClick(e);
e.preventDefault();
}, },
[getEditButtonProps] [getEditButtonProps]
); );
@ -137,19 +124,9 @@ const EditableControls = memo((props: EditableControlsProps) => {
return null; return null;
} }
if (props.draggableData) {
return (
<IAIDraggable
data={props.draggableData}
onDoubleClick={handleDoubleClick}
cursor={props.draggableData ? 'grab' : 'text'}
/>
);
}
return ( return (
<Flex <Flex
onDoubleClick={handleDoubleClick} onClick={handleClick}
position="absolute" position="absolute"
w="full" w="full"
h="full" h="full"

View File

@ -23,7 +23,8 @@ const FieldTooltipContent = ({ nodeId, fieldName, kind }: Props) => {
const isInputTemplate = isInputFieldTemplate(fieldTemplate); const isInputTemplate = isInputFieldTemplate(fieldTemplate);
const fieldTitle = useMemo(() => { const fieldTitle = useMemo(() => {
if (isInputFieldValue(field)) { if (isInputFieldValue(field)) {
if (field.label && fieldTemplate) { console.log(field, fieldTemplate);
if (field.label && fieldTemplate?.title) {
return `${field.label} (${fieldTemplate.title})`; return `${field.label} (${fieldTemplate.title})`;
} }

View File

@ -3,13 +3,16 @@ import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { import {
useDoesInputHaveValue, useDoesInputHaveValue,
useFieldTemplate, useFieldTemplate,
useIsMouseOverField,
} from 'features/nodes/hooks/useNodeData'; } from 'features/nodes/hooks/useNodeData';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { PropsWithChildren, memo, useMemo } from 'react'; import { PropsWithChildren, memo, useMemo } from 'react';
import FieldContextMenu from './FieldContextMenu';
import FieldHandle from './FieldHandle'; import FieldHandle from './FieldHandle';
import FieldTitle from './FieldTitle'; import FieldTitle from './FieldTitle';
import FieldTooltipContent from './FieldTooltipContent'; import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer'; import InputFieldRenderer from './InputFieldRenderer';
import SelectionOverlay from 'common/components/SelectionOverlay';
interface Props { interface Props {
nodeId: string; nodeId: string;
@ -48,7 +51,11 @@ const InputField = ({ nodeId, fieldName }: Props) => {
if (fieldTemplate?.fieldKind !== 'input') { if (fieldTemplate?.fieldKind !== 'input') {
return ( return (
<InputFieldWrapper shouldDim={shouldDim}> <InputFieldWrapper
nodeId={nodeId}
fieldName={fieldName}
shouldDim={shouldDim}
>
<FormControl <FormControl
sx={{ color: 'error.400', textAlign: 'left', fontSize: 'sm' }} sx={{ color: 'error.400', textAlign: 'left', fontSize: 'sm' }}
> >
@ -59,18 +66,25 @@ const InputField = ({ nodeId, fieldName }: Props) => {
} }
return ( return (
<InputFieldWrapper shouldDim={shouldDim}> <InputFieldWrapper
nodeId={nodeId}
fieldName={fieldName}
shouldDim={shouldDim}
>
<FormControl <FormControl
as={Flex} as={Flex}
isInvalid={isMissingInput} isInvalid={isMissingInput}
isDisabled={isConnected} isDisabled={isConnected}
sx={{ sx={{
alignItems: 'center', alignItems: 'stretch',
justifyContent: 'space-between', justifyContent: 'space-between',
ps: 2, ps: 2,
gap: 2, gap: 2,
h: 'full',
}} }}
> >
<FieldContextMenu nodeId={nodeId} fieldName={fieldName} kind="input">
{(ref) => (
<Tooltip <Tooltip
label={ label={
<FieldTooltipContent <FieldTooltipContent
@ -81,18 +95,19 @@ const InputField = ({ nodeId, fieldName }: Props) => {
} }
openDelay={HANDLE_TOOLTIP_OPEN_DELAY} openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top" placement="top"
shouldWrapChildren
hasArrow hasArrow
> >
<FormLabel sx={{ mb: 0 }}> <FormLabel sx={{ mb: 0 }}>
<FieldTitle <FieldTitle
ref={ref}
nodeId={nodeId} nodeId={nodeId}
fieldName={fieldName} fieldName={fieldName}
kind="input" kind="input"
isDraggable
/> />
</FormLabel> </FormLabel>
</Tooltip> </Tooltip>
)}
</FieldContextMenu>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} /> <InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</FormControl> </FormControl>
@ -113,11 +128,19 @@ export default InputField;
type InputFieldWrapperProps = PropsWithChildren<{ type InputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean; shouldDim: boolean;
nodeId: string;
fieldName: string;
}>; }>;
const InputFieldWrapper = memo( const InputFieldWrapper = memo(
({ shouldDim, children }: InputFieldWrapperProps) => ( ({ shouldDim, nodeId, fieldName, children }: InputFieldWrapperProps) => {
const { isMouseOverField, handleMouseOver, handleMouseOut } =
useIsMouseOverField(nodeId, fieldName);
return (
<Flex <Flex
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
className="nopan" className="nopan"
sx={{ sx={{
position: 'relative', position: 'relative',
@ -132,8 +155,10 @@ const InputFieldWrapper = memo(
}} }}
> >
{children} {children}
<SelectionOverlay isSelected={false} isHovered={isMouseOverField} />
</Flex> </Flex>
) );
}
); );
InputFieldWrapper.displayName = 'InputFieldWrapper'; InputFieldWrapper.displayName = 'InputFieldWrapper';

View File

@ -1,6 +1,12 @@
import { Flex, FormControl, FormLabel, Tooltip } from '@chakra-ui/react'; import { Flex, FormControl, FormLabel, Icon, Tooltip } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import IAIIconButton from 'common/components/IAIIconButton';
import SelectionOverlay from 'common/components/SelectionOverlay';
import { useIsMouseOverField } from 'features/nodes/hooks/useNodeData';
import { workflowExposedFieldRemoved } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import { memo } from 'react'; import { memo, useCallback } from 'react';
import { FaInfoCircle, FaTrash } from 'react-icons/fa';
import FieldTitle from './FieldTitle'; import FieldTitle from './FieldTitle';
import FieldTooltipContent from './FieldTooltipContent'; import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer'; import InputFieldRenderer from './InputFieldRenderer';
@ -11,8 +17,18 @@ type Props = {
}; };
const LinearViewField = ({ nodeId, fieldName }: Props) => { const LinearViewField = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { isMouseOverField, handleMouseOut, handleMouseOver } =
useIsMouseOverField(nodeId, fieldName);
const handleRemoveField = useCallback(() => {
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
return ( return (
<Flex <Flex
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
layerStyle="second" layerStyle="second"
sx={{ sx={{
position: 'relative', position: 'relative',
@ -22,6 +38,15 @@ const LinearViewField = ({ nodeId, fieldName }: Props) => {
}} }}
> >
<FormControl as={Flex} sx={{ flexDir: 'column', gap: 1, flexShrink: 1 }}> <FormControl as={Flex} sx={{ flexDir: 'column', gap: 1, flexShrink: 1 }}>
<FormLabel
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0,
}}
>
<FieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
<Tooltip <Tooltip
label={ label={
<FieldTooltipContent <FieldTooltipContent
@ -32,21 +57,24 @@ const LinearViewField = ({ nodeId, fieldName }: Props) => {
} }
openDelay={HANDLE_TOOLTIP_OPEN_DELAY} openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top" placement="top"
shouldWrapChildren
hasArrow hasArrow
> >
<FormLabel <Flex h="full" alignItems="center">
sx={{ <Icon as={FaInfoCircle} />
display: 'flex', </Flex>
justifyContent: 'space-between',
mb: 0,
}}
>
<FieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
</FormLabel>
</Tooltip> </Tooltip>
<IAIIconButton
aria-label="Remove from Linear View"
tooltip="Remove from Linear View"
variant="ghost"
size="sm"
onClick={handleRemoveField}
icon={<FaTrash />}
/>
</FormLabel>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} /> <InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</FormControl> </FormControl>
<SelectionOverlay isSelected={false} isHovered={isMouseOverField} />
</Flex> </Flex>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Flex } from '@chakra-ui/react'; import { Flex, Text } from '@chakra-ui/react';
import { skipToken } from '@reduxjs/toolkit/dist/query'; import { skipToken } from '@reduxjs/toolkit/dist/query';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage'; import IAIDndImage from 'common/components/IAIDndImage';
@ -81,6 +81,9 @@ const ImageInputFieldComponent = (
draggableData={draggableData} draggableData={draggableData}
postUploadAction={postUploadAction} postUploadAction={postUploadAction}
useThumbailFallback useThumbailFallback
uploadElement={<UploadElement />}
dropLabel={<DropLabel />}
minSize={8}
> >
<IAIDndImageIcon <IAIDndImageIcon
onClick={handleReset} onClick={handleReset}
@ -93,3 +96,14 @@ const ImageInputFieldComponent = (
}; };
export default memo(ImageInputFieldComponent); export default memo(ImageInputFieldComponent);
const UploadElement = () => (
<Text fontSize={16} fontWeight={600}>
Drop or Upload
</Text>
);
const DropLabel = () => (
<Text fontSize={16} fontWeight={600}>
Drop
</Text>
);

View File

@ -3,9 +3,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import IAIDroppable from 'common/components/IAIDroppable';
import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { AddFieldToLinearViewDropData } from 'features/dnd/types';
import { memo } from 'react'; import { memo } from 'react';
import LinearViewField from '../../fields/LinearViewField'; import LinearViewField from '../../fields/LinearViewField';
import ScrollableContent from '../ScrollableContent'; import ScrollableContent from '../ScrollableContent';
@ -20,11 +18,6 @@ const selector = createSelector(
defaultSelectorOptions defaultSelectorOptions
); );
const droppableData: AddFieldToLinearViewDropData = {
id: 'add-field-to-linear-view',
actionType: 'ADD_FIELD_TO_LINEAR',
};
const LinearTabContent = () => { const LinearTabContent = () => {
const { fields } = useAppSelector(selector); const { fields } = useAppSelector(selector);
@ -42,6 +35,7 @@ const LinearTabContent = () => {
position: 'relative', position: 'relative',
flexDir: 'column', flexDir: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
p: 1,
gap: 2, gap: 2,
h: 'full', h: 'full',
w: 'full', w: 'full',
@ -50,7 +44,7 @@ const LinearTabContent = () => {
{fields.length ? ( {fields.length ? (
fields.map(({ nodeId, fieldName }) => ( fields.map(({ nodeId, fieldName }) => (
<LinearViewField <LinearViewField
key={`${nodeId}-${fieldName}`} key={`${nodeId}.${fieldName}`}
nodeId={nodeId} nodeId={nodeId}
fieldName={fieldName} fieldName={fieldName}
/> />
@ -63,7 +57,6 @@ const LinearTabContent = () => {
)} )}
</Flex> </Flex>
</ScrollableContent> </ScrollableContent>
<IAIDroppable data={droppableData} dropLabel="Add Field to Linear View" />
</Box> </Box>
); );
}; };

View File

@ -1,9 +1,10 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { stateSelector } from 'app/store/store'; import { stateSelector } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
import { map, some } from 'lodash-es'; import { map, some } from 'lodash-es';
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { mouseOverFieldChanged } from '../store/nodesSlice';
import { FOOTER_FIELDS, IMAGE_FIELDS } from '../types/constants'; import { FOOTER_FIELDS, IMAGE_FIELDS } from '../types/constants';
import { isInvocationNode } from '../types/types'; import { isInvocationNode } from '../types/types';
@ -51,6 +52,28 @@ export const useNodeData = (nodeId: string) => {
return nodeData; return nodeData;
}; };
export const useFieldLabel = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
return node?.data.inputs[fieldName]?.label;
},
defaultSelectorOptions
),
[fieldName, nodeId]
);
const label = useAppSelector(selector);
return label;
};
export const useFieldData = (nodeId: string, fieldName: string) => { export const useFieldData = (nodeId: string, fieldName: string) => {
const selector = useMemo( const selector = useMemo(
() => () =>
@ -73,6 +96,30 @@ export const useFieldData = (nodeId: string, fieldName: string) => {
return fieldData; return fieldData;
}; };
export const useFieldInputKind = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
const fieldTemplate = nodeTemplate?.inputs[fieldName];
return fieldTemplate?.input;
},
defaultSelectorOptions
),
[fieldName, nodeId]
);
const fieldType = useAppSelector(selector);
return fieldType;
};
export const useFieldType = ( export const useFieldType = (
nodeId: string, nodeId: string,
fieldName: string, fieldName: string,
@ -236,6 +283,33 @@ export const useNodeTemplateTitle = (nodeId: string) => {
return title; return title;
}; };
export const useFieldTemplateTitle = (
nodeId: string,
fieldName: string,
kind: 'input' | 'output'
) => {
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
return nodeTemplate?.[KIND_MAP[kind]][fieldName]?.title;
},
defaultSelectorOptions
),
[fieldName, kind, nodeId]
);
const fieldTemplate = useAppSelector(selector);
return fieldTemplate;
};
export const useFieldTemplate = ( export const useFieldTemplate = (
nodeId: string, nodeId: string,
fieldName: string, fieldName: string,
@ -284,3 +358,30 @@ export const useDoesInputHaveValue = (nodeId: string, fieldName: string) => {
return doesFieldHaveValue; return doesFieldHaveValue;
}; };
export const useIsMouseOverField = (nodeId: string, fieldName: string) => {
const dispatch = useAppDispatch();
const selector = useMemo(
() =>
createSelector(
stateSelector,
({ nodes }) =>
nodes.mouseOverField?.nodeId === nodeId &&
nodes.mouseOverField?.fieldName === fieldName,
defaultSelectorOptions
),
[fieldName, nodeId]
);
const isMouseOverField = useAppSelector(selector);
const handleMouseOver = useCallback(() => {
dispatch(mouseOverFieldChanged({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
const handleMouseOut = useCallback(() => {
dispatch(mouseOverFieldChanged(null));
}, [dispatch]);
return { isMouseOverField, handleMouseOver, handleMouseOut };
};

View File

@ -81,6 +81,7 @@ export const initialNodesState: NodesState = {
}, },
nodeExecutionStates: {}, nodeExecutionStates: {},
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
mouseOverField: null,
}; };
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{ type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
@ -594,6 +595,12 @@ const nodesSlice = createSlice({
viewportChanged: (state, action: PayloadAction<Viewport>) => { viewportChanged: (state, action: PayloadAction<Viewport>) => {
state.viewport = action.payload; state.viewport = action.payload;
}, },
mouseOverFieldChanged: (
state,
action: PayloadAction<FieldIdentifier | null>
) => {
state.mouseOverField = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.pending, (state) => { builder.addCase(receivedOpenAPISchema.pending, (state) => {
@ -701,6 +708,7 @@ export const {
workflowExposedFieldRemoved, workflowExposedFieldRemoved,
fieldLabelChanged, fieldLabelChanged,
viewportChanged, viewportChanged,
mouseOverFieldChanged,
} = nodesSlice.actions; } = nodesSlice.actions;
export default nodesSlice.reducer; export default nodesSlice.reducer;

View File

@ -1,6 +1,7 @@
import { OpenAPIV3 } from 'openapi-types'; import { OpenAPIV3 } from 'openapi-types';
import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow'; import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow';
import { import {
FieldIdentifier,
FieldType, FieldType,
InvocationEdgeExtra, InvocationEdgeExtra,
InvocationTemplate, InvocationTemplate,
@ -29,4 +30,5 @@ export type NodesState = {
nodeExecutionStates: Record<string, NodeExecutionState>; nodeExecutionStates: Record<string, NodeExecutionState>;
viewport: Viewport; viewport: Viewport;
isReady: boolean; isReady: boolean;
mouseOverField: FieldIdentifier | null;
}; };