feat: resolve merge conflicts for browser recording save
This commit is contained in:
134
src/components/recorder/AddWhatCondModal.tsx
Normal file
134
src/components/recorder/AddWhatCondModal.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { WhereWhatPair } from "maxun-core";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { modalStyle } from "./AddWhereCondModal";
|
||||
import { Button, MenuItem, TextField, Typography } from "@mui/material";
|
||||
import React, { useRef } from "react";
|
||||
import { Dropdown as MuiDropdown } from "../ui/DropdownMui";
|
||||
import { KeyValueForm } from "./KeyValueForm";
|
||||
import { ClearButton } from "../ui/buttons/ClearButton";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
|
||||
interface AddWhatCondModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pair: WhereWhatPair;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const AddWhatCondModal = ({ isOpen, onClose, pair, index }: AddWhatCondModalProps) => {
|
||||
const [action, setAction] = React.useState<string>('');
|
||||
const [objectIndex, setObjectIndex] = React.useState<number>(0);
|
||||
const [args, setArgs] = React.useState<({ type: string, value: (string | number | object | unknown) })[]>([]);
|
||||
|
||||
const objectRefs = useRef<({ getObject: () => object } | unknown)[]>([]);
|
||||
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const argsArray: (string | number | object | unknown)[] = [];
|
||||
args.map((arg, index) => {
|
||||
switch (arg.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
argsArray[index] = arg.value;
|
||||
break;
|
||||
case 'object':
|
||||
// @ts-ignore
|
||||
argsArray[index] = objectRefs.current[arg.value].getObject();
|
||||
}
|
||||
})
|
||||
setArgs([]);
|
||||
onClose();
|
||||
pair.what.push({
|
||||
// @ts-ignore
|
||||
action,
|
||||
args: argsArray,
|
||||
})
|
||||
socket?.emit('updatePair', { index: index - 1, pair: pair });
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal isOpen={isOpen} onClose={() => {
|
||||
setArgs([]);
|
||||
onClose();
|
||||
}} modalStyle={modalStyle}>
|
||||
<div>
|
||||
<Typography sx={{ margin: '20px 0px' }}>Add what condition:</Typography>
|
||||
<div style={{ margin: '8px' }}>
|
||||
<Typography>Action:</Typography>
|
||||
<TextField
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
value={action}
|
||||
label='action'
|
||||
/>
|
||||
<div>
|
||||
<Typography>Add new argument of type:</Typography>
|
||||
<Button onClick={() => setArgs([...args, { type: 'string', value: null }])}>string</Button>
|
||||
<Button onClick={() => setArgs([...args, { type: 'number', value: null }])}>number</Button>
|
||||
<Button onClick={() => {
|
||||
setArgs([...args, { type: 'object', value: objectIndex }])
|
||||
setObjectIndex(objectIndex + 1);
|
||||
}}>object</Button>
|
||||
</div>
|
||||
<Typography>args:</Typography>
|
||||
{args.map((arg, index) => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
<div style={{ border: 'solid 1px gray', padding: '10px', display: 'flex', flexDirection: 'row', alignItems: 'center' }}
|
||||
key={`wrapper-for-${arg.type}-${index}`}>
|
||||
<ClearButton handleClick={() => {
|
||||
args.splice(index, 1);
|
||||
setArgs([...args]);
|
||||
}} />
|
||||
<Typography sx={{ margin: '5px' }} key={`number-argument-${arg.type}-${index}`}>{index}: </Typography>
|
||||
{arg.type === 'string' ?
|
||||
<TextField
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setArgs([
|
||||
...args.slice(0, index),
|
||||
{ type: arg.type, value: e.target.value },
|
||||
...args.slice(index + 1)
|
||||
])}
|
||||
value={args[index].value || ''}
|
||||
label="string"
|
||||
key={`arg-${arg.type}-${index}`}
|
||||
/> : arg.type === 'number' ?
|
||||
<TextField
|
||||
key={`arg-${arg.type}-${index}`}
|
||||
size='small'
|
||||
type="number"
|
||||
onChange={(e) => setArgs([
|
||||
...args.slice(0, index),
|
||||
{ type: arg.type, value: Number(e.target.value) },
|
||||
...args.slice(index + 1)
|
||||
])}
|
||||
value={args[index].value || ''}
|
||||
label="number"
|
||||
/> :
|
||||
<KeyValueForm ref={el =>
|
||||
//@ts-ignore
|
||||
objectRefs.current[arg.value] = el} key={`arg-${arg.type}-${index}`} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
display: "table-cell",
|
||||
float: "right",
|
||||
marginRight: "15px",
|
||||
marginTop: "20px",
|
||||
}}
|
||||
>
|
||||
{"Add Condition"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</GenericModal>
|
||||
)
|
||||
}
|
||||
152
src/components/recorder/AddWhereCondModal.tsx
Normal file
152
src/components/recorder/AddWhereCondModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Dropdown as MuiDropdown } from "../ui/DropdownMui";
|
||||
import {
|
||||
Button,
|
||||
MenuItem,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React, { useRef } from "react";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { WhereWhatPair } from "maxun-core";
|
||||
import { SelectChangeEvent } from "@mui/material/Select/Select";
|
||||
import { DisplayConditionSettings } from "./DisplayWhereConditionSettings";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
|
||||
interface AddWhereCondModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pair: WhereWhatPair;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const AddWhereCondModal = ({ isOpen, onClose, pair, index }: AddWhereCondModalProps) => {
|
||||
const [whereProp, setWhereProp] = React.useState<string>('');
|
||||
const [additionalSettings, setAdditionalSettings] = React.useState<string>('');
|
||||
const [newValue, setNewValue] = React.useState<any>('');
|
||||
const [checked, setChecked] = React.useState<boolean[]>(new Array(Object.keys(pair.where).length).fill(false));
|
||||
|
||||
const keyValueFormRef = useRef<{ getObject: () => object }>(null);
|
||||
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
const handlePropSelect = (event: SelectChangeEvent<string>) => {
|
||||
setWhereProp(event.target.value);
|
||||
switch (event.target.value) {
|
||||
case 'url': setNewValue(''); break;
|
||||
case 'selectors': setNewValue(['']); break;
|
||||
case 'default': return;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
switch (whereProp) {
|
||||
case 'url':
|
||||
if (additionalSettings === 'string') {
|
||||
pair.where.url = newValue;
|
||||
} else {
|
||||
pair.where.url = { $regex: newValue };
|
||||
}
|
||||
break;
|
||||
case 'selectors':
|
||||
pair.where.selectors = newValue;
|
||||
break;
|
||||
case 'cookies':
|
||||
pair.where.cookies = keyValueFormRef.current?.getObject() as Record<string, string>
|
||||
break;
|
||||
case 'before':
|
||||
pair.where.$before = newValue;
|
||||
break;
|
||||
case 'after':
|
||||
pair.where.$after = newValue;
|
||||
break;
|
||||
case 'boolean':
|
||||
const booleanArr = [];
|
||||
const deleteKeys: string[] = [];
|
||||
for (let i = 0; i < checked.length; i++) {
|
||||
if (checked[i]) {
|
||||
if (Object.keys(pair.where)[i]) {
|
||||
//@ts-ignore
|
||||
if (pair.where[Object.keys(pair.where)[i]]) {
|
||||
booleanArr.push({
|
||||
//@ts-ignore
|
||||
[Object.keys(pair.where)[i]]: pair.where[Object.keys(pair.where)[i]]
|
||||
});
|
||||
}
|
||||
deleteKeys.push(Object.keys(pair.where)[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
deleteKeys.forEach((key: string) => delete pair.where[key]);
|
||||
//@ts-ignore
|
||||
pair.where[`$${additionalSettings}`] = booleanArr;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
setWhereProp('');
|
||||
setAdditionalSettings('');
|
||||
setNewValue('');
|
||||
socket?.emit('updatePair', { index: index - 1, pair: pair });
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal isOpen={isOpen} onClose={() => {
|
||||
setWhereProp('');
|
||||
setAdditionalSettings('');
|
||||
setNewValue('');
|
||||
onClose();
|
||||
}} modalStyle={modalStyle}>
|
||||
<div>
|
||||
<Typography sx={{ margin: '20px 0px' }}>Add where condition:</Typography>
|
||||
<div style={{ margin: '8px' }}>
|
||||
<MuiDropdown
|
||||
id="whereProp"
|
||||
label="Condition"
|
||||
value={whereProp}
|
||||
handleSelect={handlePropSelect}>
|
||||
<MenuItem value="url">url</MenuItem>
|
||||
<MenuItem value="selectors">selectors</MenuItem>
|
||||
<MenuItem value="cookies">cookies</MenuItem>
|
||||
<MenuItem value="before">before</MenuItem>
|
||||
<MenuItem value="after">after</MenuItem>
|
||||
<MenuItem value="boolean">boolean logic</MenuItem>
|
||||
</MuiDropdown>
|
||||
</div>
|
||||
{whereProp ?
|
||||
<div style={{ margin: '8px' }}>
|
||||
<DisplayConditionSettings
|
||||
whereProp={whereProp} additionalSettings={additionalSettings} setAdditionalSettings={setAdditionalSettings}
|
||||
newValue={newValue} setNewValue={setNewValue} checked={checked} setChecked={setChecked}
|
||||
keyValueFormRef={keyValueFormRef} whereKeys={Object.keys(pair.where)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
display: "table-cell",
|
||||
float: "right",
|
||||
marginRight: "15px",
|
||||
marginTop: "20px",
|
||||
}}
|
||||
>
|
||||
{"Add Condition"}
|
||||
</Button>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
</GenericModal>
|
||||
)
|
||||
}
|
||||
|
||||
export const modalStyle = {
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '30%',
|
||||
backgroundColor: 'background.paper',
|
||||
p: 4,
|
||||
height: 'fit-content',
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
};
|
||||
126
src/components/recorder/DisplayWhereConditionSettings.tsx
Normal file
126
src/components/recorder/DisplayWhereConditionSettings.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { Dropdown as MuiDropdown } from "../ui/DropdownMui";
|
||||
import { Checkbox, FormControlLabel, FormGroup, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { AddButton } from "../ui/buttons/AddButton";
|
||||
import { RemoveButton } from "../ui/buttons/RemoveButton";
|
||||
import { KeyValueForm } from "./KeyValueForm";
|
||||
import { WarningText } from "../ui/texts";
|
||||
|
||||
interface DisplayConditionSettingsProps {
|
||||
whereProp: string;
|
||||
additionalSettings: string;
|
||||
setAdditionalSettings: (value: any) => void;
|
||||
newValue: any;
|
||||
setNewValue: (value: any) => void;
|
||||
keyValueFormRef: React.RefObject<{ getObject: () => object }>;
|
||||
whereKeys: string[];
|
||||
checked: boolean[];
|
||||
setChecked: (value: boolean[]) => void;
|
||||
}
|
||||
|
||||
export const DisplayConditionSettings = (
|
||||
{ whereProp, setAdditionalSettings, additionalSettings,
|
||||
setNewValue, newValue, keyValueFormRef, whereKeys, checked, setChecked }
|
||||
: DisplayConditionSettingsProps) => {
|
||||
switch (whereProp) {
|
||||
case 'url':
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MuiDropdown
|
||||
id="url"
|
||||
label="type"
|
||||
value={additionalSettings}
|
||||
handleSelect={(e) => setAdditionalSettings(e.target.value)}>
|
||||
<MenuItem value="string">string</MenuItem>
|
||||
<MenuItem value="regex">regex</MenuItem>
|
||||
</MuiDropdown>
|
||||
{additionalSettings ? <TextField
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
value={newValue}
|
||||
/> : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
case 'selectors':
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack direction='column' spacing={2}>
|
||||
{
|
||||
newValue.map((selector: string, index: number) => {
|
||||
return <TextField
|
||||
key={`whereProp-selector-${index}`}
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setNewValue([
|
||||
...newValue.slice(0, index),
|
||||
e.target.value,
|
||||
...newValue.slice(index + 1)
|
||||
])} />
|
||||
})
|
||||
}
|
||||
</Stack>
|
||||
<AddButton handleClick={() => setNewValue([...newValue, ''])} />
|
||||
<RemoveButton handleClick={() => {
|
||||
const arr = newValue;
|
||||
arr.splice(-1);
|
||||
setNewValue([...arr]);
|
||||
}} />
|
||||
</React.Fragment>
|
||||
)
|
||||
case 'cookies':
|
||||
return <KeyValueForm ref={keyValueFormRef} />
|
||||
case 'before':
|
||||
return <TextField
|
||||
label='pair id'
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
/>
|
||||
case 'after':
|
||||
return <TextField
|
||||
label='pair id'
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
/>
|
||||
case 'boolean':
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MuiDropdown
|
||||
id="boolean"
|
||||
label="operator"
|
||||
value={additionalSettings}
|
||||
handleSelect={(e) => setAdditionalSettings(e.target.value)}>
|
||||
<MenuItem value="and">and</MenuItem>
|
||||
<MenuItem value="or">or</MenuItem>
|
||||
</MuiDropdown>
|
||||
<FormGroup>
|
||||
{
|
||||
whereKeys.map((key: string, index: number) => {
|
||||
return (
|
||||
<FormControlLabel control={
|
||||
<Checkbox
|
||||
checked={checked[index]}
|
||||
onChange={() => setChecked([
|
||||
...checked.slice(0, index),
|
||||
!checked[index],
|
||||
...checked.slice(index + 1)
|
||||
])}
|
||||
key={`checkbox-${key}-${index}`}
|
||||
/>
|
||||
} label={key} key={`control-label-form-${key}-${index}`} />
|
||||
)
|
||||
})
|
||||
}
|
||||
</FormGroup>
|
||||
<WarningText>
|
||||
Choose at least 2 where conditions. Nesting of boolean operators
|
||||
is possible by adding more conditions.
|
||||
</WarningText>
|
||||
</React.Fragment>
|
||||
)
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
86
src/components/recorder/Highlighter.tsx
Normal file
86
src/components/recorder/Highlighter.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
import styled from "styled-components";
|
||||
|
||||
interface HighlighterProps {
|
||||
unmodifiedRect: DOMRect;
|
||||
displayedSelector: string;
|
||||
width: number;
|
||||
height: number;
|
||||
canvasRect: DOMRect;
|
||||
};
|
||||
|
||||
export const Highlighter = ({ unmodifiedRect, displayedSelector = '', width, height, canvasRect }: HighlighterProps) => {
|
||||
if (!unmodifiedRect) {
|
||||
return null;
|
||||
} else {
|
||||
const rect = {
|
||||
top: unmodifiedRect.top + canvasRect.top + window.scrollY,
|
||||
left: unmodifiedRect.left + canvasRect.left + window.scrollX,
|
||||
right: unmodifiedRect.right + canvasRect.left,
|
||||
bottom: unmodifiedRect.bottom + canvasRect.top,
|
||||
width: unmodifiedRect.width,
|
||||
height: unmodifiedRect.height,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HighlighterOutline
|
||||
id="Highlighter-outline"
|
||||
top={rect.top}
|
||||
left={rect.left}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
/>
|
||||
{/* <HighlighterLabel
|
||||
id="Highlighter-label"
|
||||
top={rect.top + rect.height + 8}
|
||||
left={rect.left}
|
||||
>
|
||||
{displayedSelector}
|
||||
</HighlighterLabel> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const HighlighterOutline = styled.div<HighlighterOutlineProps>`
|
||||
box-sizing: border-box;
|
||||
pointer-events: none !important;
|
||||
position: fixed !important;
|
||||
background: #ff5d5b26 !important;
|
||||
outline: 4px solid #ff00c3 !important;
|
||||
//border: 4px solid #ff5d5b !important;
|
||||
z-index: 2147483647 !important;
|
||||
//border-radius: 5px;
|
||||
top: ${(p: HighlighterOutlineProps) => p.top}px;
|
||||
left: ${(p: HighlighterOutlineProps) => p.left}px;
|
||||
width: ${(p: HighlighterOutlineProps) => p.width}px;
|
||||
height: ${(p: HighlighterOutlineProps) => p.height}px;
|
||||
`;
|
||||
|
||||
const HighlighterLabel = styled.div<HighlighterLabelProps>`
|
||||
pointer-events: none !important;
|
||||
position: fixed !important;
|
||||
background: #080a0b !important;
|
||||
color: white !important;
|
||||
padding: 8px !important;
|
||||
font-family: monospace !important;
|
||||
border-radius: 5px !important;
|
||||
z-index: 2147483647 !important;
|
||||
top: ${(p: HighlighterLabelProps) => p.top}px;
|
||||
left: ${(p: HighlighterLabelProps) => p.left}px;
|
||||
`;
|
||||
|
||||
interface HighlighterLabelProps {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
interface HighlighterOutlineProps {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
39
src/components/recorder/KeyValueForm.tsx
Normal file
39
src/components/recorder/KeyValueForm.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { KeyValuePair } from "./KeyValuePair";
|
||||
import { AddButton } from "../ui/buttons/AddButton";
|
||||
import { RemoveButton } from "../ui/buttons/RemoveButton";
|
||||
|
||||
export const KeyValueForm = forwardRef((props, ref) => {
|
||||
const [numberOfPairs, setNumberOfPairs] = React.useState<number>(1);
|
||||
const keyValuePairRefs = useRef<{ getKeyValuePair: () => { key: string, value: string } }[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getObject() {
|
||||
let reducedObject = {};
|
||||
for (let i = 0; i < numberOfPairs; i++) {
|
||||
const keyValuePair = keyValuePairRefs.current[i]?.getKeyValuePair();
|
||||
if (keyValuePair) {
|
||||
reducedObject = {
|
||||
...reducedObject,
|
||||
[keyValuePair.key]: keyValuePair.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return reducedObject;
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
new Array(numberOfPairs).fill(1).map((_, index) => {
|
||||
return <KeyValuePair keyLabel={`key ${index + 1}`} valueLabel={`value ${index + 1}`} key={`keyValuePair-${index}`}
|
||||
//@ts-ignore
|
||||
ref={el => keyValuePairRefs.current[index] = el} />
|
||||
})
|
||||
}
|
||||
<AddButton handleClick={() => setNumberOfPairs(numberOfPairs + 1)} hoverEffect={false} />
|
||||
<RemoveButton handleClick={() => setNumberOfPairs(numberOfPairs - 1)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
52
src/components/recorder/KeyValuePair.tsx
Normal file
52
src/components/recorder/KeyValuePair.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { forwardRef, useImperativeHandle } from "react";
|
||||
import { Box, TextField } from "@mui/material";
|
||||
|
||||
interface KeyValueFormProps {
|
||||
keyLabel?: string;
|
||||
valueLabel?: string;
|
||||
}
|
||||
|
||||
export const KeyValuePair = forwardRef(({ keyLabel, valueLabel }: KeyValueFormProps, ref) => {
|
||||
const [key, setKey] = React.useState<string>('');
|
||||
const [value, setValue] = React.useState<string | number>('');
|
||||
useImperativeHandle(ref, () => ({
|
||||
getKeyValuePair() {
|
||||
return { key, value };
|
||||
}
|
||||
}));
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
'& > :not(style)': { m: 1, width: '100px' },
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
id="outlined-name"
|
||||
label={keyLabel || "Key"}
|
||||
value={key}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setKey(event.target.value)}
|
||||
size="small"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
id="outlined-name"
|
||||
label={valueLabel || "Value"}
|
||||
value={value}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const num = Number(event.target.value);
|
||||
if (isNaN(num)) {
|
||||
setValue(event.target.value);
|
||||
}
|
||||
else {
|
||||
setValue(num);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
136
src/components/recorder/LeftSidePanel.tsx
Normal file
136
src/components/recorder/LeftSidePanel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Box, Paper, Tab, Tabs } from "@mui/material";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow";
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||
import { SidePanelHeader } from "./SidePanelHeader";
|
||||
import { emptyWorkflow } from "../../shared/constants";
|
||||
import { LeftSidePanelContent } from "./LeftSidePanelContent";
|
||||
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { TabContext, TabPanel } from "@mui/lab";
|
||||
import { LeftSidePanelSettings } from "./LeftSidePanelSettings";
|
||||
import { RunSettings } from "../run/RunSettings";
|
||||
|
||||
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
||||
getActiveWorkflow(id).then(
|
||||
(response) => {
|
||||
if (response) {
|
||||
callback(response);
|
||||
} else {
|
||||
throw new Error("No workflow found");
|
||||
}
|
||||
}
|
||||
).catch((error) => { console.log(error.message) })
|
||||
};
|
||||
|
||||
interface LeftSidePanelProps {
|
||||
sidePanelRef: HTMLDivElement | null;
|
||||
alreadyHasScrollbar: boolean;
|
||||
recordingName: string;
|
||||
handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void;
|
||||
}
|
||||
|
||||
export const LeftSidePanel = (
|
||||
{ sidePanelRef, alreadyHasScrollbar, recordingName, handleSelectPairForEdit }: LeftSidePanelProps) => {
|
||||
|
||||
const [workflow, setWorkflow] = useState<WorkflowFile>(emptyWorkflow);
|
||||
const [hasScrollbar, setHasScrollbar] = useState<boolean>(alreadyHasScrollbar);
|
||||
const [tab, setTab] = useState<string>('recording');
|
||||
const [params, setParams] = useState<string[]>([]);
|
||||
const [settings, setSettings] = React.useState<RunSettings>({
|
||||
maxConcurrency: 1,
|
||||
maxRepeats: 1,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
const { id, socket } = useSocketStore();
|
||||
const { setWidth, width } = useBrowserDimensionsStore();
|
||||
const { setRecordingLength } = useGlobalInfoStore();
|
||||
|
||||
const workflowHandler = useCallback((data: WorkflowFile) => {
|
||||
setWorkflow(data);
|
||||
setRecordingLength(data.workflow.length);
|
||||
}, [workflow])
|
||||
|
||||
useEffect(() => {
|
||||
// fetch the workflow every time the id changes
|
||||
if (id) {
|
||||
fetchWorkflow(id, workflowHandler);
|
||||
}
|
||||
// fetch workflow in 15min intervals
|
||||
let interval = setInterval(() => {
|
||||
if (id) {
|
||||
fetchWorkflow(id, workflowHandler);
|
||||
}
|
||||
}, (900 * 60 * 15));
|
||||
return () => clearInterval(interval)
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on("workflow", workflowHandler);
|
||||
}
|
||||
|
||||
if (sidePanelRef) {
|
||||
const workflowListHeight = sidePanelRef.clientHeight;
|
||||
const innerHeightWithoutNavbar = window.innerHeight - 70;
|
||||
if (innerHeightWithoutNavbar <= workflowListHeight) {
|
||||
if (!hasScrollbar) {
|
||||
setWidth(width - 10);
|
||||
setHasScrollbar(true);
|
||||
}
|
||||
} else {
|
||||
if (hasScrollbar && !alreadyHasScrollbar) {
|
||||
setWidth(width + 10);
|
||||
setHasScrollbar(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off('workflow', workflowHandler);
|
||||
}
|
||||
}, [socket, workflowHandler]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: 'lightgray',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* <SidePanelHeader /> */}
|
||||
<TabContext value={tab}>
|
||||
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)}>
|
||||
<Tab label="Recording" value='recording' />
|
||||
<Tab label="Settings" value='settings' onClick={() => {
|
||||
getParamsOfActiveWorkflow(id).then((response) => {
|
||||
if (response) {
|
||||
setParams(response);
|
||||
}
|
||||
})
|
||||
}} />
|
||||
</Tabs>
|
||||
<TabPanel value='recording' sx={{ padding: '0px' }}>
|
||||
<LeftSidePanelContent
|
||||
workflow={workflow}
|
||||
updateWorkflow={setWorkflow}
|
||||
recordingName={recordingName}
|
||||
handleSelectPairForEdit={handleSelectPairForEdit}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value='settings'>
|
||||
<LeftSidePanelSettings params={params}
|
||||
settings={settings} setSettings={setSettings} />
|
||||
</TabPanel>
|
||||
</TabContext>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
};
|
||||
105
src/components/recorder/LeftSidePanelContent.tsx
Normal file
105
src/components/recorder/LeftSidePanelContent.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Box from "@mui/material/Box";
|
||||
import { Pair } from "./Pair";
|
||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
import { Add } from "@mui/icons-material";
|
||||
import { Socket } from "socket.io-client";
|
||||
import { AddButton } from "../ui/buttons/AddButton";
|
||||
import { AddPair } from "../../api/workflow";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { PairEditForm } from "./PairEditForm";
|
||||
import { Fab, Tooltip, Typography } from "@mui/material";
|
||||
|
||||
interface LeftSidePanelContentProps {
|
||||
workflow: WorkflowFile;
|
||||
updateWorkflow: (workflow: WorkflowFile) => void;
|
||||
recordingName: string;
|
||||
handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void;
|
||||
}
|
||||
|
||||
export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, handleSelectPairForEdit }: LeftSidePanelContentProps) => {
|
||||
const [activeId, setActiveId] = React.useState<number>(0);
|
||||
const [breakpoints, setBreakpoints] = React.useState<boolean[]>([]);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
const activePairIdHandler = useCallback((data: string, socket: Socket) => {
|
||||
setActiveId(parseInt(data) + 1);
|
||||
// -1 is specially emitted when the interpretation finishes
|
||||
if (parseInt(data) === -1) {
|
||||
return;
|
||||
}
|
||||
socket.emit('activeIndex', data);
|
||||
}, [activeId])
|
||||
|
||||
const addPair = (pair: WhereWhatPair, index: number) => {
|
||||
AddPair((index - 1), pair).then((updatedWorkflow) => {
|
||||
updateWorkflow(updatedWorkflow);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
setShowEditModal(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("activePairId", (data) => activePairIdHandler(data, socket));
|
||||
return () => {
|
||||
socket?.off("activePairId", (data) => activePairIdHandler(data, socket));
|
||||
}
|
||||
}, [socket, setActiveId]);
|
||||
|
||||
|
||||
const handleBreakpointClick = (id: number) => {
|
||||
setBreakpoints(oldBreakpoints => {
|
||||
const newArray = [...oldBreakpoints, ...Array(workflow.workflow.length - oldBreakpoints.length).fill(false)];
|
||||
newArray[id] = !newArray[id];
|
||||
socket?.emit("breakpoints", newArray);
|
||||
return newArray;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPair = () => {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip title='Add pair' placement='left' arrow>
|
||||
<div style={{ float: 'right' }}>
|
||||
<AddButton
|
||||
handleClick={handleAddPair}
|
||||
title=''
|
||||
hoverEffect={false}
|
||||
style={{ color: 'white', background: '#1976d2' }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<GenericModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
>
|
||||
<PairEditForm
|
||||
onSubmitOfPair={addPair}
|
||||
numberOfPairs={workflow.workflow.length}
|
||||
/>
|
||||
</GenericModal>
|
||||
<div>
|
||||
{
|
||||
workflow.workflow.map((pair, i, workflow,) =>
|
||||
<Pair
|
||||
handleBreakpoint={() => handleBreakpointClick(i)}
|
||||
isActive={activeId === i + 1}
|
||||
key={workflow.length - i}
|
||||
index={workflow.length - i}
|
||||
pair={pair}
|
||||
updateWorkflow={updateWorkflow}
|
||||
numberOfPairs={workflow.length}
|
||||
handleSelectPairForEdit={handleSelectPairForEdit}
|
||||
/>)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
src/components/recorder/LeftSidePanelSettings.tsx
Normal file
86
src/components/recorder/LeftSidePanelSettings.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Button, MenuItem, TextField, Typography } from "@mui/material";
|
||||
import { Dropdown } from "../ui/DropdownMui";
|
||||
import { RunSettings } from "../run/RunSettings";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
|
||||
interface LeftSidePanelSettingsProps {
|
||||
params: any[]
|
||||
settings: RunSettings,
|
||||
setSettings: (setting: RunSettings) => void
|
||||
}
|
||||
|
||||
export const LeftSidePanelSettings = ({ params, settings, setSettings }: LeftSidePanelSettingsProps) => {
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
{params.length !== 0 && (
|
||||
<React.Fragment>
|
||||
<Typography>Parameters:</Typography>
|
||||
{params?.map((item: string, index: number) => {
|
||||
return <TextField
|
||||
sx={{ margin: '15px 0px' }}
|
||||
value={settings.params ? settings.params[item] : ''}
|
||||
key={`param-${index}`}
|
||||
type="string"
|
||||
label={item}
|
||||
required
|
||||
onChange={(e) => setSettings(
|
||||
{
|
||||
...settings,
|
||||
params: settings.params
|
||||
? {
|
||||
...settings.params,
|
||||
[item]: e.target.value,
|
||||
}
|
||||
: {
|
||||
[item]: e.target.value,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
})}
|
||||
</React.Fragment>
|
||||
)}
|
||||
<Typography sx={{ margin: '15px 0px' }}>Interpreter:</Typography>
|
||||
<TextField
|
||||
type="number"
|
||||
label="maxConcurrency"
|
||||
required
|
||||
onChange={(e) => setSettings(
|
||||
{
|
||||
...settings,
|
||||
maxConcurrency: parseInt(e.target.value),
|
||||
})}
|
||||
defaultValue={settings.maxConcurrency}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ margin: '15px 0px' }}
|
||||
type="number"
|
||||
label="maxRepeats"
|
||||
required
|
||||
onChange={(e) => setSettings(
|
||||
{
|
||||
...settings,
|
||||
maxRepeats: parseInt(e.target.value),
|
||||
})}
|
||||
defaultValue={settings.maxRepeats}
|
||||
/>
|
||||
<Dropdown
|
||||
id="debug"
|
||||
label="debug"
|
||||
value={settings.debug?.toString()}
|
||||
handleSelect={(e) => setSettings(
|
||||
{
|
||||
...settings,
|
||||
debug: e.target.value === "true",
|
||||
})}
|
||||
>
|
||||
<MenuItem value="true">true</MenuItem>
|
||||
<MenuItem value="false">false</MenuItem>
|
||||
</Dropdown>
|
||||
<Button sx={{ margin: '15px 0px' }} variant='contained'
|
||||
onClick={() => socket?.emit('settings', settings)}>change</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/recorder/Pair.tsx
Normal file
181
src/components/recorder/Pair.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Stack, Button, IconButton, Tooltip, Badge } from "@mui/material";
|
||||
import { AddPair, deletePair, UpdatePair } from "../../api/workflow";
|
||||
import { WorkflowFile } from "maxun-core";
|
||||
import { ClearButton } from "../ui/buttons/ClearButton";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { PairEditForm } from "./PairEditForm";
|
||||
import { PairDisplayDiv } from "./PairDisplayDiv";
|
||||
import { EditButton } from "../ui/buttons/EditButton";
|
||||
import { BreakpointButton } from "../ui/buttons/BreakpointButton";
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import styled from "styled-components";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
|
||||
type WhereWhatPair = WorkflowFile["workflow"][number];
|
||||
|
||||
|
||||
interface PairProps {
|
||||
handleBreakpoint: () => void;
|
||||
isActive: boolean;
|
||||
index: number;
|
||||
pair: WhereWhatPair;
|
||||
updateWorkflow: (workflow: WorkflowFile) => void;
|
||||
numberOfPairs: number;
|
||||
handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void;
|
||||
}
|
||||
|
||||
|
||||
export const Pair: FC<PairProps> = (
|
||||
{
|
||||
handleBreakpoint, isActive, index,
|
||||
pair, updateWorkflow, numberOfPairs,
|
||||
handleSelectPairForEdit
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [breakpoint, setBreakpoint] = useState(false);
|
||||
|
||||
const enableEdit = () => setEdit(true);
|
||||
const disableEdit = () => setEdit(false);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
disableEdit();
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
deletePair(index - 1).then((updatedWorkflow) => {
|
||||
updateWorkflow(updatedWorkflow);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (pair: WhereWhatPair, newIndex: number) => {
|
||||
if (newIndex !== index) {
|
||||
AddPair((newIndex - 1), pair).then((updatedWorkflow) => {
|
||||
updateWorkflow(updatedWorkflow);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
} else {
|
||||
UpdatePair((index - 1), pair).then((updatedWorkflow) => {
|
||||
updateWorkflow(updatedWorkflow);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleBreakpointClick = () => {
|
||||
setBreakpoint(!breakpoint);
|
||||
handleBreakpoint();
|
||||
};
|
||||
|
||||
return (
|
||||
<PairWrapper isActive={isActive}>
|
||||
<Stack direction="row">
|
||||
<div style={{ display: 'flex', maxWidth: '20px', alignItems: 'center', justifyContent: 'center', }}>
|
||||
{isActive ? <LoadingButton loading variant="text" />
|
||||
: breakpoint ? <BreakpointButton changeColor={true} handleClick={handleBreakpointClick} />
|
||||
: <BreakpointButton handleClick={handleBreakpointClick} />
|
||||
}
|
||||
</div>
|
||||
<Badge badgeContent={pair.what.length} color="primary">
|
||||
<Button sx={{
|
||||
position: 'relative',
|
||||
color: 'black',
|
||||
padding: '5px 20px',
|
||||
fontSize: '1rem',
|
||||
textTransform: 'none',
|
||||
}} variant='text' key={`pair-${index}`}
|
||||
onClick={() => handleSelectPairForEdit(pair, index)}>
|
||||
index: {index}
|
||||
</Button>
|
||||
</Badge>
|
||||
<Stack direction="row" spacing={0}
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
"&:hover": {
|
||||
color: 'inherit',
|
||||
}
|
||||
}}>
|
||||
<Tooltip title="View" placement='right' arrow>
|
||||
<div>
|
||||
<ViewButton
|
||||
handleClick={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Raw edit" placement='right' arrow>
|
||||
<div>
|
||||
<EditButton
|
||||
handleClick={() => {
|
||||
enableEdit();
|
||||
handleOpen();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete" placement='right' arrow>
|
||||
<div>
|
||||
<ClearButton handleClick={handleDelete} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<GenericModal isOpen={open} onClose={handleClose}>
|
||||
{edit
|
||||
?
|
||||
<PairEditForm
|
||||
onSubmitOfPair={handleEdit}
|
||||
numberOfPairs={numberOfPairs}
|
||||
index={index.toString()}
|
||||
where={pair.where ? JSON.stringify(pair.where) : undefined}
|
||||
what={pair.what ? JSON.stringify(pair.what) : undefined}
|
||||
id={pair.id}
|
||||
/>
|
||||
:
|
||||
<div>
|
||||
<PairDisplayDiv
|
||||
index={index.toString()}
|
||||
pair={pair}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</GenericModal>
|
||||
</PairWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
interface ViewButtonProps {
|
||||
handleClick: () => void;
|
||||
}
|
||||
|
||||
const ViewButton = ({ handleClick }: ViewButtonProps) => {
|
||||
return (
|
||||
<IconButton aria-label="add" size={"small"} onClick={handleClick}
|
||||
sx={{ color: 'inherit', '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PairWrapper = styled.div<{ isActive: boolean }>`
|
||||
background-color: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent'};
|
||||
border: ${({ isActive }) => isActive ? 'solid 2px red' : 'none'};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
width: 98%;
|
||||
color: gray;
|
||||
&:hover {
|
||||
color: dimgray;
|
||||
background: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent'};
|
||||
}
|
||||
`;
|
||||
309
src/components/recorder/PairDetail.tsx
Normal file
309
src/components/recorder/PairDetail.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { WhereWhatPair } from "maxun-core";
|
||||
import { Box, Button, IconButton, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material";
|
||||
import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
|
||||
import TreeView from '@mui/lab/TreeView';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import TreeItem from '@mui/lab/TreeItem';
|
||||
import { AddButton } from "../ui/buttons/AddButton";
|
||||
import { WarningText } from "../ui/texts";
|
||||
import NotificationImportantIcon from '@mui/icons-material/NotificationImportant';
|
||||
import { RemoveButton } from "../ui/buttons/RemoveButton";
|
||||
import { AddWhereCondModal } from "./AddWhereCondModal";
|
||||
import { UpdatePair } from "../../api/workflow";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
import { AddWhatCondModal } from "./AddWhatCondModal";
|
||||
|
||||
interface PairDetailProps {
|
||||
pair: WhereWhatPair | null;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const PairDetail = ({ pair, index }: PairDetailProps) => {
|
||||
const [pairIsSelected, setPairIsSelected] = useState(false);
|
||||
const [collapseWhere, setCollapseWhere] = useState(true);
|
||||
const [collapseWhat, setCollapseWhat] = useState(true);
|
||||
const [rerender, setRerender] = useState(false);
|
||||
const [expanded, setExpanded] = React.useState<string[]>(
|
||||
pair ? Object.keys(pair.where).map((key, index) => `${key}-${index}`) : []
|
||||
);
|
||||
const [addWhereCondOpen, setAddWhereCondOpen] = useState(false);
|
||||
const [addWhatCondOpen, setAddWhatCondOpen] = useState(false);
|
||||
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
const handleCollapseWhere = () => {
|
||||
setCollapseWhere(!collapseWhere);
|
||||
}
|
||||
|
||||
const handleCollapseWhat = () => {
|
||||
setCollapseWhat(!collapseWhat);
|
||||
}
|
||||
|
||||
const handleToggle = (event: React.SyntheticEvent, nodeIds: string[]) => {
|
||||
setExpanded(nodeIds);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pair) {
|
||||
setPairIsSelected(true);
|
||||
}
|
||||
}, [pair])
|
||||
|
||||
const handleChangeValue = (value: any, where: boolean, keys: (string | number)[]) => {
|
||||
// a moving reference to internal objects within pair.where or pair.what
|
||||
let schema: any = where ? pair?.where : pair?.what;
|
||||
const length = keys.length;
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
const elem = keys[i];
|
||||
if (!schema[elem]) schema[elem] = {}
|
||||
schema = schema[elem];
|
||||
}
|
||||
|
||||
schema[keys[length - 1]] = value;
|
||||
if (pair && socket) {
|
||||
socket.emit('updatePair', { index: index - 1, pair: pair });
|
||||
}
|
||||
setRerender(!rerender);
|
||||
}
|
||||
|
||||
|
||||
const DisplayValueContent = (value: any, keys: (string | number)[], where: boolean = true) => {
|
||||
switch (typeof (value)) {
|
||||
case 'string':
|
||||
return <TextField
|
||||
size='small'
|
||||
type="string"
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const obj = JSON.parse(e.target.value);
|
||||
handleChangeValue(obj, where, keys);
|
||||
} catch (error) {
|
||||
const num = Number(e.target.value);
|
||||
if (!isNaN(num)) {
|
||||
handleChangeValue(num, where, keys);
|
||||
}
|
||||
handleChangeValue(e.target.value, where, keys)
|
||||
}
|
||||
}}
|
||||
defaultValue={value}
|
||||
key={`text-field-${keys.join('-')}-${where}`}
|
||||
/>
|
||||
case 'number':
|
||||
return <TextField
|
||||
size='small'
|
||||
type="number"
|
||||
onChange={(e) => handleChangeValue(Number(e.target.value), where, keys)}
|
||||
defaultValue={value}
|
||||
key={`text-field-${keys.join('-')}-${where}`}
|
||||
/>
|
||||
case 'object':
|
||||
if (value) {
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{
|
||||
value.map((element, index) => {
|
||||
return DisplayValueContent(element, [...keys, index], where);
|
||||
})
|
||||
}
|
||||
<AddButton handleClick={() => {
|
||||
let prevValue: any = where ? pair?.where : pair?.what;
|
||||
for (const key of keys) {
|
||||
prevValue = prevValue[key];
|
||||
}
|
||||
handleChangeValue([...prevValue, ''], where, keys);
|
||||
setRerender(!rerender);
|
||||
}} hoverEffect={false} />
|
||||
<RemoveButton handleClick={() => {
|
||||
let prevValue: any = where ? pair?.where : pair?.what;
|
||||
for (const key of keys) {
|
||||
prevValue = prevValue[key];
|
||||
}
|
||||
prevValue.splice(-1);
|
||||
handleChangeValue(prevValue, where, keys);
|
||||
setRerender(!rerender);
|
||||
}} />
|
||||
</React.Fragment>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
sx={{ flexGrow: 1, overflowY: 'auto' }}
|
||||
key={`tree-view-nested-${keys.join('-')}-${where}`}
|
||||
>
|
||||
{
|
||||
Object.keys(value).map((key2, index) => {
|
||||
return (
|
||||
<TreeItem nodeId={`${key2}-${index}`} label={`${key2}:`} key={`${key2}-${index}`}>
|
||||
{DisplayValueContent(value[key2], [...keys, key2], where)}
|
||||
</TreeItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
</TreeView>
|
||||
)
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{pair &&
|
||||
<React.Fragment>
|
||||
<AddWhatCondModal isOpen={addWhatCondOpen} onClose={() => setAddWhatCondOpen(false)}
|
||||
pair={pair} index={index} />
|
||||
<AddWhereCondModal isOpen={addWhereCondOpen} onClose={() => setAddWhereCondOpen(false)}
|
||||
pair={pair} index={index} />
|
||||
</React.Fragment>
|
||||
}
|
||||
{
|
||||
pairIsSelected
|
||||
? (
|
||||
<div style={{ padding: '10px', overflow: 'hidden' }}>
|
||||
<Typography>Pair number: {index}</Typography>
|
||||
<TextField
|
||||
size='small'
|
||||
label='id'
|
||||
onChange={(e) => {
|
||||
if (pair && socket) {
|
||||
socket.emit('updatePair', { index: index - 1, pair: pair });
|
||||
pair.id = e.target.value;
|
||||
}
|
||||
}}
|
||||
value={pair ? pair.id ? pair.id : '' : ''}
|
||||
/>
|
||||
<Stack spacing={0} direction='row' sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'lightGray',
|
||||
}}>
|
||||
<CollapseButton
|
||||
handleClick={handleCollapseWhere}
|
||||
isCollapsed={collapseWhere}
|
||||
/>
|
||||
<Typography>Where</Typography>
|
||||
<Tooltip title='Add where condition' placement='right'>
|
||||
<div>
|
||||
<AddButton handleClick={() => {
|
||||
setAddWhereCondOpen(true);
|
||||
}} style={{ color: 'rgba(0, 0, 0, 0.54)', background: 'transparent' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
{(collapseWhere && pair && pair.where)
|
||||
?
|
||||
<React.Fragment>
|
||||
{Object.keys(pair.where).map((key, index) => {
|
||||
return (
|
||||
<TreeView
|
||||
expanded={expanded}
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
sx={{ flexGrow: 1, overflowY: 'auto' }}
|
||||
onNodeToggle={handleToggle}
|
||||
key={`tree-view-${key}-${index}`}
|
||||
>
|
||||
<TreeItem nodeId={`${key}-${index}`} label={`${key}:`} key={`${key}-${index}`}>
|
||||
{
|
||||
// @ts-ignore
|
||||
DisplayValueContent(pair.where[key], [key])
|
||||
}
|
||||
</TreeItem>
|
||||
</TreeView>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
: null
|
||||
}
|
||||
<Stack spacing={0} direction='row' sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'lightGray',
|
||||
}}>
|
||||
<CollapseButton
|
||||
handleClick={handleCollapseWhat}
|
||||
isCollapsed={collapseWhat}
|
||||
/>
|
||||
<Typography>What</Typography>
|
||||
|
||||
<Tooltip title='Add what condition' placement='right'>
|
||||
<div>
|
||||
<AddButton handleClick={() => {
|
||||
setAddWhatCondOpen(true);
|
||||
}} style={{ color: 'rgba(0, 0, 0, 0.54)', background: 'transparent' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
{(collapseWhat && pair && pair.what)
|
||||
? (
|
||||
<React.Fragment>
|
||||
{Object.keys(pair.what).map((key, index) => {
|
||||
return (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
sx={{ flexGrow: 1, overflowY: 'auto' }}
|
||||
key={`tree-view-2-${key}-${index}`}
|
||||
>
|
||||
<TreeItem nodeId={`${key}-${index}`} label={`${pair.what[index].action}`}>
|
||||
{
|
||||
// @ts-ignore
|
||||
DisplayValueContent(pair.what[key], [key], false)
|
||||
}
|
||||
<Tooltip title='remove action' placement='left'>
|
||||
<div style={{ float: 'right' }}>
|
||||
<CloseButton handleClick={() => {
|
||||
//@ts-ignore
|
||||
pair.what.splice(key, 1);
|
||||
setRerender(!rerender);
|
||||
}} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TreeItem>
|
||||
</TreeView>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
: <WarningText>
|
||||
<NotificationImportantIcon color="warning" />
|
||||
No pair from the left side panel was selected.
|
||||
</WarningText>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollapseButtonProps {
|
||||
handleClick: () => void;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const CollapseButton = ({ handleClick, isCollapsed }: CollapseButtonProps) => {
|
||||
return (
|
||||
<IconButton aria-label="add" size={"small"} onClick={handleClick}>
|
||||
{isCollapsed ? <KeyboardArrowDown /> : <KeyboardArrowUp />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
const CloseButton = ({ handleClick }: CollapseButtonProps) => {
|
||||
return (
|
||||
<IconButton aria-label="add" size={"small"} onClick={handleClick}
|
||||
sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'white' } }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
42
src/components/recorder/PairDisplayDiv.tsx
Normal file
42
src/components/recorder/PairDisplayDiv.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { FC } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { WhereWhatPair } from "maxun-core";
|
||||
import styled from "styled-components";
|
||||
|
||||
interface PairDisplayDivProps {
|
||||
index: string;
|
||||
pair: WhereWhatPair;
|
||||
}
|
||||
|
||||
export const PairDisplayDiv: FC<PairDisplayDivProps> = ({ index, pair }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography sx={{ marginBottom: '10px', marginTop: '25px' }} id="pair-index" variant="h6" component="h2">
|
||||
{`Index: ${index}`}
|
||||
{pair.id ? `, Id: ${pair.id}` : ''}
|
||||
</Typography>
|
||||
<Typography id="where-title" variant="h6" component="h2">
|
||||
{"Where:"}
|
||||
</Typography>
|
||||
<DescriptionWrapper id="where-description">
|
||||
<pre>{JSON.stringify(pair?.where, undefined, 2)}</pre>
|
||||
</DescriptionWrapper>
|
||||
<Typography id="what-title" variant="h6" component="h2">
|
||||
{"What:"}
|
||||
</Typography>
|
||||
<DescriptionWrapper id="what-description">
|
||||
<pre>{JSON.stringify(pair?.what, undefined, 2)}</pre>
|
||||
</DescriptionWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DescriptionWrapper = styled.div`
|
||||
margin: 0;
|
||||
font-family: "Roboto","Helvetica","Arial",sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.00938em;
|
||||
`;
|
||||
161
src/components/recorder/PairEditForm.tsx
Normal file
161
src/components/recorder/PairEditForm.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Button, TextField, Typography } from "@mui/material";
|
||||
import React, { FC } from "react";
|
||||
import { Preprocessor, WhereWhatPair } from "maxun-core";
|
||||
|
||||
interface PairProps {
|
||||
index: string;
|
||||
id?: string;
|
||||
where: string | null;
|
||||
what: string | null;
|
||||
}
|
||||
|
||||
interface PairEditFormProps {
|
||||
onSubmitOfPair: (value: WhereWhatPair, index: number) => void;
|
||||
numberOfPairs: number;
|
||||
index?: string;
|
||||
where?: string;
|
||||
what?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const PairEditForm: FC<PairEditFormProps> = (
|
||||
{
|
||||
onSubmitOfPair,
|
||||
numberOfPairs,
|
||||
index,
|
||||
where,
|
||||
what,
|
||||
id,
|
||||
}) => {
|
||||
const [pairProps, setPairProps] = React.useState<PairProps>({
|
||||
where: where || null,
|
||||
what: what || null,
|
||||
index: index || "1",
|
||||
id: id || '',
|
||||
});
|
||||
const [errors, setErrors] = React.useState<PairProps>({
|
||||
where: null,
|
||||
what: null,
|
||||
index: '',
|
||||
});
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { id, value } = event.target;
|
||||
if (id === 'index') {
|
||||
if (parseInt(value, 10) < 1) {
|
||||
setErrors({ ...errors, index: 'Index must be greater than 0' });
|
||||
return;
|
||||
} else {
|
||||
setErrors({ ...errors, index: '' });
|
||||
}
|
||||
}
|
||||
setPairProps({ ...pairProps, [id]: value });
|
||||
};
|
||||
|
||||
const validateAndSubmit = (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
let whereFromPair, whatFromPair;
|
||||
// validate where
|
||||
whereFromPair = {
|
||||
where: pairProps.where && pairProps.where !== '{"url":"","selectors":[""] }'
|
||||
? JSON.parse(pairProps.where)
|
||||
: {},
|
||||
what: [],
|
||||
};
|
||||
const validationError = Preprocessor.validateWorkflow({ workflow: [whereFromPair] });
|
||||
setErrors({ ...errors, where: null });
|
||||
if (validationError) {
|
||||
setErrors({ ...errors, where: validationError.message });
|
||||
return;
|
||||
}
|
||||
// validate what
|
||||
whatFromPair = {
|
||||
where: {},
|
||||
what: pairProps.what && pairProps.what !== '[{"action":"","args":[""] }]'
|
||||
? JSON.parse(pairProps.what) : [],
|
||||
};
|
||||
const validationErrorWhat = Preprocessor.validateWorkflow({ workflow: [whatFromPair] });
|
||||
setErrors({ ...errors, "what": null });
|
||||
if (validationErrorWhat) {
|
||||
setErrors({ ...errors, what: validationErrorWhat.message });
|
||||
return;
|
||||
}
|
||||
//validate index
|
||||
const index = parseInt(pairProps?.index, 10);
|
||||
if (index > (numberOfPairs + 1)) {
|
||||
if (numberOfPairs === 0) {
|
||||
setErrors(prevState => ({
|
||||
...prevState,
|
||||
index: 'Index of the first pair must be 1'
|
||||
}));
|
||||
return;
|
||||
} else {
|
||||
setErrors(prevState => ({
|
||||
...prevState,
|
||||
index: `Index must be in the range 1-${numberOfPairs + 1}`
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setErrors({ ...errors, index: '' });
|
||||
}
|
||||
// submit the pair
|
||||
onSubmitOfPair(pairProps.id
|
||||
? {
|
||||
id: pairProps.id,
|
||||
where: whereFromPair?.where || {},
|
||||
what: whatFromPair?.what || [],
|
||||
}
|
||||
: {
|
||||
where: whereFromPair?.where || {},
|
||||
what: whatFromPair?.what || [],
|
||||
}
|
||||
, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={validateAndSubmit}
|
||||
style={{
|
||||
display: "grid",
|
||||
padding: "0px 30px 0px 30px",
|
||||
marginTop: "36px",
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ marginBottom: '30px' }} variant='h5'>Raw pair edit form:</Typography>
|
||||
<TextField sx={{
|
||||
display: "block",
|
||||
marginBottom: "20px"
|
||||
}} id="index" label="Index" type="number"
|
||||
InputProps={{ inputProps: { min: 1 } }}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}} defaultValue={pairProps.index}
|
||||
onChange={handleInputChange}
|
||||
error={!!errors.index} helperText={errors.index}
|
||||
required
|
||||
/>
|
||||
<TextField sx={{
|
||||
marginBottom: "20px"
|
||||
}} id="id" label="Id" type="string"
|
||||
defaultValue={pairProps.id}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<TextField multiline sx={{ marginBottom: "20px" }}
|
||||
id="where" label="Where" variant="outlined" onChange={handleInputChange}
|
||||
defaultValue={where || '{"url":"","selectors":[""]}'}
|
||||
error={!!errors.where} helperText={errors.where} />
|
||||
<TextField multiline sx={{ marginBottom: "20px" }}
|
||||
id="what" label="What" variant="outlined" onChange={handleInputChange}
|
||||
defaultValue={what || '[{"action":"","args":[""]}]'}
|
||||
error={!!errors.what} helperText={errors.what} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
sx={{ padding: "8px 20px", }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
820
src/components/recorder/RightSidePanel.tsx
Normal file
820
src/components/recorder/RightSidePanel.tsx
Normal file
@@ -0,0 +1,820 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button, Paper, Box, TextField, IconButton } from "@mui/material";
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import DocumentScannerIcon from '@mui/icons-material/DocumentScanner';
|
||||
import { SimpleBox } from "../ui/Box";
|
||||
import { WorkflowFile } from "maxun-core";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { PaginationType, useActionContext, LimitType } from '../../context/browserActions';
|
||||
import { useBrowserSteps } from '../../context/browserSteps';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { ScreenshotSettings } from '../../shared/types';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import { SidePanelHeader } from './SidePanelHeader';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormLabel from '@mui/material/FormLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import { emptyWorkflow } from "../../shared/constants";
|
||||
import { getActiveWorkflow } from "../../api/workflow";
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ActionDescriptionBox from '../action/ActionDescriptionBox';
|
||||
import { useThemeMode } from '../../context/theme-provider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
||||
getActiveWorkflow(id).then(
|
||||
(response) => {
|
||||
if (response) {
|
||||
callback(response);
|
||||
} else {
|
||||
throw new Error("No workflow found");
|
||||
}
|
||||
}
|
||||
).catch((error) => { console.log(error.message) })
|
||||
};
|
||||
|
||||
// TODO:
|
||||
// 1. Add description for each browser step
|
||||
// 2. Handle non custom action steps
|
||||
interface RightSidePanelProps {
|
||||
onFinishCapture: () => void;
|
||||
}
|
||||
|
||||
export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture }) => {
|
||||
const [textLabels, setTextLabels] = useState<{ [id: string]: string }>({});
|
||||
const [errors, setErrors] = useState<{ [id: string]: string }>({});
|
||||
const [confirmedTextSteps, setConfirmedTextSteps] = useState<{ [id: string]: boolean }>({});
|
||||
const [confirmedListTextFields, setConfirmedListTextFields] = useState<{ [listId: string]: { [fieldKey: string]: boolean } }>({});
|
||||
// const [showPaginationOptions, setShowPaginationOptions] = useState(false);
|
||||
// const [showLimitOptions, setShowLimitOptions] = useState(false);
|
||||
const [showCaptureList, setShowCaptureList] = useState(true);
|
||||
const [showCaptureScreenshot, setShowCaptureScreenshot] = useState(true);
|
||||
const [showCaptureText, setShowCaptureText] = useState(true);
|
||||
const [hoverStates, setHoverStates] = useState<{ [id: string]: boolean }>({});
|
||||
const [browserStepIdList, setBrowserStepIdList] = useState<number[]>([]);
|
||||
const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false);
|
||||
const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false);
|
||||
|
||||
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog } = useGlobalInfoStore();
|
||||
const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode, captureStage, setCaptureStage, showPaginationOptions, setShowPaginationOptions, showLimitOptions, setShowLimitOptions, workflow, setWorkflow } = useActionContext();
|
||||
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField } = useBrowserSteps();
|
||||
const { id, socket } = useSocketStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const workflowHandler = useCallback((data: WorkflowFile) => {
|
||||
setWorkflow(data);
|
||||
//setRecordingLength(data.workflow.length);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on("workflow", workflowHandler);
|
||||
}
|
||||
// fetch the workflow every time the id changes
|
||||
if (id) {
|
||||
fetchWorkflow(id, workflowHandler);
|
||||
}
|
||||
// fetch workflow in 15min intervals
|
||||
let interval = setInterval(() => {
|
||||
if (id) {
|
||||
fetchWorkflow(id, workflowHandler);
|
||||
}
|
||||
}, (1000 * 60 * 15));
|
||||
return () => {
|
||||
socket?.off("workflow", workflowHandler);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [id, socket, workflowHandler]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasPairs = workflow.workflow.length > 0;
|
||||
if (!hasPairs) {
|
||||
setShowCaptureList(true);
|
||||
setShowCaptureScreenshot(true);
|
||||
setShowCaptureText(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasScrapeListAction = workflow.workflow.some(pair =>
|
||||
pair.what.some(action => action.action === 'scrapeList')
|
||||
);
|
||||
const hasScreenshotAction = workflow.workflow.some(pair =>
|
||||
pair.what.some(action => action.action === 'screenshot')
|
||||
);
|
||||
const hasScrapeSchemaAction = workflow.workflow.some(pair =>
|
||||
pair.what.some(action => action.action === 'scrapeSchema')
|
||||
);
|
||||
|
||||
setCurrentWorkflowActionsState({
|
||||
hasScrapeListAction,
|
||||
hasScreenshotAction,
|
||||
hasScrapeSchemaAction,
|
||||
});
|
||||
|
||||
const shouldHideActions = hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction;
|
||||
|
||||
setShowCaptureList(!shouldHideActions);
|
||||
setShowCaptureScreenshot(!shouldHideActions);
|
||||
setShowCaptureText(!(hasScrapeListAction || hasScreenshotAction));
|
||||
}, [workflow]);
|
||||
|
||||
const handleMouseEnter = (id: number) => {
|
||||
setHoverStates(prev => ({ ...prev, [id]: true }));
|
||||
};
|
||||
|
||||
const handleMouseLeave = (id: number) => {
|
||||
setHoverStates(prev => ({ ...prev, [id]: false }));
|
||||
};
|
||||
|
||||
const handlePairDelete = () => { }
|
||||
|
||||
const handleStartGetText = () => {
|
||||
setIsCaptureTextConfirmed(false);
|
||||
startGetText();
|
||||
}
|
||||
|
||||
const handleStartGetList = () => {
|
||||
setIsCaptureListConfirmed(false);
|
||||
startGetList();
|
||||
}
|
||||
|
||||
const handleTextLabelChange = (id: number, label: string, listId?: number, fieldKey?: string) => {
|
||||
if (listId !== undefined && fieldKey !== undefined) {
|
||||
// Prevent editing if the field is confirmed
|
||||
if (confirmedListTextFields[listId]?.[fieldKey]) {
|
||||
return;
|
||||
}
|
||||
updateListTextFieldLabel(listId, fieldKey, label);
|
||||
} else {
|
||||
setTextLabels(prevLabels => ({ ...prevLabels, [id]: label }));
|
||||
}
|
||||
if (!label.trim()) {
|
||||
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
|
||||
} else {
|
||||
setErrors(prevErrors => ({ ...prevErrors, [id]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStepConfirm = (id: number) => {
|
||||
const label = textLabels[id]?.trim();
|
||||
if (label) {
|
||||
updateBrowserTextStepLabel(id, label);
|
||||
setConfirmedTextSteps(prev => ({ ...prev, [id]: true }));
|
||||
} else {
|
||||
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStepDiscard = (id: number) => {
|
||||
deleteBrowserStep(id);
|
||||
setTextLabels(prevLabels => {
|
||||
const { [id]: _, ...rest } = prevLabels;
|
||||
return rest;
|
||||
});
|
||||
setErrors(prevErrors => {
|
||||
const { [id]: _, ...rest } = prevErrors;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTextStepDelete = (id: number) => {
|
||||
deleteBrowserStep(id);
|
||||
setTextLabels(prevLabels => {
|
||||
const { [id]: _, ...rest } = prevLabels;
|
||||
return rest;
|
||||
});
|
||||
setConfirmedTextSteps(prev => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setErrors(prevErrors => {
|
||||
const { [id]: _, ...rest } = prevErrors;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const handleListTextFieldConfirm = (listId: number, fieldKey: string) => {
|
||||
setConfirmedListTextFields(prev => ({
|
||||
...prev,
|
||||
[listId]: {
|
||||
...(prev[listId] || {}),
|
||||
[fieldKey]: true
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleListTextFieldDiscard = (listId: number, fieldKey: string) => {
|
||||
removeListTextField(listId, fieldKey);
|
||||
setConfirmedListTextFields(prev => {
|
||||
const updatedListFields = { ...(prev[listId] || {}) };
|
||||
delete updatedListFields[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
[listId]: updatedListFields
|
||||
};
|
||||
});
|
||||
setErrors(prev => {
|
||||
const { [fieldKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const handleListTextFieldDelete = (listId: number, fieldKey: string) => {
|
||||
removeListTextField(listId, fieldKey);
|
||||
setConfirmedListTextFields(prev => {
|
||||
const updatedListFields = { ...(prev[listId] || {}) };
|
||||
delete updatedListFields[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
[listId]: updatedListFields
|
||||
};
|
||||
});
|
||||
setErrors(prev => {
|
||||
const { [fieldKey]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const getTextSettingsObject = useCallback(() => {
|
||||
const settings: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
|
||||
browserSteps.forEach(step => {
|
||||
if (browserStepIdList.includes(step.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (step.type === 'text' && step.label && step.selectorObj?.selector) {
|
||||
settings[step.label] = step.selectorObj;
|
||||
}
|
||||
setBrowserStepIdList(prevList => [...prevList, step.id]);
|
||||
});
|
||||
|
||||
return settings;
|
||||
}, [browserSteps, browserStepIdList]);
|
||||
|
||||
|
||||
const stopCaptureAndEmitGetTextSettings = useCallback(() => {
|
||||
const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]);
|
||||
if (hasUnconfirmedTextSteps) {
|
||||
notify('error', t('right_panel.errors.confirm_text_fields'));
|
||||
return;
|
||||
}
|
||||
stopGetText();
|
||||
const settings = getTextSettingsObject();
|
||||
const hasTextSteps = browserSteps.some(step => step.type === 'text');
|
||||
if (hasTextSteps) {
|
||||
socket?.emit('action', { action: 'scrapeSchema', settings });
|
||||
}
|
||||
setIsCaptureTextConfirmed(true);
|
||||
resetInterpretationLog();
|
||||
onFinishCapture();
|
||||
}, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog]);
|
||||
|
||||
const getListSettingsObject = useCallback(() => {
|
||||
let settings: {
|
||||
listSelector?: string;
|
||||
fields?: Record<string, { selector: string; tag?: string;[key: string]: any }>;
|
||||
pagination?: { type: string; selector?: string };
|
||||
limit?: number;
|
||||
} = {};
|
||||
|
||||
browserSteps.forEach(step => {
|
||||
if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) {
|
||||
const fields: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
|
||||
|
||||
Object.entries(step.fields).forEach(([id, field]) => {
|
||||
if (field.selectorObj?.selector) {
|
||||
fields[field.label] = {
|
||||
selector: field.selectorObj.selector,
|
||||
tag: field.selectorObj.tag,
|
||||
attribute: field.selectorObj.attribute,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
settings = {
|
||||
listSelector: step.listSelector,
|
||||
fields: fields,
|
||||
pagination: { type: paginationType, selector: step.pagination?.selector },
|
||||
limit: parseInt(limitType === 'custom' ? customLimit : limitType),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return settings;
|
||||
}, [browserSteps, paginationType, limitType, customLimit]);
|
||||
|
||||
const resetListState = useCallback(() => {
|
||||
setShowPaginationOptions(false);
|
||||
updatePaginationType('');
|
||||
setShowLimitOptions(false);
|
||||
updateLimitType('');
|
||||
updateCustomLimit('');
|
||||
}, [updatePaginationType, updateLimitType, updateCustomLimit]);
|
||||
|
||||
const handleStopGetList = useCallback(() => {
|
||||
stopGetList();
|
||||
resetListState();
|
||||
}, [stopGetList, resetListState]);
|
||||
|
||||
const stopCaptureAndEmitGetListSettings = useCallback(() => {
|
||||
const settings = getListSettingsObject();
|
||||
if (settings) {
|
||||
socket?.emit('action', { action: 'scrapeList', settings });
|
||||
} else {
|
||||
notify('error', t('right_panel.errors.unable_create_settings'));
|
||||
}
|
||||
handleStopGetList();
|
||||
onFinishCapture();
|
||||
}, [stopGetList, getListSettingsObject, socket, notify, handleStopGetList]);
|
||||
|
||||
const hasUnconfirmedListTextFields = browserSteps.some(step => step.type === 'list' && Object.values(step.fields).some(field => !confirmedListTextFields[step.id]?.[field.id]));
|
||||
|
||||
const handleConfirmListCapture = useCallback(() => {
|
||||
switch (captureStage) {
|
||||
case 'initial':
|
||||
startPaginationMode();
|
||||
setShowPaginationOptions(true);
|
||||
setCaptureStage('pagination');
|
||||
break;
|
||||
|
||||
case 'pagination':
|
||||
if (!paginationType) {
|
||||
notify('error', t('right_panel.errors.select_pagination'));
|
||||
return;
|
||||
}
|
||||
const settings = getListSettingsObject();
|
||||
const paginationSelector = settings.pagination?.selector;
|
||||
if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) {
|
||||
notify('error', t('right_panel.errors.select_pagination_element'));
|
||||
return;
|
||||
}
|
||||
stopPaginationMode();
|
||||
setShowPaginationOptions(false);
|
||||
startLimitMode();
|
||||
setShowLimitOptions(true);
|
||||
setCaptureStage('limit');
|
||||
break;
|
||||
|
||||
case 'limit':
|
||||
if (!limitType || (limitType === 'custom' && !customLimit)) {
|
||||
notify('error', t('right_panel.errors.select_limit'));
|
||||
return;
|
||||
}
|
||||
const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType);
|
||||
if (isNaN(limit) || limit <= 0) {
|
||||
notify('error', t('right_panel.errors.invalid_limit'));
|
||||
return;
|
||||
}
|
||||
stopLimitMode();
|
||||
setShowLimitOptions(false);
|
||||
setIsCaptureListConfirmed(true);
|
||||
stopCaptureAndEmitGetListSettings();
|
||||
setCaptureStage('complete');
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
setCaptureStage('initial');
|
||||
break;
|
||||
}
|
||||
}, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]);
|
||||
|
||||
const handleBackCaptureList = useCallback(() => {
|
||||
switch (captureStage) {
|
||||
case 'limit':
|
||||
stopLimitMode();
|
||||
setShowLimitOptions(false);
|
||||
startPaginationMode();
|
||||
setShowPaginationOptions(true);
|
||||
setCaptureStage('pagination');
|
||||
break;
|
||||
case 'pagination':
|
||||
stopPaginationMode();
|
||||
setShowPaginationOptions(false);
|
||||
setCaptureStage('initial');
|
||||
break;
|
||||
}
|
||||
}, [captureStage, stopLimitMode, startPaginationMode, stopPaginationMode]);
|
||||
|
||||
const handlePaginationSettingSelect = (option: PaginationType) => {
|
||||
updatePaginationType(option);
|
||||
};
|
||||
|
||||
const discardGetText = useCallback(() => {
|
||||
stopGetText();
|
||||
browserSteps.forEach(step => {
|
||||
if (step.type === 'text') {
|
||||
deleteBrowserStep(step.id);
|
||||
}
|
||||
});
|
||||
setTextLabels({});
|
||||
setErrors({});
|
||||
setConfirmedTextSteps({});
|
||||
setIsCaptureTextConfirmed(false);
|
||||
notify('error', t('right_panel.errors.capture_text_discarded'));
|
||||
}, [browserSteps, stopGetText, deleteBrowserStep]);
|
||||
|
||||
const discardGetList = useCallback(() => {
|
||||
stopGetList();
|
||||
browserSteps.forEach(step => {
|
||||
if (step.type === 'list') {
|
||||
deleteBrowserStep(step.id);
|
||||
}
|
||||
});
|
||||
resetListState();
|
||||
setShowPaginationOptions(false);
|
||||
setShowLimitOptions(false);
|
||||
setCaptureStage('initial');
|
||||
setConfirmedListTextFields({});
|
||||
setIsCaptureListConfirmed(false);
|
||||
notify('error', t('right_panel.errors.capture_list_discarded'));
|
||||
}, [browserSteps, stopGetList, deleteBrowserStep, resetListState]);
|
||||
|
||||
|
||||
const captureScreenshot = (fullPage: boolean) => {
|
||||
const screenshotSettings: ScreenshotSettings = {
|
||||
fullPage,
|
||||
type: 'png',
|
||||
timeout: 30000,
|
||||
animations: 'allow',
|
||||
caret: 'hide',
|
||||
scale: 'device',
|
||||
};
|
||||
socket?.emit('action', { action: 'screenshot', settings: screenshotSettings });
|
||||
addScreenshotStep(fullPage);
|
||||
stopGetScreenshot();
|
||||
};
|
||||
|
||||
const isConfirmCaptureDisabled = useMemo(() => {
|
||||
// Check if we are in the initial stage and if there are no browser steps or no valid list selectors with fields
|
||||
if (captureStage !== 'initial') return false;
|
||||
|
||||
const hasValidListSelector = browserSteps.some(step =>
|
||||
step.type === 'list' &&
|
||||
step.listSelector &&
|
||||
Object.keys(step.fields).length > 0
|
||||
);
|
||||
|
||||
// Disable the button if there are no valid list selectors or if there are unconfirmed list text fields
|
||||
return !hasValidListSelector || hasUnconfirmedListTextFields;
|
||||
}, [captureStage, browserSteps, hasUnconfirmedListTextFields]);
|
||||
|
||||
const theme = useThemeMode();
|
||||
const isDarkMode = theme.darkMode;
|
||||
return (
|
||||
<Paper sx={{ height: '520px', width: 'auto', alignItems: "center", background: 'inherit' }} id="browser-actions" elevation={0}>
|
||||
{/* <SimpleBox height={60} width='100%' background='lightGray' radius='0%'>
|
||||
<Typography sx={{ padding: '10px' }}>Last action: {` ${lastAction}`}</Typography>
|
||||
</SimpleBox> */}
|
||||
<ActionDescriptionBox isDarkMode={isDarkMode} />
|
||||
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
|
||||
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>{t('right_panel.buttons.capture_list')}</Button>}
|
||||
|
||||
{getList && (
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
||||
{(captureStage === 'pagination' || captureStage === 'limit') && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleBackCaptureList}
|
||||
sx={{
|
||||
color: '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}
|
||||
>
|
||||
{t('right_panel.buttons.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleConfirmListCapture}
|
||||
disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields}
|
||||
sx={{
|
||||
color: '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}
|
||||
>
|
||||
{captureStage === 'initial' ? t('right_panel.buttons.confirm_capture') :
|
||||
captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') :
|
||||
captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') :
|
||||
t('right_panel.buttons.finish_capture')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={discardGetList}
|
||||
sx={{
|
||||
color: 'red !important',
|
||||
borderColor: 'red !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}} >
|
||||
{t('right_panel.buttons.discard')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{showPaginationOptions && (
|
||||
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
|
||||
<Typography>{t('right_panel.pagination.title')}</Typography>
|
||||
<Button
|
||||
variant={paginationType === 'clickNext' ? "contained" : "outlined"}
|
||||
onClick={() => handlePaginationSettingSelect('clickNext')}
|
||||
sx={{
|
||||
color: paginationType === 'clickNext' ? 'whitesmoke !important' : '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: paginationType === 'clickNext' ? '#ff00c3 !important' : 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.pagination.click_next')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"}
|
||||
onClick={() => handlePaginationSettingSelect('clickLoadMore')}
|
||||
sx={{
|
||||
color: paginationType === 'clickLoadMore' ? 'whitesmoke !important' : '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: paginationType === 'clickLoadMore' ? '#ff00c3 !important' : 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.pagination.click_load_more')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={paginationType === 'scrollDown' ? "contained" : "outlined"}
|
||||
onClick={() => handlePaginationSettingSelect('scrollDown')}
|
||||
sx={{
|
||||
color: paginationType === 'scrollDown' ? 'whitesmoke !important' : '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: paginationType === 'scrollDown' ? '#ff00c3 !important' : 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.pagination.scroll_down')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={paginationType === 'scrollUp' ? "contained" : "outlined"}
|
||||
onClick={() => handlePaginationSettingSelect('scrollUp')}
|
||||
sx={{
|
||||
color: paginationType === 'scrollUp' ? 'whitesmoke !important' : '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: paginationType === 'scrollUp' ? '#ff00c3 !important' : 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.pagination.scroll_up')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={paginationType === 'none' ? "contained" : "outlined"}
|
||||
onClick={() => handlePaginationSettingSelect('none')}
|
||||
sx={{
|
||||
color: paginationType === 'none' ? 'whitesmoke !important' : '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: paginationType === 'none' ? '#ff00c3 !important' : 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.pagination.none')}</Button>
|
||||
</Box>
|
||||
)}
|
||||
{showLimitOptions && (
|
||||
<FormControl>
|
||||
<FormLabel>
|
||||
<h4>{t('right_panel.limit.title')}</h4>
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
value={limitType}
|
||||
onChange={(e) => updateLimitType(e.target.value as LimitType)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '500px'
|
||||
}}
|
||||
>
|
||||
<FormControlLabel value="10" control={<Radio />} label="10" />
|
||||
<FormControlLabel value="100" control={<Radio />} label="100" />
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FormControlLabel value="custom" control={<Radio />} label={t('right_panel.limit.custom')} />
|
||||
{limitType === 'custom' && (
|
||||
<TextField
|
||||
type="number"
|
||||
value={customLimit}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
// Only update if the value is greater than or equal to 1 or if the field is empty
|
||||
if (e.target.value === '' || value >= 1) {
|
||||
updateCustomLimit(e.target.value);
|
||||
}
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
onKeyPress: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const value = (e.target as HTMLInputElement).value + e.key;
|
||||
if (parseInt(value) < 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={t('right_panel.limit.enter_number')}
|
||||
sx={{
|
||||
marginLeft: '10px',
|
||||
'& input': {
|
||||
padding: '10px',
|
||||
|
||||
},
|
||||
width: '150px',
|
||||
background: isDarkMode ? "#1E2124" : 'white',
|
||||
color: isDarkMode ? "white" : 'black', // Ensure the text field does not go outside the panel
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
{/* {!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetText}>{t('right_panel.buttons.capture_text')}</Button>} */}
|
||||
|
||||
{!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={handleStartGetText}>{t('right_panel.buttons.capture_text')}</Button>}
|
||||
{getText &&
|
||||
<>
|
||||
<Box display="flex" justifyContent="space-between" gap={2} style={{ margin: '15px' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={stopCaptureAndEmitGetTextSettings}
|
||||
sx={{
|
||||
color: '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.buttons.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={discardGetText}
|
||||
sx={{
|
||||
color: '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.buttons.discard')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
{/* {!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" sx={{backgroundColor:"#ff00c3",color:`${isDarkMode?'white':'black'}`}} onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>} */}
|
||||
{!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>{t('right_panel.buttons.capture_screenshot')}</Button>}
|
||||
{getScreenshot && (
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Button variant="contained" onClick={() => captureScreenshot(true)}>{t('right_panel.screenshot.capture_fullpage')}</Button>
|
||||
<Button variant="contained" onClick={() => captureScreenshot(false)}>{t('right_panel.screenshot.capture_visible')}</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={stopGetScreenshot}
|
||||
sx={{
|
||||
color: '#ff00c3 !important',
|
||||
borderColor: '#ff00c3 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}>
|
||||
{t('right_panel.buttons.discard')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{browserSteps.map(step => (
|
||||
<Box key={step.id} onMouseEnter={() => handleMouseEnter(step.id)} onMouseLeave={() => handleMouseLeave(step.id)} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}>
|
||||
{
|
||||
step.type === 'text' && (
|
||||
<>
|
||||
<TextField
|
||||
label={t('right_panel.fields.label')}
|
||||
value={textLabels[step.id] || step.label || ''}
|
||||
onChange={(e) => handleTextLabelChange(step.id, e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
margin="normal"
|
||||
error={!!errors[step.id]}
|
||||
helperText={errors[step.id]}
|
||||
InputProps={{
|
||||
readOnly: confirmedTextSteps[step.id],
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<EditIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('right_panel.fields.data')}
|
||||
value={step.data}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
readOnly: confirmedTextSteps[step.id],
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<TextFieldsIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
|
||||
/>
|
||||
{!confirmedTextSteps[step.id] ? (
|
||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||
<Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>{t('right_panel.buttons.confirm')}</Button>
|
||||
<Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>{t('right_panel.buttons.discard')}</Button>
|
||||
</Box>
|
||||
) : !isCaptureTextConfirmed && (
|
||||
<Box display="flex" justifyContent="flex-end" gap={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => handleTextStepDelete(step.id)}
|
||||
>
|
||||
{t('right_panel.buttons.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step.type === 'screenshot' && (
|
||||
<Box display="flex" alignItems="center">
|
||||
<DocumentScannerIcon sx={{ mr: 1 }} />
|
||||
<Typography>
|
||||
{step.fullPage ?
|
||||
t('right_panel.screenshot.display_fullpage') :
|
||||
t('right_panel.screenshot.display_visible')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{step.type === 'list' && (
|
||||
<>
|
||||
<Typography>{t('right_panel.messages.list_selected')}</Typography>
|
||||
{Object.entries(step.fields).map(([key, field]) => (
|
||||
<Box key={key} sx={{ background: `${isDarkMode ? "#1E2124" : 'white'}` }}>
|
||||
<TextField
|
||||
label={t('right_panel.fields.field_label')}
|
||||
value={field.label || ''}
|
||||
onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
readOnly: confirmedListTextFields[field.id]?.[key],
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<EditIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label={t('right_panel.fields.field_data')}
|
||||
value={field.data || ''}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<TextFieldsIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
|
||||
/>
|
||||
{!confirmedListTextFields[step.id]?.[key] ? (
|
||||
<Box display="flex" justifyContent="space-between" gap={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => handleListTextFieldConfirm(step.id, key)}
|
||||
disabled={!field.label?.trim()}
|
||||
>
|
||||
{t('right_panel.buttons.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => handleListTextFieldDiscard(step.id, key)}
|
||||
>
|
||||
{t('right_panel.buttons.discard')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : !isCaptureListConfirmed && (
|
||||
<Box display="flex" justifyContent="flex-end" gap={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => handleListTextFieldDelete(step.id, key)}
|
||||
>
|
||||
{t('right_panel.buttons.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
148
src/components/recorder/SaveRecording.tsx
Normal file
148
src/components/recorder/SaveRecording.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useEffect, useState, useContext } from 'react';
|
||||
import { Button, Box, LinearProgress, Tooltip } from "@mui/material";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { stopRecording } from "../../api/recording";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { AuthContext } from '../../context/auth';
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
import { TextField, Typography } from "@mui/material";
|
||||
import { WarningText } from "../ui/texts";
|
||||
import NotificationImportantIcon from "@mui/icons-material/NotificationImportant";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SaveRecordingProps {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [openModal, setOpenModal] = useState<boolean>(false);
|
||||
const [needConfirm, setNeedConfirm] = useState<boolean>(false);
|
||||
const [recordingName, setRecordingName] = useState<string>(fileName);
|
||||
const [waitingForSave, setWaitingForSave] = useState<boolean>(false);
|
||||
|
||||
const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore();
|
||||
const { socket } = useSocketStore();
|
||||
const { state, dispatch } = useContext(AuthContext);
|
||||
const { user } = state;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChangeOfTitle = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
if (needConfirm) {
|
||||
setNeedConfirm(false);
|
||||
}
|
||||
setRecordingName(value);
|
||||
}
|
||||
|
||||
const handleSaveRecording = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
if (recordings.includes(recordingName)) {
|
||||
if (needConfirm) { return; }
|
||||
setNeedConfirm(true);
|
||||
} else {
|
||||
await saveRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const exitRecording = useCallback(async () => {
|
||||
notify('success', t('save_recording.notifications.save_success'));
|
||||
if (browserId) {
|
||||
await stopRecording(browserId);
|
||||
}
|
||||
setBrowserId(null);
|
||||
navigate('/');
|
||||
}, [setBrowserId, browserId, notify]);
|
||||
|
||||
// notifies backed to save the recording in progress,
|
||||
// releases resources and changes the view for main page by clearing the global browserId
|
||||
const saveRecording = async () => {
|
||||
if (user) {
|
||||
const payload = { fileName: recordingName, userId: user.id };
|
||||
socket?.emit('save', payload);
|
||||
setWaitingForSave(true);
|
||||
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
||||
} else {
|
||||
console.error(t('save_recording.notifications.user_not_logged'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on('fileSaved', exitRecording);
|
||||
return () => {
|
||||
socket?.off('fileSaved', exitRecording);
|
||||
}
|
||||
}, [socket, exitRecording]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <Button onClick={() => setOpenModal(true)} variant='contained' sx={{ marginRight: '20px',backgroundColor: '#ff00c3',color: 'white' }} size="small" color="success">
|
||||
Finish */}
|
||||
|
||||
<Button
|
||||
onClick={() => setOpenModal(true)}
|
||||
variant="outlined"
|
||||
color="success"
|
||||
sx={{
|
||||
marginRight: '20px',
|
||||
color: '#00c853 !important',
|
||||
borderColor: '#00c853 !important',
|
||||
backgroundColor: 'whitesmoke !important',
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('right_panel.buttons.finish')}
|
||||
</Button>
|
||||
|
||||
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
|
||||
<form onSubmit={handleSaveRecording} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Typography variant="h6">{t('save_recording.title')}</Typography>
|
||||
<TextField
|
||||
required
|
||||
sx={{ width: '300px', margin: '15px 0px' }}
|
||||
onChange={handleChangeOfTitle}
|
||||
id="title"
|
||||
label={t('save_recording.robot_name')}
|
||||
variant="outlined"
|
||||
defaultValue={recordingName ? recordingName : null}
|
||||
/>
|
||||
{needConfirm
|
||||
?
|
||||
(<React.Fragment>
|
||||
<Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>
|
||||
{t('save_recording.buttons.confirm')}
|
||||
</Button>
|
||||
<WarningText>
|
||||
<NotificationImportantIcon color="warning" />
|
||||
{t('save_recording.errors.exists_warning')}
|
||||
</WarningText>
|
||||
</React.Fragment>)
|
||||
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>
|
||||
{t('save_recording.buttons.save')}
|
||||
</Button>
|
||||
}
|
||||
{waitingForSave &&
|
||||
<Tooltip title={t('save_recording.tooltips.optimizing')} placement={"bottom"}>
|
||||
<Box sx={{ width: '100%', marginTop: '10px' }}>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
</form>
|
||||
</GenericModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modalStyle = {
|
||||
top: '25%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '30%',
|
||||
backgroundColor: 'background.paper',
|
||||
p: 4,
|
||||
height: 'fit-content',
|
||||
display: 'block',
|
||||
padding: '20px',
|
||||
};
|
||||
29
src/components/recorder/SidePanelHeader.tsx
Normal file
29
src/components/recorder/SidePanelHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { InterpretationButtons } from "../run/InterpretationButtons";
|
||||
import { useSocketStore } from "../../context/socket";
|
||||
|
||||
export const SidePanelHeader = () => {
|
||||
|
||||
const [steppingIsDisabled, setSteppingIsDisabled] = useState(true);
|
||||
|
||||
const { socket } = useSocketStore();
|
||||
|
||||
const handleStep = () => {
|
||||
socket?.emit('step');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 'inherit' }}>
|
||||
<InterpretationButtons enableStepping={(isPaused) => setSteppingIsDisabled(!isPaused)} />
|
||||
{/* <Button
|
||||
variant='outlined'
|
||||
disabled={steppingIsDisabled}
|
||||
onClick={handleStep}
|
||||
sx={{marginLeft:'15px'}}
|
||||
>
|
||||
step
|
||||
<FastForward/>
|
||||
</Button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
250
src/components/recorder/canvas.tsx
Normal file
250
src/components/recorder/canvas.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { getMappedCoordinates } from "../../helpers/inputHelpers";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { useActionContext } from '../../context/browserActions';
|
||||
import DatePicker from '../pickers/DatePicker';
|
||||
import Dropdown from '../pickers/Dropdown';
|
||||
import TimePicker from '../pickers/TimePicker';
|
||||
import DateTimeLocalPicker from '../pickers/DateTimeLocalPicker';
|
||||
|
||||
interface CreateRefCallback {
|
||||
(ref: React.RefObject<HTMLCanvasElement>): void;
|
||||
}
|
||||
|
||||
interface CanvasProps {
|
||||
width: number;
|
||||
height: number;
|
||||
onCreateRef: CreateRefCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for mouse's x,y coordinates
|
||||
*/
|
||||
export interface Coordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { socket } = useSocketStore();
|
||||
const { setLastAction, lastAction } = useGlobalInfoStore();
|
||||
const { getText, getList } = useActionContext();
|
||||
const getTextRef = useRef(getText);
|
||||
const getListRef = useRef(getList);
|
||||
|
||||
const [datePickerInfo, setDatePickerInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const [dropdownInfo, setDropdownInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
options: Array<{
|
||||
value: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
selected: boolean;
|
||||
}>;
|
||||
} | null>(null);
|
||||
|
||||
const [timePickerInfo, setTimePickerInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const notifyLastAction = (action: string) => {
|
||||
if (lastAction !== action) {
|
||||
setLastAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
getTextRef.current = getText;
|
||||
getListRef.current = getList;
|
||||
}, [getText, getList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on('showDatePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
||||
setDatePickerInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showDropdown', (info: {
|
||||
coordinates: Coordinates,
|
||||
selector: string,
|
||||
options: Array<{
|
||||
value: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
selected: boolean;
|
||||
}>;
|
||||
}) => {
|
||||
setDropdownInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showTimePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
||||
setTimePickerInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showDateTimePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
||||
setDateTimeLocalInfo(info);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('showDatePicker');
|
||||
socket.off('showDropdown');
|
||||
socket.off('showTimePicker');
|
||||
socket.off('showDateTimePicker');
|
||||
};
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const onMouseEvent = useCallback((event: MouseEvent) => {
|
||||
if (socket && canvasRef.current) {
|
||||
// Get the canvas bounding rectangle
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const clickCoordinates = {
|
||||
x: event.clientX - rect.left, // Use relative x coordinate
|
||||
y: event.clientY - rect.top, // Use relative y coordinate
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'mousedown':
|
||||
if (getTextRef.current === true) {
|
||||
console.log('Capturing Text...');
|
||||
} else if (getListRef.current === true) {
|
||||
console.log('Capturing List...');
|
||||
} else {
|
||||
socket.emit('input:mousedown', clickCoordinates);
|
||||
}
|
||||
notifyLastAction('click');
|
||||
break;
|
||||
case 'mousemove':
|
||||
if (lastMousePosition.current.x !== clickCoordinates.x ||
|
||||
lastMousePosition.current.y !== clickCoordinates.y) {
|
||||
lastMousePosition.current = {
|
||||
x: clickCoordinates.x,
|
||||
y: clickCoordinates.y,
|
||||
};
|
||||
socket.emit('input:mousemove', {
|
||||
x: clickCoordinates.x,
|
||||
y: clickCoordinates.y,
|
||||
});
|
||||
notifyLastAction('move');
|
||||
}
|
||||
break;
|
||||
case 'wheel':
|
||||
const wheelEvent = event as WheelEvent;
|
||||
const deltas = {
|
||||
deltaX: Math.round(wheelEvent.deltaX),
|
||||
deltaY: Math.round(wheelEvent.deltaY),
|
||||
};
|
||||
socket.emit('input:wheel', deltas);
|
||||
notifyLastAction('scroll');
|
||||
break;
|
||||
default:
|
||||
console.log('Default mouseEvent registered');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const onKeyboardEvent = useCallback((event: KeyboardEvent) => {
|
||||
if (socket) {
|
||||
switch (event.type) {
|
||||
case 'keydown':
|
||||
socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current });
|
||||
notifyLastAction(`${event.key} pressed`);
|
||||
break;
|
||||
case 'keyup':
|
||||
socket.emit('input:keyup', event.key);
|
||||
break;
|
||||
default:
|
||||
console.log('Default keyEvent registered');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
onCreateRef(canvasRef);
|
||||
canvasRef.current.addEventListener('mousedown', onMouseEvent);
|
||||
canvasRef.current.addEventListener('mousemove', onMouseEvent);
|
||||
canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true });
|
||||
canvasRef.current.addEventListener('keydown', onKeyboardEvent);
|
||||
canvasRef.current.addEventListener('keyup', onKeyboardEvent);
|
||||
|
||||
return () => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.removeEventListener('mousedown', onMouseEvent);
|
||||
canvasRef.current.removeEventListener('mousemove', onMouseEvent);
|
||||
canvasRef.current.removeEventListener('wheel', onMouseEvent);
|
||||
canvasRef.current.removeEventListener('keydown', onKeyboardEvent);
|
||||
canvasRef.current.removeEventListener('keyup', onKeyboardEvent);
|
||||
}
|
||||
|
||||
};
|
||||
} else {
|
||||
console.log('Canvas not initialized');
|
||||
}
|
||||
|
||||
}, [onMouseEvent]);
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: '0px 0px 5px 5px', overflow: 'hidden', backgroundColor: 'white' }}>
|
||||
<canvas
|
||||
tabIndex={0}
|
||||
ref={canvasRef}
|
||||
height={400}
|
||||
width={900}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
{datePickerInfo && (
|
||||
<DatePicker
|
||||
coordinates={datePickerInfo.coordinates}
|
||||
selector={datePickerInfo.selector}
|
||||
onClose={() => setDatePickerInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{dropdownInfo && (
|
||||
<Dropdown
|
||||
coordinates={dropdownInfo.coordinates}
|
||||
selector={dropdownInfo.selector}
|
||||
options={dropdownInfo.options}
|
||||
onClose={() => setDropdownInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{timePickerInfo && (
|
||||
<TimePicker
|
||||
coordinates={timePickerInfo.coordinates}
|
||||
selector={timePickerInfo.selector}
|
||||
onClose={() => setTimePickerInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{dateTimeLocalInfo && (
|
||||
<DateTimeLocalPicker
|
||||
coordinates={dateTimeLocalInfo.coordinates}
|
||||
selector={dateTimeLocalInfo.selector}
|
||||
onClose={() => setDateTimeLocalInfo(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
|
||||
export default Canvas;
|
||||
Reference in New Issue
Block a user