feat: create recorder ui directory

This commit is contained in:
amhsirak
2025-01-09 20:07:42 +05:30
parent a3de19a668
commit 403345b78e
15 changed files with 13 additions and 13 deletions

View 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;
}

View 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 "../molecules/LeftSidePanelContent";
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
import { useGlobalInfoStore } from "../../context/globalInfo";
import { TabContext, TabPanel } from "@mui/lab";
import { LeftSidePanelSettings } from "../molecules/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>
);
};

View File

@@ -0,0 +1,821 @@
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 '../molecules/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 [workflow, setWorkflow] = useState<WorkflowFile>(emptyWorkflow);
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 } = 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);
}, [workflow])
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>
);
};

View 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',
};

View 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>
);
};

View 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;